Infrastructure

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. agent is a factory (StreamRunner is what it returns: an Agent, an LLMCall, a composition), called once per request with that request's StreamRunContext — 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, a ThreadBinder). A request names a thread; naming proves nothing. bind({ conversation, headers }) authenticates the caller your way (a session cookie, a bearer token through verifyRequestIdentity) and answers a ThreadBinding — the server-side key (sessionId) and the run's identity — 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 (your verify — jwksIdentity for an IdP — on the bearer token), keys the thread by the VERIFIED person, names them as the run's principal, 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. Without threads, 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 / requireJsonContentType options as every host door); a body ceiling (maxBodyBytes, default DEFAULT_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 every heartbeatMs (default DEFAULT_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 resume entry naming its interrupt, or the tool message answering the frontend tool call it waits on.
  • Frontend tools only by the app's allow-list (below).
  • state and context only through your validators — agUi({ accept: { state, context } }), each an AgUiValidator that 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. forwardedProps is never read.

Everything else is refused with its own code:

The requestCode
a system or developer message — the agent's instructions are its ownERR_INSTRUCTIONS_NOT_CARRIED
a tool the app did not allow-listERR_TOOL_NOT_ALLOWED
frontend tools on a host that keeps no paused runERR_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 onERR_UNKNOWN_TOOL_CALL
an answer when nothing waits (409), or to another interruptERR_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 answerERR_CANCEL_NOT_CARRIED
a continued conversation on a host that keeps noneERR_CONVERSATION_NOT_KEPT
a newest message that is not the person's, or empty; a media partERR_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 eventAG-UI events
stream.tokenTEXT_MESSAGE_START · TEXT_MESSAGE_CONTENT · TEXT_MESSAGE_END — one message per LLM call
stream.tool_startTOOL_CALL_START · TOOL_CALL_ARGS · TOOL_CALL_END — the call, parented to its LLM call's message
stream.tool_endTOOL_CALL_RESULT
stream.thinking_delta / stream.thinking_endREASONING_START … REASONING_END — only with reasoning: true
agent.iteration_start / _endSTEP_STARTED / STEP_FINISHED, named iteration <n>
composition.enter / exitSTEP_STARTED / STEP_FINISHED, named Sequence, Parallel:<mount> …
agent.turn_start / turn_end of a nested agentSUBAGENT_STARTED / SUBAGENT_FINISHED
pause.request, then the run pausesRUN_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 failsRUN_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-listCUSTOM, named by the event type, carrying only its listed fields
anything elsenothing

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 as CUSTOM, 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 progress payload (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 — an AgUiCustomProjection gets the event's SERVED payload and an AgUiCustomContext (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 no CUSTOM events 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 set reasoning: 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.

On this page