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:

bash
b4 dev --port 3001

Requests identify authored routes with the generated <routeId>#<kind> key, such as /research#agent.

Agent Protocol endpoints

Method and pathRequestSuccess
POST /threadsOptional { "metadata": { ... } } body200 thread object
GET /threads/:thread_id200 thread object; 404 when absent
DELETE /threads/:thread_id204; deletes thread metadata, supported checkpoints, and its sandbox sequentially
GET /threads/:thread_id/state200 { config, created_at, metadata, next, parent_config, values }; 404 without a checkpoint
GET /threads/:thread_id/pending_interrupts200 { "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/streamSame run body200 text/event-stream
POST /threads/:thread_id/resumeExact { "resume": [...], "route": "<routeId>#<kind>" } body200 text/event-stream continuation
POST /threads/:thread_id/cancelNo body200 { "thread_id", "status": "interrupted" }; 404 unknown thread; 409 no active run
GET /memory/candidates200 { "candidates": [...] }
POST /memory/candidates/:id/approveNo body200 { "record", "action", "superseded" }
POST /memory/candidates/:id/rejectNo body200 { "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.

bash
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.

text
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.

text
: ping

Interrupt and resume

A permission pause arrives as an interrupt event whose raw JSON data includes the public interruptId and permission details:

text
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:

bash
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:

bash
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:

bash
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:

text
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.