Browser client
The framework-free SSE client shipped in the package — post a turn, fold the stream into renderable parts, and re-attach automatically across a dropped connection. For React, use @adonis-agora/agent/react.
@adonis-agora/agent/client is a small, framework-free client for the chat stream: no React, no Adonis, no runtime dependencies. It posts a turn, decodes the SSE envelope into typed frames, folds them into renderable parts, and silently re-attaches when the connection drops. Use it from a Vue composable, a Svelte store, a server-side consumer, or a plain fetch loop.
Building a React chat?
Use @adonis-agora/agent/react instead. It is the full layer — history, resume, the message queue, approvals, attachments, threads — and it is what this page's client deliberately is not.
The client
createAgentChatClient streams one turn at a time and reports the assistant message as it grows.
import { createAgentChatClient, AgentChatDisconnectedError } from '@adonis-agora/agent/client'
import type { ChatPart } from '@adonis-agora/agent/client'
const client = createAgentChatClient({
basePath: '/agent',
getHeaders: () => ({ 'X-XSRF-TOKEN': csrfToken() }),
})
try {
const result = await client.send({
body: { message: 'What is the weather in Lisbon?' },
onParts: (parts) => render(parts),
onRunId: (runId) => sessionStorage.setItem('agent:lastRun', runId),
onThreadId: (threadId) => sessionStorage.setItem('agent:thread', threadId),
})
console.log(result.runId, result.threadId, result.parts)
} catch (error) {
if (error instanceof AgentChatDisconnectedError) {
// The stream dropped and could not be resumed — but `error.parts` holds
// everything that did arrive, so the partial answer is not lost.
render(error.parts)
}
}Every request it makes carries credentials: 'include', matching the cookie/session auth the default AuthActorResolver expects.
send
send({ body, signal?, ...handlers }) posts body to POST {basePath}/chat and drains the stream, resolving to { runId?, threadId?, parts }. body is the route's body (message, threadId, agent, persona, pageContext, uiCapabilities, attachments, model, regenerate, mode); uiCapabilities tells the server which components this client draws (UI capabilities). The four handlers are all optional:
| Handler | Fires |
|---|---|
onParts(parts) | On every frame, with the assistant message rebuilt from scratch — safe to render directly. |
onFrame(frame) | Once per decoded frame, in wire order. |
onRunId(runId) | As soon as the run id is known: from the X-Agent-Run-Id response header, before the first byte of body. |
onThreadId(threadId) | Likewise, from X-Agent-Thread-Id and then the meta frame. |
Two answers carry no stream. A 202 (the message waits in the thread's queue) resolves with queued. A JSON 200 — the message was an independent-approval text decision — resolves with proposalDecision, and parts holds the server's confirmation as one text part.
If the POST itself is refused, send throws an AgentChatHttpError carrying the server's status, its machine-readable code (quota_exceeded, model_not_allowed, …), the parsed body, and the server's own words as message — the shape a 401, 403, or 429 takes.
Resume, twice over
The word means two related things.
The resume option is an automatic reconnection policy. When a stream ends without a done frame, send waits backoffMs(attempt) (default min(400 * n, 3000) ms) and re-attaches to GET {basePath}/chat/:runId/stream, up to maxAttempts (default 6) consecutive failures — a successful reconnect resets the counter, so a flaky network never exhausts the budget as long as it keeps recovering. Set resume: false to disable it and fail on the first drop.
That endpoint replays the run from its first chunk and then follows live, so the client discards its accumulated parts and rebuilds them from the replay. This is why the reconnect is invisible: you get the complete message, never "Hel" + "Hello world".
The resume method is for a cold start — a page reload where you persisted the run id:
const runId = sessionStorage.getItem('agent:lastRun')
if (runId) {
const result = await client.resume(runId, { onParts: (parts) => render(parts) })
}It sends no POST at all; it goes straight into the same re-attach loop. Note that the first attempt is also delayed by backoffMs(1) — with the defaults that is a 400 ms wait before the very first GET, so seed your UI with a loading state.
AgentChatDisconnectedError
The one typed error the client exports. It is thrown when the retry budget is exhausted, and it carries the partial result rather than discarding it:
class AgentChatDisconnectedError extends Error {
readonly runId: string | undefined
readonly threadId: string | undefined
readonly parts: ChatPart[]
}Rendering error.parts and offering "try again" is a strictly better failure mode than an empty bubble, and the data is right there.
Frames and parts
The SSE layer is exported piecemeal so you can build your own consumer over it. The decoded frames, and the two renderable part types:
type ChatFrame =
| { type: 'text'; delta: string }
| { type: 'component'; name: string; data: unknown; id?: string; version?: number; fallbackText?: string; componentVersions?: Record<string, number> }
| { type: 'meta'; runId?: string; threadId?: string }
| { type: 'error'; code: string; message: string }
| { type: 'event'; event: { kind: string; [key: string]: unknown } } // reasoning, tool calls, steps, title, …
| { type: 'elicitation'; runId: string; toolCallId: string; request: ElicitationRequest }
| { type: 'approval'; runId: string; toolCallId: string; toolName: string; input: unknown; target?: { kind: 'proposal'; proposalId: string }; confirmation?: ToolConfirmation; approver?: string; expiresAt?: string; reason?: string }
| { type: 'done' }
type ChatPart =
| { type: 'text'; text: string }
| { type: 'component'; name: string; data: unknown; id?: string; version?: number; fallbackText?: string; componentVersions?: Record<string, number> }foldPart(parts, frame) is the pure reducer that turns frames into parts: consecutive text frames concatenate into one part, a component frame appends a new part (or replaces the one with the same id), a step-start event closes the text before it with a paragraph break, and every other frame returns the array untouched. It never mutates. send and resume hand every frame to onFrame, so approvals and questions can be read there; an error frame makes them throw AgentChatStreamError (code, message).
Rendering a component part is a name → component lookup you own:
const REGISTRY = { MetricCard, BarChart } as const
function renderPart(part: ChatPart, index: number) {
if (part.type === 'text') return <p key={index}>{part.text}</p>
const Component = REGISTRY[part.name as keyof typeof REGISTRY]
if (Component) return <Component key={index} {...(part.data as object)} />
return part.fallbackText ? <pre key={index}>{part.fallbackText}</pre> : null
}That is all Generative UI needs on the client — the decoder already turns the ui events into component parts. See Generative UI for the tool side.
Building your own consumer
For a non-React framework, three functions compose into a reader:
import { readSseStream, decodeFrame, foldPart } from '@adonis-agora/agent/client'
import type { ChatPart } from '@adonis-agora/agent/client'
const response = await fetch('/agent/chat', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ message: 'hi' }),
})
let parts: ChatPart[] = []
for await (const event of readSseStream(response.body!)) {
const frame = decodeFrame(event)
if (frame === null) continue
parts = foldPart(parts, frame)
render(parts)
if (frame.type === 'done') break
}readSseStream buffers across chunk boundaries (a \n\n separator split between two network packets is handled) and releases the reader lock on exit. parseSseEvent is the same parser one frame at a time, if you already have the framing. decodeFrame never throws: malformed JSON, a payload with no kind (including the retired {"delta"} envelope), and an empty text all return null, so a corrupt frame degrades to a skipped frame rather than a crashed loop.
What the client does not do
It streams over POST {basePath}/chat and GET {basePath}/chat/:runId/stream, and reads and decides independent proposals over their routes:
const proposals = await client.listActionProposals(threadId) // follows every page
await client.decideActionProposal(threadId, proposals[0].id, 'approved', { remember: true })It keeps no transcript, loads no history, and its parts are text and components only — approvals, questions and tool calls arrive as frames (onFrame) but are not folded into parts, and the queue's state and attachments are not modelled. A 202 (the message was queued behind a running turn) is reported as result.queued, and following it is up to you.
Those are all plain routes (Streaming & HTTP), so a consumer in any framework can call them; in React, @adonis-agora/agent/react already does.
React
The full React layer for an AdonisJS app — AgentProvider and useAgentChat with history, resume, the message queue, approvals, attachments, threads, models and quota — over the routes the provider already mounts. Includes a Hello chat for Adonis + Inertia.
MCP server
Expose your governed tool registry to Claude, Cursor, and any other MCP client over Streamable HTTP — with OAuth or API-key auth, fail-closed by default, and the same role checks the agent loop applies.