@assistant-ui/mcp-docs-server 0.1.25 → 0.1.27

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.
Files changed (62) hide show
  1. package/.docs/organized/code-examples/waterfall.md +4 -4
  2. package/.docs/organized/code-examples/with-a2a.md +5 -5
  3. package/.docs/organized/code-examples/with-ag-ui.md +6 -6
  4. package/.docs/organized/code-examples/with-ai-sdk-v6.md +7 -7
  5. package/.docs/organized/code-examples/with-artifacts.md +7 -7
  6. package/.docs/organized/code-examples/with-assistant-transport.md +5 -5
  7. package/.docs/organized/code-examples/with-chain-of-thought.md +7 -7
  8. package/.docs/organized/code-examples/with-cloud-standalone.md +8 -8
  9. package/.docs/organized/code-examples/with-cloud.md +7 -7
  10. package/.docs/organized/code-examples/with-custom-thread-list.md +7 -7
  11. package/.docs/organized/code-examples/with-elevenlabs-conversational.md +511 -0
  12. package/.docs/organized/code-examples/with-elevenlabs-scribe.md +10 -10
  13. package/.docs/organized/code-examples/with-expo.md +18 -18
  14. package/.docs/organized/code-examples/with-external-store.md +5 -5
  15. package/.docs/organized/code-examples/with-ffmpeg.md +220 -66
  16. package/.docs/organized/code-examples/with-google-adk.md +6 -6
  17. package/.docs/organized/code-examples/with-heat-graph.md +4 -4
  18. package/.docs/organized/code-examples/with-interactables.md +836 -0
  19. package/.docs/organized/code-examples/with-langgraph.md +6 -6
  20. package/.docs/organized/code-examples/with-livekit.md +591 -0
  21. package/.docs/organized/code-examples/with-parent-id-grouping.md +6 -6
  22. package/.docs/organized/code-examples/with-react-hook-form.md +8 -8
  23. package/.docs/organized/code-examples/with-react-ink.md +3 -3
  24. package/.docs/organized/code-examples/with-react-router.md +11 -11
  25. package/.docs/organized/code-examples/with-store.md +11 -6
  26. package/.docs/organized/code-examples/with-tanstack.md +8 -8
  27. package/.docs/organized/code-examples/with-tap-runtime.md +8 -8
  28. package/.docs/raw/blog/2026-03-launch-week/index.mdx +31 -0
  29. package/.docs/raw/docs/(docs)/cli.mdx +60 -0
  30. package/.docs/raw/docs/(docs)/copilots/model-context.mdx +9 -1
  31. package/.docs/raw/docs/(docs)/guides/attachments.mdx +65 -4
  32. package/.docs/raw/docs/(docs)/guides/interactables.mdx +354 -0
  33. package/.docs/raw/docs/(docs)/guides/message-timing.mdx +3 -3
  34. package/.docs/raw/docs/(docs)/guides/multi-agent.mdx +1 -0
  35. package/.docs/raw/docs/(docs)/guides/tool-ui.mdx +29 -0
  36. package/.docs/raw/docs/(docs)/guides/voice.mdx +333 -0
  37. package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +128 -0
  38. package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +23 -0
  39. package/.docs/raw/docs/cloud/ai-sdk-assistant-ui.mdx +6 -0
  40. package/.docs/raw/docs/cloud/ai-sdk.mdx +81 -1
  41. package/.docs/raw/docs/ink/primitives.mdx +141 -0
  42. package/.docs/raw/docs/primitives/action-bar.mdx +351 -0
  43. package/.docs/raw/docs/primitives/assistant-modal.mdx +215 -0
  44. package/.docs/raw/docs/primitives/attachment.mdx +216 -0
  45. package/.docs/raw/docs/primitives/branch-picker.mdx +221 -0
  46. package/.docs/raw/docs/primitives/chain-of-thought.mdx +311 -0
  47. package/.docs/raw/docs/primitives/composer.mdx +526 -0
  48. package/.docs/raw/docs/primitives/error.mdx +141 -0
  49. package/.docs/raw/docs/primitives/index.mdx +98 -0
  50. package/.docs/raw/docs/primitives/message.mdx +524 -0
  51. package/.docs/raw/docs/primitives/selection-toolbar.mdx +165 -0
  52. package/.docs/raw/docs/primitives/suggestion.mdx +242 -0
  53. package/.docs/raw/docs/primitives/thread-list.mdx +404 -0
  54. package/.docs/raw/docs/primitives/thread.mdx +482 -0
  55. package/.docs/raw/docs/runtimes/a2a/index.mdx +4 -0
  56. package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +2 -2
  57. package/.docs/raw/docs/runtimes/assistant-transport.mdx +6 -2
  58. package/.docs/raw/docs/ui/context-display.mdx +2 -2
  59. package/.docs/raw/docs/ui/mention.mdx +168 -0
  60. package/.docs/raw/docs/ui/model-selector.mdx +1 -1
  61. package/.docs/raw/docs/ui/voice.mdx +172 -0
  62. package/package.json +3 -4
@@ -0,0 +1,836 @@
1
+ # Example: with-interactables
2
+
3
+ ## app/api/chat/route.ts
4
+
5
+ ```typescript
6
+ import { openai } from "@ai-sdk/openai";
7
+ import {
8
+ streamText,
9
+ convertToModelMessages,
10
+ stepCountIs,
11
+ jsonSchema,
12
+ } from "ai";
13
+ import type { UIMessage } from "ai";
14
+
15
+ export const maxDuration = 30;
16
+
17
+ type ToolDef = { description?: string; parameters: Record<string, unknown> };
18
+
19
+ export async function POST(req: Request) {
20
+ const {
21
+ messages,
22
+ system,
23
+ tools: clientTools,
24
+ }: {
25
+ messages: UIMessage[];
26
+ system?: string;
27
+ tools?: Record<string, ToolDef>;
28
+ } = await req.json();
29
+
30
+ // Convert client-defined tools (forwarded from model context) to AI SDK format.
31
+ // These have no `execute` — they are frontend tools executed on the client
32
+ // via useAssistantTool / useAssistantInteractable.
33
+ const tools = clientTools
34
+ ? Object.fromEntries(
35
+ Object.entries(clientTools).map(([name, def]) => [
36
+ name,
37
+ {
38
+ description: def.description ?? "",
39
+ inputSchema: jsonSchema(def.parameters),
40
+ },
41
+ ]),
42
+ )
43
+ : undefined;
44
+
45
+ const result = streamText({
46
+ model: openai("gpt-4o"),
47
+ messages: await convertToModelMessages(messages),
48
+ stopWhen: stepCountIs(10),
49
+ ...(system ? { system } : {}),
50
+ ...(tools ? { tools } : {}),
51
+ } as Parameters<typeof streamText>[0]);
52
+
53
+ return result.toUIMessageStreamResponse();
54
+ }
55
+
56
+ ```
57
+
58
+ ## app/globals.css
59
+
60
+ ```css
61
+ @import "tailwindcss";
62
+ @import "tw-animate-css";
63
+
64
+ @source "../../../packages/ui/src";
65
+
66
+ @custom-variant dark (&:is(.dark *));
67
+
68
+ @theme inline {
69
+ --radius-sm: calc(var(--radius) - 4px);
70
+ --radius-md: calc(var(--radius) - 2px);
71
+ --radius-lg: var(--radius);
72
+ --radius-xl: calc(var(--radius) + 4px);
73
+ --color-background: var(--background);
74
+ --color-foreground: var(--foreground);
75
+ --color-card: var(--card);
76
+ --color-card-foreground: var(--card-foreground);
77
+ --color-popover: var(--popover);
78
+ --color-popover-foreground: var(--popover-foreground);
79
+ --color-primary: var(--primary);
80
+ --color-primary-foreground: var(--primary-foreground);
81
+ --color-secondary: var(--secondary);
82
+ --color-secondary-foreground: var(--secondary-foreground);
83
+ --color-muted: var(--muted);
84
+ --color-muted-foreground: var(--muted-foreground);
85
+ --color-accent: var(--accent);
86
+ --color-accent-foreground: var(--accent-foreground);
87
+ --color-destructive: var(--destructive);
88
+ --color-border: var(--border);
89
+ --color-input: var(--input);
90
+ --color-ring: var(--ring);
91
+ --color-chart-1: var(--chart-1);
92
+ --color-chart-2: var(--chart-2);
93
+ --color-chart-3: var(--chart-3);
94
+ --color-chart-4: var(--chart-4);
95
+ --color-chart-5: var(--chart-5);
96
+ --color-sidebar: var(--sidebar);
97
+ --color-sidebar-foreground: var(--sidebar-foreground);
98
+ --color-sidebar-primary: var(--sidebar-primary);
99
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
100
+ --color-sidebar-accent: var(--sidebar-accent);
101
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
102
+ --color-sidebar-border: var(--sidebar-border);
103
+ --color-sidebar-ring: var(--sidebar-ring);
104
+ }
105
+
106
+ :root {
107
+ --radius: 0.625rem;
108
+ --background: oklch(1 0 0);
109
+ --foreground: oklch(0.141 0.005 285.823);
110
+ --card: oklch(1 0 0);
111
+ --card-foreground: oklch(0.141 0.005 285.823);
112
+ --popover: oklch(1 0 0);
113
+ --popover-foreground: oklch(0.141 0.005 285.823);
114
+ --primary: oklch(0.21 0.006 285.885);
115
+ --primary-foreground: oklch(0.985 0 0);
116
+ --secondary: oklch(0.967 0.001 286.375);
117
+ --secondary-foreground: oklch(0.21 0.006 285.885);
118
+ --muted: oklch(0.967 0.001 286.375);
119
+ --muted-foreground: oklch(0.552 0.016 285.938);
120
+ --accent: oklch(0.967 0.001 286.375);
121
+ --accent-foreground: oklch(0.21 0.006 285.885);
122
+ --destructive: oklch(0.577 0.245 27.325);
123
+ --border: oklch(0.92 0.004 286.32);
124
+ --input: oklch(0.92 0.004 286.32);
125
+ --ring: oklch(0.705 0.015 286.067);
126
+ --chart-1: oklch(0.646 0.222 41.116);
127
+ --chart-2: oklch(0.6 0.118 184.704);
128
+ --chart-3: oklch(0.398 0.07 227.392);
129
+ --chart-4: oklch(0.828 0.189 84.429);
130
+ --chart-5: oklch(0.769 0.188 70.08);
131
+ --sidebar: oklch(0.985 0 0);
132
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
133
+ --sidebar-primary: oklch(0.21 0.006 285.885);
134
+ --sidebar-primary-foreground: oklch(0.985 0 0);
135
+ --sidebar-accent: oklch(0.967 0.001 286.375);
136
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
137
+ --sidebar-border: oklch(0.92 0.004 286.32);
138
+ --sidebar-ring: oklch(0.705 0.015 286.067);
139
+ }
140
+
141
+ .dark {
142
+ --background: oklch(0.141 0.005 285.823);
143
+ --foreground: oklch(0.985 0 0);
144
+ --card: oklch(0.21 0.006 285.885);
145
+ --card-foreground: oklch(0.985 0 0);
146
+ --popover: oklch(0.21 0.006 285.885);
147
+ --popover-foreground: oklch(0.985 0 0);
148
+ --primary: oklch(0.92 0.004 286.32);
149
+ --primary-foreground: oklch(0.21 0.006 285.885);
150
+ --secondary: oklch(0.274 0.006 286.033);
151
+ --secondary-foreground: oklch(0.985 0 0);
152
+ --muted: oklch(0.274 0.006 286.033);
153
+ --muted-foreground: oklch(0.705 0.015 286.067);
154
+ --accent: oklch(0.274 0.006 286.033);
155
+ --accent-foreground: oklch(0.985 0 0);
156
+ --destructive: oklch(0.704 0.191 22.216);
157
+ --border: oklch(1 0 0 / 10%);
158
+ --input: oklch(1 0 0 / 15%);
159
+ --ring: oklch(0.552 0.016 285.938);
160
+ --chart-1: oklch(0.488 0.243 264.376);
161
+ --chart-2: oklch(0.696 0.17 162.48);
162
+ --chart-3: oklch(0.769 0.188 70.08);
163
+ --chart-4: oklch(0.627 0.265 303.9);
164
+ --chart-5: oklch(0.645 0.246 16.439);
165
+ --sidebar: oklch(0.21 0.006 285.885);
166
+ --sidebar-foreground: oklch(0.985 0 0);
167
+ --sidebar-primary: oklch(0.488 0.243 264.376);
168
+ --sidebar-primary-foreground: oklch(0.985 0 0);
169
+ --sidebar-accent: oklch(0.274 0.006 286.033);
170
+ --sidebar-accent-foreground: oklch(0.985 0 0);
171
+ --sidebar-border: oklch(1 0 0 / 10%);
172
+ --sidebar-ring: oklch(0.552 0.016 285.938);
173
+ }
174
+
175
+ @layer base {
176
+ * {
177
+ @apply border-border outline-ring/50;
178
+ }
179
+ body {
180
+ @apply bg-background text-foreground;
181
+ }
182
+ }
183
+
184
+ ```
185
+
186
+ ## app/layout.tsx
187
+
188
+ ```tsx
189
+ import type { Metadata } from "next";
190
+ import "./globals.css";
191
+
192
+ export const metadata: Metadata = {
193
+ title: "assistant-ui Interactables Example",
194
+ description:
195
+ "Example using assistant-ui interactable components with a task board",
196
+ };
197
+
198
+ export default function RootLayout({
199
+ children,
200
+ }: Readonly<{
201
+ children: React.ReactNode;
202
+ }>) {
203
+ return (
204
+ <html lang="en">
205
+ <body className="h-dvh">{children}</body>
206
+ </html>
207
+ );
208
+ }
209
+
210
+ ```
211
+
212
+ ## app/page.tsx
213
+
214
+ ```tsx
215
+ "use client";
216
+
217
+ import { useRef, useState, useCallback, useEffect } from "react";
218
+ import { Thread } from "@/components/assistant-ui/thread";
219
+ import {
220
+ AssistantRuntimeProvider,
221
+ Interactables,
222
+ Suggestions,
223
+ useAui,
224
+ useAssistantInteractable,
225
+ useInteractableState,
226
+ useAssistantTool,
227
+ } from "@assistant-ui/react";
228
+ import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
229
+ import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
230
+ import { z } from "zod";
231
+ import {
232
+ CheckCircle2Icon,
233
+ CircleIcon,
234
+ ListTodoIcon,
235
+ Loader2Icon,
236
+ StickyNoteIcon,
237
+ Trash2Icon,
238
+ PlusIcon,
239
+ } from "lucide-react";
240
+
241
+ // ===========================================================================
242
+ // 1. Task Board — single-instance interactable + custom tool
243
+ // ===========================================================================
244
+
245
+ type Task = { id: string; title: string; done: boolean };
246
+ type TaskBoardState = { tasks: Task[] };
247
+
248
+ const taskBoardSchema = z.object({
249
+ tasks: z.array(
250
+ z.object({
251
+ id: z.string(),
252
+ title: z.string(),
253
+ done: z.boolean(),
254
+ }),
255
+ ),
256
+ });
257
+
258
+ const taskBoardInitialState: TaskBoardState = { tasks: [] };
259
+
260
+ let nextTaskId = 0;
261
+
262
+ function TaskBoard() {
263
+ const id = useAssistantInteractable("taskBoard", {
264
+ description:
265
+ "A task board showing the user's tasks. Use the manage_tasks tool (not update_taskBoard) to add/toggle/remove/clear tasks.",
266
+ stateSchema: taskBoardSchema,
267
+ initialState: taskBoardInitialState,
268
+ });
269
+ const [state, { setState, isPending }] = useInteractableState<TaskBoardState>(
270
+ id,
271
+ taskBoardInitialState,
272
+ );
273
+
274
+ const setStateRef = useRef(setState);
275
+ setStateRef.current = setState;
276
+
277
+ useAssistantTool({
278
+ toolName: "manage_tasks",
279
+ description:
280
+ 'Manage tasks on the task board. Actions: "add" (requires title), "toggle" (requires id), "remove" (requires id), "clear" (no extra fields).',
281
+ parameters: z.object({
282
+ action: z.enum(["add", "toggle", "remove", "clear"]),
283
+ title: z.string().optional(),
284
+ id: z.string().optional(),
285
+ }),
286
+ execute: async (args) => {
287
+ const set = setStateRef.current;
288
+ switch (args.action) {
289
+ case "add": {
290
+ const id = `task-${++nextTaskId}`;
291
+ set((prev) => ({
292
+ tasks: [
293
+ ...prev.tasks,
294
+ { id, title: args.title ?? "Untitled", done: false },
295
+ ],
296
+ }));
297
+ return { success: true, id };
298
+ }
299
+ case "toggle": {
300
+ if (!args.id) return { success: false, error: "id is required" };
301
+ set((prev) => ({
302
+ tasks: prev.tasks.map((t) =>
303
+ t.id === args.id ? { ...t, done: !t.done } : t,
304
+ ),
305
+ }));
306
+ return { success: true };
307
+ }
308
+ case "remove": {
309
+ if (!args.id) return { success: false, error: "id is required" };
310
+ set((prev) => ({
311
+ tasks: prev.tasks.filter((t) => t.id !== args.id),
312
+ }));
313
+ return { success: true };
314
+ }
315
+ case "clear": {
316
+ set({ tasks: [] });
317
+ return { success: true };
318
+ }
319
+ default:
320
+ return { success: false, error: "Unknown action" };
321
+ }
322
+ },
323
+ });
324
+
325
+ const doneCount = state.tasks.filter((t) => t.done).length;
326
+
327
+ return (
328
+ <div className="flex flex-col">
329
+ <div className="flex items-center gap-2 border-b px-4 py-3">
330
+ <ListTodoIcon className="size-4 text-muted-foreground" />
331
+ <span className="font-semibold text-sm">Task Board</span>
332
+ {isPending && (
333
+ <Loader2Icon className="size-3 animate-spin text-muted-foreground" />
334
+ )}
335
+ {state.tasks.length > 0 && (
336
+ <span className="ml-auto rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary text-xs">
337
+ {doneCount}/{state.tasks.length}
338
+ </span>
339
+ )}
340
+ </div>
341
+ <div className="flex-1 overflow-y-auto p-3">
342
+ {state.tasks.length === 0 ? (
343
+ <p className="py-6 text-center text-muted-foreground text-xs">
344
+ No tasks yet. Ask the assistant!
345
+ </p>
346
+ ) : (
347
+ <ul className="space-y-1">
348
+ {state.tasks.map((task) => (
349
+ <li
350
+ key={task.id}
351
+ className="group flex items-center gap-2 rounded-lg px-3 py-2 transition-colors hover:bg-muted"
352
+ >
353
+ <button
354
+ type="button"
355
+ onClick={() =>
356
+ setState((prev) => ({
357
+ tasks: prev.tasks.map((t) =>
358
+ t.id === task.id ? { ...t, done: !t.done } : t,
359
+ ),
360
+ }))
361
+ }
362
+ className="shrink-0"
363
+ >
364
+ {task.done ? (
365
+ <CheckCircle2Icon className="size-4 text-primary" />
366
+ ) : (
367
+ <CircleIcon className="size-4 text-muted-foreground" />
368
+ )}
369
+ </button>
370
+ <span
371
+ className={`flex-1 text-sm ${task.done ? "text-muted-foreground line-through" : ""}`}
372
+ >
373
+ {task.title}
374
+ </span>
375
+ <button
376
+ type="button"
377
+ onClick={() =>
378
+ setState((prev) => ({
379
+ tasks: prev.tasks.filter((t) => t.id !== task.id),
380
+ }))
381
+ }
382
+ className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
383
+ >
384
+ <Trash2Icon className="size-3.5 text-muted-foreground hover:text-destructive" />
385
+ </button>
386
+ </li>
387
+ ))}
388
+ </ul>
389
+ )}
390
+ </div>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // ===========================================================================
396
+ // 2. Sticky Notes — multi-instance interactable + selection + partial updates
397
+ // ===========================================================================
398
+
399
+ type NoteState = { title: string; content: string; color: string };
400
+
401
+ const noteSchema = z.object({
402
+ title: z.string(),
403
+ content: z.string(),
404
+ color: z.enum(["yellow", "blue", "green", "pink"]),
405
+ });
406
+
407
+ const noteInitialState: NoteState = {
408
+ title: "New Note",
409
+ content: "",
410
+ color: "yellow",
411
+ };
412
+
413
+ const COLORS: Record<string, string> = {
414
+ yellow: "bg-yellow-100 border-yellow-300",
415
+ blue: "bg-blue-100 border-blue-300",
416
+ green: "bg-green-100 border-green-300",
417
+ pink: "bg-pink-100 border-pink-300",
418
+ };
419
+
420
+ function NoteCard({
421
+ noteId,
422
+ selectedId,
423
+ onSelect,
424
+ onRemove,
425
+ }: {
426
+ noteId: string;
427
+ selectedId: string | null;
428
+ onSelect: (id: string) => void;
429
+ onRemove: (id: string) => void;
430
+ }) {
431
+ // Multi-instance: each NoteCard has a unique `id`, all share name "note"
432
+ // Partial updates: AI can send { color: "blue" } without resending title/content
433
+ // Selection: clicking a note calls setSelected(true)
434
+ useAssistantInteractable("note", {
435
+ id: noteId,
436
+ description:
437
+ "A sticky note. The AI can partially update any field (title, content, color) without resending the others.",
438
+ stateSchema: noteSchema,
439
+ initialState: noteInitialState,
440
+ selected: selectedId === noteId,
441
+ });
442
+ const [state, { setSelected }] = useInteractableState<NoteState>(
443
+ noteId,
444
+ noteInitialState,
445
+ );
446
+
447
+ const isSelected = selectedId === noteId;
448
+
449
+ const handleClick = () => {
450
+ onSelect(noteId);
451
+ setSelected(true);
452
+ };
453
+
454
+ return (
455
+ <div
456
+ role="button"
457
+ tabIndex={0}
458
+ onClick={handleClick}
459
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && handleClick()}
460
+ className={`group relative flex w-full cursor-pointer flex-col gap-1 rounded-lg border-2 p-3 text-left transition-all ${COLORS[state.color] ?? COLORS.yellow} ${isSelected ? "ring-2 ring-primary ring-offset-1" : "hover:shadow-sm"}`}
461
+ >
462
+ {isSelected && (
463
+ <span className="absolute top-1.5 right-2 rounded bg-primary px-1.5 py-0.5 font-medium text-[10px] text-primary-foreground leading-none">
464
+ SELECTED
465
+ </span>
466
+ )}
467
+ <span className="pr-16 font-semibold text-sm text-zinc-800">
468
+ {state.title}
469
+ </span>
470
+ <span className="line-clamp-3 text-xs text-zinc-600">
471
+ {state.content || "Empty note"}
472
+ </span>
473
+ <button
474
+ type="button"
475
+ onClick={(e) => {
476
+ e.stopPropagation();
477
+ onRemove(noteId);
478
+ }}
479
+ className="absolute right-2 bottom-2 rounded p-0.5 opacity-0 transition-opacity hover:bg-black/10 group-hover:opacity-100"
480
+ >
481
+ <Trash2Icon className="size-3 text-zinc-500" />
482
+ </button>
483
+ </div>
484
+ );
485
+ }
486
+
487
+ const NOTE_IDS_KEY = "interactables-example-note-ids";
488
+
489
+ function loadNoteIds(): string[] {
490
+ try {
491
+ const saved = localStorage.getItem(NOTE_IDS_KEY);
492
+ return saved ? JSON.parse(saved) : [];
493
+ } catch {
494
+ return [];
495
+ }
496
+ }
497
+
498
+ function NotesPanel() {
499
+ const [noteIds, setNoteIds] = useState<string[]>([]);
500
+ const [selectedId, setSelectedId] = useState<string | null>(null);
501
+ const hydratedRef = useRef(false);
502
+
503
+ useEffect(() => {
504
+ if (!hydratedRef.current) {
505
+ hydratedRef.current = true;
506
+ const saved = loadNoteIds();
507
+ if (saved.length > 0) setNoteIds(saved);
508
+ return;
509
+ }
510
+ localStorage.setItem(NOTE_IDS_KEY, JSON.stringify(noteIds));
511
+ }, [noteIds]);
512
+
513
+ const noteIdsRef = useRef(noteIds);
514
+ noteIdsRef.current = noteIds;
515
+ const setNoteIdsRef = useRef(setNoteIds);
516
+ setNoteIdsRef.current = setNoteIds;
517
+ const setSelectedIdRef = useRef(setSelectedId);
518
+ setSelectedIdRef.current = setSelectedId;
519
+
520
+ // Tool to add/remove notes (manages the list, not the note content)
521
+ useAssistantTool({
522
+ toolName: "manage_notes",
523
+ description:
524
+ 'Manage sticky notes. Actions: "add" (creates a new note, returns its id), "remove" (requires noteId), "clear" (removes all notes). After adding, use the update_note_{id} tool to set its content.',
525
+ parameters: z.object({
526
+ action: z.enum(["add", "remove", "clear"]),
527
+ noteId: z.string().optional(),
528
+ }),
529
+ execute: async (args) => {
530
+ switch (args.action) {
531
+ case "add": {
532
+ const id = `note-${Date.now().toString(36)}`;
533
+ setNoteIdsRef.current((prev) => [...prev, id]);
534
+ return { success: true, noteId: id };
535
+ }
536
+ case "remove": {
537
+ if (args.noteId) {
538
+ setNoteIdsRef.current((prev) =>
539
+ prev.filter((id) => id !== args.noteId),
540
+ );
541
+ }
542
+ return { success: true };
543
+ }
544
+ case "clear": {
545
+ setNoteIdsRef.current([]);
546
+ setSelectedIdRef.current(null);
547
+ return { success: true };
548
+ }
549
+ default:
550
+ return { success: false, error: "Unknown action" };
551
+ }
552
+ },
553
+ });
554
+
555
+ const handleSelect = useCallback((id: string) => {
556
+ setSelectedId(id);
557
+ }, []);
558
+
559
+ const handleRemove = useCallback((id: string) => {
560
+ setNoteIds((prev) => prev.filter((n) => n !== id));
561
+ setSelectedId((prev) => (prev === id ? null : prev));
562
+ }, []);
563
+
564
+ return (
565
+ <div className="flex flex-col">
566
+ <div className="flex items-center gap-2 border-b px-4 py-3">
567
+ <StickyNoteIcon className="size-4 text-muted-foreground" />
568
+ <span className="font-semibold text-sm">Notes</span>
569
+ <span className="ml-auto text-muted-foreground text-xs">
570
+ {noteIds.length}
571
+ </span>
572
+ <button
573
+ type="button"
574
+ onClick={() => {
575
+ const id = `note-${Date.now().toString(36)}`;
576
+ setNoteIds((prev) => [...prev, id]);
577
+ }}
578
+ className="rounded p-1 transition-colors hover:bg-muted"
579
+ >
580
+ <PlusIcon className="size-3.5 text-muted-foreground" />
581
+ </button>
582
+ </div>
583
+ <div className="flex-1 overflow-y-auto p-3">
584
+ {noteIds.length === 0 ? (
585
+ <p className="py-6 text-center text-muted-foreground text-xs">
586
+ No notes yet. Ask the assistant!
587
+ </p>
588
+ ) : (
589
+ <div className="grid gap-2">
590
+ {noteIds.map((noteId) => (
591
+ <NoteCard
592
+ key={noteId}
593
+ noteId={noteId}
594
+ selectedId={selectedId}
595
+ onSelect={handleSelect}
596
+ onRemove={handleRemove}
597
+ />
598
+ ))}
599
+ </div>
600
+ )}
601
+ </div>
602
+ </div>
603
+ );
604
+ }
605
+
606
+ // ===========================================================================
607
+ // App
608
+ // ===========================================================================
609
+
610
+ const STORAGE_KEY = "interactables-example";
611
+
612
+ function useInteractablePersistence(aui: ReturnType<typeof useAui>) {
613
+ useEffect(() => {
614
+ const saved = localStorage.getItem(STORAGE_KEY);
615
+ if (saved) {
616
+ try {
617
+ aui.interactables().importState(JSON.parse(saved));
618
+ } catch {
619
+ // ignore malformed data
620
+ }
621
+ }
622
+
623
+ aui.interactables().setPersistenceAdapter({
624
+ save: (state) => {
625
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
626
+ },
627
+ });
628
+ }, [aui]);
629
+ }
630
+
631
+ export default function Home() {
632
+ const runtime = useChatRuntime({
633
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
634
+ });
635
+
636
+ const aui = useAui({
637
+ interactables: Interactables(),
638
+ suggestions: Suggestions([
639
+ {
640
+ title: "Add 3 tasks",
641
+ label: "for a grocery run",
642
+ prompt: "Add 3 tasks for a grocery run",
643
+ },
644
+ {
645
+ title: "Create 2 notes",
646
+ label: "and set different colors",
647
+ prompt:
648
+ "Create 2 sticky notes: one blue note about meeting prep, and one green note about project ideas",
649
+ },
650
+ {
651
+ title: "Change selected note",
652
+ label: "to pink color",
653
+ prompt: "Change the selected note's color to pink",
654
+ },
655
+ ]),
656
+ });
657
+
658
+ useInteractablePersistence(aui);
659
+
660
+ return (
661
+ <AssistantRuntimeProvider aui={aui} runtime={runtime}>
662
+ <main className="flex h-full">
663
+ <div className="flex-1">
664
+ <Thread />
665
+ </div>
666
+ <div className="flex w-80 flex-col border-l">
667
+ <div className="flex-1 overflow-y-auto">
668
+ <NotesPanel />
669
+ </div>
670
+ <div className="flex-1 overflow-y-auto border-t">
671
+ <TaskBoard />
672
+ </div>
673
+ </div>
674
+ </main>
675
+ </AssistantRuntimeProvider>
676
+ );
677
+ }
678
+
679
+ ```
680
+
681
+ ## components.json
682
+
683
+ ```json
684
+ {
685
+ "$schema": "https://ui.shadcn.com/schema.json",
686
+ "style": "new-york",
687
+ "rsc": true,
688
+ "tsx": true,
689
+ "tailwind": {
690
+ "config": "",
691
+ "css": "app/globals.css",
692
+ "baseColor": "zinc",
693
+ "cssVariables": true,
694
+ "prefix": ""
695
+ },
696
+ "aliases": {
697
+ "components": "@/components",
698
+ "utils": "@/lib/utils",
699
+ "ui": "@/components/ui",
700
+ "lib": "@/lib",
701
+ "hooks": "@/hooks"
702
+ },
703
+ "iconLibrary": "lucide",
704
+ "registries": {
705
+ "@assistant-ui": "https://r.assistant-ui.com/{name}.json"
706
+ }
707
+ }
708
+
709
+ ```
710
+
711
+ ## next.config.ts
712
+
713
+ ```typescript
714
+ import type { NextConfig } from "next";
715
+
716
+ const nextConfig: NextConfig = {
717
+ /* config options here */
718
+ };
719
+
720
+ export default nextConfig;
721
+
722
+ ```
723
+
724
+ ## package.json
725
+
726
+ ```json
727
+ {
728
+ "name": "with-interactables",
729
+ "version": "0.0.0",
730
+ "private": true,
731
+ "type": "module",
732
+ "scripts": {
733
+ "dev": "next dev",
734
+ "build": "next build",
735
+ "start": "next start"
736
+ },
737
+ "dependencies": {
738
+ "@ai-sdk/openai": "^3.0.50",
739
+ "@assistant-ui/react": "workspace:*",
740
+ "@assistant-ui/react-ai-sdk": "workspace:*",
741
+ "@assistant-ui/ui": "workspace:*",
742
+ "ai": "^6.0.144",
743
+ "class-variance-authority": "^0.7.1",
744
+ "clsx": "^2.1.1",
745
+ "lucide-react": "^1.7.0",
746
+ "next": "^16.2.2",
747
+ "react": "^19.2.4",
748
+ "react-dom": "^19.2.4",
749
+ "tailwind-merge": "^3.5.0",
750
+ "zod": "^4.3.6"
751
+ },
752
+ "devDependencies": {
753
+ "@assistant-ui/x-buildutils": "workspace:*",
754
+ "@tailwindcss/postcss": "^4.2.2",
755
+ "@types/node": "^25.5.0",
756
+ "@types/react": "^19.2.14",
757
+ "@types/react-dom": "^19.2.3",
758
+ "postcss": "^8.5.8",
759
+ "tailwindcss": "^4.2.2",
760
+ "tw-animate-css": "^1.4.0",
761
+ "typescript": "5.9.3"
762
+ }
763
+ }
764
+
765
+ ```
766
+
767
+ ## README.md
768
+
769
+ ```markdown
770
+ # with-interactables
771
+
772
+ Demonstrates **interactable components** — persistent UI components whose state can be read and updated by both the user and the AI assistant.
773
+
774
+ ## Features Demonstrated
775
+
776
+ ### Task Board (single instance + custom tool)
777
+ - `useInteractable("taskBoard", config)` — registers a single interactable
778
+ - `useAssistantTool("manage_tasks")` — custom tool for incremental add/toggle/remove/clear
779
+ - Auto-generated `update_taskBoard` tool with **partial updates** (AI only sends changed fields)
780
+
781
+ ### Sticky Notes (multi-instance + selection + partial updates)
782
+ - Multiple `<NoteCard>` components each call `useInteractable("note", { id: noteId, ... })`
783
+ - **Multi-instance**: each note gets its own `update_note_{id}` tool automatically
784
+ - **Selection**: click a note to select it; AI sees `(SELECTED)` in system prompt and prioritizes it
785
+ - **Partial updates**: AI can change just `{ color: "pink" }` without resending title and content
786
+
787
+ ## Getting Started
788
+
789
+ ```bash
790
+ # Install dependencies (from monorepo root)
791
+ pnpm install
792
+
793
+ # Set your OpenAI API key
794
+ cp .env.example .env.local
795
+ # Edit .env.local and add your OPENAI_API_KEY
796
+
797
+ # Run the development server
798
+ pnpm --filter with-interactables dev
799
+ ```
800
+
801
+ Open [http://localhost:3000](http://localhost:3000) to see the example.
802
+
803
+ ## Key Concepts
804
+
805
+ - **`Interactables()`** — scope resource registered via `useAui`
806
+ - **`useInteractable(name, config)`** — returns `[state, setState, { id, setSelected }]`
807
+ - **Partial updates** — auto-generated tools use partial schemas; AI only sends changed fields
808
+ - **Multi-instance** — same `name`, different `id`; tools named `update_{name}_{id}`
809
+ - **Selection** — `setSelected(true)` marks a component as focused for the AI
810
+ - **`useAssistantTool`** — custom frontend tools for fine-grained control
811
+ - **`sendAutomaticallyWhen`** — auto-sends follow-up messages when tool calls complete
812
+
813
+ ```
814
+
815
+ ## tsconfig.json
816
+
817
+ ```json
818
+ {
819
+ "extends": "@assistant-ui/x-buildutils/ts/next",
820
+ "compilerOptions": {
821
+ "paths": {
822
+ "@/*": ["./*"],
823
+ "@/components/assistant-ui/*": [
824
+ "../../packages/ui/src/components/assistant-ui/*"
825
+ ],
826
+ "@/components/ui/*": ["../../packages/ui/src/components/ui/*"],
827
+ "@/lib/utils": ["../../packages/ui/src/lib/utils"],
828
+ "@assistant-ui/ui/*": ["../../packages/ui/src/*"]
829
+ }
830
+ },
831
+ "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
832
+ "exclude": ["node_modules"]
833
+ }
834
+
835
+ ```
836
+