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:
nextSteps[]— what the agent can do next, on every 2xx mutationrecoveryHint— how to fix a malformed request, on every 4xxrefreshUrl— where to re-read fresh state, on every 422currentState+allowedActions— the state machine, on every 422 from a state-machine rejection
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."
}
action— an imperative verb phrase the agent can match against its plan.method+href— the verb and concrete URL the agent fires; no template substitution required.description— one sentence that embeds the rationale, including any role or precondition the call needs.
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:
- Custom routes you mount in your deployment (if you extend AGLedger).
- Downstream integrations that re-emit the envelope to your agents.
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:
"Invalid input."— vague; the agent cannot act on it."Your data is malformed."— blame-shifted; doesn't say what is malformed."ERR_VALIDATION_3."— opaque; assumes the agent has access to an out-of-band code table.
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:
currentState— the state the resource is actually in, not the state the agent thought it was in.allowedActions— what the agent can do from here.
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
- Discovery surface projection — what
/llms.txtand/openapi.jsonadvertise about your registered schemas. - Validating authoring before deploy — the lint sweep, static schema test, and runtime hook that catch envelope violations before they reach an agent.
Validated against the live API (v0.22.18) on 2026-05-02.