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:
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’sHttpAgent, 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, useagui.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-UIRunAgentInput body (camelCase field names):
Message follows the AG-UI message shape:
threadId and at least one of messages or an approval decision (see Human-in-the-loop below).
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.
Event stream
The run endpoint responds withContent-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 asCUSTOM 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 (matcheshandle.StreamID)X-Agui-Run-Id— the AG-UI run id (echoed fromrunId, or generated)X-Agui-Thread-Id— the thread id from the request
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 (default15s).
Human-in-the-loop
When an agent pauses on an approval-gated tool, the run ends with aRUN_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:
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:- An explicit
actionverb wins. Both this SDK’sapprove/rejectand MCP’s elicitation verbs (accept/decline/cancel) are accepted, so a frontend can forward an MCP-shaped answer unchanged. - An explicit
approvedboolean. - Otherwise, a decision carrying
contentis a submission, and therefore an approval — a form arriving with no verdict field means the user filled it in and pressed submit. - Otherwise, a rejection.
Form elicitations
When the pause is an MCP form elicitation rather than a plain approval, attach the filled form ascontent:
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 (theX-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: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 answers204 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.