AG-UI frontends
Serve any agentfootprint agent to AG-UI frontends with no glue code — one Fetch-API handler that treats every request as untrusted, keeps the conversation on the server, and streams text, tool calls, steps, sub-agents and interrupts as AG-UI 1.0 events with nothing a redaction policy hides on the wire.
An AG-UI frontend already knows how to show an agent.
@ag-ui/client, CopilotKit and the AG-UI Dojo POST one request and read one stream of typed events — a message streaming in, a tool being called, a step starting, a run finishing. Give them that stream and there is no frontend glue to write.
agentfootprint/ag-ui is the adapter between an agentfootprint run and that
stream. It reads the same events every listener receives and writes AG-UI 1.0
events; nothing about the agent changes.
Serve it
streamHandler (from agentfootprint/hosting) is a framework-agnostic handler —
StreamHandler, (request: Request) => Promise<Response> — that takes an agent
FACTORY, a protocol adapter, and the two ports that keep a conversation:
/** * WHO is calling — proven, never read off a header. A real app passes its IdP: * `jwksIdentity({ jwksUrl, issuer, audience })` from `agentfootprint/security`. * This demo verifier knows one token. */const verify = async (token: string) => { if (token !== DEMO_TOKEN) throw new Error('unknown token'); return { userId: 'ada' };};/** The whole server side: one handler, mounted at any route of any framework. */function serve(provider: LLMProvider) { return streamHandler({ agent: ({ tools }) => buildAgent(provider, tools), // fresh per request adapter: agUi(), // No valid token → 401. The thread is keyed by the VERIFIED caller, so the // same thread id from someone else is someone else's conversation. threads: verifiedThreads({ verify }), sessions: memorySessions(), // sqliteSessions({ file }) to survive a restart }); // Next.js: export const POST = serve(provider); // Hono: app.post('/agent', (c) => handler(c.req.raw)); // Bun/Deno: Bun.serve({ fetch: handler }) · Deno.serve(handler)}The frontend POSTs a RunAgentInput and reads Server-Sent Events back — which is
all the example does, twice:
/** One turn, posted the way an AG-UI frontend posts it; the events it reads back. */async function turn( handler: (request: Request) => Promise<Response>, runId: string, messages: readonly Record<string, unknown>[],): Promise<AgUiEvent[]> { const response = await handler( new Request('http://localhost/agent', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${DEMO_TOKEN}`, }, body: JSON.stringify({ threadId: 'thread-1', runId, messages, tools: [], context: [] }), }), ); const text = await response.text(); return text .split('\n\n') .filter((frame) => frame.startsWith('data: ')) .map((frame) => JSON.parse(frame.slice('data: '.length)) as AgUiEvent);}Install the optional peers the framing uses: npm install @ag-ui/core @ag-ui/encoder.
Without them the handler is refused when it is built, by name — never mid-request.
What the handler does, for every protocol (StreamHandlerOptions):
- A fresh runner per request.
agentis a factory (StreamRunneris what it returns: anAgent, anLLMCall, a composition), called once per request with that request'sStreamRunContext— the frontend tools it enabled, the values your validators accepted, the bound thread. Nothing is shared between requests: no listener, recorder, buffer or tool session reaches from one caller's run into another's, and a factory that hands back a runner it handed back before is refused (ERR_RUNNER_REUSED). Requests run side by side. - The thread is bound by YOUR code (
threads, aThreadBinder). A request names a thread; naming proves nothing.bind({ conversation, headers })authenticates the caller your way (a session cookie, a bearer token throughverifyRequestIdentity) and answers aThreadBinding— the server-side key (sessionId) and the run'sidentity— or throws a refusal the caller gets. The same thread id from two callers is two conversations.verifiedThreads(identity)is that binder, ready-made: it verifies the caller with the door's identity options (yourverify—jwksIdentityfor an IdP — on the bearer token), keys the thread by the VERIFIED person, names them as the run'sprincipal, and refuses a caller with no credential (401) — never a name read off a header. - Ownership before activity. A stored conversation that is not the caller's is not found (404) — whether or not a turn is running, so a caller learns nothing about another's activity and never holds their turn.
- The conversation lives on the server (
sessions, any session store —memorySessions(),sqliteSessions(), yours), under the bound key: what was said (an Agent's checkpoint) and a run paused on an answer, kept BEFORE the caller is told. A stored conversation that is not the caller's answers as not found. Withoutthreads, every request is a conversation of one message. - One turn per thread at a time — a second turn while one runs is refused
(
ERR_CONCURRENT_RUN, 409), never raced for the stored state. The guard is PER PROCESS: with several instances (serverless, workers), route a thread to one instance (sticky sessions) — the session store has no compare-and-set, so two instances could resume one paused run twice. - The door guard first (421/403/415, the same
allowedOrigins/allowedHosts/requireJsonContentTypeoptions as every host door); a body ceiling (maxBodyBytes, defaultDEFAULT_STREAM_MAX_BODY_BYTES, 1 MiB — 413); refusals as{ error, code }under their own status, a 500 that quotes nothing; a caller who hangs up cuts the run; an SSE keep-alive everyheartbeatMs(defaultDEFAULT_STREAM_HEARTBEAT_MS, 15 s).
Mount it as a Next.js route (export const POST = handler), a Hono route
(app.post('/agent', (c) => handler(c.req.raw))), Bun.serve({ fetch: handler }) or
Deno.serve(handler).
What a request may decide
A request is browser input — readAgUiRunInput reads it as an AgUiRunInput
(messages of AgUiMessage, tools, context, state, resume entries of
AgUiResumeEntry), refusing a malformed field by its NAME, never by echoing its
value (ERR_INVALID_RUN_INPUT). threadId and runId must be letters, digits
and ._:-, at most AG_UI_MAX_ID_LENGTH (128) characters.
Then only what a page may decide is taken:
- The person's newest message is the run's message. The page's assistant and tool messages are its own copy of what the server said; the server continues ITS copy, so a page that rewrites history changes nothing the model is shown.
- An answer only for the pause the server keeps, matched to the ids that pause
handed out: a
resumeentry naming its interrupt, or thetoolmessage answering the frontend tool call it waits on. - Frontend tools only by the app's allow-list (below).
stateandcontextonly through your validators —agUi({ accept: { state, context } }), each anAgUiValidatorthat gets the value and the bound thread and returns what the agent is given (StreamRunContext.accepted).- Identity, credentials and redaction never — the binding decides who the run
is for; the agent decides its own redaction.
forwardedPropsis never read.
Everything else is refused with its own code:
| The request | Code |
|---|---|
| a system or developer message — the agent's instructions are its own | ERR_INSTRUCTIONS_NOT_CARRIED |
| a tool the app did not allow-list | ERR_TOOL_NOT_ALLOWED |
| frontend tools on a host that keeps no paused run | ERR_CLIENT_TOOLS_NOT_CARRIED |
state / context with no validator (or one that refused) | ERR_STATE_NOT_CARRIED · ERR_CONTEXT_NOT_CARRIED (…_REFUSED) |
| a tool result for a call the thread is not waiting on | ERR_UNKNOWN_TOOL_CALL |
| an answer when nothing waits (409), or to another interrupt | ERR_NO_PENDING_ASK · ERR_UNKNOWN_INTERRUPT |
| a new message while a run waits on an answer (409) | ERR_AWAITING_DECISION |
| cancelling an interrupt — a decline is an answer | ERR_CANCEL_NOT_CARRIED |
| a continued conversation on a host that keeps none | ERR_CONVERSATION_NOT_KEPT |
| a newest message that is not the person's, or empty; a media part | ERR_NO_USER_TURN · ERR_MEDIA_NOT_CARRIED |
Frontend tools
A frontend tool runs in the page — a date picker, a confirmation dialog. The app
allow-lists it by NAME with its own contract (AgUiFrontendTool: description and
input schema — never the page's words, because a description the model reads is an
instruction):
agUi({
frontendTools: {
pick_date: {
description: 'Let the person pick a date in the page.',
inputSchema: { type: 'object', properties: { month: { type: 'string' } } },
},
},
});
// the factory adds the ones the page declared this request:
streamHandler({ agent: ({ tools }) => Agent.create(opts).tools([...mine, ...tools]).build(), … });A call pauses the run: the segment completes with the call in
pendingToolCallIds, the page runs it and sends the result as a tool message in
its next request, and the SAME run continues with it. The tool holds no server
privilege (no credential, no artifacts), and a name a server tool already has is
refused when the agent is built. Frontend tools need a host that keeps the paused
run: threads and sessions.
Interrupts
A pause is continued only on a host that keeps it (threads AND sessions).
Without a session store the interrupt still reaches the page, and an answer to it
is refused (ERR_NO_PENDING_ASK) — serve an agent that asks people with both.
A tool that asks a person (askHuman, a check-in, a middleware ask) ends the
run with RUN_FINISHED { outcome: interrupt }, its question in the interrupt's
message. The frontend answers in its next request's resume; the stored run
continues from exactly where it stopped, the sub-agent and call it stopped in
re-opened under their own ids.
Without the handler
agUiSerializer translates ONE run's events into AG-UI events, for a transport of
your own (a WebSocket, a queue). agUiSse frames each one as a Server-Sent Event,
exactly as the official @ag-ui/encoder writes it. Subscribe before the run,
translate as events land, settle once when the run ends:
/** Without the handler: one run of `agent` → the AG-UI frames, for a transport of your own. */async function agUiFrames(agent: Agent, message: string): Promise<string[]> { const serializer = agUiSerializer({ threadId: 'thread-1', runId: 'run-1' }); const framing = agUiSse(); // the official @ag-ui/encoder writes every frame const frames: string[] = []; const write = (events: readonly AgUiEvent[]) => { for (const event of events) frames.push(framing.frame(event)); }; // Subscribe BEFORE the run: every event the run serves, translated as it lands. const off = agent.on('*', (event) => write(serializer.event(event))); try { const out = await agent.run({ message }); write(serializer.settle({ kind: isPaused(out) ? 'paused' : 'finished' })); } catch { // Said in fixed words — never by the thrown value. write(serializer.settle({ kind: 'failed' })); } finally { off(); } return frames;}agUiSse loads @ag-ui/encoder when the framing is built — never when the door
is imported. In a runtime this package cannot load it from (a browser or edge
bundle), import it yourself and pass it: agUiSse({ encoder }) (AgUiSseOptions,
the module typed structurally as AgUiEncoderModule); agUi({ encoder }) (AgUiOptions)
passes it on.
The serializer imports nothing from AG-UI: its events are plain data typed by
AgUiEvent (and AgUiEventType), checked against @ag-ui/core's own event union
by this repository's tests. It speaks protocol AG_UI_PROTOCOL_VERSION ('1.0'),
stated on RUN_STARTED.
What a run becomes
| agentfootprint event | AG-UI events |
|---|---|
stream.token | TEXT_MESSAGE_START · TEXT_MESSAGE_CONTENT · TEXT_MESSAGE_END — one message per LLM call |
stream.tool_start | TOOL_CALL_START · TOOL_CALL_ARGS · TOOL_CALL_END — the call, parented to its LLM call's message |
stream.tool_end | TOOL_CALL_RESULT |
stream.thinking_delta / stream.thinking_end | REASONING_START … REASONING_END — only with reasoning: true |
agent.iteration_start / _end | STEP_STARTED / STEP_FINISHED, named iteration <n> |
composition.enter / exit | STEP_STARTED / STEP_FINISHED, named Sequence, Parallel:<mount> … |
agent.turn_start / turn_end of a nested agent | SUBAGENT_STARTED / SUBAGENT_FINISHED |
pause.request, then the run pauses | RUN_FINISHED with an interrupt outcome (AgUiRunOutcome, AgUiInterrupt) |
the run pauses on a tool the CLIENT runs (settle({ kind: 'paused', awaiting: 'caller' })) | RUN_FINISHED with a success outcome naming the call in pendingToolCallIds |
error.fatal, then the run fails | RUN_ERROR — the served error.fatal when it is one of the library's fixed sentences, else "The run failed." — never the thrown value |
| an event on the CUSTOM allow-list | CUSTOM, named by the event type, carrying only its listed fields |
| anything else | nothing |
What reaches the browser
An AG-UI stream leaves the process, so nothing goes out by default that a person
using the frontend is not meant to read. AgUiSerializerOptions takes the
request's threadId and runId, and three decisions that are yours:
-
custom— the events that go out asCUSTOM, each with the fields it carries. The default,AG_UI_CUSTOM_EVENTS, is tool progress (which call, which iteration), spend (cost.tick), the loop's route, the answer's standing and a provider's retries — counts and the library's own words. The rest of the record stays off the wire: a tool's own progresspayload(whatever the tool chose to report),context.injected(what the model was shown, your system prompt included), a retry's raw error, memory, permission and credential facts. To send one, list it with a projection — anAgUiCustomProjectiongets the event's SERVED payload and anAgUiCustomContext(the AG-UI id of the tool call open where it happened) and returns what goes out:agUiSerializer({ threadId, runId, custom: { ...AG_UI_CUSTOM_EVENTS, 'agentfootprint.context.injected': (p) => ({ slot: p.slot }) }, });custom: {}sends noCUSTOMevents at all. -
toolErrors— what a FAILED tool call's result says. By default the fixed words "The tool failed." ('withheld'): the record's result for a failed call is the thrown text, which may hold a connection string, a path or a provider's response.'served'sends the record's own result. -
reasoning— the model's chain of thought (REASONING_*), from the served thinking events. Off unless you setreasoning: true; whether a person may read it is a product decision.
A failure is described in FIXED words: RUN_ERROR repeats the served
error.fatal only when it is one of the library's fixed sentences (a provider
unreachable, a key rejected, a rate limit, a timeout), else it says "The run
failed." — never the thrown value, its stack or a provider's response body,
which the record's own fallback quotes. A pause's interrupt
carries the question's text and nothing else of the pause's payload. The run's
identity (principal, tenant, sessionId) is never written.
Text is what the run STREAMED. A provider that does not stream still shows its
answer: the turn's answer is written whole when no token of it streamed. An
agent's intermediate draft (llm_end.content) is never written — the library
may be holding it back (answer validation, an output policy).
A run that continues a pause
A paused run is unfinished work: the next request continues it with
agent.resume(checkpoint, answer). AG-UI treats the continuation as the SAME
invocation, so the translation of the paused run hands back what the next one
needs — suspension() on the AgUiTranslation, an AgUiSuspension: the
interrupt's id, the call the run stopped in, and every scope (AgUiSuspendedScope)
open at the pause with its steps and its open calls (AgUiSuspendedCall — a
parallel branch that paused alongside waits its turn, and is answered under its
own id when it comes). Plain JSON holding ids and step names only; keep it
with the checkpoint. Pass it as resume when you translate the continuation:
const first = agUiSerializer({ threadId, runId });
// …the run pauses; settle it, then keep both halves
const kept = { checkpoint: outcome.checkpoint, suspension: first.suspension() };
// a later request answers it
const next = agUiSerializer({ threadId, runId: nextRunId, resume: kept.suspension });
const off = agent.on('*', (event) => send(next.event(event)));
const out = await agent.resume(kept.checkpoint, answer);The suspended sub-agents re-open under their own subagentRunId, their steps
re-open, and the first result in the paused call's scope answers that call under
its own id — the client sees one tool call, one result. RUN_STARTED waits for
the continuation's own first event: the resume's bookkeeping is not the run.
A tool whose work the CLIENT does (an AG-UI frontend tool) pauses the same way.
Settle it with { kind: 'paused', awaiting: 'caller' }: the segment completes,
pendingToolCallIds names the call, and the client answers it with a tool
message in its next input. That result is the one the client sent, so the
continuation never writes it back.
A listener that can hear more than one run — an instance several callers share —
passes owns (which events are this run's) so the translation never binds to
whichever run spoke first.
Sub-agents and parallel branches
An agent mounted inside a composition — a Sequence step, a Parallel branch —
is an AG-UI sub-agent. Its turn opens a scope at its subflow path, and every
event under that path carries the sub-agent's subagentRunId. Two parallel
branches have different paths, so their interleaved tokens, tool calls and
steps are never attributed to each other. A branch a tolerant merge let fail
ends with SUBAGENT_ERROR, never left open.
AG-UI 1.0 has no parent field on a step, so every step, sub-agent and opening
event carries the record's address in metadata.agentfootprint
(AgUiMetadata, AgUiAddress): the runtimeStageId and subflowPath of the
event it came from. A step's path prefixes the paths of everything that ran
inside it.
Ids that line up with the record
Every id the adapter mints is <runId>/<kind>/<n>/<runtimeStageId> — the
request's runId, then the stage execution that produced the entity. A model's
tool-call id is not unique (providers reuse them; two parallel branches often
both say call_1), and an AG-UI client finds a tool call by its id across the
whole conversation, so the adapter never uses it as the id. It rides beside:
metadata.agentfootprint.toolCallId. agentfootprint's own run id is never
written: it counts every run the process made, and a browser has no business
learning how many other callers ran.
Redaction
The adapter reads only the served stream — what every listener receives. Under a
redaction policy (Agent.create({ redact })) that is the placeholder wherever the
policy selected a value, so nothing the policy hides can reach an AG-UI event,
at any depth of a composition — whatever you put on the CUSTOM list, because
the guarantee is the record's, not the list's. The structure is unchanged: ids,
steps and sub-agents are built from the record's address, which a policy never
selects. The cost is stated, not hidden: under a policy, the text a frontend
shows is the placeholder too.
The seams
The protocol is an adapter: streamHandler takes any ProtocolAdapter — it
reads an untrusted request (ProtocolRequestFacts in), names the conversation the
request claims, plans the turn (ProtocolPlan: a ProtocolTurn — a message or an
answer — and what the runner is built with) against HeldConversation (what the
server keeps — never what the request carried) and PlanFacts, opens one
translation per run, and names its framing. agUi() is one; a future protocol is
one more adapter, with nothing in the host to change. A paused run a host keeps
carries the wire's own record of it (PausedWire, on the stored PausedRun).
The translation implements a port on agentfootprint/hosting:
RunTranslation (one run's served events in, protocol events out) and
RunSettlement (how the run ended: finished, paused, cancelled, failed —
the failed one carries nothing of the thrown value).
The framing is a strategy: SseFraming says how one event becomes one
frame. agUiSse is one; agentfootprintSse — the frames toSSE has always
written — is the other, and toSSE({ framing }) takes any.
Hosting & runtime
The ports between an agent and the place it runs — AgentHost, ConversationHost and SessionLifecycle, none of which names a cloud. Plus httpHost/HttpWire for a second HTTP adapter, three reply terminals, three durability modes with stated crash semantics, the human-in-the-loop resume loop, and the three ways to serve many people at once.
Writing a session store
The ownership rule every SessionLifecycle store must hold, and the shared battery that checks it — runSessionLifecycleConformance, 14 cases, three deliberately distinct non-pass outcomes, and no way to make a case quietly disappear. For anyone implementing the port over Postgres, DynamoDB, SQL Server or anything else.
