@cubos/agent-sdk-react 0.0.1136563

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,304 @@
1
+ # @cubos/agent-sdk-react
2
+
3
+ Headless React hooks for the Cubos Agent conversation API.
4
+
5
+ **No DOM.** The package renders nothing and imports no `react-dom`, so the same
6
+ hooks drive a web app, React Native, or anything else React runs on. A test in
7
+ this package fails the build if a browser global or a `<div>` sneaks in.
8
+
9
+ ```sh
10
+ npm install @cubos/agent-sdk @cubos/agent-sdk-react
11
+ ```
12
+
13
+ ## The whole thing
14
+
15
+ ```tsx
16
+ import { AgentProvider, useConversation } from "@cubos/agent-sdk-react";
17
+
18
+ function App() {
19
+ return (
20
+ <AgentProvider
21
+ baseUrl="https://agent.acme.com"
22
+ getToken={async () => (await fetch("/api/agent-token").then((r) => r.json())).token}
23
+ >
24
+ <Chat />
25
+ </AgentProvider>
26
+ );
27
+ }
28
+
29
+ function Chat() {
30
+ const [id, setId] = useState<string | null>(null);
31
+ const { messages, send, activity } = useConversation(id, {
32
+ agentSlug: "support",
33
+ onCreated: (conversation) => setId(conversation.id),
34
+ });
35
+
36
+ return (
37
+ <>
38
+ {messages.map((m) => (
39
+ <div key={m.id} data-role={m.role}>
40
+ {m.content}
41
+ </div>
42
+ ))}
43
+ {(activity.isProcessing || activity.hasPendingTurn) && <Typing />}
44
+ <Composer onSubmit={send} />
45
+ </>
46
+ );
47
+ }
48
+ ```
49
+
50
+ `getToken` must return a **short-lived end-user token**, minted on your server.
51
+ An api_key is tenant-wide and must never reach a client. The SDK asks again with
52
+ `forceRefresh` after a 401, so expiry is handled for you.
53
+
54
+ ## Two hooks, and you hold the id between them
55
+
56
+ There is no hook that owns "which conversation is open". That is your app's
57
+ state — component state in the example above, a route param in a real one — and
58
+ keeping it out of the package is what lets `/c/:id` work without syncing
59
+ anything.
60
+
61
+ ```tsx
62
+ const [selectedId, setSelectedId] = useState<string | null>(null);
63
+
64
+ const list = useConversationList();
65
+ const conversation = useConversation(selectedId, {
66
+ agentSlug: "support",
67
+ onCreated: (created) => setSelectedId(created.id),
68
+ });
69
+ ```
70
+
71
+ The one behaviour worth knowing: **a conversation is created on the first
72
+ message, not when the user clicks "new chat".** Otherwise every abandoned blank
73
+ slate leaves an empty row in the list. `send` covers both cases, so your UI never
74
+ special-cases "no conversation yet" — it just passes `null` until `onCreated`
75
+ fires. The messages already on screen survive that hand-off.
76
+
77
+ Both hooks paginate, in opposite directions, so the names stay apart:
78
+ `list.hasMore` / `list.loadMore` walk the **conversation list**;
79
+ `conversation.hasOlder` / `conversation.loadOlder` walk the **open
80
+ conversation's history**.
81
+
82
+ ## Infinite history, live present
83
+
84
+ `useConversation` opens with one page of history and points the live stream at
85
+ the end of that page, so a year-old conversation costs one page on open instead
86
+ of the whole log — and keeps costing one page on every reconnect.
87
+
88
+ ```tsx
89
+ const { messages, hasOlder, isLoadingOlder, loadOlder } = useConversation(id);
90
+
91
+ // Whatever your list uses to notice the top: a scroll handler, an
92
+ // IntersectionObserver on a sentinel, FlatList's onEndReached inverted.
93
+ const onReachedTop = () => {
94
+ if (hasOlder && !isLoadingOlder) void loadOlder();
95
+ };
96
+ ```
97
+
98
+ Two details that are handled for you and one that is not:
99
+
100
+ - **No gap at the junction.** The stream resumes from the page's cursor, and the
101
+ server replays everything past it before going live, so a message sent while
102
+ the page was in flight arrives rather than vanishing.
103
+ - **Order is stable.** Pages, live frames and your own optimistic message are
104
+ merged by id and sorted by sequence, so a page landing mid-turn can't shuffle
105
+ the transcript.
106
+ - **Scroll anchoring is yours.** Prepending grows the list upward while the
107
+ scroll offset stays put, which reads as a jump. Record `scrollHeight` before
108
+ calling `loadOlder` and add the difference back in a layout effect —
109
+ `packages/demo/src/chat/message-list.tsx` does exactly that.
110
+
111
+ Reopening a conversation the user has already visited costs no request at all:
112
+ the base SDK caches loaded conversations (in memory by default) and these hooks
113
+ drive it for you — reading on open, recording as messages arrive, and resuming
114
+ the stream from the cached cursor so nothing is replayed. Configure or replace it
115
+ on the client (`cache`, `cacheMessageLimit`); see the `@cubos/agent-sdk` README.
116
+
117
+ `loadOlder` resolves to how many messages it added. A page counts **events**, not
118
+ messages, so a page full of tool calls can add none while `hasOlder` stays true —
119
+ keep going rather than treating 0 as the end.
120
+
121
+ ## Your components in the agent's replies
122
+
123
+ Hand the hook a map of **renderers** plus the **component libraries** this screen
124
+ can draw, and the agent may use those components in a reply:
125
+
126
+ ```tsx
127
+ const { messages, renderMessage } = useConversation(id, {
128
+ components: {
129
+ Choices: (props) => <Choices {...props} onPick={send} />,
130
+ },
131
+ componentLibraries: ["support-widgets"],
132
+ renderMarkdown: (text) => <Markdown>{text}</Markdown>,
133
+ });
134
+
135
+ // In your bubble. Null means the message has no blocks — every user message —
136
+ // so that is where your own rendering of `content` goes.
137
+ {renderMessage(message) ?? message.content}
138
+ ```
139
+
140
+ The two halves have different owners, and that is deliberate. What the agent is
141
+ *told* about a component — its summary, what each prop means, the JSON Schema,
142
+ the example — lives in a component library that an operator authors over the
143
+ admin API, because those words go into the model's context verbatim. What
144
+ *draws* it is yours. You choose which libraries apply to the screen you have
145
+ open; the hook enables them on the conversation, at creation for a chat that
146
+ starts here and with a `PUT` when the list changes.
147
+
148
+ The server refuses any component the enabled libraries don't resolve to, and
149
+ validates props against the library's schema before the message is written — so
150
+ a renderer never sees a shape it didn't ask for. A tag with no renderer draws
151
+ nothing, and the hook logs a warning naming it, since that is a silent gap in a
152
+ reply. The reverse is never warned about: **keep renderers for components no
153
+ library offers any more**, because that is what draws the older messages in the
154
+ transcript.
155
+
156
+ `renderMarkdown` draws the prose around the components. Without it the markdown
157
+ renders as plain text: this package ships no parser, and your app already has
158
+ one.
159
+
160
+ ## Client tools: functions the agent calls in your app
161
+
162
+ ```tsx
163
+ useConversation(id, {
164
+ clientTools: {
165
+ get_browser_context: {
166
+ description: "Timezone, locale and local time, as this browser reports them.",
167
+ inputSchema: { type: "object", properties: {} },
168
+ readOnlyHint: true,
169
+ handler: () => ({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
170
+ },
171
+ },
172
+ onClientToolError: (err) => console.error(err),
173
+ });
174
+ ```
175
+
176
+ The agent calls it, **its turn suspends**, your handler runs here, and the turn
177
+ resumes with what you returned. A handler that throws answers with the failure
178
+ rather than leaving the agent to wait out its deadline.
179
+
180
+ Two things this does for you that are easy to get wrong by hand:
181
+
182
+ - **One stream, not two.** The runner rides the subscription the hook already
183
+ holds — a `client_tool_call` frame nudges it, and so does every reconnect, so a
184
+ call issued while the connection was down is picked up rather than left to
185
+ expire. Doing this outside the hook means a second SSE connection per
186
+ conversation, against a server that caps how many a user may hold.
187
+ - **Declared before the first turn.** On the blank slate the tools are declared
188
+ in the same breath as creating the conversation, so the agent can already reach
189
+ for them in its first reply.
190
+
191
+ The lease, the renewal while a slow handler runs, and the retry on the result
192
+ POST are the base SDK's `serveClientTools`; the hook only decides when to start
193
+ and stop it.
194
+
195
+ ## The surface
196
+
197
+ | Hook | What it gives you |
198
+ |---|---|
199
+ | `useConversationList(opts)` | the live list, plus `archive`, `loadMore` |
200
+ | `useConversation(id \| null, opts)` | messages, todos, activity, `send`, `sendAudio`, `sendImages`, `steer`, `loadOlder`, `renderMessage` |
201
+ | `useIdentity()` | who the token acts as, and which agents it may use |
202
+ | `useAgentClient()` | the underlying `AgentClient`, for anything not wrapped |
203
+
204
+ `useConversation(null)` is valid and opens no connection — that is the blank
205
+ slate before a conversation is picked.
206
+
207
+ Both the list and the conversation are **live**: the hooks load a page of
208
+ history, follow the SSE stream from where that page ends, and resume from the
209
+ exact point after a drop. Nothing to poll, nothing to invalidate.
210
+
211
+ ## Messages
212
+
213
+ ```ts
214
+ interface Message {
215
+ id: string;
216
+ role: "user" | "agent";
217
+ content: string;
218
+ attachments: Array<{
219
+ id: string;
220
+ kind: "image" | "audio";
221
+ mimeType: string;
222
+ bytes: number;
223
+ label: string | null;
224
+ }>;
225
+ seq: number;
226
+ at: string;
227
+ }
228
+ ```
229
+
230
+ Media hangs off `attachments`, and `content` is whatever text came with it — a
231
+ caption for images, the transcription for a voice note. **Both can be empty**, so
232
+ render `attachments` even when `content` is blank.
233
+
234
+ A voice message arrives as one `Message` carrying its clip *and* the
235
+ transcription, which is empty until the agent's STT model finishes — your cue to
236
+ render a player with a "transcribing" state.
237
+
238
+ Bytes come from `useAgentClient().fetchAttachment(conversationId, message.id,
239
+ attachment.id)`, as a `Blob` rather than a URL: the token travels in a header, so
240
+ an `<img src>` pointed at the route would arrive unauthenticated. Wrap it for the
241
+ platform and release it when the view goes away.
242
+
243
+ Sending images is the mirror image of that:
244
+
245
+ ```ts
246
+ // Up to 10 as ONE message, so the agent reasons over the set rather than
247
+ // spending a turn per picture. `caption` becomes the message's text.
248
+ await sendImages(
249
+ [{ image: receipt, label: "receipt" }, { image: statement, label: "statement" }],
250
+ "which charge is duplicated?",
251
+ );
252
+ ```
253
+
254
+ A `label` names an image for the model, which lets it answer about "the receipt"
255
+ instead of "the second image".
256
+
257
+ `send` appends optimistically and reconciles with the server's echo, rolling the
258
+ optimistic copy back if the send fails. A message whose `id` starts with
259
+ `optimistic:` has not been confirmed yet.
260
+
261
+ ## Rendering
262
+
263
+ This package deliberately ships no components and no CSS — that is what keeps it
264
+ running wherever React runs. The pieces that are genuinely generic, and do need
265
+ the DOM, live next door in
266
+ [`@cubos/agent-sdk-react-dom`](../sdk-react-dom): `AgentMarkdown` renders an
267
+ agent's reply (GFM, fenced code, TeX) and styles itself.
268
+
269
+ ```tsx
270
+ import { AgentMarkdown } from "@cubos/agent-sdk-react-dom";
271
+
272
+ useConversation(id, { renderMarkdown: (text) => <AgentMarkdown>{text}</AgentMarkdown> });
273
+ ```
274
+
275
+ Chat UI proper — bubbles, composer, conversation list — is where products differ
276
+ most, and a set of styled components for it is either too rigid to use or too
277
+ configurable to maintain.
278
+
279
+ `renderMessage` is the one exception, and it renders nothing of its own: it puts
280
+ *your* components where the agent placed them and hands the prose to *your*
281
+ markdown renderer. `renderBlocks` is the same function without the hook, for a
282
+ message you already hold.
283
+
284
+ For a complete worked example — message bubbles, a composer with image
285
+ attachments and voice recording, agent-rendered components, client tools, the
286
+ conversation list, a responsive drawer — see `packages/demo` in the repository.
287
+ It is written against these hooks and meant to be copied and changed, not
288
+ installed.
289
+
290
+ ## React Native
291
+
292
+ The hooks are clean. Two caveats live below them, in the base SDK:
293
+
294
+ - **Streaming.** Live updates use SSE over `fetch` response streams. React
295
+ Native's default networking does not stream response bodies; you need a fetch
296
+ polyfill that does (`react-native-fetch-api` with
297
+ `react-native-polyfill-globals`), handed to the provider as `fetch`.
298
+ - **Media.** `sendAudio` and `sendImages` take `Blob`s, and `fetchAttachment`
299
+ hands one back. Recording, picking an image and turning a blob into something
300
+ displayable are all platform-specific — use what the platform offers.
301
+
302
+ Components and client tools need nothing extra: `renderMessage` builds elements
303
+ with `createElement`, and a handler is a function. Your `render` is a Native
304
+ component like any other.
@@ -0,0 +1,47 @@
1
+ import type { Block, Message } from "@cubos/agent-sdk";
2
+ import { type ComponentType, type ReactNode } from "react";
3
+ /**
4
+ * What draws one component, keyed by tag name — PascalCase, as the agent writes
5
+ * it.
6
+ *
7
+ * Renderers only. What the *agent* is told about a component (its summary, the
8
+ * prop schema, the example) lives in a component library the operator authors,
9
+ * because those words go into the model's context verbatim and a browser must
10
+ * not be the one writing them. Your app supplies the drawing half and the list
11
+ * of library slugs it can draw; `useConversation` warns when the two disagree.
12
+ *
13
+ * Keep renderers here for components no enabled library offers any more — that
14
+ * is what draws the components of older messages in the transcript.
15
+ */
16
+ export type ComponentMap = Record<string, ComponentType<any>>;
17
+ export interface RenderBlocksOptions {
18
+ components?: ComponentMap;
19
+ /** Draws the prose between components. Without it the markdown is rendered as
20
+ * plain text — correct, just not formatted; the SDK ships no markdown parser
21
+ * and a chat app already has one. */
22
+ renderMarkdown?: (text: string) => ReactNode;
23
+ /**
24
+ * Wraps each rendered block, given the block it came from.
25
+ *
26
+ * The reason it takes the block and not just the node: laying blocks out
27
+ * needs to know which are prose and which are components — two cards side by
28
+ * side and the paragraph above them full width — and by the time a block is a
29
+ * `ReactNode` that is gone. The SDK will not decide the layout (it renders no
30
+ * DOM at all), so it hands back what the app needs to decide it.
31
+ */
32
+ wrapBlock?: (node: ReactNode, block: Block) => ReactNode;
33
+ }
34
+ /**
35
+ * The message split into nodes: prose through `renderMarkdown`, each component
36
+ * block through the component registered under its tag.
37
+ *
38
+ * Null when the message carries no blocks — a user message, or an older server.
39
+ * That distinction is real, so it is left to the caller rather than papered over
40
+ * with `content`: a user's own text usually shouldn't go through a markdown
41
+ * renderer at all.
42
+ *
43
+ * A tag with no component renders nothing. It means the set changed after the
44
+ * message was written, and showing raw `<Tag …/>` text would be worse than a
45
+ * gap.
46
+ */
47
+ export declare function renderBlocks(message: Message, options?: RenderBlocksOptions): ReactNode[] | null;
@@ -0,0 +1,23 @@
1
+ import type { AgentClient, ClientOptions } from "@cubos/agent-sdk";
2
+ import { type ReactNode } from "react";
3
+ /** An intersection, not `extends`: `ClientOptions` is a union (`getToken` or
4
+ * `token`), and an interface cannot extend one. */
5
+ export type AgentProviderProps = ClientOptions & {
6
+ children: ReactNode;
7
+ };
8
+ /**
9
+ * Holds one `AgentClient` for the tree below it.
10
+ *
11
+ * `baseUrl`, `tenant`, `fetch` and `timeoutMs` are identity keys: change one and
12
+ * a new client is built, so every subscription below reconnects. That is right
13
+ * when you switch backend or user, and a waste when it happens because the props
14
+ * object was rebuilt for nothing — keep `fetch` stable if you pass it.
15
+ *
16
+ * The credential is exempt: both `getToken` and `token` reach the client through
17
+ * a ref, so a fresh closure every render (the normal case for an inline arrow)
18
+ * neither rebuilds the client nor pins a stale token.
19
+ */
20
+ export declare function AgentProvider({ children, ...options }: AgentProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<AgentClient | null>>;
21
+ /** The client from the nearest `AgentProvider`. Throws outside one, because the
22
+ * alternative is a component that silently never loads. */
23
+ export declare function useAgentClient(): AgentClient;
@@ -0,0 +1,13 @@
1
+ export type { Activity, Attachment, AttachmentKind, Block, ClientTool, ClientToolCall, ClientToolContext, Conversation, ConversationEvent, EnabledComponents, Identity, Message, MessagePage, MessageRole, Todo, TodoStatus, } from "@cubos/agent-sdk";
2
+ export type { ComponentMap, RenderBlocksOptions } from "./blocks.js";
3
+ export { renderBlocks } from "./blocks.js";
4
+ export type { AgentProviderProps } from "./context.js";
5
+ export { AgentProvider, useAgentClient } from "./context.js";
6
+ export type { UseConversationOptions, UseConversationResult } from "./use-conversation.js";
7
+ export { useConversation } from "./use-conversation.js";
8
+ export type { UseConversationEventsOptions, UseConversationEventsResult, } from "./use-conversation-events.js";
9
+ export { useConversationEvents } from "./use-conversation-events.js";
10
+ export type { UseConversationListOptions, UseConversationListResult, } from "./use-conversation-list.js";
11
+ export { useConversationList } from "./use-conversation-list.js";
12
+ export type { UseIdentityResult } from "./use-identity.js";
13
+ export { useIdentity } from "./use-identity.js";