Skip to content

Signatures quoted from source. Usage: Bind a Statewire, Deploy a Statewire. Related: Statewire reference.

Entry points

@assistant-ui/react-statewire

StatewireThread

const StatewireThread: <T>(options: StatewireThread.Options<T>) => ResourceElement<ClientOutput<"thread">>;

Binds a statewire to an assistant-ui thread for AuiConfig({ thread }). The run-dialect commands map onto composer and message actions.

StatewireThread.*Shape
ComposerDraft{ readonly text: string; readonly attachments: readonly CompleteAttachment[] }
ComposerStorage{ read(): ComposerDraft | undefined; write(draft: ComposerDraft): void; clear(): void }; write persists synchronously

StatewireThread.Options<T>

NameTypeDefaultMeaning
transportStatewireThread.Transport<T> = ResourceElement<Statewire<T | undefined, any>>requiredStatewire transport
initialStateTrequiredPre-attach state, replaced by the attach snapshot; runs: [] projects ready, an absent runs mount projects loading until attach
converterStatewireThread.StateConverter<T> = (state: T, connectionMetadata: ConnectionMetadata) => StaterequiredProjects the statewire state into messages
optimistic?StatewireClient.Optimistic<T, Record<string, (...args: any[]) => unknown>>—Folds app-owned keys for an unsettled command; runs after the built-in run-dialect reducer
materialize?StatewireClient.Materialize<NonNullable<T>>—Makes dispatch records concrete in the draft; without it, dispatch limbo stays out of messages
contextSync?"command-send"—Mirrors the ambient model context (system, tools, callSettings, config) through native lease context on the application protocol; the latest object is restored automatically on every attachment and replaced on change; throws statewire: contextSync requires a subscribable model context without one
stopPayload?() => StopPayload—run/stop payload, merged under runId and epoch; must return a plain object. The verdict's retracted items are dequeued (run/dequeue) into an empty composer, joined by blank lines; a rejected dequeue leaves the item queued
onCommandChange?(update: Statewire.CommandUpdate<Record<string, (...args: any[]) => unknown>>, ctx: { state: NonNullable<T> }) => unknown—Observes each command lifecycle step; runs after the built-in pending-command retirement. Its return value passes through to the client, so a returned promise defers a durable settled update's ack (Settled updates)
onStateChange?(state: NonNullable<T>, ctx: { changes: Statewire.Changes }) => void—Fires with the attach snapshot, then once per change
onConnectionChange?(connection: StatewireClient.Connection) => void—Connection transitions, starting with the initial status
onError?(error: Error) => void—Receives rejections of dispatched commands and of run/stop, and composer-restore attachment failures
capabilities?{ edit?: boolean; continue?: boolean; reload?: boolean; unstable_switchBranch?: boolean }—Enables message edit, run/continue, run/reload, and message/switch-branch affordances
storage?{ composer?: ResourceElement<ComposerStorage>; session?: ResourceElement<StatewireClient.SessionStorage> }—Composer drafts restore on mount, mirror every change, clear when consumed; the session holds the lane identity and un-durable commands
adapters?{ attachments?: AttachmentAdapter; speech?: SpeechSynthesisAdapter; feedback?: FeedbackAdapter }—assistant-ui adapters

State and runs

StatewireThread.*Shape
State{ readonly messages: readonly ThreadMessage[]; readonly state?: ReadonlyJSONValue }; app-owned keys only; a returned state replaces the replicated state on Extras.state and the thread
ConnectionMetadata{ pendingCommands: Command[]; isSending: boolean; connection: StatewireClient.ConnectionState; runs: Runs; documents: StatewireDocuments.Source | undefined; mounted: ReadonlyMap<number, StatewireDocuments.Document>; invoke: (protocol: string, method: string, params: readonly unknown[]) => Promise<unknown> }; local transports are always connected; documents and invoke keep their identity across passes, mounted is a new map whenever a document changes
StopPayload{ readonly reason?: string; readonly [key: string]: ReadonlyJSONValue | undefined }
Extras{ [symbolStatewireExtras]: true; sendCommand: (command: Command) => Promise<unknown>; reconnect: () => void; connection: StatewireClient.ConnectionState; documents: StatewireDocuments.Source | undefined; invoke: (protocol: string, method: string, params: readonly unknown[]) => Promise<unknown>; state: unknown; status: ThreadStatus; isBusy: boolean; inputRequests: readonly InputRequestState[]; runs: Runs; outbox: StatewireClient.Outbox }; outbox releases, edits or removes held sends (Outbox)
RunStatus"running" | "stopped" | "error" | "input-required"
ThreadStatus"loading" | "ready" | "evicted" | "disconnected" | RunStatus; evicted/disconnected are client-derived
RunQueueItem{ readonly id: string; readonly parts: readonly (TextPart | FilePart)[]; readonly metadata?: Record<string, unknown> }
RunsQueueEntryUserMessage & { readonly meta?: ReadonlyJSONValue }; meta is host-stamped
RunsHeldEntryRunsQueueEntry & { readonly localMeta: unknown }; a held send folded from the outbox
RunsInputOutcome{ readonly request: InputRequest; readonly response: InputResponse | null; readonly meta: ReadonlyJSONValue | null }; null response is abandoned
RunsStopping{ readonly reason: "stop" | "message-edit" | "message-reload" }; message-edit/message-reload pair with nextDispatch
RunsError{ readonly message: string; readonly [key: string]: ReadonlyJSONValue | undefined }; set on an unhandled failure, cleared by the next user-initiated run
RunsState{ readonly status: "ready" | RunStatus; readonly runs: readonly RunState[] }; status projects runs[0]?.status ?? "ready", at most one entry
RunsRunState minus status, plus readonly held: readonly RunsHeldEntry[]; runs[0] with unsettled commands folded in, resting defaults while no entry is live. held lists held run/send and run/edit sends carrying a message, in queue order, with the entry's localMeta; they never fold in as a run. Release or remove them through Extras.outbox
MaterializeEffects{ append: (message: RunsQueueEntry) => void; rollback?: (to: string | null) => void }; rollback required for hosts that dispatch edits or reloads
BranchMetadata{ readonly id: number; readonly siblings: readonly number[] }; read from metadata.custom.branch, siblings in creation order, own id included

StatewireThread.RunState, one element of the mount's runs array:

NameTypeMeaning
runIdstring | nullClient-minted, adopted by the host at run creation; persists through steers, input-required, and error/stop; retires when the queue drains and the run completes
epoch?numberCounts the entry's dispatches; a run/stop carrying a stale epoch rejects
statusRunStatusRun subsystem status
queuereadonly RunsQueueEntry[]Queued user messages
steerQueuereadonly RunsQueueEntry[]Steer-lane messages
dispatching?RunsDispatchThe unapplied batch handed to the run; cleared at applied
stopping?RunsStoppingPresent while a stop of the live run is underway
nextDispatch?RunsDispatchThe complete dispatch staged behind a stopping rewind; promotes under the entry's runId
inputRequests?readonly InputRequestState[]The parked pending set; present only in input-required
error?RunsErrorSet only by an unhandled throw; cleared by the next user-initiated run

StatewireThread.RunsDispatch, the dispatch record; absent between applied and the next dispatch or take:

NameTypeMeaning
trigger"message-send" | "message-edit" | "message-reload" | "input-resume" | "error-continue" | "stop-continue" | "steer"What started the dispatch
messagesreadonly RunsQueueEntry[]The dispatched batch
rollbackTo?string | nullPresent on rewind dispatches: truncate the thread at this id first; null names the root
rootMeta?ReadonlyJSONValueThe run-starting op's meta; present only when carried
inputOutcomes?readonly RunsInputOutcome[]Input outcomes riding the dispatch; visible until applied

Commands and input requests

StatewireThread.*Shape
TextPart, FilePart{ readonly type: "text"; readonly text: string }, { readonly type: "file"; readonly mediaType: string; readonly url: string; readonly filename?: string }
UserMessage{ readonly id: string; readonly role: "user"; readonly parts: readonly (TextPart | FilePart)[]; readonly metadata?: Record<string, unknown> }
Placement{ readonly insertAfter?: string | null; readonly insertBefore?: string | null }; absent is default placement, null a queue boundary
RunSendBehavior"auto" | "steer" | "preempt" | "queue"
RunSendCommand{ readonly type: "run/send"; readonly runId: string; readonly behavior?: RunSendBehavior } & ({ readonly message: UserMessage; readonly runAnchorMessageId?: string | null } | { readonly messageId: string }) & Placement; behavior defaults to "auto"; messageId moves a queued message into the behavior's lane; runAnchorMessageId is present on run-creating sends, null asserts an empty thread
RunDequeueCommand{ readonly type: "run/dequeue"; readonly runId: string; readonly messageId: string }
RunEditCommand{ readonly type: "run/edit"; readonly runId: string; readonly message: UserMessage; readonly sourceId: string; readonly runAnchorMessageId: string | null }; runAnchorMessageId asserts sourceId's parent, null names the root
RunReloadCommand{ readonly type: "run/reload"; readonly runId: string; readonly sourceId: string; readonly runAnchorMessageId: string | null }
RunContinueCommand{ readonly type: "run/continue"; readonly runId?: string }
RunInputCommand{ readonly type: "run/input"; readonly runId?: string; readonly requestId: string; readonly response: InputResponse }
SwitchBranchCommand{ readonly type: "message/switch-branch"; readonly branchId: number }
RunCommandunion of the six run/* commands
CommandRunCommand | UserCommands (Assistant.Commands values)
ToolCallInputRequest, ToolApprovalInputRequest{ readonly type: "tool-call" | "tool-approval"; readonly id: string; readonly toolCallId: string }
CustomInputRequest{ readonly type: string; readonly id: string; readonly [key: string]: ReadonlyJSONValue | undefined }
InputRequestToolCallInputRequest | ToolApprovalInputRequest | CustomInputRequest; open union for custom types
InputResponseReadonlyJSONValue
InputRequestStateInputRequest & { readonly response?: InputResponse; readonly meta?: ReadonlyJSONValue }; a present response key marks it answered, meta is the answer's run/input meta

runsMaterialize, useStatewireSendCommand, useStatewireRuns, useStatewireState

const runsMaterialize: (mount: unknown, effects: StatewireThread.MaterializeEffects) => void;
const useStatewireSendCommand: () => (command: StatewireThread.Command) => Promise<unknown>;
const useStatewireRuns: () => StatewireThread.Runs;
function useStatewireState(): UserExternalState;
function useStatewireState<T>(selector: (state: UserExternalState) => T): T;

runsMaterialize is the run dialect's materialize fold: rollback(rollbackTo) first when present, then append per dispatched message. It throws when the record carries rollbackTo and no rollback effect. The hooks and every Extras read throw This method can only be called when you are using StatewireThread off another thread type. The hooks, inside the AuiProvider: dispatch any command (rejects with StatewireSendError), the typed runs read, and the replicated statewire state (UserExternalState is Assistant.ExternalState's value type, Record<string, unknown> when none is declared).

@assistant-ui/react-statewire/local-storage

LocalStorageSession, LocalStorageComposerDrafts

const LocalStorageSession: (options: LocalStorageSession.Options) => ResourceElement<StatewireClient.SessionStorage>;
const LocalStorageComposerDrafts: (options: LocalStorageComposerDrafts.Options) => ResourceElement<StatewireThread.ComposerStorage>;

Both options require threadId: string and a nonempty stable principal: string. Changing principal on a mounted resource throws; remount the thread/session per account. Session options also accept protocol, retention, limits, onError, onEvict, and migrate from the key-value session adapter; draft options accept ttl and max. Tabs sharing a principal and threadId share the record (clientId, per-lane nextSeq, commands, outbox, parked verdicts, undelivered inbox updates): one client id, one lane, verdicts fanned out to every attach (concurrent attaches). Record writes serialize under a Web Lock.

  • LocalStorageSession stores the SessionRecord under aui:statewire-session:<encoded-principal>:<encoded-threadId>. lock takes the Web Lock aui:statewire-session-lock:<encoded-principal>:<encoded-threadId>, notifyLock the Web Lock statewire-notify:<storage key>, subscribe rides the storage event. Throws without localStorage or navigator.locks
  • LocalStorageComposerDrafts stores under aui:composer-draft:<encoded-principal>:<encoded-threadId>. The mount sweep, across the current principal's keys, drops drafts older than ttl (default 7 days) and beyond the newest max (default 50); a corrupt entry is removed, an empty draft deletes its key. Throws without localStorage
  • Legacy unscoped records remain quarantined. Migration requires explicit ownership attribution.

localStorageSessionBackend, localStorageSessionNamespace

const localStorageSessionBackend: KeyValueSessionStorage.Backend;
const localStorageSessionNamespace: string;

The backend LocalStorageSession writes through and the namespace it stores sessions under. Pass both, with the principal, to readOutboxes / subscribeOutboxes to list every thread's outbox:

const unsubscribe = subscribeOutboxes(
  localStorageSessionBackend,
  { namespace: localStorageSessionNamespace, principal: userId },
  (outboxes) => setPending(outboxes.flatMap((o) => o.entries)),
);

@statewire/ai-sdk

useChat

resumeStream() is currently a no-op; the resume option is unused.

const useChat: (options?: UseChatOptions) => UseChatHelpers;

AI SDK useChat hosted in an in-process statewire. The ChatTransport streams UIMessageChunks into ChatState.

  • The host element is internal. ChatCommands is the whole command set; extra commands need your own host
  • UseChatOptions carries neither optimistic nor materialize
  • transport defaults to new DefaultChatTransport(), read fresh per request
  • Chunk handling: the chunk pipeline
TypeShape
UseChatOptions{ id?: string; transport?: ChatTransport<UIMessage>; messages?: UIMessage[]; messageMetadataSchema?: FlexibleSchema<unknown>; dataPartSchemas?: UIDataPartSchemas; generateId?: IdGenerator; onToolCall?; sendAutomaticallyWhen?: (options: { messages: UIMessage[] }) => boolean | PromiseLike<boolean>; onFinish?; onError?; onData?; throttle?: number; resume?: boolean }
UseChatHelpersStatewire.CommandsProxy<ChatCommands> & { id: string; messages: UIMessage[]; status: ChatStatus; error: { message: string } | undefined; addToolResult: Statewire.CommandsProxy<ChatCommands>["addToolOutput"] }: addToolResult aliases addToolOutput
ChatCommands{ sendMessage(message?: UseChatSendMessage | null, options?: ChatRequestOptions): void; regenerate(options?: { messageId?: string } & ChatRequestOptions): void; stop(): void; clearError(): void; resumeStream(): void; setMessages(messages: UIMessage[] | ((messages: UIMessage[]) => UIMessage[])): void; addToolOutput(params: ChatAddToolOutputParams): void; addToolApprovalResponse(params: ChatAddToolApprovalResponseParams): void }
UseChatSendMessage(CreateUIMessage<UIMessage> & { text?: never; files?: never; messageId?: string }) | { text: string; files?: FileList | FileUIPart[]; metadata?: unknown; parts?: never; messageId?: string } | { files: FileList | FileUIPart[]; metadata?: unknown; parts?: never; messageId?: string }: a messageId naming an existing user message truncates to it and replaces it; an unknown or non-user id rejects with StatewireSendError (failure.fate: "rejected"); omitted appends

resume and resumeStream() are inert. stop() acts only while streaming or submitted, clearError() only in error. regenerate and the tool helpers throw StatewireReject on an empty thread or an unknown messageId. | ChatState | { id: string; messages: UIMessage[]; status: ChatStatus; error?: { message: string } } | | ChatAddToolOutputParams | { tool: string; toolCallId: string; options?: ChatRequestOptions } & ({ state?: "output-available"; output: unknown; errorText?: never } \| { state: "output-error"; output?: never; errorText: string }) | | ChatAddToolApprovalResponseParams | { id: string; approved: boolean; reason?: string; options?: ChatRequestOptions } |

plain

const plain: <T>(value: T) => T;

Deep JSON snapshot. Unwraps statewire proxies into plain data.

isRecord, syncArray, syncRecord, syncIndex

const isRecord: (value: unknown) => value is Record<string, unknown>;
const syncArray: (target: unknown[], next: unknown[]) => void;
const syncRecord: (target: Record<string, unknown>, next: Record<string, unknown>) => void;
const syncIndex: (target: unknown[], index: number, next: unknown) => void;

Reconcile a full snapshot into target with minimal in-place mutations. syncIndex appends past the end.

useLatest

const useLatest: <T>(value: T) => () => T;

Stable getter for the latest render's value.

statewire-rivet

Direct StatewireWebsocket connections through the RivetKit 2.3.10 gateway currently fail during subprotocol negotiation. See deployment status and issue #325.

statewireActor, StatewireRivet

const statewireActor: <TState, TConnParams, TConnState, TVars, TInput, TDatabase, TEvents, TQueues, TActions>(config: statewireActor.Config<…>) => ActorDefinition<…>;
namespace statewireActor {
  type Config<…> = ActorConfigInput<…> & StatewireHostInternal.SocketHostOptions & { statewire: ResourceElement<Statewire>; onWebSocket?: never };
}
const StatewireRivet: (transport: ResourceElement<Statewire>, options?: StatewireHostInternal.SocketHostOptions) => StatewireRivet.Instance;

statewireActor is a rivetkit actor (peer rivetkit ^2.3.10) serving a statewire host, one socket host per actor instance. It sets canHibernateWebSocket to authorize === undefined, over any value you pass, and rebuilds the host in createVars on every wake. Client ids ride the client query parameter. StatewireRivet is the manual composition into your own actor({...}) via onWebSocket; there, set canHibernateWebSocket: false when passing authorize. Actor destroy sends every client fin: gone, runs your onDestroy, then disposes the host.

StatewireRivet.*Shape
Instance{ onWebSocket(c: Context, websocket: WebSocket): void; finish(fin: StatewireHostInternal.Fin): void; dispose(): void }
Context{ readonly request?: Request }
WebSocketStatewireHostInternal.Socket & { readonly readyState: 0 | 1 | 2 | 3; readonly url: string; addEventListener(type, listener): void }

@statewire/cloudflare

StatewireDurableObject, statewireHandlers

const StatewireDurableObject: <Env = unknown>(transport?: ResourceElement<Statewire> | StatewireDurableObject.TransportFactory<Env>, options?: StatewireDurableObject.Options) => StatewireDurableObject.Class<Env>;
const statewireHandlers: (ctx: StatewireDurableObject.Context, transport: StatewireDurableObject.Transport, options?: StatewireDurableObject.Options) => StatewireDurableObject.Handlers;
  • StatewireDurableObject: class factory for a complete Durable Object (extends StatewireDurableObject(ThreadHost())).
    • An element is sugar over the overridable transport() method. A (restored, env) => transport factory mounts durable.
    • WebSocket upgrades, /stream, and /frames are served. Every other request goes to the overridable onRequest (426 by default).
    • Client ids, the authorize value, and its original authorizedAt timestamp survive hibernation via serializeAttachment. Enabled lease expiry requires a valid unexpired timestamp before restoration; older sockets reconnect and authorize again. A retained socket with a malformed attachment is closed 1008.
    • An open SSE stream holds the object out of hibernation.
  • statewireHandlers: raw handlers for an existing Durable Object class, called from the constructor. A durable mount restores statewire:snapshot under blockConcurrencyWhile and persists it on a 1000 ms debounce, flushed on socket close and SSE stream end and written after finish and dispose. Client watermarks live under statewire:client:<id> (the ClientStore of StatewireSocketHost); retained sockets' records load under blockConcurrencyWhile. After dispose, fetch answers 410 and socket events are ignored.
NameTypeDefaultMeaning
heartbeatIntervalMs?number0Off: a quiet object hibernates, and a StatewireWebsocket client redials after 30 s of silence, re-attaching with a fresh snapshot. Any positive value holds the socket open with a setInterval while sockets are attached; keep it under the client's 30 s budget
persistSnapshot?booleantruefalse keeps the client records durable but never reads or writes statewire:snapshot: the factory receives undefined, and the transport needs no snapshot() or subscribe(). For a transport whose state lives elsewhere, or exceeds the storage value limit

Setup: Durable Objects.

routeStatewireRequest, getStatewireByName, WS_SUBPROTOCOL

const routeStatewireRequest: (request: Request, env: Record<string, unknown>) => Response | Promise<Response> | undefined;
const getStatewireByName: (binding: StatewireDurableObject.Namespace, name: string) => StatewireDurableObject.Stub;
const WS_SUBPROTOCOL = "statewire.v1";

Worker router for /:binding-name/:instance-name (binding match ignores case, -, _; the instance name is URL-decoded; undefined when nothing matches or the path has fewer than two segments) and the direct stub lookup. /frames with a non-POST method answers 405 with Allow: POST. StatewireDurableObject() with neither a transport nor a transport() override throws on first use.

StatewireDurableObject.*Shape
WebSocketStatewireHostInternal.Socket & { serializeAttachment(attachment: unknown): void; deserializeAttachment(): unknown }
Attachment{ clientId: string; authorized?: boolean; authorizedAt?: number; selection?: StatewireProtocol.Selection; attacher?: string; auth?: unknown }: the serialized attachment format
Context{ acceptWebSocket(ws): void; getWebSockets(): WebSocket[]; storage?: Storage; blockConcurrencyWhile?<T>(callback: () => Promise<T>): Promise<T> }
Storage{ get(key: string): Promise<unknown>; put(key: string, value: unknown): Promise<void>; delete(key: string): Promise<boolean>; list(options: { prefix: string }): Promise<Map<string, unknown>> }
DurableTransport{ snapshot(): unknown; subscribe(listener: () => void): () => void }: required of a factory-mounted transport; build it from getStateHost(state) (example)
TransportResourceElement<Statewire> | ((restored: unknown) => ResourceElement<Statewire>)
TransportFactory<Env>(restored: unknown, env: Env) => ResourceElement<Statewire>
Handlers{ fetch(request): Response | Promise<Response>; webSocketMessage(ws, message: string | ArrayBuffer): void; webSocketClose(ws): void; webSocketError(ws): void; finish(fin: StatewireHostInternal.Fin): void; reconnect(match?: (attach: { clientId: string; auth: unknown }) => boolean, code?: string, options?: { retry?: number }): void; dispose(): void }: reconnect ends every authorized socket whose stored { clientId, auth } match accepts with fin reconnect / code (default reauthorize) and close 1012, without waking the host
OptionsStatewireHostInternal.SocketHostOptions & { persistSnapshot?: boolean }
Instance<Env>{ ctx: DurableObjectState; env: Env; statewire: Handlers; transport(): ResourceElement<Statewire>; onRequest(request): Response | Promise<Response>; fetch; webSocketMessage; webSocketClose; webSocketError }
Class<Env>, BaseClassnew (ctx: DurableObjectState, env: Env) => Instance<Env>; new (ctx, env: unknown) => { ctx; env }
Namespace, Stub{ idFromName(name: string): unknown; get(id: unknown): Stub }; { fetch(request: Request): Response | Promise<Response> }

With the default lease lifetime, restored sockets missing the original authorizedAt reconnect before state is restored.

statewire-node

statewireHost

const statewireHost: (options: StatewireNode.Options) => StatewireNode.Host;

A statewire host over standard Request/Response for Node servers and pinned instances. fetch routes by path suffix: GET|POST …/stream attaches as SSE, POST …/frames submits statement frames (405 with Allow: POST otherwise), a WebSocket upgrade answers 426, anything else 404. upgrade serves /ws from any runtime's socket: it validates the handshake (400 {"detail":"statewire.v1 is required"} without the subprotocol, 400 {"detail":…} without a valid client query parameter, a thrown error for a request without Upgrade: websocket) and returns { protocol: "statewire.v1", attach }; attach(socket) after the handshake connects the socket, feeds text frames to the host (a binary frame gets fin: protocol-error then close 1008), and detaches on onclose. authorize runs on the socket's initial message with the upgrade headers merged in; a refusal is fin: unauthorized|forbidden then close 1008. After close, fetch and upgrade answer 410. Every StatewireHostInternal.SocketHostOptions field (protocol, key, authorize, heartbeatIntervalMs, …) passes through; an authorize refusal is a thrown Response.

NameTypeDefaultMeaning
transportResourceElement<Statewire>requiredThe served transport
clients?StatewireHostInternal.ClientStore—Keeps client watermarks across restarts: a re-sent command answers expired instead of running twice
StatewireNode.*Shape
Host{ fetch(request: Request): Promise<Response>; upgrade(request: Request): Promise<Response | Upgrade>; finish(fin: StatewireHostInternal.Fin): void; reconnect: StatewireHostInternal.SocketHost["reconnect"]; close(): Promise<void> }: close resolves once the client store holds the watermarks the host leaves behind
SocketStatewireHostInternal.Socket & { onmessage: ((event: { data: unknown }) => void) | null; onclose: (() => void) | null }: one accepted socket as the runtime exposes it; onmessage receives text frames as strings
Upgrade{ protocol: "statewire.v1"; attach: (socket: Socket) => void }
OptionsStatewireHostInternal.SocketHostOptions & { transport; clients? }

Serve it from node:http with @assistant-ui/pinned/node's toRequest/writeResponse, or from a pinned instance's fetch with /ws through upgradeResponse (apps/proxy/src/statewire.ts adapts the ws socket). Contract tests: packages/statewire/node/tests/host.test.ts.