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 |
GET /threads/:thread_id/runs/stream | — | 200 text/event-stream (reattach); 404 thread_not_found; 409 thread_route_unknown without a usable route identity |
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.
A reconnecting client rejoins the run with GET /threads/:thread_id/runs/stream
(below) rather than polling GET /threads/:thread_id/state.
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.
Reattaching to a running turn
GET /threads/:thread_id/runs/stream is the read-only mirror of the POST
stream: a disconnected client — browser reload, network blip, laptop sleep —
rejoins without cancelling anything and without a request body, so it is the one
Agent Protocol stream a stock EventSource can consume. It requires
thread-access read and middleware approval for every recorded route whose
content it discloses: the selected live producer, its anchor, and the
parked/last-run routes. Selection stays fixed while middleware runs. Checkpoint
owners come from runtime-written metadata bound to the exact checkpoint ID,
including verified ancestor routes whose state a later turn may inherit.
Client-written thread metadata cannot establish checkpoint ownership.
A checkpoint without verified provenance, including a legacy checkpoint, fails
closed with 409 thread_route_unknown. Running another turn does not clear
unknown ancestry. Start a fresh thread to establish verified provenance; no
checkpoint or conversation history is deleted automatically.
Reattachment is resumable state, not a resumable event log: there are no
cursors, no Last-Event-ID, and no retention window. Every reconnect
re-snapshots, and every failure heals by reconnecting. The stream always opens
with a single event: state frame — a B4 extension to the Agent Protocol
wire — carrying status, values (channel values), interrupts, and, when a
turn is streaming in this process, the turn's frames so far:
event: state
data: {"status":"busy","live":true,"anchor":"<checkpoint_id>","run_started_at":"<ISO>",
"resume":false,"values":{...},"input":{...},"turn":[...],"interrupts":[]}- Live turn present (
live: true):valuesis the checkpoint atanchor(the instant the run claimed its slot),turnis the turn's coalesced frames so far, and the frame is followed by the live tail — the samechunk/tool_call/tool_result/interrupt/ capability frames thePOSTstream emits — terminated by the turn's owndone.interruptsis always[]here: a live turn is not parked, and during aresumeturnresumeistrueand the client must not applyinputto the transcript. If the in-memory digest overflowed,turnisnullandturn_truncated: trueis present — reconnect for a fresh snapshot. - No live turn (
live: false): the durable path.valuesis the latest checkpoint,interruptscarries the parked prompts with theirvalue, and the stream then emits aretry:hint followed byevent: done{"output":null}before closing. This path works across restarts, replicas, and serverless, because it is checkpoint-backed. Live tail is best-effort and only available on the process holding the run.
Consumers must treat done and detached as end-of-stream. Canceling an
attach body or aborting its request releases the viewer without stopping the
producer. A slow viewer is detached with {"reason":"overflow"} when its bounded
queue fills. Heartbeats respect response backpressure.
Two bounds protect the in-memory hub: the per-thread digest is capped at 2 MiB (on overflow the turn
digest is dropped whole and the snapshot degrades to values-plus-live-tail), and
each thread serves at most 16 concurrent viewers (a viewer beyond the cap
receives event: detached {"reason":"capacity"} and closes). Both are fixed
defaults today; neither the durable snapshot nor cancellation is affected by
reaching them.
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.