Interactable Tool UIs

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.

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.

Task Board

No tasks yet.

Ask the assistant to add some!

Overview

Types of Interactables

  • 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.
  • 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.

Features

  • 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.
  • Streaming and partial updates: updates are streamed to the interactable, and the model only needs to update the fields it wants to change.
  • Versioning and history: each user edit and model update_* is recorded as a version you can display, list, and restore() (Versions)
  • 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).
  • 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.

Use Cases

  • Settings panels or dashboards the agent can edit and interact with
  • Collaborative task lists, sticky notes, document editors
  • Making artifacts editable and versioned
  • Any other React component with state the model should read or edit

Quick Start

Register the interactables scope

import {
  AuiConfig,
  unstable_Interactables,
  AssistantRuntimeProvider,
  Tools,
} from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";

function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
  const runtime = useChatRuntime();
  const config = AuiConfig({
    unstable_interactables: unstable_Interactables(),
  });

  return (
    <AssistantRuntimeProvider
      runtime={runtime}
      config={config}
    >
      {children}
    </AssistantRuntimeProvider>
  );
}

Idea

The deprecated interactables: Interactables() scope and the new unstable_interactables: unstable_Interactables() scope are mutually exclusive. Mount only one interactables API in a single provider config.

This scope is needed for both kinds of interactable. Thread-scoped interactables also live in a toolkit, which you register with Tools (shown below).

Define an interactable

Pick the path that matches who creates it.

A component you mount yourself. Define it with unstable_useInteractable where you render it (a sidebar, a panel, wherever).

import { unstable_useInteractable } from "@assistant-ui/react";
import { z } from "zod";

const taskBoardSchema = z.object({
  tasks: z.array(
    z.object({ id: z.string(), title: z.string(), done: z.boolean() }),
  ),
});

function TaskBoard() {
  const [state, { setState }] = unstable_useInteractable("taskBoard", {
    description:
      "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.",
    stateSchema: taskBoardSchema,
    initialState: { tasks: [] },
  });

  return (
    <ul>
      {state.tasks.map((task) => (
        <li key={task.id}>
          <input
            type="checkbox"
            checked={task.done}
            onChange={() =>
              setState((prev) => ({
                tasks: prev.tasks.map((t) =>
                  t.id === task.id ? { ...t, done: !t.done } : t,
                ),
              }))
            }
          />
          {task.title}
        </li>
      ))}
    </ul>
  );
}

update_taskBoard is generated automatically from your stateSchema once the TaskBoard component is mounted in your app.

Tip

App-scoped state is shared across every thread and can be persisted by defining a persistence adapter.

Surface state to the model in your route

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:

app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { convertToModelMessages, streamText } from "ai";
import { unstable_injectInteractableContext as injectInteractableContext } from "@assistant-ui/ai-sdk";

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    abortSignal: req.signal,
    model: openai("gpt-6-luna"),
    messages: await convertToModelMessages(injectInteractableContext(messages)),
  });

  return result.toUIMessageStreamResponse();
}

Info

The format of the snapshot sent to the model can be customized; see State snapshots for more details.

State snapshots

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.

Default full snapshot formatting:

`[Current state of "note" (id: "n1"): {"title":"Q3 launch","body":"Ship the beta by Friday."}]`;

Default partial snapshot formatting:

`[State of "note" (id: "n1") changed — updated fields: {"title":"Q4 launch"}; fields not listed are unchanged]`;

Customizing the format

A formatter receives one snapshot entry and returns the line the model sees:

  • name and id identify the instance. Keep the id visible so the model knows what the state belongs to.
  • state is the snapshot payload.
  • partial is true when state carries a shallow diff of only the fields that changed since the model's last known state. Handle it.

Write a custom formatter:

import { type Unstable_InteractableSnapshotEntry } from "@assistant-ui/react";

const formatSnapshot = (entry: Unstable_InteractableSnapshotEntry) =>
  entry.partial
    ? `State of "${entry.name}" (id: "${entry.id}") has been updated, the following fields have changed: ${JSON.stringify(entry.state)}`
    : `Current state of "${entry.name}" (id: "${entry.id}"): ${JSON.stringify(entry.state)}`;

Then wire it to your backend:

  • AI SDK: pass it as the second argument, unstable_injectInteractableContext(messages, formatSnapshot).
  • Other backends: use it in place of unstable_formatInteractableSnapshot in your helper (see Step 3's "Other Backends" tab).
app/api/chat/route.ts
messages: await convertToModelMessages(
  unstable_injectInteractableContext(messages, formatSnapshot),
),

Artifacts

An artifact combines two pieces that point at the same interactable:

  • a thread tool (unstable_interactableTool) the model calls to create the artifact inline (a button or small preview in the message), and
  • a panel you mount with unstable_useInteractable that opens that same instance at full size.

Both use the same id, so they are one interactable: the model creates it in the conversation, and the panel is a larger view of the same state. Because the thread holds the creating call, the artifact is thread-scoped: it persists with the thread's history (no persistence adapter needed), and each message's trigger can open that message's version (see Versions).

  in the thread (model-created)            your layout (mounted once)
──────────────────────────────          ──────────────────────────────
unstable_interactableTool("document")    ArtifactPanel()
  render: a button / inline preview        unstable_useInteractable("document", { id })
    onClick → openArtifact(id) ────┐         → live, editable, full height
                                   │                    │
                                   └─────  same id  ────┘
                                     one thread-scoped instance
const toolkit = defineToolkit({
  document: unstable_interactableTool({
    description: "A document the user can open and edit.",
    stateSchema: documentSchema,
    render: ({ state, version, id }) => (
      <ArtifactButton
        title={(version?.state ?? state).title}
        onClick={() => openArtifact(id)} // your own state: which artifact is open
      />
    ),
  }),
});

function ArtifactPanel({ id }: { id: string }) {
  const [state, { setState }] = unstable_useInteractable("document", {
    id,
    description: "A document the user can open and edit.",
    stateSchema: documentSchema,
    initialState: emptyDocument, // fallback only; existing state comes from the thread
  });
  const versions = unstable_useInteractableVersions<Document>(id, "document");

  return (
    <aside>
      <VersionMenu>
        {versions.map((v, i) => (
          <DropdownItem key={i} onSelect={v.restore}>
            v{i + 1}: {v.origin === "user-edit" ? "you" : "assistant"}
          </DropdownItem>
        ))}
      </VersionMenu>
      <Editor value={state} onChange={setState} />
    </aside>
  );
}

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 edits the live state. 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.

Warning

Keep one mount of the artifact on screen (hidden is fine) whenever its instance should stay available. If the panel is closed and every inline button has scrolled out of a virtualized thread, the instance and its update_{name} tool unregister and the tool list churns. The state itself is safe, since it rides thread history.

Companion tools

The generated update_{name} tool covers everything that lives in the state: editing fields, and adding, updating, removing, or clearing items in a list. Add a separate frontend tool only for what state can't express: a side effect like sending, exporting, or saving.

Take an email composer the user and assistant co-edit. update_email keeps the draft in sync from both sides, but sending it is a side effect that editing state cannot perform. A companion tool covers it: send_email sends the current draft.

How you wire one depends on what its executor touches.

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.

app/email-toolkit.tsx
"use generative";

import { defineToolkit, stubTool } from "@assistant-ui/react";
import { z } from "zod";

export default defineToolkit({
  send_email: {
    description:
      "Send the email currently shown in the composer. Call this only once the draft is ready.",
    parameters: z.object({}),
    execute: stubTool(),
    renderText: { running: "Sending...", complete: "Email sent" },
  },
});
app/EmailComposer.tsx
import {
  unstable_useInteractable,
  useAuiToolOverrides,
} from "@assistant-ui/react";

function EmailComposer() {
  const [draft, { setState }] = unstable_useInteractable("email", {
    description: "An email draft the user and assistant can read and edit.",
    stateSchema: emailSchema,
    initialState: { to: "", subject: "", body: "" },
  });

  return (
    <>
      <SendEmailTool draft={draft} />
      {/* inputs bound to draft + setState */}
    </>
  );
}

// A null-returning child supplies the executor, closing over the live draft.
function SendEmailTool({ draft }: { draft: Email }) {
  useAuiToolOverrides({
    send_email: {
      execute: async () => {
        await fetch("/api/send-email", {
          method: "POST",
          body: JSON.stringify(draft),
        });
        return { success: true };
      },
    },
  });
  return null;
}

update_email edits the draft, and send_email performs the side effect. The executor reads the live draft, so it always sends what is currently on screen.

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.

Custom Update Rendering

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.

Info

To vary by this message's version, or render older messages differently from the newest, you don't need updateRender. The render already receives version; see Versions.

Render edits differently from the create

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:

// Hoisted so its identity is stable; an inline updateRender re-registers the
// tool UI on every render.
const EditSummaryRender: ToolCallMessagePartComponent = ({ args }) => (
  <EditSummary changed={args} />
);

const NotepadToolUI: ToolCallMessagePartComponent<NotepadArgs> = ({
  toolCallId,
  args,
  result,
}) => {
  if (!result) return <NotepadDraft args={args} />;
  return <Notepad id={toolCallId} initial={args} />;
};

function Notepad({ id, initial }: { id: string; initial: NotepadArgs }) {
  const [state, { setState }] = unstable_useInteractable("notepad", {
    id,
    description: "A notepad the user can read and edit.",
    stateSchema: notepadSchema,
    initialState: initial,
    updateRender: EditSummaryRender,
  });
  return <NotepadEditor value={state} onChange={setState} />;
}

// NotepadToolUI is the create call's render; updateRender handles the edits.
const toolkit = defineToolkit({
  notepad: {
    type: "frontend",
    description: "A notepad the user can read and edit.",
    parameters: notepadSchema,
    display: "standalone",
    execute: async () => ({ success: true as const }),
    render: NotepadToolUI,
  },
});

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.

Mark edits on an app-scoped surface

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.

const EditMarkerRender: ToolCallMessagePartComponent = ({ args }) => (
  <EditMarker changed={args} />
);

function DocumentPanel() {
  const [state, { setState }] = unstable_useInteractable("document", {
    description: "A document the user can read and edit.",
    stateSchema: documentSchema,
    initialState: emptyDocument,
    updateRender: EditMarkerRender,
  });
  return <Editor value={state} onChange={setState} />;
}

Versions

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.

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.

Two independent choices decide how history behaves:

  • 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.
  • Restorable? Offer version.restore() to roll an old version back to live, or leave it out.
You wantRender
Frozen historyversion.state read-only on old
Live-editablestate / setState everywhere
Read-only + rollbackversion.state read-only plus restore
// Read-only history with rollback:
// old messages are frozen but can roll their version back to live
render: ({ state, setState, version }) =>
  version && !version.isLatest ? (
    <Notepad value={version.state} readOnly onRestore={version.restore} />
  ) : (
    <Notepad value={state} onChange={setState} />
  );
// Live-editable:
// every message edits the shared instance; restore reverts to this point
render: ({ state, setState, version }) => (
  <Notepad value={state} onChange={setState} onRestore={version?.restore} />
);

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:

function VersionDropdown({ id, name }: { id: string; name: string }) {
  const versions = unstable_useInteractableVersions(id, name);
  if (versions.length < 2) return null;

  return (
    <select onChange={(e) => versions[+e.target.value]!.restore()}>
      {versions.map((v, i) => (
        <option key={i} value={i}>
          v{i + 1}: {v.origin === "user-edit" ? "you" : "assistant"}
        </option>
      ))}
    </select>
  );
}

Thread-scoped:

const Notepad = ({
  id,
  state,
  setState,
}: Unstable_InteractableToolRenderProps<NotepadArgs>) => (
  <div>
    <VersionDropdown id={id} name="notepad" />
    {/* ...editor... */}
  </div>
);

App-scoped:

const [state, { id }] = unstable_useInteractable("taskBoard", config);
return <VersionDropdown id={id} name="taskBoard" />;

An app-scoped item's history covers the current conversation, not its full cross-thread lifetime.

Persistence

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:

import {
  AuiConfig,
  AuiProvider,
  useAui,
  unstable_Interactables,
} from "@assistant-ui/react";

// Module-level (or memoized): a new adapter object is a new persistence scope.
const persistenceAdapter = {
  load: () => {
    const saved = localStorage.getItem("interactables");
    return saved ? JSON.parse(saved) : undefined;
  },
  save: (state) => {
    localStorage.setItem("interactables", JSON.stringify(state));
  },
};

function MyRuntimeProvider({ children }) {
  const aui = useAui();
  const config = AuiConfig({
    unstable_interactables: unstable_Interactables({
      persistence: persistenceAdapter,
    }),
  });
  return (
    <AuiProvider extends={aui} config={config}>
      {children}
    </AuiProvider>
  );
}

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.

For dynamic setups (an adapter that depends on auth), call aui.unstable_interactables.setPersistenceAdapter(adapter) imperatively instead. Replacing an adapter resets app-scoped interactables before loading the replacement scope, including when the transition passes through undefined. Removing an adapter flushes queued changes but preserves local state, and reattaching the same adapter continues that scope. Keep the adapter memoized unless an account or workspace change should deliberately reset the scope.

save receives the complete app-scoped snapshot for the current persistence scope: mounted interactables, entries that were loaded but never mounted, and entries whose component has since unmounted. Thread-scoped entries are always excluded. This is a superset of exportState(), which returns only currently mounted interactables.

Both leave out an interactable that is still on its initialState; it joins the snapshot once it is edited, imported, or loaded. A changed initialState therefore reaches users who never edited that interactable. A default that an earlier snapshot already stored stays in storage, because a stored default cannot be told apart from an edit.

Sync Status

When a persistence adapter is set, interactable hooks expose sync metadata:

const [state, { setState, isPending, error, flush }] =
  unstable_useInteractableState<TState>(id);

// isPending: true while a save is in-flight
// error: the error from the last failed load or save, if any
// flush(): force an immediate save, retrying a failed one (useful before navigation)

State changes are automatically debounced (500ms) before saving. Once the persistence scope has loaded, unmounting the owning component flushes any pending save immediately. If the load is still pending or has failed, the state remains in memory and joins the next snapshot after a successful load. A failed load keeps queued changes and exposes the error through the sync metadata. The next edit retries the load after the debounce delay, while an explicit flush() retries it immediately. If a load is already pending, flush() waits up to 5 seconds for it to settle before saving; past that it resolves without saving and the queued changes join the next snapshot after a successful load, so an adapter whose load stalls cannot block navigation. Saving starts only after a load succeeds. If the retry started by flush() also fails, flush() resolves without saving; check error and call flush() again after the load failure is resolved.

A failed save keeps its value queued for retry and is not retried in the background. The value is sent again with the next save requested after the failure: an edit to any interactable, or an explicit flush(), which sends it immediately even when nothing else changed. While a retry is in flight, isPending is true and error still holds the last failure. Every save sends the full snapshot, so error clears once any later save succeeds without a newer edit to that interactable, including a save that was already queued when the failed one was rejected. In that case no extra retry is sent. If the retry fails too, error holds the new error. A newer edit to the same interactable supersedes the failed value. Replacing the adapter drops failed saves along with the rest of the old scope.

Export / Import

For custom persistence strategies, use exportState and importState directly:

const snapshot = aui.unstable_interactables.exportState();
// => { "note-1": { name: "note", state: { title: "Hello" } }, ... }

aui.unstable_interactables.importState(snapshot);
// Imported state is picked up when components next register

Schema Evolution

Warning

If you change a Zod schema after state has been persisted, the loaded snapshot may silently mis-match the new shape. Loaded state replaces the initial state as a whole and is not validated, so fields added to the schema are missing and 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 load or importState call.

Streaming Updates

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. A nested object streams the same way: the fields the model hasn't reached yet keep their previous values instead of disappearing, and once the object closes it replaces the previous one, as the final merge does.

While one array item streams in, the items the model isn't editing keep their object identity for the whole stream, so a memoized row for them does not re-render:

import { memo } from "react";

const TaskRow = memo(function TaskRow({ task }: { task: Task }) {
  return <li>{task.title}</li>;
});

function TaskBoard() {
  const [state] = unstable_useInteractable("taskBoard", config);

  return (
    <ul>
      {state.tasks.map((task) => (
        <TaskRow key={task.id} task={task} />
      ))}
    </ul>
  );
}

As update_taskBoard streams an edit to one task, that row re-renders as its fields arrive while every other TaskRow is left alone. The list updates live without extra code; use the partial state only when you want to show something extra during the stream.

At the start of a create call 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:

import { useAuiState } from "@assistant-ui/react";

const isRunning = useAuiState((s) => s.thread.isRunning);
const isLoading = isRunning && state.tasks.length === 0;

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.

Multiple Instances

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. The schema marks id as required, so the model is expected to send it on every call; the runtime still resolves an id-less call while exactly one instance exists. A call with an unknown id returns an error listing the valid ids, so the model can recover.

How instances come into being differs by scope.

Thread-scoped: one instance per call

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.

App-scoped: one instance per mount

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:

import { unstable_useInteractable } from "@assistant-ui/react";
import { z } from "zod";

const noteSchema = z.object({
  title: z.string(),
  content: z.string(),
  color: z.enum(["yellow", "blue", "green", "pink"]),
});

const noteInitialState = {
  title: "New Note",
  content: "",
  color: "yellow" as const,
};

function NoteCard({ noteId }: { noteId: string }) {
  const [state] = unstable_useInteractable("note", {
    id: noteId,
    description: "A sticky note",
    stateSchema: noteSchema,
    initialState: noteInitialState,
  });

  return <div>{state.title}</div>;
}

function App() {
  return (
    <>
      <NoteCard noteId="note-1" />
      <NoteCard noteId="note-2" />
      {/* one update_note tool: update_note({ id: "note-2", color: "blue" }) */}
    </>
  );
}

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 panel and its inline trigger, or to keep persisted 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.

Info

A top-level id field in your stateSchema is reserved: the update tool uses it for instance addressing, so the model cannot write a state field named id. Nest it or name it differently (e.g. noteId).

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 task board does. Use separate mounted instances only when each note is its own component; mounting and unmounting those is app work you can expose as a companion tool.

Partial Updates

Auto-generated tools use a partial schema in which every top-level field is optional. The AI only sends the fields it wants to change; omitted fields keep their current values.

// If the state is { title: "My Note", content: "Hello", color: "yellow" }
// The AI can call: update_note({ color: "blue" })
// Result: { title: "My Note", content: "Hello", color: "blue" }

This is especially useful for large state objects where regenerating the entire state would be expensive and error-prone.

For array fields, the AI sends operations (add, remove, clear) instead of a replacement array, and the framework applies them to the current list. Arrays whose items carry an id also accept update: added items get their id from the framework, and existing items are addressed by their id.

Info

Merge is shallow (one level deep). If the AI sends a nested object, it replaces that entire field rather than deep-merging into it, so the schema keeps a nested object's required fields and the AI sends it complete.

How It Works

  1. Register: the interactable joins the unstable_interactables scope with its name, description, schema, and initial state.
  2. Generate the tool: one update_{name} tool per name, with a partial schema (every top-level field optional, nested objects sent whole) plus a required id. The tool list stays stable as instances mount and unmount, so provider prompt caches stay warm.
  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).
  4. Stream: state updates field-by-field as the model generates the tool arguments, so the UI fills in live.
  5. Merge: only the fields the model sends are applied; the rest stay. Array fields take operations (add/remove/clear, plus update when items carry an id) instead of a replacement array, and the framework mints ids for items added to id-keyed arrays.
  6. Both directions: a model update_* updates state and re-renders; a user setState rides the next message as a fresh snapshot.

Unmount Behavior

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.

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.

API Reference

unstable_useInteractable

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.

const [state, { id, setState, isPending, error, flush }] =
  unstable_useInteractable(name, config);

Parameters:

Prop

Type

Returns: [state, methods]

Prop

Type

Info

Selection is not a built-in field. To tell the AI which interactable is focused, add a selection field such as selectedId to your own state.

unstable_useInteractableState

Reads and writes the state of an interactable registered elsewhere, by id. Use this from secondary readers (children, siblings of the owning component).

const [state, { setState, isPending, error, flush }] =
  unstable_useInteractableState<TState>(id);

Parameters:

Prop

Type

Returns: [state, methods], the same shape as unstable_useInteractable without id. state is TState | undefined until the owning unstable_useInteractable has registered.

unstable_interactableTool

notepad: unstable_interactableTool({ description, stateSchema, render }),

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:

Prop

Type

unstable_useInteractableVersions

const versions = unstable_useInteractableVersions<TState>(id, name);
// → [{ state, origin: "create" | "update" | "user-edit", toolCallId?, restore }, ...]

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 for usage. The non-React equivalent for backends is unstable_getInteractableVersions(messages, id, name), exported from @assistant-ui/react.

unstable_Interactables

The scope resource that manages all interactables. Register it via your provider's config, optionally with a persistence adapter:

AuiConfig({
  unstable_interactables: unstable_Interactables({ persistence: myAdapter }),
});

Migrating from the Previous API

If you used an earlier version of the interactables API:

  • useAssistantInteractable and useInteractableState have been merged into a single unstable_useInteractable hook that registers and returns state. unstable_useInteractableState remains for secondary readers.
  • Per-instance tools (update_note_note-1) are gone. Each name has one stable update_{name} tool with a required id parameter.
  • The top-level selected prop and setSelected method have been removed. Represent selection as ordinary state, such as a selectedId field.

Full Example

See the complete with-interactables example for a working implementation featuring:

  • Task Board: one interactable whose tasks array the AI edits with update_taskBoard (add/update/remove/clear)
  • Sticky Notes: one notes interactable with a selectedId field, where the AI adds, edits, removes, and selects notes through update_notes
  • localStorage persistence: state survives page refresh via a load/save persistence adapter
  • Sync indicator: spinning icon while a save is in-flight (isPending)
  • Dynamic Tools: Frontend tools whose executors close over React state
  • Tool UI: Inline tool call UIs rendered inside messages
  • LangGraph Generative UI: Structured UI components emitted by a LangGraph graph alongside messages