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.
@adonis-agora/agent/react is the React side of the agent: one provider, one hook, and everything a chat screen needs already wired — the transcript, streaming, history on reload, re-attaching to a turn that is still running, a message queue for typing ahead, approvals, questions, attachments, threads, the model picker and the quota gate.
It is headless. It hands you state and actions; every pixel is yours, so the chat looks like your app and not like a widget.
Agora owns the hooks and transport implementation in this package. Its stream protocol remains compatible with Aviary, while installation and releases are independent. The AdonisJS connection sends the session cookie and @adonisjs/shield's CSRF token, with no configuration.
Install
npm i @ai-sdk/react aiThe React implementation ships inside @adonis-agora/agent. Install its optional React and AI SDK peers when using the React entry; an app that only uses the server does not need them. An existing React app already provides react (^18 || ^19).
Hello chat — Adonis + Inertia
The provider mounts the HTTP API when the package is configured (Getting started), so there is no controller to write. A chat page is a route, a provider and a hook.
router.on('/chat').renderInertia('chat').use(middleware.auth())import { AgentProvider, useAgentChat } from '@adonis-agora/agent/react'
export default function ChatPage() {
return (
<AgentProvider>
<Chat />
</AgentProvider>
)
}
function Chat() {
const chat = useAgentChat()
const { transcript, composer, queue } = chat
return (
<main>
{transcript.items.map((item) => (
<article key={item.id} data-role={item.role}>
{item.blocks.map((block) =>
block.kind === 'text' ? <p key={block.key}>{block.text}</p> : null,
)}
</article>
))}
{/* Sent while the agent was still answering: waiting, server-side, for its turn. */}
{queue.items.map((message) => (
<article key={message.id} data-role="user" data-queued>
<p>{message.text}</p>
<button onClick={() => queue.remove(message.id)}>Remove</button>
</article>
))}
{chat.error ? <p role="alert">{chat.error.message}</p> : null}
<form
onSubmit={(event) => {
event.preventDefault()
void composer.submit()
}}
>
<input value={composer.text} onChange={(event) => composer.setText(event.target.value)} />
<button type="submit" disabled={!composer.canSend}>Send</button>
{transcript.stop.available ? (
<button type="button" onClick={transcript.stop.stop}>Stop</button>
) : null}
</form>
</main>
)
}That is a complete chat: it streams, it survives a reload mid-answer, and a message sent while a turn runs is queued instead of refused.
While an answer streams, the hook publishes it at most every 50 ms (useChat's experimental_throttle), so a chat re-renders about 20 times a second rather than once per token.
What <AgentProvider> does for an Adonis app
| Default | Change it with | |
|---|---|---|
| Routes | same origin, /agent | path (when config/agent.ts sets another), baseUrl (an API on another origin) |
| Session | the cookie rides every request (credentials: 'same-origin') | credentials="include" for a cross-origin API |
| CSRF | shield's token, read from the page per request: the XSRF-TOKEN cookie as X-XSRF-TOKEN, else <meta name="csrf-token"> as X-CSRF-TOKEN | getHeaders — yours are merged over it |
With Inertia, shield's enableXsrfCookie: true is already what makes form posts work, so nothing is left to configure. It takes every other prop of the underlying provider too (headers, onHttpError, attachments, genui, backend).
Mount the provider
A hook used outside <AgentProvider> falls back to a bare client that sends no CSRF token, and shield answers every POST from it with 403. Put the provider around whatever renders a chat — a layout is a good place.
Outside React, or to give one hook a connection of its own, createAgentClient(options?) builds the same client:
import { createAgentClient } from '@adonis-agora/agent/react'
const agent = createAgentClient()
const threads = await agent.listThreads()Continuing a conversation
Pass a threadId and the hook loads that thread's history and, when a turn is still streaming on it, re-attaches to it. A chat without one creates its thread on the first send and tells you:
const chat = useAgentChat({
threadId, // from the URL, a prop, your state
agent: 'support', // a named agent (see Personas & agents)
getPageContext: () => ({ kind: 'invoice', id }),
onThreadCreated: (id) => router.visit(`/chat/${id}`, { preserveState: true }),
})chat.isLoadingHistory is true from the first render with a threadId, so a page can show a skeleton instead of flashing an empty state. The list of conversations is useThreads() — { threads, rename, remove, refresh }.
Typing ahead — the message queue
A message sent while a turn is running is queued on the thread, server-side. It survives a reload and runs when the turn ahead of it settles.
const { queue, composer } = useAgentChat()
queue.items // [{ id, text, files, state: 'sending' | 'queued', interrupt }]
queue.paused // why it stopped draining (a failed turn, a Stop, the quota) — or null
queue.edit(id, text) // change a waiting message
queue.move(id, 0) // reorder
queue.remove(id)
queue.interrupt(id) // run THIS one now: the running turn is cancelled for it
queue.resume() // lift a pause
composer.submit({ mode: 'interrupt' }) // send now, cancelling the running turnuseAgentChat({ whileRunning: 'block' }) restores the older behaviour — the composer refuses a send mid-turn (composer.blockedBy === 'busy'). The wire contract is in Streaming & HTTP; the queue needs a store that implements it (the Lucid and in-memory stores do).
Approvals and questions
When the model calls an action tool the run parks until a person decides. The transcript carries that as state on the call — there is nothing to poll:
{item.blocks.map((block) =>
block.kind === 'tools'
? block.calls.map((call) =>
call.isAwaitingApproval ? (
<div key={call.toolCallId}>
<p>{call.description.confirm?.title ?? call.name}</p>
<button onClick={() => call.approve.run()}>Approve</button>
<button onClick={() => chat.reject({ toolCallId: call.toolCallId })}>Decline</button>
</div>
) : null,
)
: null,
)}A question set (an agent's intake, or the model's ask) arrives as an elicitation block with questions, answer.run() and skip.run(). See Elicitation and Authorization for the server side.
With independent approvals the same call is a proposal: the turn has already ended, and the same buttons decide it through the proposal routes (call.approve.run(), or chat.approve / chat.reject with the call's target). The hook reads the thread's proposals whenever the chat or connection status changes and when the window regains focus, polls every proposalPollMs (default 1000) while one is pending, queued, executing or not yet delivered, and merges each outcome message into the transcript without disturbing a turn in progress. chat.proposals is { items, error, unsupported, refresh() }; useAgentChat({ proposals: false }) turns the reads off. A server that does not serve proposals answers the list with 404, 405 or 501: the first such answer sets chat.proposals.unsupported and nothing reads the route again for that client (no polling, no refetch on focus), so onHttpError hears about it once. The library's own 404 for a thread it does not know yet is an error for that read only. Other failed reads (5xx, network) keep polling, waiting twice as long after each failure in a row (up to 30 s) and back to proposalPollMs after the next success.
What the chat can draw
Generative UI is set up on the provider — <AgentProvider genui={{ registry, catalog }}> — and every ui block renders through <GenerativeUI> (Generative UI). To tell the server what that registry can draw, pass uiCapabilities; it is sent with every turn, and components left out arrive as text:
<AgentProvider
genui={{ registry, catalog }}
uiCapabilities={{ components: [{ name: 'DataTable', version: 1 }, { name: 'OrderSummary', version: 1 }] }}
>useAgentChat({ uiCapabilities }) overrides it for one chat. The provider does not derive capabilities from the registry; omit them and the server treats every catalog component as drawable. See One output, many surfaces.
Background runs
An agent whose edge is declared detached (Background delegation) ends its turn with a receipt, and the delegate's answer lands in the thread later as a message of its own. Ask the chat to track them:
const chat = useAgentChat({ threadId, background: true })
{chat.background.runs.map((run) => (
<div key={run.runId}>
{run.agent}: {run.status === 'running' ? 'working…' : run.message?.content}
</div>
))}The list is derived from the thread itself — the receipt on the delegating message, and the delivered message stamped with the same runId — so it survives a reload and a second tab. While any run is still running the hook re-reads the thread every backgroundPollMs (default 5000; 0 turns the interval off, leaving chat.background.refresh()). The pure function behind it, backgroundRunsFromThread(messages), is exported for a list rendered elsewhere. A delegate that failed or was stopped also posts a message, so it reads as delivered rather than running for ever.
Attachments
Configure attachments in config/agent.ts (Attachments) and the composer can carry files. It reads the limits from GET /agent/config, uploads each file to POST /agent/attachments, and attaches the ready ones on submit():
const { files } = chat.composer
<input {...files.inputProps} />
{files.items.map((file) => (
<span key={file.id}>{file.name} — {file.status}</span>
))}A reloaded thread returns them on the user message; messageFiles(message) reads them back.
For large files over a resumable media library (uploads.resumable in config/media.ts), upload in chunks over tus instead — same composer, one prop. The upload client ships with Agora:
import { mediaAttachments } from '@adonis-agora/agent/react/media'
<AgentProvider attachments={{ upload: mediaAttachments() }}><Chat /></AgentProvider>The rest of the surface
| Hook | Reads |
|---|---|
useThreads() | GET /agent/threads — list, rename, delete |
useModels() / chat.models | GET /agent/models — the picker, the thread's pin, a lock |
useQuota() / chat.quota | GET /agent/quota — budget windows; sends are refused client-side while one is exhausted |
useToolCatalog() | GET /agent/tools — the words a tool call is narrated with |
useAgents() | GET /agent/agents |
useMessageFeedback() | POST /agent/messages/:id/feedback — thumbs up/down |
useAgentConfig() | GET /agent/config |
useComposerAutocomplete() + createSkillsSource() | GET /agent/skills — skills after a / |
To run the chat turns over AG-UI instead of the native stream, hand the provider backend={agUiBackend()} — see From React.
Every route is listed in Streaming & HTTP. Errors reach you as AgentHttpError carrying the server's status, code and message — chat.error.message is already the server's words — and <AgentProvider onHttpError> sees each one, for an app-wide "401 → sign in again".
Subpaths
| Import | What it is |
|---|---|
@adonis-agora/agent/react | the provider, the hooks, the transcript model, AgentClient |
@adonis-agora/agent/react/genui | GenuiProvider, GenerativeUI, useGenuiNode — draw the components an agent pushes, and skeletons while a tree streams (Generative UI, Loading states) |
@adonis-agora/agent/react/genui/json-render | composed trees through @json-render/react |
@adonis-agora/agent/react/genui/server | createReactServerRenderer — static HTML, paginated PNG and PDF from the same React components, on the server (Render HTML, PNG, or PDF) |
@adonis-agora/agent/react/genui/server/playwright | createPlaywrightCaptureAdapter — the PNG/PDF capture over playwright-core (optional peer) |
@adonis-agora/agent/react/markdown | AgentMarkdown, a streaming markdown renderer (streamdown and friends are optional peers of this package) |
@adonis-agora/agent/react/media | mediaAttachments() — resumable (tus) attachment uploads (Resumable uploads) |
Not using React?
The framework-free stream client stays at @adonis-agora/agent/client — see Browser client.
AG-UI
Serve the agent over AG-UI 1.0 — the open agent-to-application protocol — next to the native stream.
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.