@assistant-ui/mcp-docs-server 0.1.34 → 0.1.36

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 (127) 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 +73 -57
  7. package/.docs/organized/code-examples/with-browser-extension.md +7 -7
  8. package/.docs/organized/code-examples/with-chain-of-thought.md +44 -93
  9. package/.docs/organized/code-examples/with-cloud-standalone.md +7 -7
  10. package/.docs/organized/code-examples/with-cloud.md +7 -7
  11. package/.docs/organized/code-examples/with-custom-thread-list.md +7 -7
  12. package/.docs/organized/code-examples/with-elevenlabs-conversational.md +9 -9
  13. package/.docs/organized/code-examples/with-elevenlabs-scribe.md +9 -9
  14. package/.docs/organized/code-examples/with-eve.md +343 -0
  15. package/.docs/organized/code-examples/with-expo.md +943 -940
  16. package/.docs/organized/code-examples/with-external-store.md +5 -5
  17. package/.docs/organized/code-examples/with-ffmpeg.md +8 -11
  18. package/.docs/organized/code-examples/with-generative-ui.md +33 -309
  19. package/.docs/organized/code-examples/with-google-adk.md +5 -5
  20. package/.docs/organized/code-examples/with-heat-graph.md +4 -4
  21. package/.docs/organized/code-examples/with-image-generation.md +7 -7
  22. package/.docs/organized/code-examples/with-interactables.md +169 -341
  23. package/.docs/organized/code-examples/with-langchain.md +7 -7
  24. package/.docs/organized/code-examples/with-langgraph.md +23 -160
  25. package/.docs/organized/code-examples/with-livekit.md +10 -10
  26. package/.docs/organized/code-examples/with-mcp.md +7 -7
  27. package/.docs/organized/code-examples/with-opencode.md +106 -580
  28. package/.docs/organized/code-examples/with-pi.md +2046 -0
  29. package/.docs/organized/code-examples/with-react-hook-form.md +16 -9
  30. package/.docs/organized/code-examples/with-react-ink-web.md +691 -0
  31. package/.docs/organized/code-examples/with-react-ink.md +29 -17
  32. package/.docs/organized/code-examples/with-react-router.md +12 -12
  33. package/.docs/organized/code-examples/with-resumable-stream.md +8 -8
  34. package/.docs/organized/code-examples/with-store.md +14 -10
  35. package/.docs/organized/code-examples/with-tanstack.md +21 -7
  36. package/.docs/organized/code-examples/with-tap-runtime.md +5 -5
  37. package/.docs/organized/code-examples/with-virtualized-thread.md +676 -0
  38. package/.docs/raw/docs/(docs)/architecture.mdx +13 -12
  39. package/.docs/raw/docs/(docs)/cli.mdx +4 -2
  40. package/.docs/raw/docs/(docs)/devtools.mdx +25 -2
  41. package/.docs/raw/docs/(docs)/index.mdx +5 -2
  42. package/.docs/raw/docs/(docs)/installation.mdx +5 -2
  43. package/.docs/raw/docs/(reference)/api-reference/external-store/message-conversion.mdx +16 -16
  44. package/.docs/raw/docs/(reference)/api-reference/hooks/composer-triggers.mdx +73 -42
  45. package/.docs/raw/docs/(reference)/api-reference/hooks/model-context.mdx +3 -20
  46. package/.docs/raw/docs/(reference)/api-reference/hooks/primitives.mdx +234 -123
  47. package/.docs/raw/docs/(reference)/api-reference/integrations/eve.mdx +51 -0
  48. package/.docs/raw/docs/(reference)/api-reference/integrations/index.mdx +4 -1
  49. package/.docs/raw/docs/(reference)/api-reference/integrations/react-ai-sdk.mdx +70 -38
  50. package/.docs/raw/docs/(reference)/api-reference/model-context/context.mdx +1 -1
  51. package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar.mdx +30 -0
  52. package/.docs/raw/docs/(reference)/api-reference/primitives/attachment.mdx +7 -0
  53. package/.docs/raw/docs/(reference)/api-reference/primitives/branch-picker.mdx +26 -0
  54. package/.docs/raw/docs/(reference)/api-reference/primitives/chain-of-thought.mdx +15 -0
  55. package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +103 -0
  56. package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +16 -0
  57. package/.docs/raw/docs/(reference)/api-reference/primitives/message.mdx +56 -0
  58. package/.docs/raw/docs/(reference)/api-reference/primitives/queue-item.mdx +12 -0
  59. package/.docs/raw/docs/(reference)/api-reference/primitives/selection-toolbar.mdx +12 -0
  60. package/.docs/raw/docs/(reference)/api-reference/primitives/suggestion.mdx +14 -0
  61. package/.docs/raw/docs/(reference)/api-reference/primitives/thread.mdx +57 -1
  62. package/.docs/raw/docs/(reference)/api-reference/tools/index.mdx +6 -0
  63. package/.docs/raw/docs/(reference)/api-reference/tools/interactables-legacy.mdx +55 -0
  64. package/.docs/raw/docs/(reference)/api-reference/tools/interactables.mdx +151 -0
  65. package/.docs/raw/docs/(reference)/api-reference/tools/rendering.mdx +17 -38
  66. package/.docs/raw/docs/(reference)/api-reference/tools/status.mdx +22 -0
  67. package/.docs/raw/docs/(reference)/api-reference/tools/toolkits.mdx +20 -27
  68. package/.docs/raw/docs/(reference)/api-reference/utilities/miscellaneous.mdx +40 -25
  69. package/.docs/raw/docs/guides/chain-of-thought.mdx +1 -1
  70. package/.docs/raw/docs/guides/headless-composer-input.mdx +113 -0
  71. package/.docs/raw/docs/guides/index.mdx +3 -0
  72. package/.docs/raw/docs/guides/input-history.mdx +55 -0
  73. package/.docs/raw/docs/guides/latex.mdx +28 -22
  74. package/.docs/raw/docs/guides/mentions.mdx +32 -7
  75. package/.docs/raw/docs/guides/slash-commands.mdx +3 -6
  76. package/.docs/raw/docs/guides/speech.mdx +5 -7
  77. package/.docs/raw/docs/guides/virtualization.mdx +133 -0
  78. package/.docs/raw/docs/guides/voice.mdx +3 -2
  79. package/.docs/raw/docs/ink/hooks.mdx +2 -2
  80. package/.docs/raw/docs/integrations/attachments/custom-adapter.mdx +4 -12
  81. package/.docs/raw/docs/integrations/auth/better-auth.mdx +7 -10
  82. package/.docs/raw/docs/integrations/auth/clerk.mdx +3 -9
  83. package/.docs/raw/docs/integrations/auth/next-auth.mdx +3 -8
  84. package/.docs/raw/docs/integrations/frameworks/ai-sdk.mdx +8 -5
  85. package/.docs/raw/docs/integrations/index.mdx +5 -12
  86. package/.docs/raw/docs/integrations/observability/helicone.mdx +3 -4
  87. package/.docs/raw/docs/integrations/observability/langfuse.mdx +3 -5
  88. package/.docs/raw/docs/integrations/observability/langsmith.mdx +3 -2
  89. package/.docs/raw/docs/integrations/persistence/custom-adapter.mdx +4 -3
  90. package/.docs/raw/docs/primitives/composer.mdx +8 -0
  91. package/.docs/raw/docs/primitives/thread.mdx +24 -0
  92. package/.docs/raw/docs/react-native/hooks.mdx +1 -1
  93. package/.docs/raw/docs/runtimes/ag-ui/runtime-options.mdx +22 -3
  94. package/.docs/raw/docs/runtimes/concepts/stability.mdx +1 -1
  95. package/.docs/raw/docs/runtimes/custom/data-stream.mdx +1 -1
  96. package/.docs/raw/docs/runtimes/custom/external-store.mdx +54 -0
  97. package/.docs/raw/docs/runtimes/custom/local-runtime.mdx +108 -4
  98. package/.docs/raw/docs/runtimes/eve/overview.mdx +100 -0
  99. package/.docs/raw/docs/runtimes/eve/quickstart.mdx +143 -0
  100. package/.docs/raw/docs/runtimes/langchain.mdx +386 -18
  101. package/.docs/raw/docs/runtimes/langgraph/quickstart.mdx +1 -1
  102. package/.docs/raw/docs/runtimes/langgraph/tutorial/part-1.mdx +1 -1
  103. package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +12 -6
  104. package/.docs/raw/docs/tools/defining-tools.mdx +4 -0
  105. package/.docs/raw/docs/tools/interactables-legacy.mdx +410 -0
  106. package/.docs/raw/docs/tools/interactables.mdx +892 -223
  107. package/.docs/raw/docs/tools/mcp.mdx +4 -4
  108. package/.docs/raw/docs/tools/multi-agent.mdx +5 -0
  109. package/.docs/raw/docs/tools/tool-ui.mdx +37 -1
  110. package/.docs/raw/docs/ui/composer-trigger-popover.mdx +2 -0
  111. package/.docs/raw/docs/ui/dot-matrix.mdx +133 -0
  112. package/.docs/raw/docs/ui/file.mdx +1 -1
  113. package/.docs/raw/docs/ui/model-selector.mdx +219 -52
  114. package/.docs/raw/docs/ui/number-roll.mdx +154 -0
  115. package/.docs/raw/docs/ui/part-grouping.mdx +38 -0
  116. package/.docs/raw/docs/ui/reasoning.mdx +3 -3
  117. package/.docs/raw/docs/ui/streamdown.mdx +2 -0
  118. package/.docs/raw/docs/ui/syntax-highlighting.mdx +1 -1
  119. package/.docs/raw/docs/ui/thread.mdx +52 -0
  120. package/.docs/raw/docs/ui/tool-fallback.mdx +2 -2
  121. package/.docs/raw/docs/utilities/react-o11y.mdx +92 -6
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +18 -2
  124. package/dist/index.js.map +1 -1
  125. package/package.json +4 -4
  126. package/src/index.ts +14 -6
  127. package/src/tools/tests/mcp-protocol.test.ts +9 -0
@@ -1,45 +1,58 @@
1
1
  ---
2
- title: Interactable Components
3
- description: Build persistent UI elements whose state the AI can read and update — copilot interactables in React with assistant-ui for forms, dashboards, and tools.
2
+ title: Interactable Tool UIs
3
+ description: Build stateful components and tool UIs that both the user and the model can read and edit. Render them beside the thread, or inside messages as versioned, editable surfaces like notepads and artifacts.
4
4
  platforms: ["react"]
5
5
  ---
6
6
 
7
7
  import { InteractableSample } from "@/components/docs/samples/interactable";
8
8
 
9
- Interactables are React components that live outside the chat message flow and have state that both the user and the AI can read and write. This enables AI-driven UI patterns where the assistant controls parts of your application beyond the chat window.
9
+ Interactables allow both agents and users to read and edit tool UIs and components. They can be in-thread tool UIs like an email composer, or app-scoped components, like artifacts, task boards, or settings panels.
10
10
 
11
11
  <InteractableSample />
12
12
 
13
13
  ## Overview
14
14
 
15
- Unlike regular tool UIs that appear inline within messages, interactables:
15
+ ### Types of Interactables
16
16
 
17
- - **Persist across messages** — they live outside the chat thread
18
- - **Have shared state** — both the user (via React) and the AI (via auto-generated tools) can update them
19
- - **Support partial updates** — the AI only needs to send the fields it wants to change
20
- - **Are developer-placed** — you decide where they render in your app
21
- - **Auto-register tools** — the AI automatically gets a tool to update each interactable's state
17
+ - **App-scoped:** a component you mount yourself with `unstable_useInteractable`, anywhere in your app (a sidebar, a panel, wherever). The model automatically gets an `update_{name}` tool to read and edit it, and its state can persist across threads.
18
+ - **Thread-scoped:** an interactable tool UI the model can call in-thread. You define it with `unstable_interactableTool` inside `defineToolkit`, and it renders inline when called.
22
19
 
23
- Common use cases:
20
+ ### Features
24
21
 
25
- - Task boards that the AI can add items to
26
- - Data dashboards that update based on conversation
27
- - Forms that the AI pre-fills
28
- - Canvas/editor components that the AI can manipulate
22
+ - **Shared, editable state**: the user (via React) and the model (via the auto-generated `update_{name}` tool) both write to the same state, and each sees the other's edits.
23
+ - **Streaming and partial updates**: updates are streamed to the interactable, and the model only needs to update the fields it wants to change.
24
+ - **Versioning and history**: each user edit and model `update_*` is recorded as a version you can display, list, and `restore()` ([Versions](#versions))
25
+ - **Persistent**: state outlives the tool call and the turn, and can survive a reload (thread-scoped via thread history; app-scoped with a persistence adapter).
26
+ - **Auto-generated update tools:** based on the interactable's state schema, an `update_{name}` tool is generated for the model to update and edit it.
27
+
28
+ ### Use Cases
29
+
30
+ - Settings panels or dashboards the agent can edit and interact with
31
+ - Collaborative task lists, sticky notes, document editors
32
+ - Making artifacts editable and versioned
33
+ - Anything else, **any React component can be an interactable!**
29
34
 
30
35
  ## Quick Start
31
36
 
32
- ### 1. Register the Interactables scope
37
+ <Steps>
38
+ <Step>
39
+
40
+ ### Register the interactables scope
33
41
 
34
42
  ```tsx
35
- import { useAui, Interactables, AssistantRuntimeProvider } from "@assistant-ui/react";
43
+ import {
44
+ useAui,
45
+ unstable_Interactables,
46
+ AssistantRuntimeProvider,
47
+ Tools,
48
+ } from "@assistant-ui/react";
36
49
  import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
37
50
 
38
51
  function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
39
52
  const runtime = useChatRuntime();
40
53
 
41
54
  const aui = useAui({
42
- interactables: Interactables(),
55
+ unstable_interactables: unstable_Interactables(), // [!code ++]
43
56
  });
44
57
 
45
58
  return (
@@ -50,277 +63,577 @@ function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
50
63
  }
51
64
  ```
52
65
 
53
- ### 2. Create an interactable
54
-
55
- <Callout type="warn">
56
- Define the `stateSchema` and `initialState` **outside** the component (or
57
- memoize them). Creating a new schema on every render will cause the
58
- interactable to re-register and reset its state.
66
+ <Callout type="idea">
67
+ The legacy `interactables: Interactables()` scope and the new
68
+ `unstable_interactables: unstable_Interactables()` scope are mutually
69
+ exclusive. Mount only one interactables API in a single `useAui` provider.
59
70
  </Callout>
60
71
 
72
+ This scope is needed for both kinds of interactable. Thread-scoped interactables also live in a toolkit, which you register with `Tools` (shown below).
73
+
74
+ </Step>
75
+ <Step>
76
+
77
+ ### Define an interactable
78
+
79
+ Pick the path that matches who creates it.
80
+
81
+ <Tabs items={["App-scoped", "Thread-scoped"]}>
82
+ <Tab>
83
+
84
+ A component you mount yourself. Define it with `unstable_useInteractable` where you render it (a sidebar, a panel, wherever).
85
+
61
86
  ```tsx
62
- import { useAssistantInteractable, useInteractableState } from "@assistant-ui/react";
87
+ import { unstable_useInteractable } from "@assistant-ui/react";
63
88
  import { z } from "zod";
64
89
 
65
90
  const taskBoardSchema = z.object({
66
91
  tasks: z.array(
67
- z.object({
68
- id: z.string(),
69
- title: z.string(),
70
- done: z.boolean(),
71
- }),
92
+ z.object({ id: z.string(), title: z.string(), done: z.boolean() }),
72
93
  ),
73
94
  });
74
95
 
75
- const taskBoardInitialState = { tasks: [] };
76
-
77
96
  function TaskBoard() {
78
- const id = useAssistantInteractable("taskBoard", {
79
- description: "A task board showing the user's tasks",
97
+ const [state, { setState }] = unstable_useInteractable("taskBoard", {
98
+ description:
99
+ "A task board panel that lists tasks. Use update_taskBoard with tasks.add/update/remove/clear to manage tasks. New tasks need a title and done=false.",
80
100
  stateSchema: taskBoardSchema,
81
- initialState: taskBoardInitialState,
101
+ initialState: { tasks: [] },
82
102
  });
83
- const [state, { setState }] = useInteractableState(id, taskBoardInitialState);
84
103
 
85
104
  return (
86
- <div>
87
- <h2>Tasks</h2>
88
- <ul>
89
- {state.tasks.map((task) => (
90
- <li key={task.id}>
91
- <label>
92
- <input
93
- type="checkbox"
94
- checked={task.done}
95
- onChange={() =>
96
- setState((prev) => ({
97
- tasks: prev.tasks.map((t) =>
98
- t.id === task.id ? { ...t, done: !t.done } : t,
99
- ),
100
- }))
101
- }
102
- />
103
- {task.title}
104
- </label>
105
- </li>
106
- ))}
107
- </ul>
108
- </div>
105
+ <ul>
106
+ {state.tasks.map((task) => (
107
+ <li key={task.id}>
108
+ <input
109
+ type="checkbox"
110
+ checked={task.done}
111
+ onChange={() =>
112
+ setState((prev) => ({
113
+ tasks: prev.tasks.map((t) =>
114
+ t.id === task.id ? { ...t, done: !t.done } : t,
115
+ ),
116
+ }))
117
+ }
118
+ />
119
+ {task.title}
120
+ </li>
121
+ ))}
122
+ </ul>
109
123
  );
110
124
  }
111
125
  ```
112
126
 
113
- ### 3. Place it in your layout
127
+ That's all you need, `update_taskBoard` is generated automatically from your `stateSchema` once the `TaskBoard` component is mounted in your app.
128
+
129
+ <Callout type="tip">
130
+ App-scoped state is shared across every thread and can be persisted by
131
+ defining a [persistence adapter](#persistence).
132
+ </Callout>
133
+
134
+ </Tab>
135
+ <Tab>
136
+
137
+ An interactable tool UI the model can call in-thread. Define it with `unstable_interactableTool` inside `defineToolkit`; it renders inline when the model calls it, and its `update_{name}` tool is generated automatically from your `stateSchema` once the tool is called by the model.
138
+
139
+ ```tsx
140
+ "use generative";
141
+
142
+ import { defineToolkit, unstable_interactableTool } from "@assistant-ui/react";
143
+ import { z } from "zod";
144
+
145
+ const notepadSchema = z.object({
146
+ title: z.string(),
147
+ content: z.string(),
148
+ });
149
+
150
+ const toolkit = defineToolkit({
151
+ notepad: unstable_interactableTool({
152
+ description: "A notepad with drafted text the user can read and edit.",
153
+ stateSchema: notepadSchema,
154
+ render: ({ state, setState, version, streaming }) => (
155
+ <Notepad
156
+ value={state}
157
+ onChange={setState}
158
+ busy={streaming}
159
+ readOnly={version ? !version.isLatest : false}
160
+ />
161
+ ),
162
+ }),
163
+ });
164
+ ```
165
+
166
+ Register the toolkit alongside the `unstable_Interactables` scope:
114
167
 
115
168
  ```tsx
116
- function App() {
117
- return (
118
- <MyRuntimeProvider>
119
- <div className="flex">
120
- <Thread className="flex-1" />
121
- <TaskBoard /> {/* Lives outside the chat */}
122
- </div>
123
- </MyRuntimeProvider>
124
- );
169
+ const aui = useAui({
170
+ unstable_interactables: unstable_Interactables(),
171
+ tools: Tools({ toolkit }), // [!code ++]
172
+ });
173
+ ```
174
+
175
+ <Callout type="info">
176
+ Thread-scoped state rides the thread's history, so it survives reloads with
177
+ nothing extra to persist. `render` is run when the interactable is created,
178
+ and whenever it's updated (via `update_{name}`).
179
+ </Callout>
180
+
181
+ </Tab>
182
+ </Tabs>
183
+
184
+ </Step>
185
+ <Step>
186
+
187
+ ### Surface state to the model in your route
188
+
189
+ <Tabs items={["AI-SDK", "Other Backends"]}>
190
+ <Tab>
191
+ Each user message carries its state snapshots in its metadata, but the AI SDK's `convertToModelMessages` ignores metadata. Use `unstable_injectInteractableContext` to pass interactable state to the model:
192
+
193
+ ```ts title="app/api/chat/route.ts"
194
+ import { openai } from "@ai-sdk/openai";
195
+ import { convertToModelMessages, streamText } from "ai";
196
+ import { unstable_injectInteractableContext as injectInteractableContext } from "@assistant-ui/react-ai-sdk";
197
+
198
+ export async function POST(req: Request) {
199
+ const { messages } = await req.json();
200
+
201
+ const result = streamText({
202
+ model: openai("gpt-5.4"),
203
+ messages: await convertToModelMessages(injectInteractableContext(messages)), // [!code ++]
204
+ });
205
+
206
+ return result.toUIMessageStreamResponse();
125
207
  }
126
208
  ```
127
209
 
128
- Now when the user says _"Add a task called 'Buy groceries'"_, the AI will automatically call the `update_taskBoard` tool to update the state. Thanks to partial updates, the AI only needs to send the fields it wants to change.
210
+ <Callout type="info">
211
+ The format of the snapshot sent to the model can be customized, see [State
212
+ snapshots](#state-snapshots) for more details.
213
+ </Callout>
129
214
 
130
- ## Partial Updates
215
+ </Tab>
216
+ <Tab>
131
217
 
132
- Auto-generated tools use a partial schema — all fields become optional. The AI only sends the fields it wants to change; omitted fields keep their current values.
218
+ Each user message carries its state snapshots at `metadata.custom.interactables`. For other backends (LangGraph, Mastra, a custom runtime), build the equivalent injection with the two helpers exported from `@assistant-ui/react`:
133
219
 
134
- ```tsx
135
- // If the state is { title: "My Note", content: "Hello", color: "yellow" }
136
- // The AI can call: update_note({ color: "blue" })
137
- // Result: { title: "My Note", content: "Hello", color: "blue" }
220
+ ```ts
221
+ import {
222
+ unstable_getInteractableSnapshots, // (message) => snapshot entries | undefined
223
+ unstable_formatInteractableSnapshot, // (entry) => the model-facing formatting of the snapshot injection
224
+ } from "@assistant-ui/react";
225
+
226
+ for (const message of messages) {
227
+ if (message.role !== "user") continue;
228
+ const items = unstable_getInteractableSnapshots(message);
229
+ if (!items?.length) continue;
230
+ const text = items.map(unstable_formatInteractableSnapshot).join("\n");
231
+ // prepend `text` to the message content in whatever shape your backend expects
232
+ }
138
233
  ```
139
234
 
140
- This is especially useful for large state objects where regenerating the entire state would be expensive and error-prone.
235
+ <Callout type="info">
236
+ For more details, and how to customize the snapshot format for other backends,
237
+ see [State snapshots](#customizing-the-format).
238
+ </Callout>
239
+
240
+ </Tab>
241
+ </Tabs>
242
+
243
+ </Step>
244
+ </Steps>
245
+
246
+ ## State snapshots
247
+
248
+ Outgoing user messages can carry the interactable's state to the model as a snapshot, stamped when the state has changed since the model last saw it. When only some fields change, a partial snapshot is created containing a shallow diff of only the changed fields and the id.
249
+
250
+ Default full snapshot formatting:
251
+
252
+ ```ts
253
+ `[Current state of "note" (id: "n1"): {"title":"Q3 launch","body":"Ship the beta by Friday."}]`;
254
+ ```
255
+
256
+ Default partial snapshot formatting:
257
+
258
+ ```ts
259
+ `[State of "note" (id: "n1") changed — updated fields: {"title":"Q4 launch"}; fields not listed are unchanged]`;
260
+ ```
261
+
262
+ ### Customizing the format
263
+
264
+ A formatter receives one snapshot `entry` and returns the line the model sees:
265
+
266
+ - `name` and `id` identify the instance. Keep the `id` visible so the model knows what the state belongs to.
267
+ - `state` is the snapshot payload.
268
+ - `partial` is `true` when `state` carries a shallow diff of only the fields that changed since the model's last known state. Handle it.
269
+
270
+ Write a custom formatter:
271
+
272
+ ```ts
273
+ import { type Unstable_InteractableSnapshotEntry } from "@assistant-ui/react";
274
+
275
+ const formatSnapshot = (entry: Unstable_InteractableSnapshotEntry) =>
276
+ entry.partial
277
+ ? `State of "${entry.name}" (id: "${entry.id}") has been updated, the following fields have changed: ${JSON.stringify(entry.state)}`
278
+ : `Current state of "${entry.name}" (id: "${entry.id}"): ${JSON.stringify(entry.state)}`;
279
+ ```
141
280
 
142
281
  <Callout type="info">
143
- Merge is shallow (one level deep). If the AI sends a nested object, it replaces
144
- that entire field rather than deep-merging into it.
282
+ When customizing format, remember to:
283
+ - Handle `partial: true` entries, whose `state` carries a shallow diff of only the fields that changed.
284
+ - Keep each instance's `id` visible so the model knows what the state belongs to.
145
285
  </Callout>
146
286
 
147
- ## Multiple Instances
287
+ Then wire it to your backend:
148
288
 
149
- You can render multiple interactables with the same `name` but different `id`s. Each gets its own update tool:
289
+ - **AI SDK:** pass it as the second argument, `unstable_injectInteractableContext(messages, formatSnapshot)`.
290
+ - **Other backends:** use it in place of `unstable_formatInteractableSnapshot` in your helper (see [Step 3](#surface-state-to-the-model-in-your-route)'s "Other Backends" tab).
150
291
 
151
- ```tsx
152
- import { useAssistantInteractable, useInteractableState } from "@assistant-ui/react";
153
- import { z } from "zod";
292
+ ```ts title="app/api/chat/route.ts"
293
+ messages: await convertToModelMessages(
294
+ unstable_injectInteractableContext(messages, formatSnapshot),
295
+ ),
296
+ ```
154
297
 
155
- const noteSchema = z.object({
156
- title: z.string(),
157
- content: z.string(),
158
- color: z.enum(["yellow", "blue", "green", "pink"]),
159
- });
298
+ ## Artifacts
160
299
 
161
- const noteInitialState = { title: "New Note", content: "", color: "yellow" as const };
300
+ An artifact combines two pieces that point at the same interactable:
162
301
 
163
- function NoteCard({ noteId }: { noteId: string }) {
164
- useAssistantInteractable("note", {
165
- id: noteId,
166
- description: "A sticky note",
167
- stateSchema: noteSchema,
168
- initialState: noteInitialState,
302
+ - a thread tool (`unstable_interactableTool`) the model calls to create the artifact inline (a button or small preview in the message), and
303
+ - a panel you mount with `unstable_useInteractable` that opens that same instance at full size.
304
+
305
+ Both use the same `id`, so they are one interactable, not two: the model creates it in the conversation, and the panel is just a larger view of the very same state. Because the thread holds the creating call, the artifact is **thread-scoped**: it persists with the thread's history (no [persistence adapter](#persistence) needed), and each message's trigger can open that message's version (see [Versions](#versions)).
306
+
307
+ ```
308
+ in the thread (model-created) your layout (mounted once)
309
+ ────────────────────────────── ──────────────────────────────
310
+ unstable_interactableTool("document") ArtifactPanel()
311
+ render: a button / inline preview unstable_useInteractable("document", { id })
312
+ onClick → openArtifact(id) ────┐ → live, editable, full height
313
+ │ │
314
+ └───── same id ────┘
315
+ one thread-scoped instance
316
+ ```
317
+
318
+ ```tsx
319
+ const toolkit = defineToolkit({
320
+ document: unstable_interactableTool({
321
+ description: "A document the user can open and edit.",
322
+ stateSchema: documentSchema,
323
+ render: ({ state, version, id }) => (
324
+ <ArtifactButton
325
+ title={(version?.state ?? state).title}
326
+ onClick={() => openArtifact(id)} // your own state: which artifact is open
327
+ />
328
+ ),
329
+ }),
330
+ });
331
+
332
+ function ArtifactPanel({ id }: { id: string }) {
333
+ const [state, { setState }] = unstable_useInteractable("document", {
334
+ id,
335
+ description: "A document the user can open and edit.",
336
+ stateSchema: documentSchema,
337
+ initialState: emptyDocument, // fallback only; existing state comes from the thread
169
338
  });
170
- const [state] = useInteractableState(noteId, noteInitialState);
339
+ const versions = unstable_useInteractableVersions<Document>(id, "document");
171
340
 
172
- return <div>{state.title}</div>;
341
+ return (
342
+ <aside>
343
+ <VersionMenu>
344
+ {versions.map((v, i) => (
345
+ <DropdownItem key={i} onSelect={v.restore}>
346
+ v{i + 1}: {v.origin === "user-edit" ? "you" : "assistant"}
347
+ </DropdownItem>
348
+ ))}
349
+ </VersionMenu>
350
+ <Editor value={state} onChange={setState} />
351
+ </aside>
352
+ );
173
353
  }
354
+ ```
355
+
356
+ You don't mount anything per artifact: the model renders the inline part on its own, and `openArtifact(id)` is your own state setter for which artifact the panel currently shows. Outside message parts the hook always returns the live state (`version` is `undefined`), so the panel is plainly editable. The panel and the inline tool UIs register the same `id`, so they share one instance; registration is reference-counted, and the instance stays alive until the last one unmounts.
357
+
358
+ <Callout type="warn">
359
+ Keep one mount of the artifact on screen (hidden is fine) whenever its instance
360
+ should stay available. If the panel is closed and every inline button has
361
+ scrolled out of a virtualized thread, the instance and its `update_{name}` tool
362
+ unregister and the tool list churns. The state itself is safe, since it rides
363
+ thread history.
364
+ </Callout>
365
+
366
+ ## Companion tools
367
+
368
+ The generated `update_{name}` tool covers everything that lives in the state: editing fields, and adding, updating, removing, or clearing items in a list. Reach for a separate frontend tool only for what state can't express: a side effect like sending, exporting, or saving.
369
+
370
+ Take an email composer the user and assistant co-edit. `update_email` keeps the draft in sync from both sides, but actually **sending** it is a side effect that no amount of state editing can perform. That is what a companion tool is for: `send_email` acts on the current draft and fires it.
371
+
372
+ How you wire one depends on what its executor touches.
373
+
374
+ **Closes over React state** (here, the live draft): declare the contract with `stubTool()` in your `"use generative"` toolkit, and supply the executor with `useAuiToolOverrides` in the component that owns the state. See [Dynamic Tools](/docs/tools/dynamic-tools).
375
+
376
+ ```tsx title="app/email-toolkit.tsx"
377
+ "use generative";
378
+
379
+ import { defineToolkit, stubTool } from "@assistant-ui/react";
380
+ import { z } from "zod";
381
+
382
+ export default defineToolkit({
383
+ send_email: {
384
+ description:
385
+ "Send the email currently shown in the composer. Call this only once the draft is ready.",
386
+ parameters: z.object({}),
387
+ execute: stubTool(),
388
+ renderText: { running: "Sending...", complete: "Email sent" },
389
+ },
390
+ });
391
+ ```
392
+
393
+ ```tsx title="app/EmailComposer.tsx"
394
+ import {
395
+ unstable_useInteractable,
396
+ useAuiToolOverrides,
397
+ } from "@assistant-ui/react";
398
+
399
+ function EmailComposer() {
400
+ const [draft, { setState }] = unstable_useInteractable("email", {
401
+ description: "An email draft the user and assistant can read and edit.",
402
+ stateSchema: emailSchema,
403
+ initialState: { to: "", subject: "", body: "" },
404
+ });
174
405
 
175
- function App() {
176
406
  return (
177
407
  <>
178
- <NoteCard noteId="note-1" /> {/* → update_note_note-1 tool */}
179
- <NoteCard noteId="note-2" /> {/* → update_note_note-2 tool */}
408
+ <SendEmailTool draft={draft} />
409
+ {/* inputs bound to draft + setState */}
180
410
  </>
181
411
  );
182
412
  }
413
+
414
+ // A null-returning child supplies the executor, closing over the live draft.
415
+ function SendEmailTool({ draft }: { draft: Email }) {
416
+ useAuiToolOverrides({
417
+ send_email: {
418
+ execute: async () => {
419
+ await fetch("/api/send-email", {
420
+ method: "POST",
421
+ body: JSON.stringify(draft),
422
+ });
423
+ return { success: true };
424
+ },
425
+ },
426
+ });
427
+ return null;
428
+ }
183
429
  ```
184
430
 
185
- When only one instance of a name exists, the tool is named `update_{name}` (e.g., `update_note`). When multiple instances exist, tools are named `update_{name}_{id}` (e.g., `update_note_note-1`).
431
+ The split is the whole point: `update_email` edits the draft, `send_email` does the thing the draft can't describe. The executor reads the live `draft`, so it always sends what is currently on screen.
186
432
 
187
- ## Selection
433
+ **Self-contained** (needs nothing from React, only its args and browser APIs): put a real `execute` with an inner `"use client"` directly in the toolkit. A `copy_share_link({ id })` that builds a URL and writes it to the clipboard is a good fit. See [Defining Tools](/docs/tools/defining-tools#frontend-tools).
188
434
 
189
- When multiple interactables are present, you can mark one as "selected" to tell the AI which one the user is focused on:
435
+ ## Custom Update Rendering
436
+
437
+ `render` draws the create call; `updateRender` draws each `update_{name}` call. `unstable_interactableTool` reuses your one `render` for both, so edits look like the creation. Supply your own `updateRender` to render edits differently.
438
+
439
+ <Callout type="info">
440
+ To vary by this message's version, or render older messages differently from
441
+ the newest, you don't need `updateRender`. The render already receives
442
+ `version`; see [Versions](#versions).
443
+ </Callout>
444
+
445
+ ### Render edits differently from the create
446
+
447
+ A thread tool locks the create and edit renders together. Drop to `unstable_useInteractable` to split them, here showing the full notepad on the create call and a one-line summary on every edit:
190
448
 
191
449
  ```tsx
192
- function NoteCard({ noteId }: { noteId: string }) {
193
- useAssistantInteractable("note", {
194
- id: noteId,
195
- description: "A sticky note",
196
- stateSchema: noteSchema,
197
- initialState: noteInitialState,
450
+ // Hoisted so its identity is stable; an inline updateRender re-registers the
451
+ // tool UI on every render.
452
+ const EditSummaryRender: ToolCallMessagePartComponent = ({ args }) => (
453
+ <EditSummary changed={args} />
454
+ );
455
+
456
+ const NotepadToolUI: ToolCallMessagePartComponent<NotepadArgs> = ({
457
+ toolCallId,
458
+ args,
459
+ result,
460
+ }) => {
461
+ if (!result) return <NotepadDraft args={args} />;
462
+ return <Notepad id={toolCallId} initial={args} />;
463
+ };
464
+
465
+ function Notepad({ id, initial }: { id: string; initial: NotepadArgs }) {
466
+ const [state, { setState }] = unstable_useInteractable("notepad", {
467
+ id,
468
+ description: "A notepad the user can read and edit.",
469
+ stateSchema: notepadSchema,
470
+ initialState: initial,
471
+ updateRender: EditSummaryRender,
198
472
  });
199
- const [state, { setSelected }] = useInteractableState(noteId, noteInitialState);
200
-
201
- return (
202
- <div onClick={() => setSelected(true)}>
203
- {state.title}
204
- </div>
205
- );
473
+ return <NotepadEditor value={state} onChange={setState} />;
206
474
  }
207
- ```
208
475
 
209
- The AI sees `(SELECTED)` in the system prompt for the focused interactable, allowing it to prioritize that one in responses. For example, the user can say _"Change the color to blue"_ and the AI knows which note to update.
476
+ // NotepadToolUI is the create call's render; updateRender handles the edits.
477
+ const toolkit = defineToolkit({
478
+ notepad: {
479
+ type: "frontend",
480
+ description: "A notepad the user can read and edit.",
481
+ parameters: notepadSchema,
482
+ display: "standalone",
483
+ execute: async () => ({ success: true as const }),
484
+ render: NotepadToolUI,
485
+ },
486
+ });
487
+ ```
210
488
 
211
- ## API Reference
489
+ Pass the create call's `toolCallId` as the `id`: that one convention ties both renders to a single instance and restores its state from thread history after a reload.
212
490
 
213
- ### `useAssistantInteractable`
491
+ ### Mark edits on an app-scoped surface
214
492
 
215
- Registers an interactable with the AI assistant. Returns the instance id.
493
+ An app-scoped interactable lives in your layout (a sidebar, a panel), so it has no inline presence in the thread. Pass `updateRender` and each `update_{name}` call gains one: an inline marker of what the model just did, while the live component keeps updating in place.
216
494
 
217
495
  ```tsx
218
- const id = useAssistantInteractable(name, config);
496
+ const EditMarkerRender: ToolCallMessagePartComponent = ({ args }) => (
497
+ <EditMarker changed={args} />
498
+ );
499
+
500
+ function DocumentPanel() {
501
+ const [state, { setState }] = unstable_useInteractable("document", {
502
+ description: "A document the user can read and edit.",
503
+ stateSchema: documentSchema,
504
+ initialState: emptyDocument,
505
+ updateRender: EditMarkerRender,
506
+ });
507
+ return <Editor value={state} onChange={setState} />;
508
+ }
219
509
  ```
220
510
 
221
- **Parameters:**
511
+ ## Versions
512
+
513
+ A thread is an append-only log, so an instance accumulates **versions**: each user edit, each `update_*` call, and (thread-scoped only) the creating call. For thread-scoped interactables these are computed from the thread record, so history survives reloads with nothing extra to persist. There are two ways to reach them.
222
514
 
223
- | Parameter | Type | Description |
224
- | --- | --- | --- |
225
- | `name` | `string` | Name for the interactable (used in tool names) |
226
- | `config.description` | `string` | Description shown to the AI |
227
- | `config.stateSchema` | `StandardSchemaV1 \| JSONSchema7` | Schema for the state (e.g., a Zod schema) |
228
- | `config.initialState` | `unknown` | Initial state value |
229
- | `config.id` | `string?` | Optional unique instance ID (auto-generated if omitted) |
230
- | `config.selected` | `boolean?` | Whether this interactable is selected |
515
+ **This message's version.** Inside a thread-scoped `render`, `state` / `setState` are the **live** instance (there's exactly one, shared by every message), while `version` is **this message's** snapshot: `{ state, isLatest, restore }`. `version.state` is the interactable as it was at that point in the conversation; `restore()` sets the live state back to it.
231
516
 
232
- **Returns:** `string` — the instance id (auto-generated or provided).
517
+ Those three fields are all you need, and two independent choices decide how history behaves:
233
518
 
234
- ### `useInteractableState`
519
+ - **Editable or read-only?** Render the live `state` / `setState` to let any message edit the shared instance, or `version.state` read-only to freeze it.
520
+ - **Restorable?** Offer `version.restore()` to roll an old version back to live, or leave it out.
235
521
 
236
- Reads and writes the state of a registered interactable.
522
+ | You want | Render |
523
+ | -------------------- | --------------------------------------- |
524
+ | Frozen history | `version.state` read-only on old |
525
+ | Live-editable | `state` / `setState` everywhere |
526
+ | Read-only + rollback | `version.state` read-only plus `restore` |
237
527
 
238
528
  ```tsx
239
- const [state, { setState, setSelected, isPending, error, flush }] = useInteractableState<TState>(id, fallback?);
529
+ // Read-only history with rollback:
530
+ // old messages are frozen but can roll their version back to live
531
+ render: ({ state, setState, version }) =>
532
+ version && !version.isLatest ? (
533
+ <Notepad value={version.state} readOnly onRestore={version.restore} />
534
+ ) : (
535
+ <Notepad value={state} onChange={setState} />
536
+ );
240
537
  ```
241
538
 
242
- **Parameters:**
243
-
244
- | Parameter | Type | Description |
245
- | --- | --- | --- |
246
- | `id` | `string` | The interactable instance id (from `useAssistantInteractable`) |
247
- | `fallback` | `TState?` | Fallback value before the interactable is registered |
539
+ ```tsx
540
+ // Live-editable:
541
+ // every message edits the shared instance; restore reverts to this point
542
+ render: ({ state, setState, version }) => (
543
+ <Notepad value={state} onChange={setState} onRestore={version?.restore} />
544
+ );
545
+ ```
248
546
 
249
- **Returns:** `[state, methods]`
547
+ **Every version at once.** `unstable_useInteractableVersions(id, name)` returns them oldest-first, each with the full `state` and a `restore()`. Use it for a history dropdown; it works for both app- and thread-scoped interactables:
250
548
 
251
- | Return | Type | Description |
252
- | --- | --- | --- |
253
- | `state` | `TState` | Current state |
254
- | `setState` | `(updater: TState \| (prev: TState) => TState) => void` | State setter (like `useState`) |
255
- | `setSelected` | `(selected: boolean) => void` | Mark this interactable as selected |
256
- | `isPending` | `boolean` | Whether a persistence save is in-flight |
257
- | `error` | `unknown` | Error from the last failed save |
258
- | `flush` | `() => Promise<void>` | Force an immediate persistence save |
549
+ ```tsx
550
+ function VersionDropdown({ id, name }: { id: string; name: string }) {
551
+ const versions = unstable_useInteractableVersions(id, name);
552
+ if (versions.length < 2) return null;
259
553
 
260
- ### `Interactables`
554
+ return (
555
+ <select onChange={(e) => versions[+e.target.value]!.restore()}>
556
+ {versions.map((v, i) => (
557
+ <option key={i} value={i}>
558
+ v{i + 1}: {v.origin === "user-edit" ? "you" : "assistant"}
559
+ </option>
560
+ ))}
561
+ </select>
562
+ );
563
+ }
564
+ ```
261
565
 
262
- The scope resource that manages all interactables. Register it via `useAui`:
566
+ **Thread-scoped:**
263
567
 
264
568
  ```tsx
265
- const aui = useAui({
266
- interactables: Interactables(),
267
- });
569
+ const Notepad = ({
570
+ id,
571
+ state,
572
+ setState,
573
+ }: Unstable_InteractableToolRenderProps<NotepadArgs>) => (
574
+ <div>
575
+ <VersionDropdown id={id} name="notepad" />
576
+ {/* ...editor... */}
577
+ </div>
578
+ );
268
579
  ```
269
580
 
270
- ## How It Works
581
+ **App-scoped:**
271
582
 
272
- When you call `useAssistantInteractable("taskBoard", config)`:
583
+ ```tsx
584
+ const [state, { id }] = unstable_useInteractable("taskBoard", config);
585
+ return <VersionDropdown id={id} name="taskBoard" />;
586
+ ```
273
587
 
274
- 1. **Registration** — the interactable is registered in the `interactables` scope with its name, description, schema, and initial state.
275
- 2. **Tool generation** — an `update_taskBoard` frontend tool is automatically created with a partial schema (all fields optional). For multiple instances, tools are named `update_{name}_{id}`.
276
- 3. **System prompt** — the AI receives a system message describing the interactable, its current state, and whether it is selected.
277
- 4. **Streaming updates** — as the AI generates the tool arguments, the interactable's state updates progressively rather than waiting for complete arguments. This gives users immediate visual feedback.
278
- 5. **Partial merge** — only the fields the AI sends are updated; the rest are preserved.
279
- 6. **Bidirectional updates** — when the AI calls the tool, the state updates and React re-renders. When the user updates state via `setState`, the model context is notified so the AI sees the latest state on the next turn.
588
+ An app-scoped item's history covers the current conversation, not its full cross-thread lifetime.
280
589
 
281
590
  ## Persistence
282
591
 
283
- By default, interactable state is in-memory and lost on page refresh. You can add persistence by providing a save callback:
592
+ By default, app-scoped interactable state is in-memory and lost on page refresh. You can add persistence by passing an adapter to `unstable_Interactables`:
284
593
 
285
594
  ```tsx
286
- import { useEffect } from "react";
287
- import { useAui, Interactables } from "@assistant-ui/react";
288
-
289
- function MyRuntimeProvider({ children }) {
290
- const aui = useAui({ interactables: Interactables() });
595
+ import { useAui, unstable_Interactables } from "@assistant-ui/react";
291
596
 
292
- useEffect(() => {
293
- // Set up persistence adapter
294
- aui.interactables().setPersistenceAdapter({
295
- save: async (state) => {
296
- localStorage.setItem("interactables", JSON.stringify(state));
297
- },
298
- });
299
-
300
- // Restore saved state on mount
597
+ // Module-level (or memoized) so the adapter identity is stable across renders.
598
+ const persistenceAdapter = {
599
+ load: () => {
301
600
  const saved = localStorage.getItem("interactables");
302
- if (saved) {
303
- aui.interactables().importState(JSON.parse(saved));
304
- }
305
- }, [aui]);
601
+ return saved ? JSON.parse(saved) : undefined;
602
+ },
603
+ save: (state) => {
604
+ localStorage.setItem("interactables", JSON.stringify(state));
605
+ },
606
+ };
607
+
608
+ function MyRuntimeProvider({ children }) {
609
+ const aui = useAui({
610
+ unstable_interactables: unstable_Interactables({
611
+ persistence: persistenceAdapter,
612
+ }),
613
+ });
306
614
 
307
615
  return /* ... */;
308
616
  }
309
617
  ```
310
618
 
619
+ `load` is called when the adapter is attached and may be async. Loaded state seeds interactables as they register; a local edit made while a slow `load` is still in flight wins over the loaded value. Thread-scoped interactables are not touched by the adapter; they persist via thread history.
620
+
621
+ For dynamic setups (an adapter that depends on auth), call `aui.interactables().setPersistenceAdapter(adapter)` imperatively instead.
622
+
311
623
  ### Sync Status
312
624
 
313
- When a persistence adapter is set, `useInteractableState` exposes sync metadata:
625
+ When a persistence adapter is set, interactable hooks expose sync metadata:
314
626
 
315
627
  ```tsx
316
- const [state, { setState, isPending, error, flush }] = useInteractableState(id, fallback);
628
+ const [state, { setState, isPending, error, flush }] =
629
+ unstable_useInteractableState<TState>(id);
317
630
 
318
- // isPending — true while a save is in-flight
319
- // error — the error from the last failed save, if any
320
- // flush() — force an immediate save (useful before navigation)
631
+ // isPending: true while a save is in-flight
632
+ // error: the error from the last failed save, if any
633
+ // flush(): force an immediate save (useful before navigation)
321
634
  ```
322
635
 
323
- State changes are automatically debounced (500ms) before saving. When a component unregisters, any pending save is flushed immediately.
636
+ State changes are automatically debounced (500ms) before saving. When the owning component unmounts, any pending save is flushed immediately.
324
637
 
325
638
  ### Export / Import
326
639
 
@@ -334,70 +647,426 @@ aui.interactables().importState(snapshot);
334
647
  // Imported state is picked up when components next register
335
648
  ```
336
649
 
337
- ## Combining with Tools
650
+ ### Schema Evolution
651
+
652
+ <Callout type="warn">
653
+ If you change a Zod schema after state has been persisted, the loaded snapshot
654
+ may silently mis-match the new shape. The adapter does a shallow merge, so
655
+ extra fields are preserved and missing fields keep their initial values, but
656
+ type mismatches are not caught at runtime. To avoid silent corruption, version
657
+ your schema key (e.g. `"taskBoard_v2"`) or namespace it by schema hash
658
+ whenever you make breaking changes. Alternatively, add a migration step in
659
+ your `load` or `importState` call.
660
+ </Callout>
661
+
662
+ ## Streaming Updates
663
+
664
+ The same partial merge runs token by token as the model generates an `update_{name}` call, so the interactable fills in live: a field the model is writing updates character by character, and only the fields and array items the call touches change. Everything it doesn't mention stays exactly as it was.
338
665
 
339
- You can use `Interactables` alongside `Tools`:
666
+ That stability is concrete, not just visual. While one array item streams in, the items the model isn't editing keep their exact object identity for the whole stream, so a memoized row for them never re-renders:
340
667
 
341
668
  ```tsx
342
- const aui = useAui({
343
- tools: Tools({ toolkit: myToolkit }),
344
- interactables: Interactables(),
669
+ import { memo } from "react";
670
+
671
+ const TaskRow = memo(function TaskRow({ task }: { task: Task }) {
672
+ return <li>{task.title}</li>;
345
673
  });
674
+
675
+ function TaskBoard() {
676
+ const [state] = unstable_useInteractable("taskBoard", config);
677
+
678
+ return (
679
+ <ul>
680
+ {state.tasks.map((task) => (
681
+ <TaskRow key={task.id} task={task} />
682
+ ))}
683
+ </ul>
684
+ );
685
+ }
346
686
  ```
347
687
 
348
- ## Streaming Updates
688
+ As `update_taskBoard` streams an edit to one task, that row re-renders as its fields arrive while every other `TaskRow` stays put, no flicker and no work. You get a live-updating list for free; you only reach for the partial state when you want to show something extra during the stream.
349
689
 
350
- While the AI is generating tool arguments, `useInteractableState` reflects the partial state in real time as fields stream in. You can use the partial state itself plus the thread's running status to show a skeleton UI while the AI is mid-stream:
690
+ One case worth handling: at the very start of a fresh create the model may not have produced any items yet. Use the thread's `isRunning` to tell "still streaming, nothing yet" apart from "the model returned an empty result", and show a skeleton only for that gap:
351
691
 
352
692
  ```tsx
353
693
  import { useAuiState } from "@assistant-ui/react";
354
694
 
355
- function TaskBoard() {
356
- const id = useAssistantInteractable("taskBoard", config);
357
- const [state] = useInteractableState(id, taskBoardInitialState);
358
- const isRunning = useAuiState((s) => s.thread.isRunning);
695
+ const isRunning = useAuiState((s) => s.thread.isRunning);
696
+ const isLoading = isRunning && state.tasks.length === 0;
697
+ ```
698
+
699
+ Inside a thread-scoped `render`, `streaming: true` carries the same live state: at the creating call `state` is the partial draft (fields may be missing); at an `update_{name}` call it's the live state filling in as the edit streams. Render a preview; edits made during a create stream are dropped.
700
+
701
+ ## Multiple Instances
702
+
703
+ A `name` can have many live instances at once. They all share one `update_{name}` tool: the model addresses an instance with the tool's `id` parameter, which it reads from the state snapshots in the conversation. The tool's name, schema, and description never change as instances mount and unmount, so the model's tool list (and provider prompt caches) stay stable. While exactly one instance exists, the model may omit `id`; a call with an unknown `id` returns an error listing the valid ids, so the model can recover.
704
+
705
+ How instances come into being differs by scope.
359
706
 
360
- const isLoading = isRunning && state.tasks.length === 0;
707
+ ### Thread-scoped: instances for free
361
708
 
709
+ A thread-scoped interactable gets a fresh instance every time the model calls its tool. The instance `id` is the creating call's `toolCallId`, so two calls are two instances with no extra code on your side: the same `render` and the same `update_{name}` tool serve all of them. This is how a model spins up several artifacts or notepads in one conversation, each addressed by its own `toolCallId`.
710
+
711
+ ### App-scoped: one instance per mount
712
+
713
+ For app-scoped interactables you decide how many instances exist by mounting `unstable_useInteractable` in more than one place, one instance per mount. Give each mount a distinct `id`:
714
+
715
+ ```tsx
716
+ import { unstable_useInteractable } from "@assistant-ui/react";
717
+ import { z } from "zod";
718
+
719
+ const noteSchema = z.object({
720
+ title: z.string(),
721
+ content: z.string(),
722
+ color: z.enum(["yellow", "blue", "green", "pink"]),
723
+ });
724
+
725
+ const noteInitialState = {
726
+ title: "New Note",
727
+ content: "",
728
+ color: "yellow" as const,
729
+ };
730
+
731
+ function NoteCard({ noteId }: { noteId: string }) {
732
+ const [state] = unstable_useInteractable("note", {
733
+ id: noteId,
734
+ description: "A sticky note",
735
+ stateSchema: noteSchema,
736
+ initialState: noteInitialState,
737
+ });
738
+
739
+ return <div>{state.title}</div>;
740
+ }
741
+
742
+ function App() {
362
743
  return (
363
- <div>
364
- <h2>Tasks</h2>
365
- {isLoading ? (
366
- <div className="animate-pulse h-8 rounded bg-muted" />
367
- ) : (
368
- <ul>
369
- {state.tasks.map((task) => (
370
- <li key={task.id}>{task.title}</li>
371
- ))}
372
- </ul>
373
- )}
374
- </div>
744
+ <>
745
+ <NoteCard noteId="note-1" />
746
+ <NoteCard noteId="note-2" />
747
+ {/* one update_note tool: update_note({ id: "note-2", color: "blue" }) */}
748
+ </>
375
749
  );
376
750
  }
377
751
  ```
378
752
 
379
- The state object updates progressively as the AI streams in each field, so partial renders work without any extra wiring. Use the runtime's `isRunning` to distinguish "still streaming, no fields yet" from "model returned an empty result".
753
+ Pass an explicit `id` whenever you need to reach a specific instance: to read or write it from another component with `unstable_useInteractableState(id)`, to share it between an [artifact](#artifacts) panel and its inline trigger, or to keep [persisted](#persistence) state attached across reloads. Omitting `id` also works, and each mount then gets its own auto-generated id, but those ids are positional and shift as a dynamic list adds and removes items, so persisted state keyed by an old id would not reattach. Leave `id` off only when the component is the sole reader and its state is not persisted.
754
+
755
+ <Callout type="info">
756
+ A top-level `id` field in your `stateSchema` is reserved: the update tool uses
757
+ it for instance addressing, so the model cannot write a state field named
758
+ `id`. Nest it or name it differently (e.g. `noteId`).
759
+ </Callout>
380
760
 
381
- ## Schema Evolution
761
+ `update_note` edits a note that is already mounted: its `title`, `content`, `color`, and any other schema fields. It cannot mount a new `NoteCard` or unmount one; which components exist is your app's state. If you want the model to add and remove notes, model them as a single interactable holding an array (one `notes` field), and `update_notes` then adds, updates, removes, and clears entries directly, the way the [Quick Start](#quick-start) task board does. Use separate mounted instances only when each note is genuinely its own component; mounting and unmounting those is app work you can expose as a [companion tool](#companion-tools).
382
762
 
383
- <Callout type="warn">
384
- If you change a Zod schema after state has been persisted, the imported snapshot may silently mis-match the new shape. The adapter does a shallow merge, so extra fields are preserved and missing fields keep their initial values, but type mismatches are not caught at runtime. To avoid silent corruption, version your schema key (e.g. `"taskBoard_v2"`) or namespace it by schema hash whenever you make breaking changes. Alternatively, add a migration step in your `importState` call.
763
+ ## Partial Updates
764
+
765
+ Auto-generated tools use a partial schema in which all fields are optional. The AI only sends the fields it wants to change; omitted fields keep their current values.
766
+
767
+ ```tsx
768
+ // If the state is { title: "My Note", content: "Hello", color: "yellow" }
769
+ // The AI can call: update_note({ color: "blue" })
770
+ // Result: { title: "My Note", content: "Hello", color: "blue" }
771
+ ```
772
+
773
+ This is especially useful for large state objects where regenerating the entire state would be expensive and error-prone.
774
+
775
+ For array fields whose items carry an `id`, the AI doesn't send a replacement array, it sends operations (`add`, `update`, `remove`, `clear`) and the framework applies them to the current list. Added items get their `id` from the framework; existing items are addressed by the `id` you gave them.
776
+
777
+ <Callout type="info">
778
+ Merge is shallow (one level deep). If the AI sends a nested object, it
779
+ replaces that entire field rather than deep-merging into it.
385
780
  </Callout>
386
781
 
782
+ ## How It Works
783
+
784
+ 1. **Register**: the interactable joins the `interactables` scope with its name, description, schema, and initial state.
785
+ 2. **Generate the tool**: one `update_{name}` tool per name, with a partial schema (every field optional) plus a required `id`. The tool list stays stable as instances mount and unmount, so provider prompt caches stay warm.
786
+ 3. **Snapshot**: each sent user message carries the current state in `metadata.custom`, but only when the model doesn't already know it. A user edit stamps a snapshot; the model's own `update_*` calls and an in-message instance's create args don't. When the change fits a shallow merge it stamps only the changed fields (`partial: true`). Your route turns snapshots into model-visible text (see [State snapshots](#state-snapshots)).
787
+ 4. **Stream**: state updates field-by-field as the model generates the tool arguments, so the UI fills in live.
788
+ 5. **Merge**: only the fields the model sends are applied; the rest stay. Array fields keyed by `id` take operations (add/update/remove/clear) instead of a replacement array, and the framework mints ids for added items.
789
+ 6. **Both directions**: a model `update_*` updates state and re-renders; a user `setState` rides the next message as a fresh snapshot.
790
+
387
791
  ## Unmount Behavior
388
792
 
389
- When a component that called `useAssistantInteractable` unmounts, the interactable is unregistered from the AI's tool list and system prompt. However, its state is preserved in the `Interactables` scope. When the component mounts again with the same name and id, the scope re-merges the preserved state rather than resetting to `initialState`. This means transient unmounts (such as React Strict Mode double-mounts or tab switches) do not lose state.
793
+ When a component that called `unstable_useInteractable` unmounts, the interactable is unregistered, but its state is preserved in the `unstable_Interactables` scope. When the component mounts again with the same name and id, the scope restores the preserved state rather than resetting to `initialState`. This means transient unmounts (such as React Strict Mode double-mounts or tab switches) do not lose state.
794
+
795
+ An instance registered from several places (its creating tool call, `update_*` calls, an artifact panel) stays registered until the last one unmounts, so scrolling one out of a virtualized thread doesn't tear the instance down while another is visible.
796
+
797
+ ## API Reference
798
+
799
+ ### `unstable_useInteractable`
800
+
801
+ Registers an interactable with the AI assistant and returns its state. It behaves like `useState`, except the model can also read and update the value. Call it once per instance.
802
+
803
+ ```tsx
804
+ const [state, { id, setState, isPending, error, flush }] =
805
+ unstable_useInteractable(name, config);
806
+ ```
807
+
808
+ **Parameters:**
809
+
810
+ <PrimitivesTypeTable
811
+ type="unstable_useInteractable-params"
812
+ parameters={[
813
+ {
814
+ name: "name",
815
+ type: "string",
816
+ required: true,
817
+ description: (
818
+ <>
819
+ Name for the interactable (determines the{" "}
820
+ <code>update_{"{name}"}</code> tool).
821
+ </>
822
+ ),
823
+ },
824
+ {
825
+ name: "config",
826
+ type: "Unstable_InteractableConfig<TSchema>",
827
+ required: true,
828
+ description: "Configuration for the interactable.",
829
+ children: [
830
+ {
831
+ parameters: [
832
+ {
833
+ name: "description",
834
+ type: "string",
835
+ required: true,
836
+ description: "Description shown to the AI.",
837
+ },
838
+ {
839
+ name: "stateSchema",
840
+ type: "StandardSchemaV1 | JSONSchema7",
841
+ required: true,
842
+ description: "Schema for the state (e.g., a Zod schema).",
843
+ },
844
+ {
845
+ name: "initialState",
846
+ type: "TState",
847
+ required: true,
848
+ description: (
849
+ <>
850
+ Initial state value. The type is inferred from{" "}
851
+ <code>stateSchema</code>.
852
+ </>
853
+ ),
854
+ },
855
+ {
856
+ name: "id",
857
+ type: "string",
858
+ required: false,
859
+ description:
860
+ "Unique instance ID, used to address this instance when multiple interactables share a name. Auto-generated if omitted.",
861
+ },
862
+ {
863
+ name: "updateRender",
864
+ type: "ToolCallMessagePartComponent",
865
+ required: false,
866
+ description: (
867
+ <>
868
+ Renders the model's <code>update_{"{name}"}</code> tool calls
869
+ yourself; installed once per name. See{" "}
870
+ <a href="#custom-update-rendering">Custom Update Rendering</a>.
871
+ </>
872
+ ),
873
+ },
874
+ ],
875
+ },
876
+ ],
877
+ },
878
+ ]}
879
+ />
880
+
881
+ **Returns:** `[state, methods]`
882
+
883
+ <PrimitivesTypeTable
884
+ type="unstable_useInteractable-returns"
885
+ parameters={[
886
+ {
887
+ name: "state",
888
+ type: "TState",
889
+ required: true,
890
+ description: (
891
+ <>
892
+ Current state. Inferred from <code>stateSchema</code>.
893
+ </>
894
+ ),
895
+ },
896
+ {
897
+ name: "id",
898
+ type: "string",
899
+ required: true,
900
+ description: (
901
+ <>
902
+ The instance id; pass it to <code>unstable_useInteractableState</code>{" "}
903
+ in other components.
904
+ </>
905
+ ),
906
+ },
907
+ {
908
+ name: "setState",
909
+ type: "(updater: TState | ((prev: TState) => TState)) => void",
910
+ required: true,
911
+ description: (
912
+ <>
913
+ State setter, like <code>useState</code>.
914
+ </>
915
+ ),
916
+ },
917
+ {
918
+ name: "version",
919
+ type: "{ state: TState; isLatest: boolean; restore: () => void } | undefined",
920
+ required: true,
921
+ description: (
922
+ <>
923
+ This message's version of the instance, when rendered inside a
924
+ tool-call part; see <a href="#versions">Versions</a>.{" "}
925
+ <code>undefined</code> outside messages and for app scope.
926
+ </>
927
+ ),
928
+ },
929
+ {
930
+ name: "isPending",
931
+ type: "boolean",
932
+ required: true,
933
+ description: "Whether a persistence save is in-flight.",
934
+ },
935
+ {
936
+ name: "error",
937
+ type: "unknown",
938
+ required: true,
939
+ description: "Error from the last failed save.",
940
+ },
941
+ {
942
+ name: "flush",
943
+ type: "() => Promise<void>",
944
+ required: true,
945
+ description: "Force an immediate persistence save.",
946
+ },
947
+ ]}
948
+ />
949
+
950
+ <Callout type="info">
951
+ Selection is not a built-in field. To tell the AI which interactable is
952
+ focused, add a selection field such as `selectedId` to your own state; see
953
+ [Selection](#selection).
954
+ </Callout>
955
+
956
+ ### `unstable_useInteractableState`
957
+
958
+ Reads and writes the state of an interactable registered elsewhere, by id. Use this from secondary readers (children, siblings of the owning component).
959
+
960
+ ```tsx
961
+ const [state, { setState, isPending, error, flush }] =
962
+ unstable_useInteractableState<TState>(id);
963
+ ```
964
+
965
+ **Parameters:**
966
+
967
+ <PrimitivesTypeTable
968
+ type="unstable_useInteractableState-params"
969
+ parameters={[
970
+ {
971
+ name: "id",
972
+ type: "string",
973
+ required: true,
974
+ description: (
975
+ <>
976
+ The interactable instance id (from{" "}
977
+ <code>unstable_useInteractable</code>).
978
+ </>
979
+ ),
980
+ },
981
+ ]}
982
+ />
983
+
984
+ **Returns:** `[state, methods]`, the same shape as `unstable_useInteractable` without `id`. `state` is `TState | undefined` until the owning `unstable_useInteractable` has registered.
985
+
986
+ ### `unstable_interactableTool`
987
+
988
+ ```tsx
989
+ notepad: unstable_interactableTool({ description, stateSchema, render }),
990
+ ```
991
+
992
+ Returns a complete toolkit tool entry (a frontend tool with standalone display); the entry key is the interactable name, and the tool's arguments are its initial state. The same `render` then appears at every message that creates or updates the instance. `render` receives:
993
+
994
+ <PrimitivesTypeTable
995
+ type="unstable_interactableTool-render-props"
996
+ parameters={[
997
+ {
998
+ name: "state",
999
+ type: "TState",
1000
+ required: true,
1001
+ description:
1002
+ "The live state. While streaming, fields the model has not finished generating may be missing.",
1003
+ },
1004
+ {
1005
+ name: "setState",
1006
+ type: "(updater: TState | ((prev: TState) => TState)) => void",
1007
+ required: true,
1008
+ description: "Updates the live state.",
1009
+ },
1010
+ {
1011
+ name: "version",
1012
+ type: "{ state: TState; isLatest: boolean; restore: () => void } | undefined",
1013
+ required: true,
1014
+ description:
1015
+ "This message's version of the instance; undefined while streaming.",
1016
+ },
1017
+ {
1018
+ name: "id",
1019
+ type: "string",
1020
+ required: true,
1021
+ description: "The instance id (the creating call's toolCallId).",
1022
+ },
1023
+ {
1024
+ name: "streaming",
1025
+ type: "boolean",
1026
+ required: true,
1027
+ description: "True while the tool call's arguments are still streaming.",
1028
+ },
1029
+ ]}
1030
+ />
1031
+
1032
+ ### `unstable_useInteractableVersions`
1033
+
1034
+ ```tsx
1035
+ const versions = unstable_useInteractableVersions<TState>(id, name);
1036
+ // → [{ state, origin: "create" | "update" | "user-edit", toolCallId?, restore }, ...]
1037
+ ```
1038
+
1039
+ Every version of an interactable recorded in the current thread, oldest first. Each entry carries the full `state` and a `restore()` that sets the live instance back to it. Works for both scopes; see [Versions](#versions) for usage. The non-React equivalent for backends is `unstable_getInteractableVersions(messages, id, name)`, exported from `@assistant-ui/react`.
1040
+
1041
+ ### `unstable_Interactables`
1042
+
1043
+ The scope resource that manages all interactables. Register it via `useAui`, optionally with a [persistence adapter](#persistence):
1044
+
1045
+ ```tsx
1046
+ const aui = useAui({
1047
+ unstable_interactables: unstable_Interactables({ persistence: myAdapter }),
1048
+ });
1049
+ ```
1050
+
1051
+ ## Migrating from the Previous API
1052
+
1053
+ If you used an earlier version of the interactables API:
1054
+
1055
+ - `useAssistantInteractable` and `useInteractableState` have been merged into a single [`unstable_useInteractable`](#unstable_useinteractable) hook that registers and returns state. `unstable_useInteractableState` remains for secondary readers.
1056
+ - Per-instance tools (`update_note_note-1`) are gone. Each name has one stable `update_{name}` tool with a required `id` parameter.
1057
+ - The top-level `selected` prop and `setSelected` method have been removed. Represent selection as ordinary state; see [Selection](#selection).
390
1058
 
391
1059
  ## Full Example
392
1060
 
393
1061
  See the complete [with-interactables example](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-interactables) for a working implementation featuring:
394
1062
 
395
- - **Task Board** — single-instance interactable with a custom `manage_tasks` tool
396
- - **Sticky Notes** — multi-instance interactables with selection and partial updates
397
- - **localStorage persistence** — state survives page refresh via `setPersistenceAdapter`
398
- - **Sync indicator** — spinning icon while a save is in-flight (`isPending`)
1063
+ - **Task Board**: one interactable whose `tasks` array the AI edits with `update_taskBoard` (add/update/remove/clear)
1064
+ - **Sticky Notes**: one `notes` interactable with a `selectedId` field, where the AI adds, edits, removes, and selects notes through `update_notes`
1065
+ - **localStorage persistence**: state survives page refresh via a `load`/`save` persistence adapter
1066
+ - **Sync indicator**: spinning icon while a save is in-flight (`isPending`)
399
1067
 
400
1068
  ## Related
401
1069
 
402
- - [Tool UI](/docs/tools/tool-ui) — Inline tool call UIs rendered inside messages
403
- - [LangGraph Generative UI](/docs/runtimes/langgraph/generative-ui) — Structured UI components emitted by a LangGraph graph alongside messages
1070
+ - [Dynamic Tools](/docs/tools/dynamic-tools): Frontend tools whose executors close over React state
1071
+ - [Tool UI](/docs/tools/tool-ui): Inline tool call UIs rendered inside messages
1072
+ - [LangGraph Generative UI](/docs/runtimes/langgraph/generative-ui): Structured UI components emitted by a LangGraph graph alongside messages