Aviary
Guides

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 katex

ai 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.

OptionTypePurpose
baseUrlstringOrigin + base path; endpoints append /agent/*. Default '' (same origin).
headersRecord<string,string>Static headers merged into every request.
getHeaders() => headers | Promise<headers>Resolved per request — for short-lived bearer tokens. Merged over headers.
credentialsRequestCredentialsForwarded to fetch so cookie auth / impersonation works.
agentstringNamed agent to run each turn (multi-agent).
threadIdstringBind the chat to a thread; omitted → backend creates one on first send.
initialMessagesUIMessage[]Persisted history to seed useChat (consumed on mount).
resumeRunIdstringReconnect 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 | nullRead at each send to attach a page snapshot (page-assistant).
onFinish() => voidFired after each streamed turn (e.g. refetch the sidebar).
onRunSettled(outcome: { runId, status: 'completed' | 'failed' }) => voidFired 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 / fetchAgentClient / typeof fetchInject 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.

FieldTypeWhat it does
runIdstring | undefinedCurrent run — the target of approve / reject / cancel.
clientAgentClientThe 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 | nullToday'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.


On this page