@cubos/agent-sdk-react
Headless React hooks for the Cubos Agent conversation API.
No DOM. The package renders nothing and imports no react-dom, so the same
hooks drive a web app, React Native, or anything else React runs on. A test in
this package fails the build if a browser global or a <div> sneaks in.
npm install @cubos/agent-sdk @cubos/agent-sdk-react
The whole thing
import { AgentProvider, useConversation } from "@cubos/agent-sdk-react";
function App() {
return (
<AgentProvider
baseUrl="https://agent.acme.com"
getToken={async () => (await fetch("/api/agent-token").then((r) => r.json())).token}
>
<Chat />
</AgentProvider>
);
}
function Chat() {
const [id, setId] = useState<string | null>(null);
const { messages, send, activity } = useConversation(id, {
agentSlug: "support",
onCreated: (conversation) => setId(conversation.id),
});
return (
<>
{messages.map((m) => (
<div key={m.id} data-role={m.role}>
{m.content}
</div>
))}
{(activity.isProcessing || activity.hasPendingTurn) && <Typing />}
<Composer onSubmit={send} />
</>
);
}
getToken must return a short-lived end-user token, minted on your server.
An api_key is tenant-wide and must never reach a client. The SDK asks again with
forceRefresh after a 401, so expiry is handled for you.
Two hooks, and you hold the id between them
There is no hook that owns "which conversation is open". That is your app's
state — component state in the example above, a route param in a real one — and
keeping it out of the package is what lets /c/:id work without syncing
anything.
const [selectedId, setSelectedId] = useState<string | null>(null);
const list = useConversationList();
const conversation = useConversation(selectedId, {
agentSlug: "support",
onCreated: (created) => setSelectedId(created.id),
});
The one behaviour worth knowing: a conversation is created on the first
message, not when the user clicks "new chat". Otherwise every abandoned blank
slate leaves an empty row in the list. send covers both cases, so your UI never
special-cases "no conversation yet" — it just passes null until onCreated
fires. The messages already on screen survive that hand-off.
Both hooks paginate, in opposite directions, so the names stay apart:
list.hasMore / list.loadMore walk the conversation list;
conversation.hasOlder / conversation.loadOlder walk the open
conversation's history.
Infinite history, live present
useConversation opens with one page of history and points the live stream at
the end of that page, so a year-old conversation costs one page on open instead
of the whole log — and keeps costing one page on every reconnect.
const { messages, hasOlder, isLoadingOlder, loadOlder } = useConversation(id);
// Whatever your list uses to notice the top: a scroll handler, an
// IntersectionObserver on a sentinel, FlatList's onEndReached inverted.
const onReachedTop = () => {
if (hasOlder && !isLoadingOlder) void loadOlder();
};
Two details that are handled for you and one that is not:
- No gap at the junction. The stream resumes from the page's cursor, and the server replays everything past it before going live, so a message sent while the page was in flight arrives rather than vanishing.
- Order is stable. Pages, live frames and your own optimistic message are merged by id and sorted by sequence, so a page landing mid-turn can't shuffle the transcript.
- Scroll anchoring is yours. Prepending grows the list upward while the
scroll offset stays put, which reads as a jump. Record
scrollHeightbefore callingloadOlderand add the difference back in a layout effect — or, on the web, takeuseTranscriptAnchorfrom@cubos/agent-sdk-react-dom, which is exactly that plus the paging trigger.
Reopening a conversation the user has already visited costs no request at all:
the base SDK caches loaded conversations (in memory by default) and these hooks
drive it for you — reading on open, recording as messages arrive, and resuming
the stream from the cached cursor so nothing is replayed. Configure or replace it
on the client (cache, cacheMessageLimit); see the @cubos/agent-sdk README.
loadOlder resolves to how many messages it actually added. History is paged
by event, not by message, and the tool calls behind one reply can fill a
page on their own — so the hook keeps paging until something you did not
already hold lands, and one call is always one visible step back. Read 0
together with hasOlder: false is the beginning of the conversation, true
(rare — the scan hit its page cap) means call again.
Your components in the agent's replies
Hand the hook a map of renderers plus the component libraries this screen can draw, and the agent may use those components in a reply:
const { messages, renderMessage } = useConversation(id, {
components: {
Choices: (props) => <Choices {...props} onPick={send} />,
},
componentLibraries: ["support-widgets"],
renderMarkdown: (text) => <Markdown>{text}</Markdown>,
});
// In your bubble. Null means the message has no blocks — every user message —
// so that is where your own rendering of `content` goes.
{
renderMessage(message) ?? message.content;
}
The two halves have different owners, and that is deliberate. What the agent is
told about a component — its summary, what each prop means, the JSON Schema,
the example — lives in a component library that an operator authors over the
admin API, because those words go into the model's context verbatim. What
draws it is yours. You choose which libraries apply to the screen you have
open; the hook enables them on the conversation, at creation for a chat that
starts here and with a PUT when the list changes.
The server refuses any component the enabled libraries don't resolve to, and validates props against the library's schema before the message is written — so a renderer never sees a shape it didn't ask for. A tag with no renderer draws nothing, and the hook logs a warning naming it, since that is a silent gap in a reply. The reverse is never warned about: keep renderers for components no library offers any more, because that is what draws the older messages in the transcript.
renderMarkdown draws the prose around the components. Without it the markdown
renders as plain text: this package ships no parser, and your app already has
one.
Client tools: functions the agent calls in your app
useConversation(id, {
clientTools: {
get_browser_context: {
description: "Timezone, locale and local time, as this browser reports them.",
inputSchema: { type: "object", properties: {} },
readOnlyHint: true,
handler: () => ({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
},
},
onClientToolError: (err) => console.error(err),
});
The agent calls it, its turn suspends, your handler runs here, and the turn resumes with what you returned. A handler that throws answers with the failure rather than leaving the agent to wait out its deadline.
Two things this does for you that are easy to get wrong by hand:
- One stream, not two. The runner rides the subscription the hook already
holds — a
client_tool_callframe nudges it, and so does every reconnect, so a call issued while the connection was down is picked up rather than left to expire. Doing this outside the hook means a second SSE connection per conversation, against a server that caps how many a user may hold. - Declared before the first turn. On the blank slate the tools are declared in the same breath as creating the conversation, so the agent can already reach for them in its first reply.
The lease, the renewal while a slow handler runs, and the retry on the result
POST are the base SDK's serveClientTools; the hook only decides when to start
and stop it.
The surface
| Hook | What it gives you |
|---|---|
useConversationList(opts) | the live list, plus archive, loadMore |
useConversation(id | null, opts) | messages, todos, activity, send, sendAudio, sendImages, loadOlder, renderMessage |
useIdentity() | who the token acts as, and which agents it may use |
useAgentClient() | the underlying AgentClient, for anything not wrapped |
useConversation(null) is valid and opens no connection — that is the blank
slate before a conversation is picked.
Both the list and the conversation are live: the hooks load a page of history, follow the SSE stream from where that page ends, and resume from the exact point after a drop. Nothing to poll, nothing to invalidate.
When the stream is not live
A drop is ordinary and the SDK handles it: it reconnects with backoff, replays the cursor so nothing is missed, and mints a fresh token if the old one expired under a stream that outlived it. A connection that goes silent — a phone leaving the background, a proxy that hung up without telling either end — is dropped by a watchdog and reopened within ~30s, because the server sends a keep-alive comment every 15s and two missed in a row means the socket is gone.
What the SDK cannot fix, it reports. useConversation returns:
| Field | Meaning |
|---|---|
isStreamLive | connected right now. False before the first connect and between a drop and its reconnect — a discreet "reconnecting…" hint, never an error |
streamError | the subscription died and will not come back: the token was rejected twice, the conversation is gone, or the server refused the stream |
streamError is the case a UI cannot infer on its own — everything still
renders, the agent simply never answers again — so show it. Nothing recovers
from it automatically: remount the hook (or change conversationId and back) to
resubscribe. useConversationList reports the same thing in its own
streamError.
Messages
interface Message {
id: string;
role: "user" | "agent";
content: string;
attachments: Array<{
id: string;
kind: "image" | "audio";
mimeType: string;
bytes: number;
label: string | null;
}>;
seq: number;
at: string;
}
Media hangs off attachments, and content is whatever text came with it — a
caption for images, the transcription for a voice note. Both can be empty, so
render attachments even when content is blank.
A voice message arrives as one Message carrying its clip and the
transcription, which is empty until the agent's STT model finishes — your cue to
render a player with a "transcribing" state.
Bytes come from useAgentClient().fetchAttachment(conversationId, message.id, attachment.id), as a Blob rather than a URL: the token travels in a header, so
an <img src> pointed at the route would arrive unauthenticated. Wrap it for the
platform and release it when the view goes away.
Sending images is the mirror image of that:
// Up to 10 as ONE message, so the agent reasons over the set rather than
// spending a turn per picture. `caption` becomes the message's text.
await sendImages(
[
{ image: receipt, label: "receipt" },
{ image: statement, label: "statement" },
],
"which charge is duplicated?",
);
A label names an image for the model, which lets it answer about "the receipt"
instead of "the second image".
send appends optimistically and reconciles with the server's echo, rolling the
optimistic copy back if the send fails. A message whose id starts with
optimistic: has not been confirmed yet.
Rendering
This package deliberately ships no components and no CSS — that is what keeps it
running wherever React runs. The pieces that are genuinely generic, and do need
the DOM, live next door in
@cubos/agent-sdk-react-dom: AgentMarkdown renders an
agent's reply (GFM, fenced code, TeX) and styles itself.
import { AgentMarkdown } from "@cubos/agent-sdk-react-dom";
useConversation(id, { renderMarkdown: (text) => <AgentMarkdown>{text}</AgentMarkdown> });
Chat UI proper — bubbles, composer, conversation list — is where products differ most, and a set of styled components for it is either too rigid to use or too configurable to maintain.
renderMessage is the one exception, and it renders nothing of its own: it puts
your components where the agent placed them and hands the prose to your
markdown renderer. renderBlocks is the same function without the hook, for a
message you already hold.
For a complete worked example — message bubbles, a composer with image
attachments and voice recording, agent-rendered components, client tools, the
conversation list — see the dashboard's packages/web/components/chats/ in the
repository, which is built on these hooks.
It is written against these hooks and meant to be copied and changed, not
installed.
React Native
The hooks are clean. Two caveats live below them, in the base SDK:
- Streaming. Live updates use SSE over
fetchresponse streams. React Native's default networking does not stream response bodies; you need a fetch polyfill that does (react-native-fetch-apiwithreact-native-polyfill-globals), handed to the provider asfetch. - Media.
sendAudioandsendImagestakeBlobs, andfetchAttachmenthands one back. Recording, picking an image and turning a blob into something displayable are all platform-specific — use what the platform offers.
Components and client tools need nothing extra: renderMessage builds elements
with createElement, and a handler is a function. Your render is a Native
component like any other.