The Agent-UX Envelope

Every mutation response on AGLedger carries a small set of fields whose job is to keep an LLM agent from getting stuck. Author yours the same way.

The principle

A human developer who hits a vague error, an unstated state machine, or a missing pointer to the next action will work around it — they read prose, follow links, ask colleagues. An LLM agent has none of those affordances. It either finds the next-action hook in the response body or it stops.

Author every response against the question: can an LLM use this correctly on the first attempt without human interpretation? If the answer is no, the response is missing a hook.

The agent-UX envelope is four fields that, together, answer that question:

Each is necessary. Together they make the response self-describing.

nextSteps[] on every 2xx mutation

Every 2xx response to a POST, PUT, PATCH, or DELETE on /v1/* carries a nextSteps array. Each entry has four fields:

{
  "action": "Submit receipt",
  "method": "POST",
  "href": "/v1/records/019d3b10-03cb-7b00-9f3e-f1a2c3d4e5f6/receipts",
  "description": "Submit evidence (performer only). Body: { evidence: {...} } matching the type's receiptSchema."
}

The array, not a single recommendation: most resources have more than one legitimate next move. The agent ranks; the API enumerates.

The engine populates nextSteps[] on built-in routes via the helpers in src/shared/next-steps.ts (recordNextSteps, webhookNextSteps, schemaRegistrationNextSteps, disputeNextSteps, etc.). Customer-registered schemas inherit the record-side helpers automatically — the helper computes from the record's current status, so a record returned at RECEIPT_ACCEPTED carries different nextSteps than the same record at FULFILLED without the handler doing anything.

Wire your own nextSteps[] on:

recoveryHint on 4xx responses

A recoveryHint is a static, declarative, action-oriented string the agent can pattern-match against. Static so it carries reproducible instructions; action-oriented so the next call is obvious.

A good hint:

Field path "/qty" looks like a JSON Pointer (RFC 6901) but the verification engine
uses bare dot notation. Use "qty" instead.

A bad hint:

Hints must reproduce the corrected canonical shape, not paraphrase it. Agents copy hints verbatim into their next request — a wrong hint actively teaches the wrong call.

refreshUrl on 422 state-machine responses

When the request body was valid but the resource cannot accept it in its current state, the response carries a refreshUrl. The agent re-reads the URL and gets a fresh nextActions / validTransitions / state envelope to plan against.

The engine derives refreshUrl via a longest-prefix-match resolver in src/middleware/error-handler.ts:

| Request path | Resolved refreshUrl | |--------------|---| | POST /v1/records/{id}/transition | /v1/records/{id} | | POST /v1/records/{id}/dispute | /v1/records/{id}/dispute | | POST /v1/records/{id}/dispute/evidence | /v1/records/{id}/dispute |

The dispute branch is load-bearing. A dispute is a subresource that owns its own state — currentTier, evidenceWindowClosesAt, resolution. Pointing a 422 from /dispute/evidence at the parent record GET would ship the agent back to a stale shape.

Customer subresources that surface their own state envelope opt in by adding a branch to resolveRefreshUrl(). Default is the parent resource; subresources with their own state shape need a one-line addition.

currentState + allowedActions on 422

The 422 body also carries:

Both are caller-supplied on the error and the error handler spreads them onto the response. Pair with refreshUrl so the agent can re-read both the current state and the allowed forward edges in a single GET.

{
  "type": "/problems/invalid-action",
  "status": 422,
  "title": "Invalid Action",
  "detail": "Action 'activate' is not valid in state FULFILLED",
  "currentState": "FULFILLED",
  "attemptedAction": "activate",
  "allowedActions": ["dispute"],
  "recoveryHint": "Read `nextActions` on the resource. Do not infer from /llms.txt or /openapi.json enums.",
  "refreshUrl": "/v1/records/019d3b10-03cb-...",
  "instance": "/v1/records/019d3b10-03cb-.../transition",
  "retryable": false
}

Worked example

A registered schema's record-creation response (status 201):

{
  "id": "019d3b10-03cb-7b00-9f3e-f1a2c3d4e5f6",
  "type": "DELIVERY-ATTEMPT-v1",
  "status": "ACTIVE",
  "criteria": { "expected_count": 100 },
  "createdAt": "2026-05-04T12:00:00.000Z",
  "nextSteps": [
    {
      "action": "Submit receipt",
      "method": "POST",
      "href": "/v1/records/019d3b10-03cb-.../receipts",
      "description": "Submit evidence matching the type's receiptSchema."
    },
    {
      "action": "Cancel record",
      "method": "POST",
      "href": "/v1/records/019d3b10-03cb-.../cancel",
      "description": "Terminate the record if no longer needed. Body: { reason?: \"<text>\" }."
    },
    {
      "action": "View record",
      "method": "GET",
      "href": "/v1/records/019d3b10-03cb-...",
      "description": "Re-fetch the record with current state."
    }
  ]
}

A 422 from POST /v1/records/{id}/transition to a non-edge state:

{
  "type": "/problems/invalid-action",
  "status": 422,
  "title": "Invalid Action",
  "detail": "Action 'activate' is not valid in state FULFILLED",
  "currentState": "FULFILLED",
  "attemptedAction": "activate",
  "allowedActions": ["dispute"],
  "recoveryHint": "Read `nextActions` on the resource. Do not infer from /llms.txt or /openapi.json enums.",
  "refreshUrl": "/v1/records/019d3b10-03cb-...",
  "retryable": false,
  "requestId": "req_..."
}

The agent has everything it needs in one response: the state, the legal moves, the URL to re-read, and a static hint that points at the field to consult.

Common mistakes

These are bug shapes hit on AGLedger's own surface before the runtime hook caught them. Each is grounded in a finding from the testbed simulator.

Stale steps after state changes

Finding F-444 in our testbed simulator surfaced parent records continuing to advertise "Submit receipt" in nextSteps after delegation cascade had already settled them. The fix scrubs stale steps when the record terminalizes. The lesson: nextSteps is computed from the current state, not appended over the record's lifetime. If your handler caches a step list and mutates it incrementally, you will eventually ship a step that points at a route the resource can no longer reach.

Empty nextActions with non-empty validTransitions

Finding F-456 in our testbed simulator surfaced multi-hop delegation chains where a deepest-level FAILED record had nextActions: [] despite validTransitions listing five legitimate recovery edges. Agents reading nextActions alone concluded the chain was stuck. The fix made nextActions correctly empty for FAILED (no /transition action applies — recovery happens via /revision, /outcome, /dispute, /cancel) and pointed nextSteps at the five recovery actions with explicit description text explaining who can call each. The lesson: when nextActions is empty, ALWAYS pair it with rich nextSteps describing the recovery paths an agent should consider; an empty list with no companion narrative leaves the agent stuck.

Wrong recoveryHint body shape

Finding F-461 in our testbed simulator surfaced a 400 response on POST /v1/records/{id}/dispute whose recoveryHint referenced an obsolete request shape ({initiator, role, ...}) instead of the current {grounds, context?}. Agents copy hints verbatim, so a wrong hint teaches the wrong call. The lesson: the hint must be the corrected canonical shape, never a paraphrase. Re-check every hint against the route's actual schema after every body change.

Pointing nextSteps[].href at routes that 404

The static sweep test in src/test/agent-ux-mutation-sweep.test.ts exists specifically because this class of bug ships invisibly. The response shape passes JSON serialization, the agent follows the href, the agent hits 404. The static sweep enumerates every href returned by the helpers in src/shared/next-steps.ts and asserts each resolves to a registered route. See Validating authoring before deploy for how to adopt the same pattern in your codebase.

See also


Validated against the live API (v0.22.18) on 2026-05-02.