Keep updates connected with stable IDs
Use stable IDs to keep every update connected to the correct run, step, response, tool, interaction, or approval.
Use a stable ID for each piece of work
runId and stepId identify workflow entities, while their instance IDs
separate attempts. responseId identifies an answer, toolId identifies a tool
call, and interactionId or approvalId connects each request to its result.
For example, a regenerated answer keeps responseId: "report" while changing
responseInstanceId from "attempt-1" to "attempt-2". Every later delta and
terminal event carries "attempt-2". See
stop and retry for the transition event.
| Entity | Identity | Why it matters |
|---|---|---|
| Run attempt | runId + runInstanceId | Separates workflow retries and delegated work |
| Step attempt | stepId + stepInstanceId | Preserves concurrent siblings and rejects stale retry output |
| Response | responseId | Keeps text and the final state together |
| Attempt | responseInstanceId | Ignores late text from an older retry |
| Tool | toolId + toolInstanceId | Separates repeated tool runs |
| Interaction | interactionId | Connects a request to its result |
| Adapter mount | scopeId | Prevents ID collisions across chats |
The pattern is the same everywhere: a stable ID names the logical thing, and an instance ID names one attempt at it. The logical ID never changes for the life of the work; the instance ID changes exactly when the work restarts. When an event arrives, the runtime looks up the logical ID first. If it is unknown, the event is diagnosed and ignored. If it is known but terminal, the event is diagnosed and ignored. If the instance ID does not match the current attempt, the event is diagnosed as stale and ignored. Only a live logical ID with a current instance ID produces output. That ordering is the whole safety model: identity decides whether an event may speak.
Workflows carry context, not just identity
Responses, tools, approvals, and interactions are not steps, but they can
declare which workflow owns them. Every one of these events accepts the
workflow context fields: runId, runInstanceId, stepId, and
stepInstanceId. Supply them when the work genuinely belongs to a reported
run or step, for example a tool call the agent made inside step "sources"
of run "report". The runtime uses the context for diagnostics and for the
devtools trace, so a tool failure can be read as "the search tool failed
inside the sources step" rather than as a free-floating failure.
The type system enforces the shape of that context: you can supply a runId
alone, or a runId with a stepId, but you cannot supply a stepId
without its owning runId, and you cannot supply instance IDs without
their logical IDs. Partial context, a bare step name with no stepId, for
instance, produces an ephemeral partial-identity diagnostic and no
snapshot, no announcement, and no run count. The runtime never treats
display text, array position, or timing as identity, so a name without an ID
is evidence of nothing.
runtime.dispatch({
type: "tool.started",
toolId: "web-search-1",
label: "Search the web",
runId: "report",
runInstanceId: "run-1",
stepId: "sources",
stepInstanceId: "sources-2",
});Generate IDs the host can reproduce
Stable means stable across renders, retries, and replays, and reproducible by the host without consulting the runtime. The best IDs are the ones your framework already gives you: the message ID for a response, the tool-call ID for a tool, the approval ID the framework tracks for an approval. Derive your dispatched IDs from those, generate them once per logical entity, and reuse them for every event in that entity's lifecycle.
A few rules keep generated IDs honest:
- Generate the ID when the logical work begins, not when the first event fires. If the work exists before you report it, the ID should already exist too.
- Never derive an ID from displayed text, a label, an array position, or a render count. All four change: text gets translated, lists reorder, renders repeat. An ID built from any of them will silently fork one logical entity into several.
- Keep instance IDs ordered and human-readable where you can:
"attempt-1","attempt-2". They appear in diagnostics and in the retry announcement's attempt number, and a readable sequence is easier to debug than a UUID. - Do not reuse a logical ID for new work. When the user asks a follow-up
question, that is a new response with a new
responseId, even if it looks like a continuation. Reusing the old ID attaches the new answer to a terminal response, and terminal responses do not speak.
Scope IDs prevent cross-surface collisions
One runtime can serve more than one surface: a main chat and a sidebar
assistant, two tabs sharing a worker, a preview pane beside an editor. Each
surface gets its own scopeId at adapter construction, and the adapter
prefixes every stable framework ID with it. The AI SDK adapter builds
`${scopeId}:message:${messageId}` for responses,
`${scopeId}:tool:${toolCallId}` for tools, and
`${scopeId}:approval:${id}` for approvals. Two surfaces reporting
message "abc" become chat-1:message:abc and chat-2:message:abc: no
collision, no cross-talk, no sidebar announcement attributed to the main
chat.
If you write a custom integration over a shared runtime, do the same. The
scopeId is not a dispatched event field; it is a construction-time
namespace your adapter applies before dispatching. Pick it per surface,
keep it stable for the surface's lifetime, and never reuse one scope's IDs
in another.
Capacity has a safety limit
Identity tracking is bounded on both sides of the dispatch. Adapters cap
their tracked response, tool, approval, and source identities with
maxTrackedEntities; once the limit is reached, the adapter stops accepting
unknown IDs rather than growing without bound. The runtime caps active
entities with maxActiveEntities, 1,000 in the balanced policy, and rejects
new work past the cap as an invalid event.
These limits are why a long-running surface can go quiet without any error being announced. If your adapter stops reporting new tools on a chat that has been open for hours, check whether it reached its tracked-entity limit before assuming the runtime swallowed the events. The limit is a safety property, not a bug: an unbounded identity map is a memory leak with an announcement system attached.
Unknown and stale IDs are diagnosed, not announced
Every identity check that fails produces a diagnostic instead of an
announcement. The diagnostic reasons name the failure precisely:
unknown-response and unknown-tool for IDs the runtime never saw,
terminal-response and terminal-tool for work that already ended,
stale-response, stale-tool, stale-run, and stale-step for events
carrying an older instance ID, and partial-identity for name-only
evidence without a stable ID. Subscribe with onDiagnostic on
createRuntime to watch these decisions during development.
Diagnostics are content-free by design: they carry the event type, the IDs, the disposition, and the reason, never the announcement text. That is what makes them safe to log, to render in the devtools inspector, and to paste into a bug report. When a user says "I never heard the tool finish," the diagnostic trace answers whether the completion was announced, suppressed by policy, or rejected for a stale instance ID, without exposing what the tool said.
Labels are copy, not identity
Do not use displayed text, labels, array positions, or render counts as IDs because they can change. Framework adapters keep stable IDs from your framework and stop accepting unknown IDs after reaching their safety limit.
This bears repeating because it is the most common identity bug in custom integrations: the label is right there, it is unique right now, and using it as the ID works in every demo. Then the app ships in twelve languages, the label becomes a translation key's output, and one logical tool forks into twelve identities, or two tools with the same display name merge into one. Labels are copy. They are translated, punctuated, and rewritten by product. IDs are identity. They are stable, opaque, and boring. Keep the two jobs separate and the runtime can do its job.
What stable IDs prove
Stable IDs prove routing: that an update reached the right response, the
right tool, the right attempt. They prove nothing about what a screen reader
spoke. An event with a perfect responseId and a current
responseInstanceId still only proves that the runtime prepared the
announcement. Treat identity as the addressing on the envelope, test the
heard experience with real screen readers, and let the diagnostics tell you
when the address was wrong.
Interactions and approvals
Announce approvals, confirmations, and requests for input when your app can confirm that they opened or closed.
Debug AI accessibility with the trace explorer
Inspect bounded, redacted runtime decisions and browser delivery evidence with the optional generative-a11y devtools package.