bindThread
Connect public assistant-ui thread state to your application runtime.
Signature and import
Create one binding per thread scope and dispose it when that subscription owner unmounts.
import { bindThread } from "@generative-a11y/assistant-ui";
const binding = bindThread({
runtime,
scopeId: "support-thread",
thread,
});
// Call when the owning scope ends; removes only subscription and adapter state.
function disposeThreadBinding() {
binding.dispose();
}Keep scopeId stable and unique across mounted conversations. The binding uses
only getState and subscribe; disposal releases the subscription and adapter
records while preserving your thread and core runtimes.
Options
Prop
Type
After the tracking limit is exhausted, the binding suppresses subsequent lifecycle events, including events for known identities. Create a fresh binding at an intentional session boundary to resume observation.
Events reported from thread state
Each public thread snapshot is compared with its previous value, and confirmed changes become events.
| assistant-ui state | generative-a11y event | Source |
|---|---|---|
| Assistant message starts running | response.started | Public message state |
| Text grows | response.text.delta | New text only |
| Message finishes, stops, or fails | completed, interrupted, failed | Public message status |
| Tool call and result parts | tool lifecycle | Public content parts |
| Approval state | approval requested or resolved | Public content parts |
| Source parts | citation.available | Optional content parts |
Baselining existing history
bindThread calls its observer once during construction, before subscribing.
That first pass treats every message already in the thread as historical: it
records assistant messages, tools, approvals, and sources without dispatching
a single event. A user who opens a conversation with forty old messages hears
nothing. Only changes observed after binding become events.
Historical messages are not inert. If an old assistant message gains new text
later, the binding emits response.started followed by response.text.delta
for the new text, and a later terminal status still resolves the response.
Terminal outcomes (response.completed, response.interrupted,
response.failed) only dispatch for records that became active, so a
conversation opened on an already-finished exchange never announces stale
finishes.
The binding observes assistant messages only. A message must have a string
id, role: "assistant", an array content, and a defined status to be
tracked. User and system messages are never inspected.
Response identity
Every dispatched event is namespaced under your scopeId, which is trimmed at
construction and validated to be a non-empty string. Response identity is
derived, not borrowed: a response ID has the shape
`${scopeId}:message:${messageId}`, so two conversations mounted at the
same time produce disjoint event streams as long as their scope IDs differ.
Tool IDs take the shape `${scopeId}:tool:${toolCallId}` and approval IDs
`${scopeId}:approval:${approvalId}`.
Text deltas are emitted only for append-only growth. Each text part is
remembered per part index; when the observed text extends the previous value,
only the new slice is dispatched in response.text.delta. If the observed text
is rewritten instead of extended, the binding marks that part as poisoned and
stops reporting it. Poisoning never invents content: the user hears the text
that was already announced, not a reconstruction.
Terminal statuses
Public assistant-ui statuses map to terminal events as follows:
status.type: "complete"with reason"stop"or"unknown"becomesresponse.completed.status.type: "incomplete"with reason"cancelled"becomesresponse.interrupted.status.type: "incomplete"with reason"error"becomesresponse.failed.- Any other incomplete reason produces no terminal event.
An unknown reason fails closed: the binding never invents a stop, error, or interruption it did not see. A terminal event dispatches at most once per response record, and only after the record became active.
Tool and approval translation
Tool-call parts (type: "tool-call" with a string toolCallId) produce
tool.started when first seen after the initial baseline, carrying a label
from your copy or the default "A tool". When the part later gains a defined
result, the binding dispatches tool.completed, or tool.failed when
isError is true. A tool-call part with a result already present during the
initial baseline emits neither event, which keeps history replays quiet. The
same part first observed after the baseline emits tool.started followed by
its terminal event.
Approval parts work in two phases. An approval observed while its approved
field is still undefined dispatches approval.requested. When the same
approval later resolves to a boolean, the binding dispatches
approval.resolved with outcome "approved" or "rejected" and the matching
resolved label from your copy. An approval that was already decided in history
never announces a request.
Source parts (type: "source" with a string id) dispatch
citation.available with a count equal to the number of distinct sources
tracked so far. Each source ID is reported once.
Localized copy is captured and validated once at construction through
normalizeAdapterCopy. The copy's locale is stamped onto tool.* and
approval.* events only; response events carry no locale. It never translates
response text.
Identity tracking and saturation
maxTrackedEntities (default 1000) bounds five independent counters: message
records, tools, approvals, sources, and text parts per message record. The
default accommodates long conversations; raise it only for sessions you expect
to exceed a thousand distinct entities per counter.
When any counter is exhausted, the binding sets a saturated flag and suppresses all subsequent dispatch, including events for identities it already knows. This is a deliberate fail-closed choice: a saturated observer stops talking rather than reporting an incomplete picture. Create a fresh binding at an intentional session boundary (for example, when the user starts a new conversation) to resume observation with a clean slate.
Adapter fidelity
The package exports a frozen adapterInfo declaration describing what the
adapter can truthfully observe. Framework-independent consumers and the
devtools store use it to report public evidence without overclaiming.
| Lifecycle dimension | Fidelity |
|---|---|
| Runs | unavailable: the thread snapshot exposes no run identity |
| Steps | unavailable |
| Hierarchy | unavailable |
| Tools | exact |
| Interactions | unavailable |
| Replay | partial |
| Reconnection | unavailable |
| Custom events | unsupported |
| Interruption | exact |
| Retries | unavailable |
| Connection | unavailable |
| Optional events | tool.failed, citation.available |
| Observed runtime methods | getState, subscribe |
Error semantics
Construction validates its options before touching the thread runtime:
scopeIdmust be a string that is non-empty after trimming, otherwise aTypeErroris thrown.maxTrackedEntities, when provided, must be a positive safe integer, otherwise aTypeErroris thrown.- An invalid
copyis rejected bynormalizeAdapterCopyat construction.
Host delivery is isolated: if your runtime's dispatch throws, the binding
swallows the exception and continues observing. A misbehaving host listener
cannot break the subscription. Disposal releases only the binding's own state:
the unsubscribe function, message records, tool records, approval records, and
source IDs. Your thread and core runtimes are preserved.
Worked example
Two conversations mounted at once need two bindings with distinct, stable scope IDs. Dispose each binding when its owning scope ends.
import { bindThread, type ThreadBinding } from "@generative-a11y/assistant-ui";
export function bindConversation(options: {
runtime: Parameters<typeof bindThread>[0]["runtime"];
scopeId: string;
thread: Parameters<typeof bindThread>[0]["thread"];
}): ThreadBinding {
const binding = bindThread({
runtime: options.runtime,
scopeId: options.scopeId,
thread: options.thread,
maxTrackedEntities: 2000,
});
return binding;
}
// In a React component:
// const bindingRef = useRef<ThreadBinding | null>(null);
// useEffect(() => {
// bindingRef.current = bindConversation({ runtime, scopeId: "support-thread", thread });
// return () => bindingRef.current?.dispose();
// }, [runtime, thread]);Keep the scope ID stable for the life of the conversation and unique across mounted conversations. Re-using a scope ID for a new conversation can replay identities the runtime has already attributed; when the user starts a new session, bind with a new scope ID instead.