@cubos/agent-sdk-react

中文文档
0.0.1174337发布于 2026/11/07SEE LICENSE IN LICENSETypeScriptES Module零依赖近 30 天下载 1.1万

Headless React hooks for the Cubos Agent conversation API. No DOM — works in React Native too.

原始使用文档
文档目录10 节
  1. The whole thing
  2. Two hooks, and you hold the id between them
  3. Infinite history, live present
  4. Your components in the agent's replies
  5. Client tools: functions the agent calls in your app
  6. The surface
  7. When the stream is not live
  8. Messages
  9. Rendering
  10. React Native

@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 scrollHeight before calling loadOlder and add the difference back in a layout effect — or, on the web, take useTranscriptAnchor from @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_call frame 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

HookWhat 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:

FieldMeaning
isStreamLiveconnected right now. False before the first connect and between a drop and its reconnect — a discreet "reconnecting…" hint, never an error
streamErrorthe 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 fetch response streams. React Native's default networking does not stream response bodies; you need a fetch polyfill that does (react-native-fetch-api with react-native-polyfill-globals), handed to the provider as fetch.
  • Media. sendAudio and sendImages take Blobs, and fetchAttachment hands 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.

版本与发布记录

@cubos/agent-sdk-react 共发布 113 个版本,首次发布于 2026/09/08;过去 12 个月发布了 113 个版本。

版本发布日期距今
0.0.1174337latest2026/11/073 天前
0.0.11725352026/11/055 天前
0.0.11717452026/11/046 天前
0.0.11714852026/11/046 天前
0.0.11712492026/11/047 天前
0.0.11704982026/11/037 天前

关于 @cubos/agent-sdk-react 的常见问题

@cubos/agent-sdk-react 是什么?

Headless React hooks for the Cubos Agent conversation API. No DOM — works in React Native too.

如何安装 @cubos/agent-sdk-react?

在项目目录运行 npm install @cubos/agent-sdk-react,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=18)、@cubos/agent-sdk(0.0.x)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@cubos/agent-sdk-react 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cubos/agent-sdk-react 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cubos/agent-sdk-react 可以商用吗?

@cubos/agent-sdk-react 使用 SEE LICENSE IN LICENSE 许可。许可条款写在包内的独立文件中,请阅读该文件确认可否商用。以上为概括,不构成法律意见,请以许可原文为准。

@cubos/agent-sdk-react 的下载量有多少?

根据 npm 官方下载统计,@cubos/agent-sdk-react 在 2026/10/11 – 2026/11/09 这 30 天内被下载 11,118 次,日均约 371 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cubos/agent-sdk-react 还在维护吗?

最新版本 0.0.1174337 发布于 2026/11/07(3 天前);至今共发布 113 个版本,其中过去 12 个月发布了 113 个;首次发布于 2026/09/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。