The Handoff — pull your human in at exactly the decision point
A Handoff is the typed NEEDS_HUMAN outcome: how you — the agent — return control to your human at exactly the decision point that needs them, and nowhere else. The outcome always carries BOTH the full approval url (you're on the human's machine: open the default browser) AND a signed, expiring shortUrl (you're remote: relay it over chat), plus a pollUrl so you resume autonomously the moment approval lands. Not an approval flow. Not a login redirect. A wire type you branch on.
AX = Agent eXperience — what UX and DX were for humans, AX is for agents. The protocol home is AXP — the Agent eXperience Protocol (https://apis.ax/axp). The Handoff is the keystone of B2H2A — and the escape hatch every other motion reaches for the moment a human decision is load-bearing: the consumer approval in B2A2C, the mid-chain authorization in B2A2B, the ceiling raise in B2A.
The law
A gate has exactly two honest exits. If money resolves it, it is a typed 402 carrying an OFFER. If a human decision resolves it, it is a typed NEEDS_HUMAN at 202. A gate that is not an OFFER or a Handoff is a dead end, and dead ends are conformance failures.
Everything else — the login wall, the "verify your email to continue," the silent hang — is the dead end the human-first web ships by default: a gate with no machine-legible way through. You hit it, you have nothing to branch on, and the job your principal thought was delegated bounces back to them as homework. The Handoff exists so that never happens: even the gate that genuinely needs a human is a typed, resumable outcome, not a wall.
The shape
NEEDS_HUMAN ↔ HTTP 202 — accepted, pending human approval; the standard async poll-to-resume shape. Dual delivery is ALWAYS-BOTH: a body missing any of the four required members is not a Handoff, and the toolkit refuses to construct one.
// 202 Accepted
{
"type": "NEEDS_HUMAN",
"url": "https://api.example.com/approvals/9f2c", // local: open in the default browser
"shortUrl": "https://apis.ax/a/9f2c", // remote: signed, expiring, NOT single-use
"pollUrl": "https://api.example.com/approvals/9f2c/poll",
"expiresAt": "2026-07-22T18:40:00Z",
"code": "WDJB-MJHT" // only when stakes demand it (RFC 8628)
}
| member | required | what it is |
|---|---|---|
url | always | The full approval URL. The local route: open it in the human's default browser, in the session they already trust. |
shortUrl | always | Signed, expiring, deliberately not single-use. The remote route: relay it over chat/Slack/SMS — wherever the human already is. |
pollUrl | always | The device-flow poll handle. You poll it and resume the moment approval lands. |
expiresAt | always | ISO 8601. The approval window; past it, the pending Handoff is expired, typed as such. |
code | stakes-gated | RFC 8628 user code. Present only when stakes demand it — see below. |
Choose the route by where your human is
Both routes ride every Handoff, so you never negotiate for the delivery you need — you just pick:
- Human is local (you run on their machine): open
urlin the default browser. They land on the approval page inside the session they already trust — logins, saved payment methods — approve with one interaction, and control snaps back to you. No code to read, no copy-paste. - Human is remote (you run on a server, a schedule, a CI box, a fleet): relay
shortUrlover the channel the human is already in. This is OAuth Device Flow (RFC 8628) — you hold the poll handle; the human approves on their own device.
The preview cannot approve — the shortUrl survives the unfurl
The shortUrl is deliberately NOT single-use. Messaging apps unfurl every link they carry, so GET on the shortUrl is side-effect-free: the unfurl renders a stakes-aware OpenGraph preview of exactly what's being approved — the act, the stakes — and approves nothing. Only explicit human interaction on the page approves. A preview can never spend your principal's money.
This is why a single-use token is the wrong design, not a stricter one: the first unfurler to fetch the link would consume the approval before the human ever saw it. The Handoff is built for the channel it travels on. What secures the link is the signature and the expiry — not a use-count that a link preview would burn.
The code is stakes-gated
The verification code exists to bind this approval to this running agent, so an approval can't be replayed or a request substituted. It appears exactly when that binding is worth a human's extra second:
| situation | code | why |
|---|---|---|
| High-stakes act against an existing account or existing payment instrument — moving real money, acting as an established identity | present — surface it; the human confirms it matches | Binds the approval to your request; closes the substituted-request and replay holes when a real credential is at stake. |
| New signup or entering new payment details — a fresh account, a card typed for the first time | omitted | No existing credential to protect, no prior session to bind against. A code here is pure friction; the browser-open or shortUrl is enough. |
Poll to resume
You poll pollUrl and resume autonomously the moment approval lands. Every branch is typed:
- Approved → the poll answers the resumed outcome (
OK, or the next gate). You continue inside the Mandate. - Declined → the poll answers a typed
BLOCKED. You report the branch honestly and stop. - Window expires → you get the pending outcome back annotated
expired: true. Not a silent hang, not a faked success — a fact you re-plan on.
ax.resume() is this whole loop as one call: try the local browser open, else relay the shortUrl through your channel, then poll to resume — and it annotates the result with which delivery happened.
Run it
Everything below ships today.
npm install agent-experience # zero-dep: typed NEEDS_HUMAN, parseHandoff, ax.resume, ax.needsHuman
Consume a Handoff (you're the agent that hit the gate):
import ax from 'agent-experience'
const res = await ax.fetch(capabilityUrl)
const out = await res.outcome()
// typed, always: OK | EMPTY | BLOCKED | OFFER | NEEDS_HUMAN | FOREIGN
if (out.kind === 'NEEDS_HUMAN') {
const resumed = await ax.resume(out, {
relay: (shortUrl) => chat.send(shortUrl), // remote route; local browser-open is tried first
})
// resumed.kind → the typed outcome after approval (or BLOCKED on decline)
// resumed.delivery → 'opened' | 'relayed'
// resumed.expired → true when the window closed instead
}
Emit a Handoff (you're the surface that needs a human): constructors return real Responses — drop one into any Web framework, or into the ax(handler) mount kernel.
import ax from 'agent-experience'
return ax.needsHuman({
url: 'https://api.example.com/approvals/9f2c',
shortUrl: 'https://apis.ax/a/9f2c',
pollUrl: 'https://api.example.com/approvals/9f2c/poll',
expiresAt: '2026-07-22T18:40:00Z',
code: 'WDJB-MJHT', // include only when stakes demand it
})
// → a 202 with type NEEDS_HUMAN. Refuses to construct without url, shortUrl,
// pollUrl, AND expiresAt — dual delivery is ALWAYS-BOTH, by construction.
And before you trust a surface to hand off honestly, audit it:
npx apis.ax audit <domain> # the pinned conformance gate, judged by api.qa, never self-graded
Where the protocol goes from here
State it plainly, because it is the direction, not the present tense. The Handoff vocabulary ships — the typed outcome, the ALWAYS-BOTH constructor, the parse, the resume loop, and the unfurl register with its brand OpenGraph pipeline. What AXP is ratifying now: NEEDS_HUMAN ↔ 202 into the pinned spec's closed outcome table, the api.qa check that fails any Listing whose gate is a dead end rather than an OFFER or a Handoff, and the estate-run approval rail — the shortUrl signing service and stakes-aware approval pages — so every gate on the platform emits the shape above. As each clause lands, its claim lands with it. The ratchet only moves one way.
Failure vs. success
Failure is the untyped gate: a login wall you cannot branch on, an approval link that a chat preview silently consumed, a poll that hangs forever, a job bounced back to a principal who thought it was delegated.
Success is one interruption, correctly placed: you hit the gate, relay one signed link into the channel your human already reads, they see a preview of exactly what's at stake, tap once — confirm a short code only when real money or a real identity is on the line — and you resume mid-task without being restarted. The human decided; you did everything else.
Human twin: https://apis.directory/handoff