Frontend
Wire a React chat UI to the agent with useAgentChat — the AI SDK v7 transport, threads, quota, cancel, HITL approve/reject, and styling-agnostic components.
@dudousxd/nestjs-agent-react connects a React app to the /agent endpoints. Its useAgentChat
hook wraps the Vercel AI SDK v7 useChat with an AgentChatTransport that speaks the backend's
/agent/chat SSE protocol, then layers on everything the REST surface exposes: thread history,
quota, cancel, regenerate, and the human-in-the-loop approve/reject. The components ship
no styles — every action is a callback and every class is yours via classNames.
Install
The base package carries the hook, the transport, the REST client, and the components. The rich markdown renderer lives behind a separate subpath so its heavy peers stay opt-in.
pnpm add @dudousxd/nestjs-agent-react ai @ai-sdk/react
# optional — only if you use the /markdown renderer:
pnpm add streamdown @streamdown/code @streamdown/math @streamdown/mermaid \
react-markdown remark-gfm remark-math rehype-katex rehype-sanitize katexai and @ai-sdk/react are peers — the hook returns the AI SDK's own useChat result, spread with
the agent extras, so you interoperate with the SDK directly.
The minimal hook
useAgentChat needs only where the backend lives and who is calling. baseUrl is the origin (plus
any base path) the endpoints hang off — the transport appends /agent/chat itself. Omit it for a
same-origin app.
import { useAgentChat } from '@dudousxd/nestjs-agent-react';
function Assistant({ me }: { me: { id: string; roles: string[] } }) {
const chat = useAgentChat({
baseUrl: 'https://api.example.com', // omit → same origin
getHeaders: () => ({
'x-actor-id': me.id,
'x-actor-role': me.roles.join(','),
}),
});
return (
<>
{chat.messages.map((m) => (
<p key={m.id} data-role={m.role}>
{m.parts.map((p, i) => (p.type === 'text' ? <span key={i}>{p.text}</span> : null))}
</p>
))}
<button onClick={() => chat.sendMessage({ text: 'What is the weather in Lisbon?' })}>
Send
</button>
</>
);
}The return value is the AI SDK useChat result (messages, sendMessage, status, stop,
regenerate, error, …) plus the agent extras below. AgentChatTransport wires the SDK's own
regenerate() to a real backend regenerate — see Regenerating a turn below.
| Option | Type | Purpose |
|---|---|---|
baseUrl | string | Origin + base path; endpoints append /agent/*. Default '' (same origin). |
headers | Record<string,string> | Static headers merged into every request. |
getHeaders | () => headers | Promise<headers> | Resolved per request — for short-lived bearer tokens. Merged over headers. |
credentials | RequestCredentials | Forwarded to fetch so cookie auth / impersonation works. |
agent | string | Named agent to run each turn (multi-agent). |
threadId | string | Bind the chat to a thread; omitted → backend creates one on first send. |
initialMessages | UIMessage[] | Persisted history to seed useChat (consumed on mount). |
resumeRunId | string | Reconnect to a buffered in-flight stream — wire it to the thread's activeStreamId. Its presence gates the resume and names the run; omit it and no resume request fires. |
getPageContext | () => Record | null | Read at each send to attach a page snapshot (page-assistant). |
onFinish | () => void | Fired after each streamed turn (e.g. refetch the sidebar). |
onRunSettled | (outcome: { runId, status: 'completed' | 'failed' }) => void | Fired exactly once per run when the stream settles (send and resume paths) — by then the server has persisted the thread's title and the run's outcome, so this is the right place to refetch thread/list queries (fixes a sidebar stuck on "Untitled"). |
client / fetch | AgentClient / typeof fetch | Inject an existing client or a custom fetch (tests / non-browser). |
Only the latest message goes over the wire
The backend hydrates prior history from its store, so the transport sends only the newest user message text each turn. Payloads stay tiny and the client can't corrupt replayed history.
Wiring headers to identity
The backend never invents a caller — it resolves the actor from request headers (or a session/JWT)
via its ActorResolver. On the client, getHeaders is where you supply that identity. It runs on
every request, so it's the right place for a token that rotates:
const chat = useAgentChat({
getHeaders: async () => ({
authorization: `Bearer ${await auth.getFreshToken()}`,
}),
credentials: 'include', // or lean on cookies instead of a header
});Use headers for values fixed for the session (a tenant ref), getHeaders for anything short-lived.
When the backend authenticates from a cookie, drop the headers entirely and set
credentials: 'include'. See Identity & Authorization
for how the server turns these into an actor.
Headers are only as trusted as your gateway
The shipped HeaderActorResolver reads x-actor-id / x-actor-role verbatim — safe only behind a
gateway that strips and re-sets them. Production apps typically resolve the actor from a verified
session server-side, in which case the client sends a token, not an actor.
Rendering the conversation
The components are the proven chat UX with the styling removed. ChatInput is the composer (Enter
submits, Shift+Enter newlines, optional dictation); MessageList windows the thread, shows a typing
indicator, an inline error+retry banner, and follow-up chips. Everything is driven through
classNames and callbacks.
import { ChatInput, MessageList } from '@dudousxd/nestjs-agent-react';
function ChatPanel({ chat }: { chat: ReturnType<typeof useAgentChat> }) {
return (
<div className="chat">
<MessageList
messages={chat.messages}
status={chat.status}
error={chat.error ?? null}
onRetry={() => chat.regenerate()}
classNames={{
root: 'messages',
message: { byRole: { user: 'bubble-user', assistant: 'bubble-assistant' } },
}}
emptyState={<p>Ask me anything about your fleet.</p>}
typingIndicator={<Spinner />}
/>
<ChatInput
onSubmit={(text) => chat.sendMessage({ text })}
disabled={chat.status === 'streaming' || chat.status === 'submitted'}
className="composer"
textareaClassName="composer-input"
sendButtonClassName="composer-send"
/>
</div>
);
}MessageList also accepts renderText, renderToolPart / renderToolGroup (to render tool calls),
onFork / editable + onEditSubmit / regeneratable + onRegenerate, and per-message
getUsage / getCreatedAt resolvers for the cost + timestamp line. Pass <MessageItem> directly if
you want to lay the list out yourself.
Rich markdown (the /markdown subpath)
By default renderText receives plain text. To render assistant output as full markdown — GFM,
KaTeX math, syntax-highlighted code, and Mermaid — drop AgentMarkdown from the
@dudousxd/nestjs-agent-react/markdown subpath into the renderText slot. It ships the exact
streamdown stack and honours the isStreaming hint to defer expensive renders until a block settles.
import { AgentMarkdown } from '@dudousxd/nestjs-agent-react/markdown';
<MessageList
messages={chat.messages}
status={chat.status}
renderText={(text, { isStreaming }) => (
<AgentMarkdown isStreaming={isStreaming}>{text}</AgentMarkdown>
)}
/>;AgentMarkdown also takes remarkPlugins, components, allowedTags, and literalTagContent —
the seam for app-specific markdown (e.g. mention chips) that keeps domain logic in your app rather
than the library. Because the renderer's peers are optional, apps that don't import the subpath never
pull streamdown, KaTeX, or Mermaid into their bundle.
Human-in-the-loop: approve / reject
A kind: 'action' tool never auto-executes — the backend suspends the run and waits for a decision
(see Human-in-the-loop & Durability). On the
client that pending call arrives as a tool part on the assistant message: it has input but no
output yet. Render an Approve/Reject affordance and wire the buttons to the hook's approve / reject
— both target the current run automatically and identify the call by its toolCallId.
<MessageList
messages={chat.messages}
status={chat.status}
renderToolPart={(part, key) => {
const awaitingDecision = part.state === 'input-available';
return (
<div key={key} className="tool-call">
<code>{part.type}</code>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
{awaitingDecision ? (
<div className="tool-actions">
<button onClick={() => chat.approve({ toolCallId: part.toolCallId })}>Approve</button>
<button
onClick={() => chat.reject({ toolCallId: part.toolCallId, reason: 'Not now' })}
>
Reject
</button>
</div>
) : null}
</div>
);
}}
/>;approve resolves the durable suspend and the run resumes streaming; reject (with an optional
reason) tells the model the human declined. Both throw if there is no active run. The wire protocol
is identical whether the backend runs the turn durably or in-process, so the UI code never changes.
Where the decision goes
approve / reject POST to /agent/tool-call/approve · /reject for the hook's current runId.
The same run id backs cancel, which stops the SSE client-side and then hard-aborts the run server-side.
Regenerating a turn
Calling chat.regenerate() (the AI SDK's own method, unchanged on the surface) now sends
regenerate: true on the /agent/chat request instead of just replaying client-side state. The
backend truncates everything after the thread's last user message and re-answers it — no duplicate
user message appears, and the assistant's prior reply is what gets replaced.
<MessageList
messages={chat.messages}
status={chat.status}
onRetry={() => chat.regenerate()}
/>Regenerate requires an existing threadId (there's no prior exchange to redo on a fresh thread) and,
same as cancel, is ownership-scoped over HTTP — it operates on the caller's own thread.
Run failures and quota errors
A run that fails mid-stream (quota exceeded, an unhandled error) doesn't append error text to the
assistant message — it ends the stream with a structured event: error SSE frame carrying
{ code, message } (code is quota_exceeded or run_failed). useAgentChat's transport surfaces
that as an AI SDK error chunk, so it shows up on the standard chat.error the SDK already gives
you — the same error prop MessageList renders in its inline error+retry banner:
<MessageList messages={chat.messages} status={chat.status} error={chat.error ?? null} />Non-streaming REST calls (loadThreads, approve, forkThread, …) throw AgentHttpError on a
non-2xx response — it carries .status, so a 403 from an ownership-scoped endpoint (someone else's
thread or tool call) is easy to distinguish from a 404 or a 5xx.
Threads, quota, and cancel
Beyond the streaming surface, the hook returns loaders and state for the rest of the REST API. Each loader calls the backend and caches the result in the paired state field.
| Field | Type | What it does |
|---|---|---|
runId | string | undefined | Current run — the target of approve / reject / cancel. |
client | AgentClient | The underlying REST client, usable standalone. |
threads / loadThreads() | ThreadSummary[] | List thread history into state. |
loadThread(id) | Promise<ThreadDetail> | Fetch one thread's messages (to seed initialMessages). |
deleteThread(id) | Promise<void> | Delete a thread; drops it from threads. |
forkThread(threadId, messageId) | Promise<ThreadSummary> | Fork a new thread from a message. |
quota / loadQuota() | QuotaToday | null | Today's token usage. |
cancel() | Promise<void> | Stop the current stream and abort the run server-side. |
addToolResult({ tool, toolCallId, output }) | Promise<void> | Supply a client-side tool result to the SDK loop. |
function Sidebar({ chat }: { chat: ReturnType<typeof useAgentChat> }) {
useEffect(() => {
void chat.loadThreads();
void chat.loadQuota();
}, []);
return (
<aside>
<p>{chat.quota?.usedTokens ?? 0} tokens today</p>
{chat.threads.map((t) => (
<button key={t.id} onClick={() => void chat.deleteThread(t.id)}>{t.title}</button>
))}
{chat.status === 'streaming' ? <button onClick={() => chat.cancel()}>Stop</button> : null}
</aside>
);
}To reopen an existing thread, load its detail, convert its stored history to UIMessage[], seed the
hook, and — if it reports a buffered stream — let the transport reconnect:
import { storedThreadToUiMessages } from '@dudousxd/nestjs-agent-react';
const detail = await chat.loadThread(threadId);
const reopened = useAgentChat({
threadId,
initialMessages: storedThreadToUiMessages(detail.messages),
resumeRunId: detail.activeStreamId ?? undefined,
});storedThreadToUiMessages merges consecutive assistant rows into one UIMessage per turn — the
loop persists one row per model iteration, so a turn with tool calls appends a "thinking" row and a
separate final-answer row; mapped 1:1 that's 2-3 fragmented bubbles, each with its own cost/token
footer, for what the live stream renders as one continuous response. The merged turn carries
metadata.usage with summed tokens and cost (costUsd stays null only when every merged row's
cost is unknown — unknown is never treated as $0). For a single row that needs no merging, or for
any other 1:1 conversion, storedMessageToUiMessage is still available and unchanged.
No doomed resume GET
resumeRunId gates the reconnect: when a thread has no buffered stream (activeStreamId is
null), resumeRunId is undefined and the transport resolves null without a network
round-trip, so a normal mount never fires a resume request that 404s.
Related
- Bring your own UI — skip
MessageList/ChatInputand render straight offuseAgentChat, with a fully custom design system - Getting Started — install the module and stream your first turn
- Human-in-the-loop & Durability — the durable suspend behind approve/reject
- Identity & Authorization — how the backend resolves the actor your headers carry
- Multi-agent — target a named
agentper turn - Cost & Governance — the usage the
quotaand per-message cost line report
Persistence
The AgentStore SPI — threads, messages, tool calls, and a token-usage ledger — behind one ORM-portable interface, wired on MikroORM or Drizzle.
Bring your own UI
The package ships no components by design — build a chat entirely on useAgentChat, AgentChatTransport, and the stored-history mappers, owning every pixel yourself.