Skip to main content
The pkg/agui package serves your agents over the AG-UI protocol — the same wire format CopilotKit and @ag-ui/client speak. You get streaming assistant text, reasoning, tool calls, conversation threads, and human-in-the-loop approvals over a single SSE endpoint, plus an optional embedded chat UI so you can talk to an agent in the browser without building a frontend. There are three layers, from highest to lowest level: *hastekit.AgentRegistry satisfies the agui.Registry interface (Agent(name) + AgentNames()), so you pass one to any of these. Agents register into a package-global registry when you create them with hastekit.NewAgent, so a zero-value &hastekit.AgentRegistry{} already sees every agent.

Embedded chat UI

The fastest way to interact with an agent. web.Serve mounts a ready-made CopilotKit chat client and the AG-UI protocol endpoints on the same address:
Open http://localhost:8080. The default UI lists registered agents, shows a sidebar of prior conversations to resume, streams assistant text / reasoning / tool calls live, and renders approval cards inline for human-in-the-loop pauses. web.Handler(registry, opts...) returns the same surface as an http.Handler for mounting into an existing server. It serves: The protocol endpoints are mounted under /api/agui (exported as web.APIPrefix), so external AG-UI clients can target them too — the embedded UIs are just two more consumers.

Protocol handler

If you have your own frontend (CopilotKit’s HttpAgent, raw @ag-ui/client, or a custom SSE consumer), serve just the protocol with agui.NewHandler:

Routes

GET /agents answers {"agents": [...], "full_history": false}. The flag tells a client whether this server needs the whole conversation on every run. It normally does not — the agent loads the thread itself from threadId — so a client that reads it can post just the new turn instead of re-uploading a thread that grows every time. The threads endpoints power conversation pickers. Listing requires the agent’s persistence adapter to implement history.ThreadLister (the SDK’s in-memory and file adapters do); when it doesn’t, the listing endpoint answers 501 so clients can hide the picker. See Conversation History for persistence setup.

Single agent

To mount one agent’s run endpoint on an existing mux, use agui.AgentHandler, which runs the agent for every POST regardless of path:
AgentHandler serves only the run endpoint — it has no route to carry a stop or a stream rejoin. Mount NewHandler for those, or call agent.Stop(ctx, streamID) from a route of your own.

Request body

The run endpoint accepts the canonical AG-UI RunAgentInput body (camelCase field names):
Each Message follows the AG-UI message shape:
A request must have a threadId and at least one of messages or an approval decision (see Human-in-the-loop below).
By default the handler extracts only the new trailing turn from messages and relies on the SDK’s conversation persistence for prior history. Use WithFullHistory if the client owns history and the agent has no persistence.

Grounding context

context entries reach the model two ways. They are flattened into the run’s RunContext so prompt templates can reference them: a {description, value} pair becomes {{Context.<description>}}, while state and forwardedProps are available as {{State.x}} and {{ForwardedProps.y}}. Inbound X-* and Authorization request headers land under {{Header.x}} (with - replaced by _), so a per-tenant token on the request is reachable from the prompt and from hooks without threading it through by hand. They are also appended to the user message as a <context>…</context> block, so the model sees them even when the prompt does not template them:
These injected blocks are stripped again when a thread is read back through the messages endpoint, so a conversation picker shows the user what they actually typed rather than the grounding the client attached.
Template placeholders are only substituted if the prompt’s resolver chain includes prompts.ResolveTemplate — see Prompt resolvers. The <context> block is appended either way.

Event stream

The run endpoint responds with Content-Type: text/event-stream and emits canonical AG-UI events. It always starts with RUN_STARTED and always ends with RUN_FINISHED (or RUN_ERROR), synthesising the terminal event if the stream closes early so clients never hang. The main event types: Right after RUN_STARTED, the handler also emits a CUSTOM event carrying the broker streamId, runId, and threadId so a client can correlate the AG-UI run with the SDK’s streaming surface — and so it has the stream id needed to stop the run.

HasteKit CUSTOM events

AG-UI has no native event for some of what the SDK streams, so those surface as CUSTOM events under hastekit.* names: Reasoning is streamed with the canonical REASONING_* events; when a thread is read back through the messages endpoint it rehydrates as a message with role reasoning instead, carrying encryptedValue for provider-encrypted reasoning.

Correlation headers

The response sets these headers before the first event:
  • X-Stream-Id — the broker stream id (matches handle.StreamID)
  • X-Agui-Run-Id — the AG-UI run id (echoed from runId, or generated)
  • X-Agui-Thread-Id — the thread id from the request
An idle connection receives a keep-alive SSE comment every 15 seconds (configurable) so reverse proxies don’t reap it.

Options

All three entry points (web.Serve, web.Handler, agui.NewHandler, agui.AgentHandler) accept the same options:
  • agui.WithNamespace(ns) — Conversation namespace (default "default").
  • agui.WithSenderID(id) — Sender attribution for messages POSTed by AG-UI clients (default "user").
  • agui.WithFullHistory() — Forward the client’s complete message list into the run instead of only the trailing turn. Use this when the agent has no conversation persistence and the client owns history; with persistence enabled (the SDK default) it would duplicate prior turns in the thread on every POST.
  • agui.WithKeepalive(d) — SSE keep-alive comment interval (default 15s).

Human-in-the-loop

When an agent pauses on an approval-gated tool, the run ends with a RUN_FINISHED in a paused state and the client renders an approval card. To resume, the client POSTs back to the same threadId with the decision under forwardedProps.command.resume (CopilotKit’s useInterrupt shape) — no new messages are required:
A flat forwardedProps.hastekitApprovals array with the same {toolCallId, approved} entries is also accepted for simpler clients. The handler maps these decisions onto the SDK’s approval resume flow and continues the paused run.

What counts as approval

The zero value of a bool is a rejection, and silently discarding a submitted form is the worst possible default, so a decision is read in this order:
  1. An explicit action verb wins. Both this SDK’s approve/reject and MCP’s elicitation verbs (accept/decline/cancel) are accepted, so a frontend can forward an MCP-shaped answer unchanged.
  2. An explicit approved boolean.
  3. Otherwise, a decision carrying content is a submission, and therefore an approval — a form arriving with no verdict field means the user filled it in and pressed submit.
  4. Otherwise, a rejection.

Form elicitations

When the pause is an MCP form elicitation rather than a plain approval, attach the filled form as content:
Content rides through to the resuming tool. A rejected decision drops its content rather than delivering it — a declined form has no answer to pass on.

Stopping a run

A run already streaming can’t be stopped on its own connection — that connection is busy streaming — so stopping is a separate request keyed on the run’s stream id (the X-Stream-Id response header, or the streamId CUSTOM event):
?streamId=… as a query parameter works too. The endpoint answers 202 Accepted once the stop is recorded; the run winds down and ends on its own SSE connection with RUN_FINISHED, which is where a client should watch for the outcome. The stop travels through the agent’s broker, so with a shared broker (Redis) it does not have to reach the replica holding the SSE connection. The agent in the path selects whose broker to ask; it is not an ownership check — the stream id is the capability, and only the client that started the run has it.

Rejoining a run

A thread streams on the same channel every turn, so a client that reloaded or navigated away can pick a run back up without having kept anything:
The broker replays what the run has emitted so far and then follows it live. Events carry the same ids as the run’s own connection, because the run id comes off the replayed run.created chunk. A thread with no run in flight answers 204 No Content, so a client can attach without checking first and without being left holding a stream nothing will ever publish to.

Steering a run in flight

A turn POSTed for a thread that is already running folds into that run instead of starting a second one on the same channel. The server answers 204 No Content — the caller gets no stream of its own, because the reply appears on the live run’s stream, which it can already read or rejoin. That is how a client steers: send the new turn and keep reading the stream it already has. The agent drains queued messages at iteration boundaries, and the mid-run arrival is marked for the model with a steering notice so a correction shouted mid-task isn’t mistaken for the instruction already being carried out.
Folding requires a broker implementing agents.RunClaimBroker; the built-in memory and Redis brokers both do. With any other broker a concurrent POST starts its own run as before.