AG-UI and Web Clients
B4.run serves AG-UI for browser clients alongside Agent Protocol. @b4run/ag-ui is the translation library. It maps B4.run stream chunks to AG-UI events, and maps RunAgentInput back to a B4.run-shaped run input. It has no server, transport, or agent runtime of its own.
b4 dev and the production runtime started by b4 start use that adapter to serve an SSE endpoint. This page covers requests, resume, threading, and lifecycle. See Middleware for execution gating and Security Architecture for the service routes that need outer authentication.
The endpointCopy link to section: The endpoint
POST /agui/{routeId}
content-type: application/json
accept: text/event-streamThe URL segment is the URL-encoded <routeId>#<kind> assistant id. For example, /chat#agent becomes /agui/%2Fchat%23agent. The request body is an AG-UI RunAgentInput. B4.run creates the thread if it's new, invokes the same route runtime Agent Protocol uses, and streams translated events back as SSE, or as the HTTP+protobuf binding when accept admits application/vnd.ag-ui.event+proto (see Transport).
B4.run speaks AG-UI protocol 1.0; RUN_STARTED declares protocolVersion: "1.0". A request declaring another 1.x, or no parseable version, is served; a different major is refused (see Envelope validation). @b4run/ag-ui takes @ag-ui/client >=1.0.1 <2.0.0 as an optional peer; the runtime validators moved to @ag-ui/core/schemas.
GET on the same URL reports what the route honors. See Capabilities.
TransportCopy link to section: Transport
accept selects the AG-UI HTTP binding, by AG-UI's own rule: text/event-stream unless the header admits application/vnd.ag-ui.event+proto with a positive quality — named, or through a wildcard range such as */* — in which case the response is content-type: application/vnd.ag-ui.event+proto and each event is a 4-byte unsigned big-endian length followed by exactly that many bytes of one protobuf-encoded event, with no separator between frames. The rule asks only whether protobuf is admitted, never how it ranks against SSE: text/event-stream, */*;q=0.1 selects protobuf too. A client that cannot read protobuf must name text/event-stream; @ag-ui/client and CopilotKit do. A bare curl sends */* and receives protobuf: pass -H 'accept: text/event-stream' to read the stream by eye.
Both bindings carry the same events. The Agent Protocol attach stream (GET /threads/:thread_id/runs/stream, which b4 threads tail reads) is SSE whichever binding the primary client chose. WebSocket is not served.
Envelope validationCopy link to section: Envelope validation
The runtime validates the run envelope itself, so no app has to re-check
B4.run's own wire format. Envelope refusals — the 422s and the
foreign-major 400 — carry B4_E5401 and are told apart by
error.details.code; a body the schema itself rejects is a plain 400.
Unknown top-level keys are tolerated and never echoed; they do not become
route params.
| Status | details.code | When |
|---|---|---|
422 | invalid_envelope | the body is not a JSON object |
422 | invalid_thread_id | threadId is missing, not a string, blank, or longer than 256 characters |
422 | invalid_run_id | runId is missing, not a string, blank, or longer than 256 characters |
400 | unsupported_protocol_version | the request declared an AG-UI protocol major this runtime does not speak |
422 | invalid_state | state is present and is not a JSON object |
422 | client_tools_not_allowed | tools is not an array, or is non-empty on a route that did not opt in |
422 | forwarded_props_not_allowed | forwardedProps is not an object, or carries a key the route does not allow (the message names it) |
Client-supplied tools and forwardedProps are closed by defaultCopy link to section: Client-supplied tools and forwardedProps are closed by default
These two fields are not the app's inputs — they are the caller's attempt to
add to what the route decided. A client that sends tools: [...] on a route
whose tool set the server chose is asking for authority it was not given, and
forwardedProps reaches route code the same way.
B4.run rejects them rather than ignoring them. Silently dropping a field is
indistinguishable, from outside, from honoring it: a client could not tell
whether it had just widened the run. A route that genuinely wants them says so
in b4.config.ts:
server: { agui: { clientTools: ["/chat"], clientForwardedProps: ["/chat"] } }clientForwardedProps can also open single keys, per route or for every
route with "*", so a route can accept a response schema
without accepting anything else a client adds:
server: { agui: { clientForwardedProps: { "/chat": ["responseSchema"] } } }See server.agui for the full key. An empty
tools: [] or forwardedProps: {} — what an AG-UI client sends when it has
nothing to add — is always accepted, so an ordinary client is unaffected.
Opting in to clientTools makes the model able to call the client's tools.
See Client-provided tools for the round trip, the
limits, and what the opt-in does and does not protect against.
Where the check runsCopy link to section: Where the check runs
Before route middleware and before the thread-access policy. The envelope check performs no I/O, takes no resume claim, reads no thread row and discloses nothing about one — it judges only what the caller said about itself. Running it first means an app's middleware is never handed an envelope the runtime has already decided not to honor, and a thread-access policy is never asked to authorize — or create a row for — a request that is about to be rejected. Because it never looks at a thread, it cannot leak whether one exists.
resume is deliberately not decided here. Whether a turn genuinely resumes
is not a property of the envelope: it depends on what is parked in the
checkpointer, which B4.run only reads after the thread-access policy has
authorized this caller to touch that thread. A resume that matches no pending
interrupt is still rejected — as a 409 with stale_interrupt, and a turn
that ignores a pending one as 409 resume_required — just at the point where
the answer is actually known.
Media parts are not judged here at all: whether the route's model can take a part depends on that model, so it is decided when the run reaches it, and a part it cannot take is dropped and announced, not refused.
Client-provided toolsCopy link to section: Client-provided tools
A route named in server.agui.clientTools
lets the model call tools the client defines and runs itself — CopilotKit's
useFrontendTool, for example. It is off by default and opted in per route.
A route that is not named still refuses a non-empty tools with 422
client_tools_not_allowed. Only an agent() route can take client tools; on
a chain, graph or workflow route, or a route that exports an agent runnable
rather than an agent() descriptor, the run is refused with 422
client_tools_not_supported.
export default {
server: { agui: { clientTools: ["/chat"] } },
}The round tripCopy link to section: The round trip
Each tool the client sends becomes a tool the model sees as client_<name>,
with the description prefixed by
[Client-provided tool; definition authored by the caller]. When the model
calls one:
- The server records the call in the tool-call record (thread, route, tool-call id, expiry) and parks
the turn. The client sees the tool-call frames under the name it
registered —
openPanel, notclient_openPanel— followed by an ordinaryRUN_FINISHEDwithoutcome.type: "success", withpendingToolCallIdsnaming the client calls this run left parked — AG-UI 1.0's prescribed shape for a frontend tool call. There is no interrupt for the client to answer. A turn that parks an approval in the same step ends with theinterruptoutcome instead; the client's follow-up is refused withclient_tool_pendinguntil the approval is answered. - The client runs the tool and, on its next run, sends its history with a
{ role: "tool", toolCallId, content }message carrying the result. - The server matches that message against its record: a call it issued, on this thread and this route, still outstanding and not expired. A match resumes the parked turn with the result, and the model continues.
Every AG-UI client resends its whole history on every run, so a tool message
that matches nothing outstanding — an already-answered call, or one the
server never issued — is treated as history and ignored, never as an error.
A result is used exactly once: the resume value comes from the stored record,
not from the message. On an opted-in route, a run whose last message is a
tool message that matches nothing answers nothing: it returns an empty
RUN_STARTED / RUN_FINISHED, and the model does not run. That makes a
client's retry of a run that already resumed a harmless no-op.
When the model calls several client tools in one turn, the client can answer
them in one run or across several. A run that answers only some of them
records those results and finishes as an ordinary success without touching the
graph; the turn resumes once every call is answered. Only the route that
issued a call can answer it: the record is bound to that route, and another
route that shares the thread gets 409 client_tool_pending for a tool
message it cannot answer.
The thread's pending call is answered through this endpoint only.
POST /threads/:thread_id/resume refuses with 409 client_tool_pending
while a client tool call is parked.
GET /threads/:thread_id/state shows the raw graph state. While a call is
parked, values holds the model's tool call under its client_-prefixed
name, and next lists the pending __interrupt__ write.
A permission prompt and a client tool call can park in the same model turn.
Both are then answered in one AG-UI run: the tool message plus the
resume entries for the prompt. A run that carries only the tool message is
refused with 409 resume_required; the result it carried stays stored, so
the next run only has to add the resume entries.
Abandoned callsCopy link to section: Abandoned calls
A client that never answers must not leave the thread stuck, and because the park is invisible it cannot know it is holding one up. The call is abandoned when:
- the client sends a new user message while the call is unanswered, or
- the call has expired. The default lifetime is 10 minutes; set
server.agui.clientToolTtlMsto change it. Expiry is checked when the thread is next used; no timer abandons a call.
Abandoning closes each parked call with a tool result — the one the client
already sent, if it did, otherwise
The client did not return a result for this tool call. — and then runs the
new user message as an ordinary turn. With no new message, the model replies
to the closed calls. A result stored before the call expired is used; one
that arrives after it expired is discarded.
The closing tool results are written straight into the thread's checkpoint. No tool-result frame is streamed for them, and the route's result offloading and stream transformers do not see them.
Any route on the thread can abandon a parked call with a new user message,
including a route that did not issue it or does not take client tools, so the
thread is never a dead end. A permission prompt pending on the same thread
must be answered first. On the route that issued the call, the abandon is
refused with 409 resume_required, as any new turn over a pending prompt
is; on any other route, with 409 client_tool_pending.
Abandon a parked call over AG-UI. While one is parked, the Agent Protocol run
endpoints (POST /threads/:thread_id/runs/stream and /runs/wait) and
/resume refuse with 409 client_tool_pending: a new run there would drop
the park and leave the model's tool call with no result, which the provider
rejects on every later turn.
Repairing a thread after a deployment change
Closing a parked call needs the route that issued it and the store that
recorded it. If a deployment removes or renames that route, or removes the
configured clientToolStore, while a call is parked, requests on the thread
fail closed: 409 client_tool_close_failed for the route, 503
client_tool_store_unavailable for the store. Restore the route or the store
so the call can be abandoned, or start a new thread.
The tool-call recordCopy link to section: The tool-call record
When some route is listed in server.agui.clientTools, or
server.agui.clientToolStore is set, every tool call the model makes on an
AG-UI run is recorded, not only client ones, on every route. A server tool's
row is identity only: thread, the route that issued it, run, tool name, when it was issued and
when it returned or threw. No result text is stored; the checkpoint holds the
tool message. A tool whose permission gate parks the turn is issued but not
settled until it actually runs. The task call that launches a subagent is
recorded like any other tool, open while the subagent is parked; the
subagent's own tool calls are recorded against the parent thread, each naming the child route that issued it and the task call that launched the subagent (parentToolCallId; null for a root call).
A default .b4/client-tool-calls.sqlite left over after every route dropped
its opt-in is still opened, so calls parked back then can be closed, but it
records no server calls; boot logs one warning saying so. Delete the file to
drop it.
The record is what makes the rule above a guarantee rather than a habit: a
role: "tool" message is used only when it names an open client call this
server issued on this thread; one naming a server call, an already-closed
call, or nothing the server knows is history. It also means the server will
not run a tool call it cannot account for: a row is written before the tool
body runs, and if that write fails, the tool call fails like any other tool
error. With a store that is unavailable, every server tool call on every
AG-UI route fails until it is back. Each server tool call costs two store
writes on such an app.
Server rows join the same sweep as client ones. When a turn settles, at most
once an hour per store, the runtime deletes rows of every thread closed longer
ago than
server.agui.clientToolRetentionMs (7 days
by default, never less than clientToolTtlMs): a server row once it is that
long past settling, a client row once it is that long past its answer, void
or expiry. Open rows — an unsettled server call, an outstanding unexpired
client call — are never deleted. b4 client-tools prune runs the same pass on
demand.
An app with no store records nothing.
LimitsCopy link to section: Limits
These are starting bounds, not measured ones. They cap context budget; a
definition that breaks one is refused with a 422 before the run starts.
| Bound | Value |
|---|---|
| Tools per run | 32 |
| Tool name | matches ^[a-zA-Z0-9_-]{1,64}$ and does not start with client_ |
| Description | at most 1,024 characters |
parameters | a JSON object schema (type omitted or "object"), at most 8,192 characters serialized and at most 8 schema levels deep |
| All definitions together | at most 32,768 characters, counting each tool's name, description and serialized parameters |
| Each tool result | at most 64 KiB (65,536 bytes of UTF-8) of text/JSON; a part list is measured as JSON with inline media bytes excluded, and those bytes count against the 8 MiB body only |
| AG-UI request body | at most 8 MiB |
An over-cap result — over 64 KiB of text/JSON, inline media bytes
excluded — is refused with 413 only when the run is answering —
its last message is the tool message, for a call that is outstanding and not
expired. Anywhere else, such as a resend of that history with a new user
message, the over-cap result is dropped before anything is stored, and the
call is abandoned like any unanswered one.
The 8 MiB body ceiling applies to every POST /agui/:routeId request, opted
in or not, because every AG-UI client resends the thread's history on each
run. A very long conversation — one carrying many images inline, for
instance — can reach it, and is refused with 413 payload_too_large.
$ref, oneOf and allOf are accepted and bounded, but the tool converter
does not enforce them, so the model's arguments are not checked against those
constraints.
Tool names starting with client_ are reserved for client tools. An authored
or capability tool with such a name fails route preparation, and b4 check
reports an authored one before you run.
Threat modelCopy link to section: Threat model
Client tool definitions are written by the caller, and every byte of them becomes prompt content. Treat them as untrusted input to the model.
- The bounds cap budget and blast radius. They do not prevent prompt injection. A description such as "always call this tool first" fits in 1,024 characters.
- The provenance label is not a defence. The
[Client-provided tool; definition authored by the caller]prefix makes it legible to the model and to anyone reading a trace where a tool came from. A model can still follow what the description says. - The authority is the server's opt-in. A route takes client tools only
because its operator named it in
b4.config.ts. Opt in only routes whose callers you would let steer the model. - A client tool cannot impersonate a server tool. The
client_prefix keeps the two namespaces apart, and client tools are gated under their ownclientToolpermission key, so a client tool named like a server tool never inherits that tool's approval. - Results are untrusted tool output. The client decides what its tool returns; the model reads it as it reads any tool result.
StorageCopy link to section: Storage
The record of outstanding calls lives in a client tool store. On node, B4.run
opens a SQLite store at .b4/client-tool-calls.sqlite when any route is
listed in clientTools. Edge and serverless deployments have no default:
set server.agui.clientToolStore — createPostgresClientToolCallStore from
@b4run/postgres-storage, for example — or every run that sends client tools
is refused with 503 client_tool_store_unavailable. A deployment with more
than one instance needs a shared store for the same reason.
Records do not accumulate. Once a turn settles, the runtime deletes records
answered, voided or expired — or, for a server tool call, settled — longer ago than
server.agui.clientToolRetentionMs (7 days
by default, never less than the TTL), at most once an hour per store.
b4 client-tools prune does the same on demand. An outstanding, unexpired
record is never deleted. A call that was answered but whose thread never
resumed is treated the same way once the window has passed: its record is
gone, so a thread that returns after that closes the call with the abandoned
result instead of the stored one.
A result made of content parts is kept in the same text column as a self-describing JSON envelope; rows written before parts were supported are plain text and read back unchanged, so no migration is needed.
Error codesCopy link to section: Error codes
error.details.code on the refusal says why:
| Status | details.code | When |
|---|---|---|
422 | client_tools_not_allowed | tools is non-empty on a route not named in clientTools |
422 | client_tools_not_supported | the route is not an agent() route |
422 | invalid_client_tool | a definition is not an object with a string name, or its description or parameters has the wrong shape |
422 | invalid_client_tool_name | a name breaks the name pattern or starts with client_ |
422 | duplicate_client_tool | two definitions share a name |
422 | client_tool_too_large | a description or parameters is over its limit |
422 | client_tool_too_deep | parameters nests more than 8 schema levels |
422 | client_tool_budget_exceeded | more than 32 tools, or more than 32,768 characters of definitions |
413 | client_tool_result_too_large | a run whose last message is a tool message answers an outstanding, unexpired call with a result over 64 KiB of text/JSON (inline media bytes excluded); nothing is recorded |
503 | client_tool_store_unavailable | no client tool store is configured |
409 | client_tool_pending | a client tool call is parked and this request can neither answer nor abandon it |
409 | client_tool_close_failed | the parked calls could not be closed, for example because the route that issued them cannot be resolved |
409 | resume_required | a permission prompt must be answered before the parked calls can be abandoned |
500 | client_tool_close_incomplete | closing the parked calls did not complete; the new turn did not run |
The 422 refusals carry B4_E5401.
Multimodal contentCopy link to section: Multimodal content
A 1.0 message's content may be a list of parts: text, and image, audio, video or document parts whose source is inline bytes (data, base64 with a mimeType), a URL (url), or a handle the model provider issued (file). B4.run carries them to the route's model as LangChain content blocks: inline bytes as bytes, a URL as a URL — B4.run never fetches it; the provider does — and a provider handle as that provider's file id, when the route runs on the provider that minted it. A document is a PDF: a part whose mimeType is not application/pdf is not guessed at.
Which parts a route's model takes is read from the model's LangChain profile, then narrowed by what each provider's LangChain converter can actually send. Images are carried inline and by URL where the profile allows; documents (application/pdf only) likewise, except that a PDF by URL never reaches an OpenAI model; audio and video only where the profile says so. A model with no profile — every ollama and mistral model, and an id the provider package does not know — takes images only (ollama inline only, since its converter cannot pass a URL through), plus the file handles below. Provider file handles are carried only on OpenAI (documents) and Anthropic (images and documents). Media in a tool result reaches the model only where both the profile and the provider allow it, which is never on OpenAI: B4.run uses its Chat Completions path, which sends a tool message as text.
What the model cannot use is dropped, never refused, as the protocol requires: the run continues with the parts that remain, and the drop is announced for the developer — in the server log, one warning per user turn or tool result that lost parts, and on the stream as a CUSTOM event named b4.content_parts_dropped. Drops inside a subagent are logged only; the adapter does not map them to a CUSTOM event. Its value is { provider?, model?, toolCallId?, parts }: provider and model name the route's model (omitted for a route that exports its own runnable), toolCallId is present when the parts came from a tool result, and each entry of parts is { index, type, source?, reason }, where index is the part's position in the message and reason is one of:
reason | The part was dropped because |
|---|---|
modality_unsupported | the model does not take this kind of media, or not as inline bytes |
url_source_unsupported | the model or its provider cannot take this kind of media by URL |
file_source_unsupported | the provider cannot take a file handle for this kind of media |
foreign_file_provider | the handle names a different provider than the route's model |
document_not_pdf | a document part's mimeType is not application/pdf |
tool_result_media_unsupported | the media came from a tool result, and the model or provider does not take media in a tool message |
malformed_part | the entry is not a valid content part (only reachable outside /agui, whose schema check refuses it with 400 first) |
Inline parts count against the 8 MiB body ceiling; there is no per-part limit. The capability document says which parts a route's model accepts as inline bytes (multimodal.input), so a client can hide an attach control ahead of time; the server log warning for a drop ends by pointing at GET /agui/<route> for the same reason. URL support varies by provider and is not in the document; a URL part the model cannot take is reported by the drop event and warning.
Tools can return parts too: a tool's result may be an array of the same parts (or { result: parts, state }), and it travels as TOOL_CALL_RESULT.content. The model sees the media only as described above; the UI sees every part regardless. A client-provided tool may answer with parts as well: a role: "tool" message's parts are stored as sent and replayed to the model under the same tool-result rules, so media reach the model only where the profile and provider allow it — never on OpenAI's Chat Completions path — and the UI gets every part on TOOL_CALL_RESULT. The one exception is a call closed by the abandon path, such as a new user message sent instead of resuming: the stored result is replayed as its text only, and the server logs a warning once the call is closed.
For a client that attaches images, renders media parts, and shows the dropped-part notices, see Attach an image in the flight-planner web UI recipe.
role: "reasoning" and role: "activity" history is dropped on the way in: it is the client's stored artefact of an earlier turn, not something the assistant said.
CapabilitiesCopy link to section: Capabilities
GET /agui/{routeId} answers with the route's AG-UI AgentCapabilities. Each claim is computed by the same check that POST enforces it with, so what a route advertises and what it honors cannot drift apart:
| Field | Comes from |
|---|---|
tools.clientProvided | the route is named in server.agui.clientTools, is an agent() route, and a client tool store is configured — every check POST applies to a non-empty tools |
tools.supported, tools.parallelCalls | the route is an agent() route; its tool calls run in parallel |
output.structuredOutput | the route allows the responseSchema key in clientForwardedProps, and its provider can bind the schema (the check behind response_schema_not_supported) |
humanInTheLoop.interrupts | the route is an agent route, the only kind that can park |
humanInTheLoop.approvals | an agent() route under an interactive permissions mode, on a runtime that can answer the approval: approvals.grants: "required" with no grant store refuses every resume, so it reports false. It means the route can pause for approval; whether a turn does depends on its tools and the grants already recorded |
humanInTheLoop.approveWithEdits | always false: a resume answers once, always or deny |
transport.streaming, transport.httpBinary | always true: the handler serves SSE, and protobuf when accept asks (see Transport) |
reasoning | { supported: true, streaming: true, encrypted: false } when the route's reasoning config makes reasoning text stream (an OpenAI reasoning summary or an Anthropic thinking budget), framed as REASONING_* events; redacted or encrypted material is dropped, never carried. Otherwise { supported: false }: effort alone, no reasoning, a config the provider rejects, a chain, graph or workflow route, or a raw runnable. See Reasoning |
multimodal.input.image, .pdf, .audio, .video | the route model's LangChain profile with the provider's converter limits — the same judgment that keeps or drops each part at run time. image and pdf describe the inline data source; URL support varies by provider (for example Gemini, Ollama and OpenRouter drop image URLs; OpenAI drops PDF URLs), and a dropped URL part is reported in the run's dropped-parts warning |
multimodal.input.file | always false: AG-UI's flag for arbitrary uploads the four part types do not cover; a provider file handle is a source, not that |
multimodal.output | always false: AG-UI 1.0 defines no image or audio output carrier |
state.snapshots, state.deltas | always false: no state snapshot or state delta event is emitted |
state.persistentState | true for an agent() route, whose graph runs under the boot checkpointer; false for a chain, graph or workflow route, invoked once without one |
multiAgent | { supported: true, delegation, handoffs, subagents: [{ name, description }] } from the route's dispatchable subagents: the same registry the task tool is built from (explicit registrations and subagents/ convention routes, minus any the delegation policy denies outright), each with its model-facing description. delegation is true, because what task starts streams as SUBAGENT_STARTED, SUBAGENT_FINISHED and SUBAGENT_ERROR with subagentRunId attribution; handoffs is false, because B4.run never transfers the conversation. Omitted when the route has no subagents, and for every route that is not an agent() route |
A chain, graph or workflow route reports false for client tools, tool calls, structured output, interrupts, approvals and persistent state. AG-UI treats an omitted field as unknown, so a claim B4.run cannot settle is left out rather than guessed. An agent route that exports a raw runnable omits approvals, parallel calls and state.persistentState, because its own code decides them. multimodal is omitted for a raw runnable, a chain, graph or workflow route, and a route whose provider package is missing or cannot be read; the rest of the document stands, and a run on that route still reports the real error. A runtime that cannot load route modules at all (no node fallbacks and no static manifest) answers only transport, reasoning, state.snapshots and state.deltas for an agent route — the claims that hold for every route. A route whose module fails to load is an error, as it is for a run. state.memory is omitted, because whether an app wires long-term memory is tool wiring the endpoint cannot see; transport.websocket, transport.resumable and transport.pushNotifications are omitted because nothing in the runtime serves them.
Route middleware runs first, as it does for a run, but sees method: "GET", no body and no route params, so middleware that authorizes on the request body refuses it. An unknown route is 404. The response is Cache-Control: no-store, because middleware decides per caller who may see it.
The stock HttpAgent does not read this endpoint. B4HttpAgent from @b4run/ag-ui/client is an HttpAgent whose getCapabilities() sends GET to the URL it runs against, with the same headers. CopilotKit's runtime calls getCapabilities() on each registered agent when it answers /info:
import { B4HttpAgent } from "@b4run/ag-ui/client"
const agent = new B4HttpAgent({ url: `${b4Url}/agui/${encodeURIComponent("/chat#agent")}` })
await agent.getCapabilities()getCapabilities() throws on any non-2xx answer, including a 404 from a B4.run release that predates the endpoint, and after 10 seconds without an answer, instead of reporting nothing declared. CopilotKit catches the error and reports the agent without capabilities. @ag-ui/client is an optional peer dependency of @b4run/ag-ui; install it alongside if you import this subpath.
Consuming it from a web UICopy link to section: Consuming it from a web UI
examples/chat/web is the reference client. Its CopilotKit runtime registers a B4HttpAgent pointed at B4.run's encoded route URL. The browser talks to the Next.js runtime route, and only the B4.run server holds model credentials:
browser
-> CopilotKit runtime
-> B4HttpAgent -> POST /agui/%2Fchat%23agent
-> B4.run route runtime
-> AG-UI event streamCopilotKit's sidebar uses the literal agent id default when none is supplied, so the example registers the B4.run route under that key. See examples/chat/web/README.md for the complete setup and smoke checklist.
The client shows what the agent did by wrapping a stock <CopilotChat> in the activity connector from @b4run/ag-ui/react/copilotkit:
import "@b4run/ag-ui/styles.css"
import { B4Activity, useB4ChatSlots } from "@b4run/ag-ui/react/copilotkit"
import { CopilotChat, CopilotKit } from "@copilotkit/react-core/v2"
function Chat() {
return <CopilotChat {...useB4ChatSlots()} />
}
export default function Page() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" useSingleEndpoint={false}>
<B4Activity>
<Chat />
</B4Activity>
</CopilotKit>
)
}Each turn renders as one TurnActivity (the summary line and the step list, nested for subagents) in place of CopilotKit's generic tool rows. Reasoning the turn's activity already shows gets no separate reasoning row; a turn that only reasoned and answered keeps CopilotKit's. A parked interrupt renders as an ApprovalCard in the chat. The labels, hiddenTools and renderStep props on B4Activity reword, hide or re-render steps per tool. Host UI outside the chat reads the same turns through useB4ActivityContext: the navlog example drives its map and sheet from it, and mounts <CopilotChat> with useB4ChatSlots inside B4Activity. <CopilotSidebar> takes the same messageView slots; the chat example uses it that way. A host with its own transcript skips the slots and uses useB4Turns with TurnActivity and ApprovalCard from @b4run/ag-ui/react directly.
Restoring a conversation after a reloadCopy link to section: Restoring a conversation after a reload
CopilotKit's chat calls connect when it mounts. Its default in-memory runner only remembers runs this process served, so give the runtime a runner from createB4AgentRunner and the chat restores from B4's checkpoints instead:
import { AsyncLocalStorage } from "node:async_hooks"
import { B4HttpAgent } from "@b4run/ag-ui/client"
import { createB4AgentRunner, forwardIdentity } from "@b4run/ag-ui/copilotkit-runtime"
import { CopilotRuntime, createCopilotRuntimeHandler, InMemoryAgentRunner } from "@copilotkit/runtime/v2"
const b4Url = process.env.B4_SERVER_URL ?? "http://127.0.0.1:3001"
// The caller of the request being served. The agent and runner are built once
// per process, so the identity travels by context, not by constructor.
const caller = new AsyncLocalStorage<{ userId: string }>()
// Every B4 request, run and restore alike, carries the current caller and the
// server-to-server secret. Both headers are stripped from what the browser sent
// first, so a browser can't claim to be someone else.
const callerFetch = forwardIdentity({
headers: ["b4-user-id", "b4-internal-token"],
resolve: () => {
const userId = caller.getStore()?.userId
if (userId === undefined) return undefined
return { "b4-user-id": userId, "b4-internal-token": process.env.B4_INTERNAL_TOKEN ?? "" }
},
})
const agent = new B4HttpAgent({ url: `${b4Url}/agui/${encodeURIComponent("/chat#agent")}`, fetch: callerFetch })
const runtime = new CopilotRuntime({
agents: { default: agent },
// CopilotKit's own runner class goes in: @b4run/ag-ui never imports @copilotkit/runtime.
runner: createB4AgentRunner(InMemoryAgentRunner, { url: b4Url, fetch: callerFetch }),
})
const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" })
async function serve(request: Request): Promise<Response> {
const userId = await authenticate(request) // your session lookup
return caller.run({ userId }, () => handler(request))
}
// CopilotKit's handler serves `GET /info` as well as the POST routes.
export const GET = serve
export const POST = serveThe runner reads GET /threads/:thread_id/events, so the thread-access policy and route middleware that guard the run guard the restore too. While a run is live in this process, the restore drops that run's unfinished turn from the replay and continues with the live run's events; a resumed run continues from the parked run it answers.
On the B4.run side, the app's src/auth.ts turns those headers back into the principal. It compares the secret in constant time and trusts the user id only beside it, so a request that didn't come through your route is anonymous:
import { timingSafeEqual } from "node:crypto"
import { defineAuth } from "@b4run/sdk"
const same = (a: string | undefined, b: string) =>
a !== undefined && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b))
export default defineAuth({
authenticate: ({ headers }) => {
const token = process.env.B4_INTERNAL_TOKEN
if (!token || !same(headers["b4-internal-token"], token)) return undefined
const id = headers["b4-user-id"]
return id ? { id } : undefined
},
})Avoid x-* names for the secret on a LangSmith deployment, which copies x-* request headers into the stored thread config. The navlog example uses this same pattern with its visitor id.
The read carries only the headers your fetch adds, never the browser's. That fetch must carry the current caller's identity, derived per request as above (the navlog example does the same with its visitor id), never a fixed service credential: a fixed credential lets any browser restore any thread whose id it knows. A read the thread gate refuses (404) restores as an empty chat, with no error.
React and @copilotkit/react-core >=1.76.0 are optional peer dependencies, so a server-only consumer of the root or /sse entry installs nothing extra; only the /react/copilotkit entry imports CopilotKit. The server-side /copilotkit-runtime entry, which only a CopilotKit runtime route imports, never imports @copilotkit/runtime: the route passes CopilotKit's InMemoryAgentRunner class to createB4AgentRunner, so the runner extends the copy the route resolves. Its rxjs is a regular dependency of the package. 1.76.0 is the first @copilotkit/react-core whose bundled AG-UI client speaks 1.0, the protocol B4.run serves; earlier releases resolve a pre-1.0 @ag-ui/* (0.0.59 on 1.70–1.75), and npm 7+ rejects the unmet peer with ERESOLVE where pnpm only warns. The /react subpath also exports the kit's building blocks (Step, StepGroup, PlanStep, ReasoningStep, SubagentStep, Disclosure, StepIcon) for a client that presents the activities its own way. The reducers live in @b4run/ag-ui/view, which has no React dependency: reduceTurns folds a whole thread into turns, steps and approvals, for any framework.
For the full application wiring (a thread rail, suggestions, generic tool cards, and permissions), see the flight-planner web UI recipe and its examples/navlog/web implementation.
An Angular clientCopy link to section: An Angular client
The same activity kit ships for Angular in the same package: standalone components at @b4run/ag-ui/angular that render the same DOM contract as the React kit, styled by the same sheet. Angular 22 or later is required; @angular/core, @angular/common, @angular/platform-browser and @copilotkit/angular are optional peer dependencies of @b4run/ag-ui, so install the ones your app uses:
npm install @b4run/ag-uiLoad the stylesheet once, in angular.json's styles or a global stylesheet:
"styles": ["@b4run/ag-ui/styles.css", "src/styles.css"]With CopilotKit's Angular chat (@copilotkit/angular 0.5.3 or later), provide the activity store next to the chat and hand <copilot-chat> the kit's assistant message, reasoning message and approvals:
import { Component, inject } from "@angular/core"
import { CopilotChat } from "@copilotkit/angular"
import {
B4ActivityApprovalsComponent,
B4ActivityAssistantMessageComponent,
B4ActivityReasoningMessageComponent,
B4ActivityStore,
provideB4Activity,
} from "@b4run/ag-ui/angular/copilotkit"
@Component({
selector: "app-chat",
imports: [CopilotChat],
providers: [provideB4Activity({ agentId: "default" })],
template: `<copilot-chat agentId="default"
[assistantMessageComponent]="assistant"
[reasoningMessageComponent]="reasoning"
[messageViewChildrenComponent]="approvals" />`,
})
export class ChatComponent {
readonly activity = inject(B4ActivityStore) // created with the chat, so it sees every event
readonly assistant = B4ActivityAssistantMessageComponent
readonly reasoning = B4ActivityReasoningMessageComponent
readonly approvals = B4ActivityApprovalsComponent
}Each assistant message renders its text through CopilotKit's own assistant message, then the turn's activity once per turn. Reasoning the turn's activity already shows gets no separate reasoning row; a turn that only reasoned and answered keeps CopilotKit's. Each parked interrupt renders as an approval card that resolves or cancels it through CopilotKit.
Without CopilotKit — your own transcript, or another Angular chat framework that renders a slot per message — @b4run/ag-ui/angular/events folds the thread's AG-UI events (an RxJS Observable<BaseEvent>, or anything with the same subscribe) into turns: provideB4Turns provides the store, <b4-message-activity> renders a turn's activity on its first assistant message in your transcript, and <b4-approvals> renders the parked interrupts' approval cards for you to resume.
The events source is whatever already carries the run's AG-UI events: an @ag-ui/client agent's event stream, or the event observable your chat framework exposes. [messages] takes AG-UI Messages, or your host's own message type as long as each message has an id, a role, and its tool calls either as toolCalls: { id }[] or as toolCallIds: string[].
B4.run resumes a parked thread only when every parked interrupt is answered at once, and an approval may carry a single-use grant to echo. toResumeEntries(decisions, interrupts) from @b4run/ag-ui/view builds the run's resume from the cards' decisions: it waits until every parked interrupt is decided, maps once and always to a resolved entry and deny to a cancelled one, and puts each grant at metadata.grant.
import { Component, inject, input } from "@angular/core"
import {
ApprovalsComponent,
type B4ApprovalDecision,
B4TurnsStore,
MessageActivityComponent,
provideB4Turns,
} from "@b4run/ag-ui/angular/events"
import { pendingApprovals, toResumeEntries } from "@b4run/ag-ui/view"
// ThreadEvents: your service that owns the thread's AG-UI event stream and starts runs.
@Component({
selector: "app-chat",
imports: [MessageActivityComponent, ApprovalsComponent],
providers: [provideB4Turns(() => ({ events$: inject(ThreadEvents).events$ }))],
template: `
@for (message of messages(); track message.id) {
<app-message [message]="message" />
<b4-message-activity [messageId]="message.id" [messages]="messages()" />
}
<b4-approvals [resume]="resume" />
`,
})
export class ChatComponent {
// Your messages: AG-UI `Message`s, or { id, role, toolCallIds } from your chat framework.
readonly messages = input.required<readonly { id: string; role: string; toolCallIds?: string[] }[]>()
readonly #thread = inject(ThreadEvents)
readonly #turns = inject(B4TurnsStore)
#decisions: B4ApprovalDecision[] = []
readonly resume = async (decision: B4ApprovalDecision) => {
this.#decisions = [...this.#decisions, decision]
const parked = pendingApprovals(this.#turns.turns(), this.#turns.labels).map((c) => c.approval)
const result = toResumeEntries(this.#decisions, parked)
if (!result.ok) return // other interrupts still need a decision
this.#decisions = []
await this.#thread.run({ resume: result.entries })
}
}The Angular entries ship in Angular's partial compilation format, which your Angular CLI build links, so import them from an Angular application, not from Node. See @b4run/ag-ui reference for every export.
Adapter APICopy link to section: Adapter API
The root package exports the transport-independent mapping surface:
import { fromRunAgentInput, toAguiEvents } from "@b4run/ag-ui"
const b4Input = fromRunAgentInput(runAgentInput)
for await (const event of toAguiEvents(b4Chunks, { threadId, runId })) {
// Send `event` through the transport chosen by the application.
}Transport helpers live behind subpaths. For example, an HTTP server can encode an event like this:
import { agUiContentType, encodeAgUiEvent } from "@b4run/ag-ui/sse"
response.writeHead(200, { "content-type": agUiContentType(request.headers.accept) })
response.write(encodeAgUiEvent(event, request.headers.accept))Outbound eventsCopy link to section: Outbound events
toAguiEvents(chunks, context) is a stateful async generator. It frames B4.run's implicit assistant text and keeps upstream tool-call ids so results can be correlated:
| B4.run chunk | AG-UI event(s) |
|---|---|
| stream start | RUN_STARTED |
token | TEXT_MESSAGE_START once, then TEXT_MESSAGE_CONTENT per delta |
tool_call | close open text, then TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END; TOOL_CALL_START carries parentMessageId, the id of the model message that announced the call (see Tool calls and their message) |
tool_result | close open text, then TOOL_CALL_RESULT |
tool_result for a tool that threw | TOOL_CALL_RESULT under the same toolCallId, with readable error content, failure metadata["b4.step"], and an error string; a v1 failed CUSTOM step also carries the failure (see Step events) |
step (server tool execution) | CUSTOM { name: "b4.step", value: B4ToolStep }, with toolCallId and v: 1; actual execution starts after approval/constraint gates allow it; a child's step is tagged subagentRunId |
tool_call and tool_result for a writeTodos call whose plan activity was emitted | none (the activity is the whole presentation) |
plan_update | replacement ACTIVITY_SNAPSHOT with activity type b4.plan — b4:plan:${runId} for the root agent, b4:plan:${call_id} tagged subagentRunId for a subagent's plan |
subagent.start | SUBAGENT_STARTED { subagentRunId: call_id, name, description?, parentToolCallId: call_id, parentSubagentRunId? } |
the child's token/reasoning/message_end/tool_call/tool_call_args/tool_result/plan_update chunks | the same events the root chunk produces, each tagged subagentRunId: call_id |
subagent.end with final_message / with error | SUBAGENT_FINISHED { result, outcome: success } / SUBAGENT_ERROR { message } |
| a subagent still open at an interrupt, a cancel, or a stream that ended | SUBAGENT_FINISHED { outcome: suspended, interruptIds? } / SUBAGENT_ERROR { code: "cancelled" } / SUBAGENT_ERROR { code: "unterminated" }, before the terminal event |
reasoning (per model invocation, alongside its tokens) | REASONING_START and REASONING_MESSAGE_START { role: "reasoning" } once, then REASONING_MESSAGE_CONTENT per delta; both closed at the invocation's message_end or the next tool or terminal boundary |
usage (one per model call, root or subagent) | none on its own; aggregated into usage on the terminal event |
interrupt | terminal RUN_FINISHED with outcome.type: "interrupt"; each interrupt's approval grant is at metadata.grant |
done | terminal RUN_FINISHED with outcome.type: "success"; when this run left client-provided tool calls parked, outcome.pendingToolCallIds names them; a null route result is omitted, never null |
POST /threads/:id/cancel or a server shutdown during the run | terminal RUN_FINISHED with outcome.type: "cancelled" |
| upstream error | terminal RUN_ERROR |
Tool calls and their messageCopy link to section: Tool calls and their message
Every TOOL_CALL_START carries parentMessageId: the id of the model message that announced the call. When that message also streamed text, it is the id its TEXT_MESSAGE_* events used, so a client that builds its message list from events keeps the text and the calls in one assistant message; when the model only called tools, no TEXT_MESSAGE_START names the id, and the client creates an assistant message for the calls. A subagent's call names the subagent's own message, tagged with its subagentRunId. A thread restored with GET /threads/:id/events files every call under the same message as the live stream did. A call re-presented when a parked run resumes names a fresh id; a client that already holds the call keeps it where it was.
Token usageCopy link to section: Token usage
Every terminal event — RUN_FINISHED with a success, interrupt or cancelled outcome, and RUN_ERROR — carries usage: TokenUsage[] when at least one model call in the run reported token counts, and omits the key otherwise (never []). There is one entry per provider and model, so a coordinator on gpt-5-mini with a subagent on gpt-5-nano reports two entries; sum them for a run total. Counts follow the protocol's accounting: inputTokens and outputTokens are inclusive totals, and cachedInputTokens, cacheWriteInputTokens and reasoningTokens are parts of them. A count the provider did not return is absent, not zero. A cancelled or failed run reports what it accrued before the stop; a resumed run reports only its own calls.
The same usage chunk reaches the Agent Protocol stream as event: usage with the provider's raw LangChain usage_metadata; it has no typed API there.
ReasoningCopy link to section: Reasoning
When a route's reasoning asks the provider to show its thinking (OpenAI summary, Anthropic budgetTokens), each model invocation's reasoning is framed as one span and one role: "reasoning" message with their own ids, distinct from the invocation's assistant text. Reasoning and text of one invocation may interleave; both close when the invocation ends, and anything still open at a tool call, an interrupt, cancellation or an error is closed before the terminal event, as 1.0 requires. Redacted or encrypted reasoning is dropped, never carried, so reasoning.encrypted is false.
GET /agui/{routeId} advertises reasoning from the same config the model is built with: { supported: true, streaming: true, encrypted: false } when the route streams reasoning, and { supported: false } otherwise. A route with only effort set reasons but streams nothing, and advertises { supported: false }.
Activity snapshotsCopy link to section: Activity snapshots
Activities use standard AG-UI framing and complete replacement snapshots. The one activity B4.run emits is the plan: activity type b4.plan, content holding only the complete todo list, and a stable message id per owner — b4:plan:${runId} for the root agent and b4:plan:${call_id} for a subagent's own plan, which also carries that subagent's subagentRunId. Todo status is one of pending, in_progress, or completed. B4.run emits no seeded-plan activity at run start; the first snapshot follows a valid plan_update. Every event has replace: true, so later snapshots replace the same stable activity message. B4.run emits no activity deltas.
Unknown capability chunks close any open text message and are then ignored. Activities are informational. Only the standard interrupt UI resolves or cancels permission requests.
SubagentsCopy link to section: Subagents
A subagent is presented the way AG-UI 1.0 defines it, not as an activity. When the task tool starts a child, the adapter emits SUBAGENT_STARTED with subagentRunId set to the task call's own tool-call id (parentToolCallId names that call; a nested child also carries parentSubagentRunId), the child's declared name and description. Everything the child then does — its assistant text, reasoning, tool calls and results, its plan snapshot — goes on the wire as the ordinary AG-UI events for those things, each tagged with that subagentRunId, so a client can show the child's full transcript or fold it away. The child's final_message ends it as SUBAGENT_FINISHED { result, outcome: { type: "success" } }; a thrown child ends it as SUBAGENT_ERROR { message }. The task call itself is an ordinary tool call with its own frames and result.
Every announced invocation closes before the run ends, as 1.0 requires. A permission interrupt raised inside a child suspends it: SUBAGENT_FINISHED { outcome: { type: "suspended", interruptIds } } names the interrupts it raised, the interrupt itself carries the child's subagentRunId, and a parent suspended only because its child did carries no interruptIds. The resumed run announces the same subagentRunId again — the id is the tool-call id, which a resume replays unchanged. A cancel closes an open child with SUBAGENT_ERROR { code: "cancelled" }; a stream that ends without the child's end closes it with code: "unterminated". RUN_ERROR abandons open children, as the protocol says it does. A chunk for a child that was never announced in this run is dropped.
Root events never carry subagentRunId, and RUN_STARTED, RUN_FINISHED and RUN_ERROR never do. GET /agui/{routeId} advertises multiAgent: { supported: true, delegation: true, handoffs: false, subagents: [{ name, description }] } from the same subagent registry the task tool dispatches from, and omits the section for a route with no subagents.
Step eventsCopy link to section: Step events
Every identifiable server tool call emits a versioned CUSTOM event named b4.step when execution starts (status: "running"), including tools without a display. A tool's display supplies its icon, running/done/failed labels and sources. A call waiting for permission keeps its display on the approval interrupt; the runtime emits the execution-start event only after every approval and argument constraint allows the body to run. A denied call has status: "denied", with no success label or sources, and no execution start time if it never ran.
Settled updates and the final TOOL_CALL_RESULT.metadata["b4.step"] carry the outcome. Failures also carry error: { message, code? } inside the step and readable error text in the result, so clients and models both see the failure. Every CUSTOM step names its call in value.toolCallId; metadata and persisted steps omit that id because their carrier names it. A subagent's step carries subagentRunId.
The b4.step contract
The step is a versioned contract owned by @b4run/sdk (B4ToolStep). A v1 value is camelCase and carries v: 1. It is what B4.run's clients read and what other producers, such as a provider adapter, should write. B4.run emits and persists this v1 shape, with epoch-millisecond times and stable source ids. readToolStep also reads the legacy CUSTOM and persisted shapes, including ISO timestamps, so existing stored threads remain readable. START and END carry the current step when one already exists. They are streamed immediately even when execution has not started; the mapper does not fabricate execution timing or wait for approval before announcing the call. RESULT carries the final persisted step. The contract defines four carriers:
| Carrier | Key |
|---|---|
Tool-call metadata on TOOL_CALL_START and TOOL_CALL_END (merges onto the call), and on TOOL_CALL_RESULT (lands on the tool message) | "b4.step" |
CUSTOM event, with value.toolCallId required | b4.step |
Assistant-message metadata on TEXT_MESSAGE_START and TEXT_MESSAGE_END: citations, uncited sources and attribution for the final answer. Read it with readAnswerAnnotations from @b4run/sdk, a public reader; B4.run's own chat components do not show it yet | "b4.answer" |
LangChain persistence on a ToolMessage | additional_kwargs.b4_step |
| Field | Meaning |
|---|---|
v | Always 1. |
toolCallId | The call the step belongs to. Required on the CUSTOM event only. |
kind | "server" for a tool your agent runs, "provider" for a tool the model provider runs. |
builtin, provider | For provider tools, an open string such as web_search or code_execution, and the provider name. |
status | running, completed, failed, denied or cancelled. |
label, icon | Plain text of at most 120 code points, and an icon name. |
queries, sources | Search strings, and the files, URLs, documents or records the call drew on. Each source has a stable id, a kind and a title. |
startedAt, settledAt | Epoch milliseconds on the producer's clock. |
parentToolCallId | The call that started this one. |
error | { message, code? }. Present if and only if status is failed. |
decision | How a permission gate was answered: once, always or deny. |
A client merges steps with mergeToolStep, reading each new value with readToolStepUpdate. Status follows these rules:
failedwins and stays. A failed step always has anerror, which defaults to{ message: "Tool call did not finish" }.completed,deniedandcancelledsettle a step that has not failed.runningonly lifts a step that is still open.- A step from a
TOOL_CALL_RESULTbeats one from the call. Adeniedorcancelledstep from either replaces acompletedone. - A
TOOL_CALL_RESULTwith no step of its own completes a running step and clears its running label; the sources stay. - A result flagged as an error fails the step, even one a call-side step already called
completed. A result is flagged as an error when theTOOL_CALL_RESULTcarries a top-levelerrorstring (not part of the AG-UI schema, which tolerates it) or, in a snapshot or stored thread, when theToolMessagehaserror. The error string becomes the step'serror.message, so ag-ui-langgraph's bare"error"reads as{ message: "error" }. - A step that first arrives after a
TOOL_CALL_RESULTthat carried none merges onto the step the frames imply:{ v: 1, kind: "server", status: "completed" }, orfailedwhen the result was flagged as an error. A laterunningstep can't lift it, and adeniedstep replaces it. - A re-presented
TOOL_CALL_STARTreopens acancelledstep. A resumed run does this for the calls an interrupt cancelled. - At
RUN_ERROR, open steps becomefailed. At a cancelledRUN_FINISHED, every open step becomescancelled. At an interruptRUN_FINISHED, open steps becomecancelled, except the calls named ininterrupts[].toolCallIdand inoutcome.pendingToolCallIds, which stay open until the user or the client answers. A successful run settles nothing. A restored thread whose turn ended without a call's result shows that call asfailed. A step held for a call the client never saw is dropped when the run ends.
Fields merge by these rules:
- The defined fields of the newer step overlay the stored ones. On a settled step, a step from the
TOOL_CALL_RESULTbeats one from the call. - A settle out of
runningdrops the running-onlylabelandsourcesunless the settling step restates them.icon,startedAt,queries,kind,builtinandprovidercarry over. - Moving into a different bad status (
failed,deniedorcancelled) fromcompleteddrops the label and sources too, and keeps the icon unless the new step gives one. A repeat of the same status keeps what the step shows. - A
runningstep never adds fields to a settled step.
Versioning is additive. A new field never bumps v, a consumer ignores fields it does not know, and a status it does not know falls back to the lifecycle the frames show. A value whose v is anything other than 1 is not a step: the readers return undefined and the call keeps the step its frames imply. A breaking version would travel under a new metadata key and CUSTOM name, and producers would write both for one release. Older values still read: a missing v is the legacy shape and reads as 1, a missing kind is "server", a source without an id gets <toolCallId>:<index>, a source without a kind is "url" if it has an href and "record" otherwise, and ISO timestamps become epoch milliseconds. readToolStep applies all of this, and readAnswerAnnotations applies the same version rule to b4.answer.
Producers and clients can test against the same corpus: TOOL_STEP_FIXTURES and foldToolStepEvents from @b4run/sdk/testing, and the JSON Schemas at @b4run/sdk/tool-step.schema.json and @b4run/sdk/answer.schema.json. See the SDK reference.
B4.run's vendor events all use the b4. prefix: b4.step here, and b4.content_parts_dropped from Multimodal content. A client that does not know a name ignores it. The step's done payload, with its timing and the gate decision, is also stored on the checkpointed tool message as additional_kwargs.b4_step (the task tool's message carries b4_subagent as well), so GET /threads/:thread_id/turns rebuilds its labels, sources, outcomes and timing. /state retains the raw LangChain additional_kwargs.b4_step and status; /events projects the same step into AG-UI RESULT metadata and exposes failures through the result error. Inbound provider tool results, whether marked directly or through their assistant call, are excluded from model input. PersistedStep and B4StepEventValue are deprecated in favor of B4ToolStep, and readStepEvent now drops an empty-string label.
Canonical orchestration presentationCopy link to section: Canonical orchestration presentation
B4.run presents its built-in planning action once. When a writeTodos call produced the b4.plan activity, the adapter emits no TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, or TOOL_CALL_RESULT for that call. The chunk behind the activity carries the same tool-call id as the model's call (plan_update.tool_call_id); that internal correlation decides the suppression, and it stays out of activity content. A subagent's own writeTodos is never suppressed: its plan snapshot and its tool frames both flow, tagged with the child's id.
Every other tool is unchanged. task, recall, computeNavlog, readDoc, writeFile, fileFlightPlan, and any application tool produce ordinary tool events.
The rule fails open. When the adapter can't prove the suppression is safe, the client sees the ordinary tool card. That covers a call with no tool-call id or a colliding one, a malformed plan payload, a result that arrives with no correlation, buffer limits, and a stream that ends early.
At a permission interrupt that belongs to the held call, the adapter drops the held frames rather than flushing them. The resumed run presents the same call again under the same tool-call id, and the client merges the two into one card. Unrelated held calls still flush before the interrupt.
Inbound inputCopy link to section: Inbound input
fromRunAgentInput(input) translates every AG-UI message and keeps the original request as raw, where you can inspect tools, state, and context. B4Message.content is a string or the ordered content parts the client sent; see Multimodal content. The CLI endpoint forwards only the newest user message, because B4.run already keeps the checkpointed history for the thread.
The standard top-level RunAgentInput.resume array becomes a list of B4.run resume requests:
resume?: Array<{
interruptId: string
status: "resolved" | "cancelled"
payload?: unknown
metadata?: { grant?: string }
}>Under approval grants, echo the interrupt's grant back at metadata.grant on its resume entry. A 1.0 client strips any grant outside metadata.
For a permission prompt, a resolved payload can carry a B4.run decision such as "once" or "always". The runtime treats a cancelled interrupt as a denial. Every answer must address one currently pending interrupt.
A permission interrupt names the tool call it gates in toolCallId, whichever gate raised it: tool, command, memory or path. Inside a subagent it also carries subagentRunId, the task call that started that subagent (see Subagents). A gate whose model call had no id gets the enclosing task call as its toolCallId instead. When the gated tool has a display, the envelope in metadata also carries step: { icon, label }, the running label the call streamed before it parked, so a thread restored from storage shows the parked call with the same label (and the approval card with the same title) as the live run. A permission prompt carries responseSchema: { type: "string", enum: ["once", "always", "deny"] }, so a client knows what a resolved payload may say. A tool approved with allowAlways: false (see Approve every call) carries enum: ["once", "deny"] instead, and its envelope in metadata has allowAlways: false; an always answer to it is treated as once.
Response schemaCopy link to section: Response schema
Some AG-UI clients render the assistant's reply as UI, and need the final assistant message to match a JSON Schema. The client sends that schema as forwardedProps.responseSchema, AG-UI's own field for client additions, so no client library gets its own key.
The route must allow the key, like any other forwardedProps key:
// b4.config.ts
server: { agui: { clientForwardedProps: { "/chat": ["responseSchema"] } } }On a route that does not allow it, the run is refused with 422 forwarded_props_not_allowed, and the message names the key and the setting.
A CopilotKit runtime route can send the schema itself. Register the route as a B4HttpAgent with a responseSchema, and every run it makes carries forwardedProps.responseSchema, next to any other forwardedProps the run already has:
import { B4HttpAgent } from "@b4run/ag-ui/client"
const agent = new B4HttpAgent({ url: `${b4Url}/agui/chat`, responseSchema })A UI kit that builds the schema for you, such as Hashbrown's createUiJsonSchema, works the same way: hand its schema to B4HttpAgent.
The runtime either applies that schema or rejects the run. It never ignores it, because to the client an ignored schema looks like an honored one until a reply fails to parse.
Applied. On an agent route whose provider can constrain its final message to a schema while still calling tools, the schema is bound on the root model as that provider's native structured output:
| Provider | How the schema is applied |
|---|---|
openai | response_format: { type: "json_schema", json_schema: { name: "b4_response", schema, strict: true } } on every root-model call, on both the Chat Completions and Responses paths. |
anthropic | output_config.format = { type: "json_schema", schema } on every root-model call. |
Tool-calling turns are unaffected. A turn that calls tools still returns tool calls, and only the answering turn is constrained. The schema is bound on the root model only. Subagents, summarization, and memory extraction never see it. Because the binding is per request, a run that carries a schema always compiles a fresh agent graph and skips the process cache.
Strict OpenAI structured outputs need a schema OpenAI accepts in strict mode: every object has additionalProperties: false and lists all of its properties in required. Hashbrown's schema builder, for one, emits that shape. OpenAI rejects a hand-written schema that breaks these rules, and you'll see it as a RUN_ERROR.
Rejected. Anywhere else, the run is refused with 422 and error code B4_E5402. This happens before middleware context is built into a run, before the thread is created, and before a run slot is claimed. error.details.code says why:
details.code | Meaning |
|---|---|
invalid_response_schema | forwardedProps.responseSchema is present but not a JSON Schema object. Judged from the body alone, before middleware runs. |
response_schema_not_supported | The route is not an agent route, or its provider (google, mistral, groq, ollama, xai, openrouter) has no schema-constrained output mode that coexists with tool calls. Judged after middleware has admitted the caller. |
A run without forwardedProps.responseSchema is unchanged. B4.run reads no library-specific key, such as hashbrown, from the body.
ThreadingCopy link to section: Threading
AG-UI's threadId is the B4.run thread id, with no mapping table in between. AG-UI and Agent Protocol requests run the same route code and use the same thread store and checkpointer, so a caller can inspect state through Agent Protocol while a UI drives later turns through AG-UI.
A permission prompt is answered on the route that parked it. An AG-UI resume sent to /agui/<another route> for the same thread is refused with 409 resume_route_mismatch before anything is consumed, so a caller admitted to one route cannot answer, and run under that route's graph, a prompt another route parked. The response does not name the parking route.
A turn that parks on a permission prompt leaves the thread interrupted (not idle) on both surfaces. The park is written to the checkpointer, so it outlives the response that carried it. After a reload, a client reading GET /threads/:thread_id sees that the agent is still waiting on a person. GET /threads/:thread_id/turns rebuilds the activity view the chat showed, approvals included, from the checkpoint.
Both surfaces also share the same process-local one-active-run-per-thread gate. An ordinary run-slot collision (an AG-UI turn or Agent Protocol run that reaches an occupied slot) returns 409 with error.details.code set to run_in_flight. A second concurrent resume is stopped by the shared resume claim before it reaches the run registry, and returns resume_in_progress. The gate lives in one process. Shared durable stores won't extend it across replicas.
Middleware and service authenticationCopy link to section: Middleware and service authentication
B4.run execution middleware gates AG-UI route execution the same way it gates Agent Protocol execution. It can reject the request or pass context into authored tools. It only covers route execution. Thread management, state, cancellation, memory-candidate management, and health routes bypass it. See Middleware for the request contract, and read Security Architecture before you expose the runtime outside a trusted local environment.
Disconnect and reconnectCopy link to section: Disconnect and reconnect
Aborting the run keeps any park it already reached. A permission prompt raised before the disconnect is durable, so the thread stays interrupted and the next request can resume it.
The two surfaces differ here. Agent Protocol viewer disconnects continue the run. AG-UI viewer disconnects abort the run. For Agent Protocol, closing a runs/stream, runs/wait, or resume connection only detaches the viewer, so stop the run explicitly with POST /threads/:thread_id/cancel. Server shutdown also aborts in-flight work.
A client disconnect emits nothing further, since no one is left to receive it. A run stopped by POST /threads/:thread_id/cancel or by a server shutdown ends on the AG-UI stream with RUN_FINISHED and outcome.type: "cancelled", not RUN_ERROR. Agent Protocol attach viewers of an AG-UI-started run see { cancelled: true } for a cancel and an error for a shutdown or a disconnect.