AG-UI adapter
Render any AG-UI agent with chatcn: a pure reducer from protocol events to messages, and a hook around HttpAgent with interrupts as approvals.
On this page
Installation
This installs the hook and the chatcn-ag-ui reducer it builds on. Install only chatcn-ag-ui if you feed events yourself.
Usage
"use client"
import { HttpAgent } from "@ag-ui/client"
import { useMemo } from "react"
import { Chatbot } from "@/components/chatcn/chatbot/chatbot"
import { useChatcnAgUi } from "@/hooks/use-chatcn-ag-ui"
export function AgentChat() {
const agent = useMemo(() => new HttpAgent({ url: "/api/agent" }), [])
const { messages, status, error, actions } = useChatcnAgUi({ agent })
return (
<Chatbot
messages={messages}
actions={actions}
status={status === "running" ? "streaming" : status === "error" ? "error" : "ready"}
error={error ? new Error(error.message) : null}
/>
)
}Keep the agent stable (useMemo or module scope): a new instance starts a new conversation. agentState holds the shared state from StateSnapshot and StateDelta, and state.step names the running step for a status line.
Actions
| ChatcnActions | AG-UI |
|---|---|
send(text, { files }) | agent.addMessage(user) then agent.runAgent(). Files go as image, audio, video or document content parts |
stop() | agent.abortRun(). Unfinished tool calls render as cancelled |
approve(id), deny(id, reason) | Answers the interrupt id. Once every open interrupt has an answer, the run resumes with resume: [{ interruptId, status: "resolved", payload }] |
submitToolOutput(toolCallId, output) | Adds a tool message for a frontend tool, then runs again when no other call waits. Pass an Error to report a failure |
regenerate(messageId?) | Trims the agent's history to the last user message and runs again |
editMessage(id, text) | Replaces the user message, drops what followed and runs again |
The default resume payload is { approved, reason }. If your agent expects another shape, pass resumePayload: (decision, interrupt) => ....
The reducer on its own
import { agUiReducer, createAgUiState, reduceAgUiEvents } from "@/lib/chatcn-ag-ui"
// Fold a recorded stream, for tests or server rendering.
const state = reduceAgUiEvents(events)
// Or step it from any event source.
let next = createAgUiState()
agent.subscribe({ onEvent: ({ event }) => { next = agUiReducer(next, event) } })agUiReducer(state, event) is pure, matches events by their type string (plain JSON from an SSE parser works), and returns the same state for events it does not render. Helpers: fromAgUiMessages for history and MessagesSnapshot, answerAgUiInterrupt, toAgUiResume, stopAgUiRun, rewindAgUiState and applyJsonPatch.
Event mapping
| Event | Result |
|---|---|
RunStarted | Status running. A new assistant message, or the last one when a run resumes after an interrupt |
TextMessageStart/Content/End, TextMessageChunk | A text part that streams in place. Role user or system starts its own message |
ReasoningStart/MessageStart/Content/End | A reasoning part. Encrypted reasoning with no text leaves an empty part that renders as redacted |
ToolCallStart/Args/End, ToolCallChunk | A tool part: input-streaming with partial JSON input, then input-available |
ToolCallResult | output-available with JSON content parsed. A denied call keeps showing the denial |
RunFinished with outcome: interrupt | Status interrupted. Each interrupt puts its tool call in approval-requested, or adds a dynamic-tool part when it names no call |
RunFinished with outcome: cancelled | Status stopped |
RunError | Status error; unfinished tool calls fail with the message |
StateSnapshot, StateDelta | agentState, patched with JSON Patch. A patch that does not apply keeps the last good state |
MessagesSnapshot | Messages rebuilt from the snapshot, open interrupts kept |
ActivitySnapshot/Delta | A data-activity part, reconciled by message id |
SubagentStarted/Finished/Error | A data-subagent part whose messages hold the subagent's own reduced transcript |
StepStarted/Finished | state.step |
Custom, Raw, anything newer | Ignored |
Examples
Protocol in, components out
Both demos replay recorded events. The chat runs a real HttpAgent whose fetch answers with an SSE stream, so the client's parser and verifier see a real stream with no server.
Notes
- AG-UI requires every open interrupt to be answered in one resume. The hook waits for all answers, then resumes once.
- AG-UI has no approval scope. Persist "always allow" in
onApprovalScope. - Leaf components never import this adapter; only your app and blocks do.