Agent Protocol
Agent Protocol is B4.run's durable HTTP surface for threads, checkpointed runs,
streaming, human-in-the-loop resume, cancellation, and memory-candidate review.
Both b4 dev and a Node runtime started with b4 start expose it.
Local quickstart
Start a server on a known local port:
b4 dev --port 3001Requests identify authored routes with the generated <routeId>#<kind> key,
such as /research#agent.
Agent Protocol endpoints
| Method and path | Request | Success |
|---|---|---|
POST /threads | Optional { "metadata": { ... } } body | 200 thread object |
GET /threads/:thread_id | — | 200 thread object; 404 when absent |
DELETE /threads/:thread_id | — | 204; deletes thread metadata, supported checkpoints, and its sandbox sequentially |
GET /threads/:thread_id/state | — | 200 { config, created_at, metadata, next, parent_config, values }; 404 without a checkpoint |
GET /threads/:thread_id/pending_interrupts | — | 200 { "interrupts": [...] }; 404 thread_not_found; 409 thread_route_unknown without a usable route identity |
POST /threads/:thread_id/runs/wait | { "route": "<routeId>#<kind>", "input": { ... } } | 200 final state JSON |
POST /threads/:thread_id/runs/stream | Same run body | 200 text/event-stream |
POST /threads/:thread_id/resume | Exact { "resume": [...], "route": "<routeId>#<kind>" } body | 200 text/event-stream continuation |
POST /threads/:thread_id/cancel | No body | 200 { "thread_id", "status": "interrupted" }; 404 unknown thread; 409 no active run |
GET /memory/candidates | — | 200 { "candidates": [...] } |
POST /memory/candidates/:id/approve | No body | 200 { "record", "action", "superseded" } |
POST /memory/candidates/:id/reject | No body | 200 { "ok": true } |
A run body requires route; input is optional and defaults to {}. A bare
route id without #<kind> is not a registered assistant id and returns 404.
The { route, input } envelope is shared by B4.run's dev, Node, and Hono HTTP
runtimes. It is not the LangSmith request envelope, which uses assistant_id.
Thread lifecycle with curl
This copyable sequence creates a thread, waits for a route, then reads its latest
checkpoint. It uses jq only to extract the returned thread id.
BASE_URL=http://127.0.0.1:3001
THREAD_ID=$(curl -sS -X POST "$BASE_URL/threads" \
-H 'content-type: application/json' \
-d '{}' | jq -r '.thread_id')
curl -sS -X POST "$BASE_URL/threads/$THREAD_ID/runs/wait" \
-H 'content-type: application/json' \
-d '{
"route": "/research#agent",
"input": {
"messages": [{ "role": "user", "content": "Explain checkpoints briefly." }]
}
}'
curl -sS "$BASE_URL/threads/$THREAD_ID/state"Runs create the named thread if it does not exist, but creating it explicitly is useful when you need metadata or want to distinguish setup from execution.
Streaming over SSE
runs/stream and resume return Server-Sent Events. The event: line is the
runtime chunk type. The data: line is the chunk payload serialized directly as
JSON—not a wrapper containing the full chunk.
event: chunk
data: "partial text"
event: tool_call
data: {"id":"call-1","name":"search","input":{"query":"B4.run"}}
event: done
data: {"output":{"messages":[]}}While a stream is quiet, B4.run sends the SSE comment below every 15 seconds by default. SSE clients ignore comment frames; intermediaries see activity.
: pingInterrupt and resume
A permission pause arrives as an interrupt event whose raw JSON data includes
the public interruptId and permission details:
event: interrupt
data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","detail":{"command":"ls","suggestedPattern":"ls"}}Resume every pending interrupt on the root thread in one request:
curl -N -X POST "$BASE_URL/threads/$THREAD_ID/resume" \
-H 'content-type: application/json' \
-d '{
"resume": [
{ "interruptId": "perm-abc123", "status": "resolved", "payload": "once" },
{ "interruptId": "perm-def456", "status": "cancelled" }
],
"route": "/research#agent"
}'The body accepts exactly resume and route. A resolved entry accepts exactly
interruptId, status, and a payload of "once", "always", or "deny".
A cancelled entry accepts only interruptId and status and maps to denial.
The array must contain every pending public interrupt id exactly once: stale,
partial, duplicate, or extra sets return 409. Nested subagent interrupts are
still addressed through the root thread. The removed scalar
{ interrupt_id, decision } form returns 400.
Only one Agent Protocol or AG-UI resume can consume a thread's pending snapshot
at a time. The resume claim is acquired before the run registry, so a concurrent
resume returns 409 with error.details.code set to resume_in_progress.
Other attempts to start work while the thread's run slot is occupied return
run_in_flight instead.
Although route is required in the request body, it does not normally select a
new route for a parked thread. B4.run resolves the route from the in-process
thread-route map first, then persisted thread metadata. The body route is the
last fallback. Changing it does not redirect a parked thread while either
recorded route exists.
Recovering prompts without a live stream
A client that reloaded has no stream left to read the interrupt event from.
GET /threads/:thread_id/pending_interrupts returns the prompts still parked on
a thread, which is enough to put the permission UI back on screen:
curl -sS "$BASE_URL/threads/$THREAD_ID/pending_interrupts"
# {"interrupts":[{"interruptId":"perm-abc123","resumeKey":"...","value":{...}}]}value is the interrupt payload as stored in the checkpoint—for a permission
prompt, { interruptId, type, kind, detail }. It is the same payload the
interrupt event carries, minus what the stream projection adds on the way out:
a prompt raised inside a subagent picks up a callId on the wire naming the
parent's subagent tool call, and the stored copy has no such field. resumeKey
is the checkpoint write's own key, or null when the write carries no usable
one; clients address prompts by interruptId.
A thread with nothing parked answers 200 with an empty array. Responses are
sent cache-control: no-store, because checkpoint state moves under the client.
Answer the prompts with the same POST /threads/:thread_id/resume body shown
above: exactly { resume, route }, with route required even though the server
prefers its own recorded route. A reloaded client that no longer knows the route
reads it from GET /threads/:thread_id, whose metadata.route is the route key
recorded at the start of every run on the thread.
Unlike every other gated endpoint, this one is gated on an identity the caller
cannot repoint: the route that parked the interrupts, recorded as
metadata.parked_route when a turn parks and retired once the last prompt is
answered. Gating on the last-run route alone would let a caller allowed to run
some cheaper route start a run, move the thread's route identity, and read a
prompt that route never raised. Only when no parking route is recorded does
resolution fall back to the last-run chain—the in-process map, then
metadata.route.
A thread carrying none of those has no identity to gate on, so it is refused
rather than served: this endpoint hands back the same interrupt payloads a run
stream does. That is the ordinary state of a thread created but never run, and
it returns 409 with error.details.code set to thread_route_unknown. The
same code answers a thread whose recorded route is no longer registered, and the
response deliberately does not name that route. An unknown thread returns 404
with thread_not_found.
One run at a time per thread
B4.run admits one active run per thread. An ordinary run-slot collision—such as a
competing runs/wait or runs/stream, or a resume colliding with a non-resume
run—returns 409 with error.details.code set to run_in_flight. A second
concurrent resume is stopped by the earlier resume claim and returns
resume_in_progress. The run registry is in-memory and process-local;
persisted thread status does not provide distributed serialization.
Cancel the active run explicitly:
curl -sS -X POST "$BASE_URL/threads/$THREAD_ID/cancel"
# {"status":"interrupted","thread_id":"..."}The cancel endpoint returns 404 with thread_not_found for an unknown thread
and 409 with no_run_in_flight when the thread exists on this process but no
run is active. Cancellation keeps checkpointed state; it does not roll back.
Cancellation is reported differently after execution has begun. A cancelled SSE run or resume ends in band with:
event: done
data: {"output":{"cancelled":true}}A cancelled blocking runs/wait has not committed a response, so it returns
409 with error.details.code set to run_cancelled. A route failure instead
ends a stream with a done payload containing output.error.
interrupted covers cancelled and parked
GET /threads/:thread_id reports status: "interrupted" for a run stopped by
POST /threads/:thread_id/cancel and for a turn parked on a human-in-the-loop
interrupt alike. pending_interrupts is the discriminator: a non-empty
interrupts array means the thread is waiting on a human, and an empty one means
it is not—the run was cancelled, or the prompts have already been answered.
Parked turns previously reported "idle", which a reloaded client could not tell
from a finished run. The streaming endpoints runs/stream and resume report
the parked status. runs/wait is a blocking JSON call and still returns its
thread to "idle" when its turn parks, so check pending_interrupts there
rather than the thread status.
Client disconnect
Disconnecting an Agent Protocol runs/stream, runs/wait, or resume client
only detaches that viewer; the checkpointed run continues. To stop the intent,
call POST /threads/:thread_id/cancel. Server shutdown also aborts active work.
Because run admission and cancel routing are process-local, a multi-replica service needs guaranteed thread-keyed routing to one process or distributed per-thread serialization and cancel routing. Shared Postgres stores add durability, not that coordination.
Review memory candidates
GET /memory/candidates lists candidates across every memory namespace. Approval
uses identity-aware reconciliation and reports an action of activated,
superseded, or deduped; it returns 404 for a missing record and 409 when
the record is not a candidate. Rejection deletes the record.
Candidate listing spans namespaces, while approve and reject are destructive mutations. All three management routes bypass B4.run execution middleware. Apply outer authentication, tenant authorization, and audit controls before exposing them beyond a trusted local environment.
Production topology
The Node runtime exposes the same B4.run request envelope, but production needs
more than replacing b4 dev with b4 start: configure durable stores,
outer authentication, network policy, health behavior, and replica coordination.
See Production Topology,
Persistence and Tenancy, and
Security Architecture.
AG-UI is a different client surface
AG-UI uses POST /agui/{routeId} with an encoded assistant id and an AG-UI
RunAgentInput, then translates B4.run chunks into AG-UI events. It also has the
opposite disconnect policy: the ephemeral run aborts when its viewer disconnects
and there is no event replay. See AG-UI and Web Clients for that
endpoint and lifecycle.