index
The browser client for aai agents — React 19 hooks and components over a framework-agnostic session core (WebSocket + microphone + playback).
Start with a mount
Section titled “Start with a mount”A client is one client.tsx calling one mount, and WHICH mount is the only
structural decision on this surface — it follows the agent’s front door:
| The agent is | The page calls | It talks to |
|---|---|---|
| a voice agent (the default) | mountClient | a live session: socket, microphone, playback |
a workflowApp() / agent({ mode: "workflow-app" }) |
mountPage | the workflow HTTP API — no session, no socket, no mic |
There is no route to write and no glue file: the agent server already serves both, so a component talks to a live agent directly. createBrowserSession is the same session core with no React, for a page built on something else.
Then the hook that answers your question
Section titled “Then the hook that answers your question”The hooks are grouped by what they read, and within a group the narrow one exists so a component re-renders on its own slice rather than on every frame:
| Reading | Hooks |
|---|---|
| the call itself | useSession (everything), useSessionStatus, useSessionError, useSessionActions, useSessionSelector |
| who the client is | useSessionId, useClientId, browserClientId, createLinkedClient |
| a run reaching the page later | useInbox (a reminder, a finished job — played when it lands) |
| what was said | useConversation, useUserTranscript, useConversationLog (across sessions, persisted) |
| the talk button | useTapToTalk (tap on, tap off), usePushToTalk (hold, for turnTaking: { detection: "manual" }) |
the agent’s own /api routes |
useRoute, useRouteMutation, routeFetch, useClientRuns (a clientRunsRoutes() pair) |
| what this browser remembers | useStoredValue / createStoredValue, phoneE164 |
| what the agent projects | useAgentState — pass the slot.projected the agent declared in syncState, and it selects that slot, types it AND supplies the frame rendered before the first push; selectAgentState is the same slot as a useSessionSelector selector |
| tools, as they run | useToolCallStart, useToolResult, useEvent |
| a durable run | useWorkflowSubmit (start one), useWorkflowRun (watch one), useWorkflowRuns / useWorkflows (list), useWorkflowProgress / useWorkflowStream (its output as it arrives) |
| page chrome | useTheme, useCopy, useFlash, useDownloadUrl, useRunKey |
Everything else here is a COMPONENT — chat chrome (MessageList,
ChatView, Controls, StartScreen), workflow forms
(Form, WorkflowFields, the *Field set), and the small
primitives pages kept re-writing (AutoScroll, Markdown,
Facts, ToolCallRow). None is required: the hooks are the API
and the components are one rendering of it.
Two things worth knowing before the reference below
Section titled “Two things worth knowing before the reference below”Several names are re-exported from @alexkroman1/aai —
WorkflowInputOf, WorkflowOutputOf, WorkflowSummary and
AgentClient, plus isTerminal and ClientConfigResponse.
They are one declaration with two reference pages, not two types; a page takes
them from here, an agent.ts from there.
createWorkflowApi is the browser’s client, and there is one other.
createAgentClient (@alexkroman1/aai/workflow-api) is the same
AgentClient for a caller with no page to default its base URL from — a
script, a cron job, a server. Reach for the one here whenever the code runs in
a page the agent serves; it delegates to that factory, so there is one
implementation of the routes and one config(). (It used to answer the
narrower WorkflowApi, which made a page wanting the agent’s own name build a
second client for one read.)
Functions
Section titled “Functions”appendDictation()
Section titled “appendDictation()”appendDictation(
draft,text):string
draft with text added after it, joined by exactly one space: a draft
ending in whitespace or a newline does not grow a double space, and an empty
or blank draft is replaced rather than given a leading one.
Never REPLACES what was typed — the words someone said are an addition to the box, which is what makes speaking into a half-written prompt safe.
Parameters
Section titled “Parameters”string
string
Returns
Section titled “Returns”string
Example
Section titled “Example”import { appendDictation } from "@alexkroman1/aai-ui";
appendDictation("Build me an agent", "that books tables."); // "Build me an agent that books tables."appendDictation("", "Hello"); // "Hello"AudioResult()
Section titled “AudioResult()”AudioResult(
props):ReactNode
The player for a file a RUN produced: a heading, the fetch’s pending line,
its announced error, the <audio> with an optional caption track, the
download link, and whatever the page renders beneath — the spoken text.
Two templates exist because of the audio round trip, and both had written
this block over useDownloadUrl identically: the same pending
sentence, the same role="alert" paragraph, the same <audio controls> over
the object URL, the same anchor with download set. The download attribute
works on an object URL because the bytes are already in the tab — it was the
href that could not carry the agent’s bearer token, never the attribute —
and that is the whole reason both pages hand this a hook result rather than a
path.
A caption track is a judgement, not a default. spoken-summary-workflow passes
captions: the summary was written before it was spoken, so one cue
spanning the clip is an honest transcript of it. call-audit-workflow deliberately
does not: the spoken text is rendered in full immediately below the player,
which is the same information a track would carry. Both are right, which is
why the prop is optional in both directions.
Parameters
Section titled “Parameters”See AudioResultProps.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { AudioResult, createWorkflowApi, useDownloadUrl } from "@alexkroman1/aai-ui";
const api = createWorkflowApi();
function Player({ id, spoken, ms }: { id: string; spoken: string; ms: number }) { const audio = useDownloadUrl(id, { api }); return ( <AudioResult download={audio} filename="summary.wav" label="Summary read aloud" heading="Read aloud" captions={{ text: spoken, durationMs: ms }} > <p className="text-sm opacity-70">{spoken}</p> </AudioResult> );}AutoScroll()
Section titled “AutoScroll()”AutoScroll(
props):ReactNode
A scroll container that stays pinned to the bottom as its content grows, releases when the reader scrolls up, and re-engages once they return to the bottom.
For clients that render their own chat chrome instead of using
MessageList — a terminal, a dispatch board, a themed transcript.
MessageList already behaves this way; this is the same mechanism with no
opinion about what goes inside it.
Parameters
Section titled “Parameters”Scroll container props.
children
Section titled “children”ReactNode
The scrollable content.
className?
Section titled “className?”string
Classes for the outer container, appended to its own.
The container must end up with a bounded height (flex-1 min-h-0,
h-full, a fixed height). This is the one constraint callers get wrong:
an unbounded container grows with its content and never scrolls, so
nothing pins and the component silently does nothing.
contentClassName?
Section titled “contentClassName?”string
Classes for the inner content element, where padding and the children’s own layout belong.
initial?
Section titled “initial?”"instant" | "smooth"
Scroll behavior on mount. Defaults to "instant" — start at the latest
content without animating a scroll the reader did not ask for.
resize?
Section titled “resize?”"instant" | "smooth"
Scroll behavior when pinned content grows. Defaults to "smooth".
scrollClassName?
Section titled “scrollClassName?”string
Classes for the scrolling element itself. Defaults to hiding the
scrollbar; pass "overflow-y-auto" to show a native one.
style?
Section titled “style?”CSSProperties
Inline styles for the outer container.
Returns
Section titled “Returns”ReactNode
Remarks
Section titled “Remarks”The pattern this replaces is a useEffect that calls
ref.current?.scrollIntoView() on every message change. That version has
three faults, and they compound: it fights the reader, since scrolling up to
re-read is undone by the next transcript delta; it misses growth that is not
a new message, because a streamed reply, an expanding tool block or a
markdown reflow changes height without changing the dependency array; and it
needs a synthetic dependency (messages.length + transcript.length) to fire
at all, which is where the dead if (version < 0) return; line comes from.
A ResizeObserver on the content — what this uses — has none of those.
Example
Section titled “Example”import { AutoScroll, useSession } from "@alexkroman1/aai-ui";
function Transcript() { const session = useSession(); return ( <AutoScroll className="flex-1 min-h-0" contentClassName="flex flex-col gap-2 p-4"> {session.messages.map((m) => ( <div key={m.id}>{m.content}</div> ))} </AutoScroll> );}browserClientId()
Section titled “browserClientId()”browserClientId(
platformUrl?):string
This browser’s stable client id for the agent at platformUrl —
browser-<32 hex>, minted once and kept in localStorage. The id
mountClient({ client: "auto" }) sends; call it yourself to compose it, e.g.
a page joined to a speaker that sends the speaker’s id instead:
Parameters
Section titled “Parameters”platformUrl?
Section titled “platformUrl?”string
The agent’s base URL; the id is per agent. Defaults to
the page’s own, which is mountClient()’s default too.
Returns
Section titled “Returns”string
The id — the same one on every call for the same agent.
Example
Section titled “Example”import { browserClientId, mountClient } from "@alexkroman1/aai-ui";
declare function linkedSpeaker(): string | undefined;
mountClient({ client: () => linkedSpeaker() ?? browserClientId() });A stored value that is not a valid client id (letters, digits, -, _, at
most 64) is replaced. Where storage is unavailable the id lasts for this tab.
BulletList()
Section titled “BulletList()”BulletList(
props):ReactNode
A disc-bulleted list of short strings — a run’s key points, findings, risks.
Five pages had written this, byte-identical apart from a text-sm suffix on
two of them, and all five had the same two defects. Both are the reason this
is a component rather than four lines a page repeats:
- All five keyed by the string itself, and these lists are MODEL OUTPUT.
Two identical bullets are entirely plausible — a summariser that repeats
itself is a bad summary, not a bad program — and a repeated string is then
a duplicate
key: React warns, and the two<li>s contend for one slot in the reconciliation. What is keyed here instead is the content PLUS how many times that content has already appeared in this list, which is unique by construction and unchanged by a re-render that did not change the text. (Position alone would also be sound — these lists are replaced wholesale by each new output and never reordered — but it is whatnoArrayIndexKeyexists to talk you out of, and a unique key is oneMapaway, so there is no reason to spend a lint suppression on it.) - Three of the five rendered an empty
<ul>under a heading. Two had hand-rolledif (items.length === 0) return nulland three had not, so the same absent field was “nothing” on two pages and a stray heading with a void under it on three. Emptiness renders NOTHING here,titleincluded: a heading over no bullets is a claim the run did not make.
Parameters
Section titled “Parameters”Bullet-list props.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { BulletList } from "@alexkroman1/aai-ui";
function Findings({ risks }: { risks: string[] }) { return <BulletList title="Risks" items={risks} size="sm" />;}Button()
Section titled “Button()”Button(
props):Element
A styled button with variant and size presets.
Accepts all standard <button> HTML attributes in addition to the props
listed below.
Parameters
Section titled “Parameters”object & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className">
Button props: variant (visual style — see
ButtonVariant, defaults to "default"), size (see
ButtonSize, defaults to "default"), className (appended to the
button’s own classes), children (the label), and any <button> attribute.
Returns
Section titled “Returns”Element
Example
Section titled “Example”import { Button } from "@alexkroman1/aai-ui";
function Actions({ onStop }: { onStop: () => void }) { return ( <> <Button variant="secondary" onClick={onStop}>Stop</Button> <Button size="lg" className="w-full">Start Conversation</Button> </> );}ChatView()
Section titled “ChatView()”ChatView(
props):ReactNode
The main chat interface for a voice agent session — the design-system “voice agent console”: a 760px column on the page with a header (optional icon + live-status eyebrow), the conversation on a raised card, and the session controls beneath it.
Must be rendered inside a SessionProvider.
Parameters
Section titled “Parameters”Chat surface props.
className?
Section titled “className?”string
Additional CSS class names for the root element, appended to its own.
ReactNode
Element rendered before the title in the header. None by default.
title?
Section titled “title?”string
Title string for the header. Defaults to the agent’s declared name.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { ChatView, StartScreen } from "@alexkroman1/aai-ui";
function App() { return ( <StartScreen icon="🍕" title="Pizza Palace"> <ChatView /> </StartScreen> );}CheckboxField()
Section titled “CheckboxField()”CheckboxField(
props):Element
A checkbox. Contributes a BOOLEAN to FormValues.
Accepts every <input> attribute except name, className and type,
plus the shared FieldShell props. The label renders beside the box
rather than above it, so hint is the place for guidance.
Parameters
Section titled “Parameters”FieldShell & Omit<InputHTMLAttributes<HTMLInputElement>, "className" | "name" | "type">
FieldShell props plus <input> attributes.
Returns
Section titled “Returns”Element
ConsoleShell()
Section titled “ConsoleShell()”ConsoleShell(
props):ReactNode
The design-system “console” chrome: a 760px column on the themed page with a header (icon + live-status eyebrow), an announced error banner, the main content on a raised card, and a footer row beneath it.
ChatView is this shell with <MessageList> inside it and
<Controls> under it, and until now that was the only way to get it — the
shell itself was internal, so a client wanting its own conversation markup
had to rebuild the chrome as well. Each one that did re-derived the error
banner WITHOUT role="alert".
The banner is SessionErrorBanner now, composed here rather than
spelled out. It was four lines of this file, and this file is a whole
FRAME — a centred max-w-190 column — so the full-bleed chromes that needed
the announced banner could not take it without taking a layout that would
replace the design they exist to demonstrate. Composing means there is one
banner, and it means this component no longer takes an error prop: the
banner reads the session itself, which is one fewer thing a caller can wire
up wrong.
Reach for it when the conversation is yours and the frame is not. Reach for
<ChatView> when both are ours. Reach for <SessionErrorBanner> alone when
neither is.
Must be rendered inside the providers mountClient() installs.
Parameters
Section titled “Parameters”See ConsoleShellProps.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”A custom conversation in the stock chrome
import { ConsoleShell, Controls, useConversation, useSessionStatus,} from "@alexkroman1/aai-ui";
function Console() { const state = useSessionStatus(); const { items } = useConversation(); return ( <ConsoleShell title="Dispatch" state={state} pulsing={state === "listening"} footer={<Controls />} > <ul> {items.map((item) => item.kind === "message" ? ( <li key={item.message.id}>{item.message.content}</li> ) : item.kind === "tool" ? ( <li key={item.toolCall.callId}>{item.toolCall.name}</li> ) : null, )} </ul> </ConsoleShell> );}ConversationView()
Section titled “ConversationView()”ConversationView(
props):ReactNode
The conversation’s skeleton over useConversation, with every row a render slot: a pinned scroll region holding the empty state, the interleaved messages and tool calls, the streaming reply and the announced thinking row, plus the live transcript — inside the scroll or pinned beneath it.
useConversation() already made the DATA one thing: the interleave, the
streaming utterance, the null-vs-"" transcript distinction and the
thinking-suppression rule. What three custom chromes then each wrote around
it was the same fifty lines of STRUCTURE: an AutoScroll with a bounded
height, an empty-state guard on items.length === 0 && streaming === null,
the map with its keys, the streaming row, the thinking row with its
role="status" and aria-label (and the same comment about screen readers
hearing punctuation), the transcript guarded on speaking. The bubbles are
the part each template exists to show, so those are slots; the order and the
accessibility contract are this component’s.
MessageList is this with the stock bubbles filled in.
Must be rendered inside the providers mountClient() installs.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”A board’s radio log: its own bubbles, the stock tool row, the transcript pinned below
import { ConversationView } from "@alexkroman1/aai-ui";
function RadioLog() { return ( <ConversationView contentClassName="p-4 flex flex-col gap-2" empty={<p className="text-center opacity-60">Standing by.</p>} renderMessage={({ role, content }) => ( <div className={role === "assistant" ? "self-start" : "self-end"}>{content}</div> )} renderTranscript={({ text }) => <div className="px-4 py-2 italic">{text}</div>} transcriptPosition="below" thinkingLabel="Dispatch is thinking" /> );}createBrowserSession()
Section titled “createBrowserSession()”createBrowserSession(
options):BrowserSession
Create a framework-agnostic voice session core that connects to an AAI server via WebSocket.
Uses a subscribe/getSnapshot pattern for state management, compatible with
React’s useSyncExternalStore and other external store integrations.
Most clients never call this: mountClient() creates a core and installs it in
React context for the hooks. Reach for it directly when building a
non-React UI (or wiring the session into another framework’s store).
Parameters
Section titled “Parameters”options
Section titled “options”Session configuration including the platform server URL.
Returns
Section titled “Returns”A BrowserSession handle for controlling the session.
Example
Section titled “Example”import { createBrowserSession, type SessionSnapshot } from "@alexkroman1/aai-ui";
declare function render(snapshot: SessionSnapshot): void;
const session = createBrowserSession({ platformUrl: "https://host/my-agent/" });session.subscribe(() => render(session.getSnapshot()));session.start();createInbox()
Section titled “createInbox()”createInbox(
options):Inbox
Hold WS /inbox?client=&holder= open, reconnecting with backoff, and answer
every notice the way the protocol requires — ack once it has arrived whole,
busy when busy() says so, a repeat acked without being delivered twice.
Most clients call useInbox(), which fills in the client, holder and base URL
from the session and plays each notice. This is the same thing for a page
with no React, or for an inbox held under a different client than the
session’s.
Parameters
Section titled “Parameters”options
Section titled “options”Where to connect, as whom, and what to do with a notice.
Returns
Section titled “Returns”The Inbox handle.
Example
Section titled “Example”import { createInbox } from "@alexkroman1/aai-ui";
const inbox = createInbox({ platformUrl: "https://speaker.example/", client: "kitchen-speaker", holder: "kitchen-speaker-tab1", onNotice: (notice) => console.log(notice.event, notice.data),});// laterinbox.close();Throws
Section titled “Throws”RangeError when holder is not a valid holder id.
createLinkedClient()
Section titled “createLinkedClient()”createLinkedClient(
options):LinkedClient
A page’s client id with a link on top — see this module’s doc.
Parameters
Section titled “Parameters”options
Section titled “options”Where the link is kept, and the unlinked id; see LinkedClientOptions.
Returns
Section titled “Returns”The handle; see LinkedClient.
Example
Section titled “Example”A page that can be linked to a speaker
import { createLinkedClient, mountClient } from "@alexkroman1/aai-ui";
const client = createLinkedClient({ key: "my-speaker:linked" });
mountClient({ client: client.id });// …after the speaker reads out its id: client.set(spokenId); location.reload();createStoredValue()
Section titled “createStoredValue()”createStoredValue(
key,options?):StoredValue
A string remembered in this browser under key — see this module’s doc.
Create it once at module scope and share it: get() in a getter outside
React, useStoredValue in a component.
The key is used as given, so choose one that is yours ("my-app:phone");
two agents on one origin share a key they both name.
Parameters
Section titled “Parameters”string
The storage key.
options?
Section titled “options?”initial and which storage; see StoredValueOptions.
Returns
Section titled “Returns”The value’s handle; see StoredValue.
Example
Section titled “Example”A phone number for the session, edited in a settings field
import { createStoredValue, mountClient, phoneE164, useStoredValue } from "@alexkroman1/aai-ui";
const phone = createStoredValue("my-speaker:phone");
function PhoneField() { const [value, setValue] = useStoredValue(phone); return <input defaultValue={value} onBlur={(e) => setValue(e.currentTarget.value.trim())} />;}
mountClient({ phone: () => phoneE164(phone.get()), component: PhoneField });createWorkflowApi()
Section titled “createWorkflowApi()”createWorkflowApi(
options?):AgentClient
Create a client for the agent serving this page.
You usually do not need one. Every hook and component here builds this
exact client lazily and once when no api is passed
(_workflow-api-ref.ts), so useWorkflowSubmit("digest") already talks to
the right agent. Reach for this when the client has to be DIFFERENT from that
default — another agent’s baseUrl, or a token — or when a page wants
config() and the run calls on one object.
If you do build one, hoist it out of the component that uses it.
useWorkflowRun holds the client in a ref precisely so a fresh object per
render does not restart its watch, but a client built in render is still a new
fetch closure every time and reads as though it were free.
Parameters
Section titled “Parameters”options?
Section titled “options?”See WorkflowApiOptions. Both fields are optional; the default base URL is the page’s own origin and path.
Returns
Section titled “Returns”Every workflow call plus config() and baseUrl — see
AgentClient. It was the narrow WorkflowApi, which left a page
that also wanted the agent’s own name building a SECOND client (or a bare
fetch and a hand-written URL join) for one read; the SDK documents
createAgentClient as a superset of the same routes, so delegating to it
widens the return without a second implementation of anything.
Example
Section titled “Example”import { createWorkflowApi, useWorkflowRun } from "@alexkroman1/aai-ui";import { useState } from "react";
// A DIFFERENT agent than the one serving this page, so the client is// explicit — and module scope, not render scope, per above.const api = createWorkflowApi({ baseUrl: "https://agents.example/digest" });
function StartDigest() { const [runId, setRunId] = useState<string>(); const { run } = useWorkflowRun(runId, { api }); return ( <button type="button" onClick={() => void api.start("digest", { url: "…" }).then(setRunId)} > {run ? run.status : "Start"} </button> );}dictationTranscriber()
Section titled “dictationTranscriber()”dictationTranscriber(
url,options?): (audio,options) =>Promise<string>
A transcribe for useDictation that POSTs each clip to url and
reads the { text } | { error } reply handleDictationRequest answers with.
Rejects with the server’s own error sentence on a refusal, and with a
status-naming message when the reply is not that shape (a proxy’s HTML error
page) — useDictation shows either as its error.
Parameters
Section titled “Parameters”string
The route, resolved like any fetch URL (relative to the page).
options?
Section titled “options?”Returns
Section titled “Returns”(audio, options) => Promise<string>
Example
Section titled “Example”import { dictationTranscriber } from "@alexkroman1/aai-ui";
export const transcribe = dictationTranscriber("/api/dictation", { headers: () => ({ authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }),});errorMessage()
Section titled “errorMessage()”errorMessage(
err):string
Extract an error message from an unknown thrown value.
It never answers with an empty string. That is the contract, and it is
worth stating as one: SessionError.message is rendered directly by a
browser client, so "" paints a banner that says an error occurred and
refuses to say what — strictly worse than a generic sentence, because an
absent message reads as absence rather than as a problem.
The shape that produced one is not exotic, it is the FIRST failure a new
project hits. The AI SDK builds an APICallError whose message is
response.statusText whenever the provider’s error body does not match the
schema it expected (createJsonErrorResponseHandler), and a reason phrase is
optional in HTTP/1.1 and does not exist at all in HTTP/2 — so a rejected API
key arrived as {"code":"llm","message":"","fatal":false} with the status,
the URL, and the provider’s own explanation all sitting unread on the error
object.
So a value that says nothing on its own is read one level down, in this
order: the HTTP fields an APICallError-shaped failure carries (the status,
the host that answered, the sentence in the response body), then cause,
then an AggregateError’s members. Detection is STRUCTURAL for the same
reason the schema-issue reading below it is — this module is published,
zod-free, and may not import ai to ask APICallError.isInstance — and it
costs nothing: a numeric statusCode beside a responseBody is the shape,
whoever built it.
An error that DOES state something keeps its own words — an HTTP failure has
the status appended to them, since Unauthorized alone answers neither “which
provider” nor “refused or fell over”, and everything else is returned
verbatim. One message is replaced outright, and it has precedent:
fetch failed (and the browser’s failed to fetch) is
Node’s own placeholder, with the reason — ECONNREFUSED, a DNS failure, a
certificate rejection — one level down in cause. The AI SDK makes exactly
this substitution for its own calls (handleFetchError, which rewrites the
pair as “Cannot connect to API: …”); this extends the same reading to every
direct fetch in the SDK.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”string
Facts()
Section titled “Facts()”Facts(
props):ReactNode
A muted line of run facts, joined by · — “6 segments · 12:04 of audio ·
1,840 words”.
Nine pages had written this by hand under four different typographies for
one role, two of them (call-audit-workflow and spoken-summary-workflow) byte-identical down
to the payload. Three things it takes off the caller:
- The separator cannot be forgotten, and neither can the space around it.
Four of the nine carried a literal
{" "}at the end of a line, because Prettier’s wrap ate the space that made·read as a separator rather than as punctuation glued to the next word. A line that is correct only because somebody remembered an invisible JSX expression is exactly the thing a component should own. - A fact worth omitting is omitted, and by the caller’s own condition.
The hand-written shape for a conditional fact was to splice the separator
into the string —
{x ? \· budget exhausted` : “”}` — which puts the punctuation in two places and gets the leading separator wrong the moment the fact before it also disappears. Passing the condition and letting this drop it keeps the separator in one place. - A line with nothing left to say renders NOTHING. With every fact
conditional, the alternative is a muted empty row, or a bare
·.
The facts are JOINED into one string rather than interleaved as elements, and
that is why the prop is text: joined, there is no per-fact key to invent —
the same reasoning WorkflowProgress gives for its log lines. A line that
genuinely needs an element in it (a link) wants its own markup.
Parameters
Section titled “Parameters”Facts-line props.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { Facts } from "@alexkroman1/aai-ui";
function RunFacts({ words, cut }: { words: number; cut: number }) { return <Facts size="xs" items={[`${words} words`, cut > 0 && `${cut} blind cuts`]} />;}fetchClientConfig()
Section titled “fetchClientConfig()”fetchClientConfig(
options?):Promise<ClientConfigResponse>
Fetch the agent’s declared name, greeting and front door; any failure
yields the agent default ({}).
This is what a workflow app’s own component calls instead of receiving the
config. Both mounts fetch GET client-config for the shell they render
themselves — mountClient() for the chat shell, mountPage() for the
generated workflow shell — so neither DEFAULT has to be told the agent’s
name. A component: replaces that shell, and with it the lookup, so a page
that wants the agent’s own name or greeting asks for them here.
Every failure path degrades to the empty default rather than throwing: a
network error, a 404 from a server older than the endpoint, a malformed
body, and a lookup that hangs past
CLIENT_CONFIG_ATTEMPT_TIMEOUT_MS all read as “the agent declared nothing”.
So a page may render straight from the result and never needs a catch —
treat every field as optional, because an agent that declared none is a
normal agent.
Parameters
Section titled “Parameters”options?
Section titled “options?”Where to look and how; see FetchClientConfigOptions.
Returns
Section titled “Returns”Promise<ClientConfigResponse>
The agent’s config, or {} when the lookup produced no answer.
Example
Section titled “Example”import { fetchClientConfig, mountPage } from "@alexkroman1/aai-ui";
const { name, greeting } = await fetchClientConfig();
function App() { return ( <main> <h1>{name ?? "Workflows"}</h1> {greeting ? <p>{greeting}</p> : null} </main> );}
mountPage({ name: name ?? "Workflows", component: App });Field()
Section titled “Field()”Field(
props):Element
Label + control + hint, in the layout every field here uses.
Exported so a caller’s own control gets the same shell rather than an approximation of it.
Parameters
Section titled “Parameters”Field-shell props.
children
Section titled “children”ReactNode
The control itself.
className?
Section titled “className?”string
Additional CSS class names for the wrapper, appended to its own.
string
One line of guidance under the control.
htmlFor?
Section titled “htmlFor?”string
Id of the control this labels.
label?
Section titled “label?”string
Visible label. Omitted leaves the control unlabelled.
Returns
Section titled “Returns”Element
Example
Section titled “Example”import { Field, Form } from "@alexkroman1/aai-ui";
function ColorForm() { return ( <Form onSubmit={() => undefined}> <Field label="Accent" hint="Any CSS color." htmlFor="accent"> <input id="accent" name="accent" type="color" /> </Field> </Form> );}fieldKindFor()
Section titled “fieldKindFor()”fieldKindFor(
schema,options?):WorkflowFieldKind
Which control <WorkflowFields> renders for one property of an input schema.
Parameters
Section titled “Parameters”schema
Section titled “schema”unknown
The property’s JSON Schema, as GET workflows reports it.
Anything that is not an object reads as "none".
options?
Section titled “options?”upload says the property is named in the workflow’s own
uploads declaration, which is what step 1 above tests; render is the
page’s own choice for the property (<WorkflowFields render>).
render?
Section titled “render?”upload?
Section titled “upload?”boolean
Returns
Section titled “Returns”Remarks
Section titled “Remarks”The ORDER is the contract, and it is the half a reader cannot infer:
- A declared upload wins outright. It is a plain
stringin the schema — the id — so testing the type first would render a text box asking a person to type an id no person has. The declaration is the workflow’s (workflow({ uploads: [...] })), not the schema’s, precisely because a marker inside the schema would only work for the library that carried it. - Then a closed set of choices, before the type switch: a non-empty
enum, or a union ofconstliterals (schemaChoicesOf) — an enum of strings is astringtoo, and a select is the narrower, better control. A nullable union (anyOf: [X, { type: "null" }]) is read asXthroughout, astype: [X, "null"]already was. - Then the type —
boolean,number/integer,string. - Then the two non-scalar shapes with a control — an array of strings
is
"lines", an object of exactly{ name: string, text: string }is"namedText". A page may also name either throughrender, which beats the schema for that property (it is the author saying what the shape means) but never a declared upload. - Anything else is
"none"rather than a guess. See WorkflowFieldKind.
Example
Section titled “Example”import { fieldKindFor, type WorkflowFieldKind } from "@alexkroman1/aai-ui";import type { WorkflowSummary } from "@alexkroman1/aai/workflow-api";
function kindsOf(summary: WorkflowSummary): Record<string, WorkflowFieldKind> { const uploads = new Set(summary.uploads ?? []); const schema = summary.inputSchema as { properties?: Record<string, unknown> }; return Object.fromEntries( Object.entries(schema.properties ?? {}).map(([name, property]) => [ name, fieldKindFor(property, { upload: uploads.has(name) }), ]), );}FileField()
Section titled “FileField()”FileField(
props):Element
A file picker. Contributes a FileValue (or an array, with multiple)
to FormValues — or nothing when no file was chosen.
upload is what a workflow input wants. A run’s input is serialized into
the run record and replayed from it on every resume, so a file’s BYTES cannot
travel in it. With upload the field contributes the File itself,
useWorkflowSubmit stores it through POST /workflows/uploads before
starting the run, and the input carries the upload id — which a step reads
windows of with stepReadUpload. Declaring the property in the workflow’s
uploads list makes <WorkflowFields> render exactly this, so a declared
form needs no file markup at all.
Without it the field describes the file and does not read it. read
exists for the cases where the bytes really are small and really are the
input — a CSV of ids, a config — and the size is the author’s to check. See
FileReadMode for the four values; upload is shorthand for
read="upload".
as="namedText" contributes { name, text } (NamedTextValue)
instead of the metadata-bearing FileValue, and implies
read="text": the shape a schema declares for a draft to redline or a
config to apply, and the one <WorkflowFields> renders for an object
property of exactly that shape. document-redline-workflow reshaped the
FileValue by hand on submit; the field does it now.
Otherwise accepts every <input> attribute except name, className and
type, plus the shared FieldShell props — so accept and
multiple are passed straight through.
Parameters
Section titled “Parameters”FieldShell & object & Omit<InputHTMLAttributes<HTMLInputElement>, "className" | "name" | "type">
FieldShell props, read/upload/as, and <input>
attributes.
Returns
Section titled “Returns”Element
Form()
Section titled “Form()”Form(
props):Element
A form that hands its values to onSubmit as one object.
Native validation still applies — a required field blocks the submit and the
browser says so, which is better than anything this could render.
Parameters
Section titled “Parameters”See FormProps. Every <form> attribute except
onSubmit and className is passed through.
Returns
Section titled “Returns”Element
Example
Section titled “Example”import { Form, SubmitButton, TextField } from "@alexkroman1/aai-ui";
function NameForm() { return ( <Form onSubmit={(values) => console.log(values.topic)}> <TextField name="topic" label="Topic" required /> <SubmitButton>Start</SubmitButton> </Form> );}inboxEventToItem()
Section titled “inboxEventToItem()”inboxEventToItem(
event,id?):ConversationItem|undefined
The log item for one frame of a client’s live conversation — what
useInbox({ onEvent }) delivers for another session of the same client (a
device’s, when the page is linked to one) — or undefined for a frame that
is not shown: a partial transcript, a recovery phrase (“sorry, say that
again”, the agent’s filler rather than a reply), a tool’s completion, a
session_ended.
useConversationLog().mirror is built on this; call it yourself to render
another session some other way.
Parameters
Section titled “Parameters”The inbox frame.
number
The id to give the item: its index in that session’s list, say.
Messages take it as id, tool calls as seq.
Returns
Section titled “Returns”ConversationItem | undefined
A committed user or assistant turn, a tool call (pending), or undefined.
isFileValue()
Section titled “isFileValue()”isFileValue(
value):value is FileValue
Whether one FormValues entry is what a FileField contributed.
A field with nothing chosen contributes no key at all, so the absent case is
the ordinary one — this is false for undefined, not an error.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”value is FileValue
isTerminal()
Section titled “isTerminal()”isTerminal<
R>(run):run is TerminalWorkflowRun<R>
Is this run finished?
A type guard rather than a boolean, so the narrow it performs is usable:
if (isTerminal(run)) leaves run.status as the three-member union a caller
can switch over exhaustively. Accepts undefined (nothing started yet, or the
first poll has not landed) because that is what every call site holds.
Type Parameters
Section titled “Type Parameters”R
Parameters
Section titled “Parameters”WorkflowRunSnapshot<R> | undefined
Returns
Section titled “Returns”run is TerminalWorkflowRun<R>
mountClient()
Section titled “mountClient()”mountClient(
config):ClientHandle
Define and mount a client UI for a voice agent.
Config only: leave component out and the default shell renders
(StartScreen + ChatView, optional sidebar).
A custom component: pass component and it is rendered inside the same
providers instead of the default shell — beside a sidebar if one is given,
in the same SidebarLayout. The page title is the HTML’s own; a
custom component that wants another sets document.title itself.
The agent’s name on the start card and in the header, and each tool’s icon
and label, are what the agent declared (agent({ name }), tool({ ui })),
read from its GET /client-config body — not options here.
Mounts into target — a CSS selector or DOM element, defaulting to
"#app" — and throws Element not found: <target> when the selector
matches nothing.
Parameters
Section titled “Parameters”config
Section titled “config”Returns
Section titled “Returns”A ClientHandle for cleanup.
Examples
Section titled “Examples”The default shell
import { mountClient } from "@alexkroman1/aai-ui";
function OrderPanel() { return <div>Cart</div>;}
mountClient({ theme: { bg: "#1a1a1a", primary: "#e55" }, sidebar: OrderPanel,});A custom component
import { mountClient, useSession } from "@alexkroman1/aai-ui";
function MyCustomApp() { const session = useSession(); return <div>{session.state}</div>;}
mountClient({ component: MyCustomApp });Throws
Section titled “Throws”If the target element is not found in the DOM.
mountPage()
Section titled “mountPage()”mountPage(
config?):PageHandle
Mount a page for an agent whose work happens in workflows.
There is deliberately no session, no microphone, and no socket: the page talks
to the agent over the workflow HTTP API (useWorkflowSubmit/useWorkflowRun),
which is durable and outlives the tab.
Config only: leave component out and the generated shell renders — a
form per declared workflow, the run’s progress, its failure, its output.
A custom component: pass component and it is rendered inside the theme
provider instead. The pieces the default shell is built from are all published
(useWorkflows, <WorkflowFields>, useWorkflowSubmit,
<WorkflowProgress>, <WorkflowRunError>), so replacing the shell does not
mean starting from fetch.
Parameters
Section titled “Parameters”config?
Section titled “config?”Returns
Section titled “Returns”Examples
Section titled “Examples”The default shell
import { mountPage } from "@alexkroman1/aai-ui";
mountPage({ name: "Digest" });A custom component
import { mountPage, useWorkflows } from "@alexkroman1/aai-ui";
function App() { // No client to build and none to hoist: every workflow hook defaults to one // aimed at the agent serving this page, built lazily and once. const { workflows, loading } = useWorkflows(); if (loading) return <p>Loading…</p>; return ( <ul> {workflows.map((entry) => ( <li key={entry.name}>{entry.description ?? entry.name}</li> ))} </ul> );}
mountPage({ name: "Digest", component: App });Throws
Section titled “Throws”If the target element is not found in the DOM.
NumberField()
Section titled “NumberField()”NumberField(
props):Element
A number input. Contributes a NUMBER to FormValues, or nothing when left empty.
Accepts every <input> attribute except name, className and type,
plus the shared FieldShell props — so min, max and step are
passed straight through.
Parameters
Section titled “Parameters”FieldShell & Omit<InputHTMLAttributes<HTMLInputElement>, "className" | "name" | "type">
FieldShell props plus <input> attributes.
Returns
Section titled “Returns”Element
phoneE164()
Section titled “phoneE164()”phoneE164(
typed,options?):string|undefined
The phone number typed in E.164 form ("+15035550123"), or undefined
when it cannot be one — see PhoneE164Options for how a number
without a country code is read.
Parameters
Section titled “Parameters”string
The number as entered.
options?
Section titled “options?”The country to assume; see PhoneE164Options.
Returns
Section titled “Returns”string | undefined
The E.164 number, or undefined.
Example
Section titled “Example”The session’s phone, from a stored setting, assuming North America
import { mountClient, phoneE164 } from "@alexkroman1/aai-ui";
declare function storedPhone(): string;
mountClient({ phone: () => phoneE164(storedPhone(), { countryCode: "1" }) });routeFetch()
Section titled “routeFetch()”routeFetch<
T>(method,path,body?,options?):Promise<T>
Call one of the agent’s own routes and return its JSON — see this module’s doc for what it handles.
path is the route as declared, without /api: routeFetch("GET", "/memories") calls "GET /memories". A body is sent as JSON. A response
that is not 2xx throws an Error whose message is the route’s { error }
field when it answered one (routeResponse(404, { error: "No such memory" })),
else "<METHOD> <path>: <status>". A 2xx with no JSON body resolves {}.
Type Parameters
Section titled “Type Parameters”T = unknown
The shape the route answers. Not checked: annotate what you know.
Parameters
Section titled “Parameters”method
Section titled “method”The route’s method.
string
The route’s path as declared, starting with /; ?query allowed.
unknown
Sent as JSON when given.
options?
Section titled “options?”client, baseUrl, headers and signal; see RouteFetchOptions.
Returns
Section titled “Returns”Promise<T>
The parsed JSON body.
Example
Section titled “Example”Saving a field for the session’s client
import { routeFetch, useClientId } from "@alexkroman1/aai-ui";
function SaveName({ name }: { name: string }) { const client = useClientId(); return ( <button type="button" onClick={() => void routeFetch("PUT", "/profile", { name }, { client })}> Save </button> );}selectAgentState()
Section titled “selectAgentState()”selectAgentState<
V>(slot): (snapshot) =>V|undefined
A useSessionSelector selector for ONE slot of the agent’s projected state:
its value, or undefined before the agent has pushed it.
The same function for the same name on every call, which is load-bearing:
useSessionSelector caches its selection on the selector’s identity, and an
inline arrow would re-run it on every snapshot.
import { selectAgentState, useSessionSelector } from "@alexkroman1/aai-ui";
function CartBadge() { // Re-renders when the `cart` slot changes, and on nothing else. const cart = useSessionSelector(selectAgentState<{ count: number }>("cart")); return <span>{cart?.count ?? 0}</span>;}Type Parameters
Section titled “Type Parameters”V = any
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”(snapshot) => V | undefined
SelectField()
Section titled “SelectField()”SelectField(
props):Element
A dropdown.
options is the short form — a list of strings, or of
{ value, label } pairs when the two differ. Pass children instead for
full control over the <option> elements; children wins when both are
given.
Otherwise accepts every <select> attribute except name and className,
plus the shared FieldShell props. Note multiple works and
contributes an ARRAY ([] when nothing is chosen).
Parameters
Section titled “Parameters”FieldShell & object & Omit<SelectHTMLAttributes<HTMLSelectElement>, "className" | "name">
FieldShell props, options, and <select>
attributes.
Returns
Section titled “Returns”Element
SessionControls()
Section titled “SessionControls()”SessionControls(
props):ReactNode
The full control row of a custom chrome: Start before the call, then Pause / Resume, New Conversation and End once it is up.
Controls is the stock console’s footer — Stop/Resume, New
Conversation and End Call, with the URL chips — and it has no Start branch,
because the default shell shows a start SCREEN, which End Call returns to. A
chrome that owns its whole frame has no start screen, so
three of them each wrote this row: the same !started branch, the same
three buttons behind it, and the same twelve-line comment on why the middle
one is end(); start() and not reset(). The buttons’ look is the
chrome’s — renderButton — and everything else is here once.
Why restart is end() then start(), and never reset(). reset()
clears the transcript and reconnects carrying the same session id, so every
sessionSlot on the agent survives: a caller who pressed “New Conversation”
on a stateful agent got a blank transcript in front of their old cart, game
or incident board, with nothing on screen saying so, and the next tool call
repopulated it. end() drops the resume identity, so the redial is a
brand-new session — fresh state, greeting included — and start() puts the
chrome straight back on the call rather than at its Start button.
Why End is end(). It hangs up and flips started back, so the row
returns to its Start button and the next start is a new session. reset()
would keep the call live — the buttons never toggle back.
Reads the session through useSessionControls: two one-field subscriptions, so the row re-renders when a flag flips and not on every transcript partial.
Parameters
Section titled “Parameters”See SessionControlsProps.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”A board’s controls in its own colours, with a trailing count
import { SessionControls } from "@alexkroman1/aai-ui";
const BUTTON = "px-4 py-2 rounded-md text-xs font-semibold cursor-pointer";
function ShiftControls({ logged }: { logged: number }) { return ( <SessionControls labels={{ start: "Start Dispatch", end: "End Shift" }} renderButton={({ action, label, onClick }) => ( <button type="button" className={BUTTON} style={{ background: action === "end" ? "#dc2626" : "#2563eb", color: "white" }} onClick={onClick} > {label} </button> )} > <span className="ml-auto text-[10px]">{logged} incidents logged</span> </SessionControls> );}SessionErrorBanner()
Section titled “SessionErrorBanner()”SessionErrorBanner(
props):ReactNode
The announced banner for a failed session: the error’s message and code, or nothing at all when the session is fine.
This used to be four lines inside ConsoleShell, and that is why it is its
own component. The banner was the reason ConsoleShell was published —
role="alert" is the one part of that component a reviewer cannot see is
missing, since per the fatalError latch in session/browser-session.ts the banner is
the ONLY remaining signal a session died (the state eyebrow beside it goes
back to reading like a live session), and a screen reader is never told an
unannounced one appeared. But ConsoleShell is a whole FRAME: a centred
max-w-190 column with its own header and footer. Every full-bleed chrome —
a two-pane board, a CRT — therefore could not adopt it, rebuilt the banner
instead, and the three that did had ALREADY drifted: one rendered
ERROR: {message} and dropped the code entirely, one ERROR: {message} ({code}), one {message} ({code}). Splitting the banner out is what lets a
chrome take the announced-error decision without taking the layout, and
ConsoleShell composes this rather than keeping a second copy, so the two
cannot drift again.
It reads the session itself. There is no error prop: a banner that
takes its text from the caller is a banner a caller can forget to wire, which
is exactly the failure above with an extra step. It subscribes narrowly via
useSessionError, so a page that renders it does not re-render with
the transcript.
The code is shown, always. SessionError.code is the eight-member wire
union — it is what a user pastes into a bug report and the only part of the
error that is stable across wordings — and the chrome that dropped it left
its readers with a sentence and no way to say which failure it was.
Must be rendered inside the providers client() installs.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”A full-bleed chrome that wants the banner and not the frame
import { SessionErrorBanner } from "@alexkroman1/aai-ui";
function Board() { return ( <div className="grid grid-cols-[1fr_320px] h-screen"> <main>…</main> <aside>…</aside> <SessionErrorBanner className="col-span-2" /> </div> );}SessionStateDot()
Section titled “SessionStateDot()”SessionStateDot(
props):ReactNode
The live session state as a coloured dot and a word, on its own narrow subscription.
Three custom chromes had each written this: a satisfies Record<AgentState, string> palette (kept — it is the prop), a STATE_LABELS spread over
AGENT_STATE_LABELS (kept — it is labels), and then the same
fourteen lines of markup around them, including the same two-arm ternary
deciding which states pulse and how fast. agent-state-labels.ts argued
against a dot component on the grounds that it would take the shared part
(the words) hostage to the part that is not (the palette); this takes the
palette as a prop precisely so it does not. What is shared is the structure
— the colour lookup and its theme fallback, the label fallback, the pulse rule, the
useSessionStatus() subscription that keeps the rest of the header from
re-rendering at STT-partial rate.
Must be rendered inside the providers mountClient() installs.
Parameters
Section titled “Parameters”See SessionStateDotProps.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”A dispatch board’s readout: coarse colours, three of its own words
import { SessionStateDot } from "@alexkroman1/aai-ui";
function StatusReadout() { return ( <SessionStateDot colors={{ idle: "#6b7280", live: "#22c55e", busy: "#eab308", speaking: "#3b82f6" }} labels={{ listening: "LISTENING", thinking: "PROCESSING", speaking: "TRANSMITTING" }} labelClassName="text-[11px] uppercase" /> );}With no colors at all the dot follows the mounted theme — a chrome that
sets mountClient({ theme }) gets a matching dot for free.
SidebarLayout()
Section titled “SidebarLayout()”SidebarLayout(
props):Element
A two-column layout with a fixed-width sidebar and a flexible main area.
Commonly used to pair a custom sidebar (cart, dashboard) with <ChatView />.
Parameters
Section titled “Parameters”Layout props.
children
Section titled “children”ReactNode
The main pane, normally a <ChatView />.
className?
Section titled “className?”string
Additional CSS class names for the root element, appended to its own.
sidebar
Section titled “sidebar”ReactNode
The sidebar pane — a cart, a dashboard, a run history.
sidebarPosition?
Section titled “sidebarPosition?”"left" | "right"
Which side the sidebar sits on. Defaults to "left".
sidebarWidth?
Section titled “sidebarWidth?”string
Width of the sidebar as a CSS length. Defaults to "18rem", and applies
from the md breakpoint up: below it the two panes stack, because a fixed
width that never shrinks leaves a phone-width main pane unreadable.
Returns
Section titled “Returns”Element
Example
Section titled “Example”import { ChatView, SidebarLayout } from "@alexkroman1/aai-ui";
function OrderPanel() { return <div>Cart</div>;}
function App() { return ( <SidebarLayout sidebar={<OrderPanel />}> <ChatView /> </SidebarLayout> );}StartScreen()
Section titled “StartScreen()”StartScreen(
props):ReactNode
A centered start screen: a card with an optional icon, an eyebrow label, a
title, subtitle, and the start CTA. Renders
children (the main app) once the session has started.
Parameters
Section titled “Parameters”Start-screen props.
buttonText?
Section titled “buttonText?”string
Label of the start CTA. Defaults to "Start Conversation".
children
Section titled “children”ReactNode
The app, rendered once the session has started.
className?
Section titled “className?”string
Additional CSS class names for the root element, appended to its own.
ReactNode
Element rendered above the eyebrow on the card. None by default.
subtitle?
Section titled “subtitle?”string
A line under the title.
title?
Section titled “title?”string
The card’s title. Defaults to the agent’s declared name.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { ChatView, StartScreen } from "@alexkroman1/aai-ui";
function MyAgent() { return ( <StartScreen icon="🍕" title="Pizza Palace" subtitle="Voice-powered ordering"> <ChatView /> </StartScreen> );}SubmitButton()
Section titled “SubmitButton()”SubmitButton(
props):Element
The form’s submit button, disabled and relabelled while a submit is in flight.
Accepts all standard <button> HTML attributes except type and disabled,
in addition to the props below — so aria-label on an icon-only submit,
form, id, title and onClick all work here exactly as they do on
Button. type and disabled stay owned: this component sets both
from pending, and letting a caller set either is how a form gets a submit
button that does not submit.
Parameters
Section titled “Parameters”object & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "disabled" | "type">
Button props.
Returns
Section titled “Returns”Element
Example
Section titled “Example”import { Form, SubmitButton, TextField } from "@alexkroman1/aai-ui";
function Digest({ pending }: { pending: boolean }) { return ( <Form onSubmit={() => undefined}> <TextField name="url" label="Link" required /> <SubmitButton pending={pending} variant="secondary" size="lg"> Summarize </SubmitButton> </Form> );}TextAreaField()
Section titled “TextAreaField()”TextAreaField(
props):Element
A multi-line text input.
Accepts every <textarea> attribute except name and className, plus the
shared FieldShell props. rows defaults to 4.
split="lines" contributes a string[] — one entry per non-blank line,
trimmed — which is the control for a schema’s z.array(z.string()): “one
point per line”. document-redline-workflow reshaped the textarea’s string
by hand on submit; the field does it now, so <WorkflowFields> can render
it for a string[] property and a hand-written one can match. See
TextSplitMode.
Parameters
Section titled “Parameters”FieldShell & object & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "className" | "name">
FieldShell props, split, and <textarea> attributes.
Returns
Section titled “Returns”Element
TextField()
Section titled “TextField()”TextField(
props):Element
A single-line text input.
Accepts every <input> attribute except name and className, which this
component owns, plus the shared FieldShell props.
Parameters
Section titled “Parameters”FieldShell & Omit<InputHTMLAttributes<HTMLInputElement>, "className" | "name">
FieldShell props plus <input> attributes.
Returns
Section titled “Returns”Element
ToolCallRow()
Section titled “ToolCallRow()”ToolCallRow(
props):ReactNode
The design system’s console row for one tool invocation: a small outlined
“TOOL” chip (or a custom icon), the tool title in mono, a truncated
detail preview, and a rotating chevron that expands to the panel content.
Purely presentational — callers own the mapping from their tool-call data
to title/detail/pending and the expanded panel. The deployed agent
UI’s message list renders it via its tool-call block, and the studio’s
chat transcript renders it with variant="compact", so the two surfaces
read as one component.
Colors come from the nearest theme context (see useTheme); without a provider the default AssemblyAI theme applies.
Parameters
Section titled “Parameters”See ToolCallRowProps.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { ToolCallRow, useSession } from "@alexkroman1/aai-ui";
// A custom chrome's tool log, from the snapshot's own `toolCalls`.function ToolLog() { const { toolCalls } = useSession(); return ( <div> {toolCalls.map((call) => ( <ToolCallRow key={call.callId} title={call.name} detail={call.result} pending={call.status === "pending"} /> ))} </div> );}UploadProgressBar()
Section titled “UploadProgressBar()”UploadProgressBar(
props):ReactNode
How far a form’s files have got, rendered as a bar.
The wait this covers is the one a run cannot describe: a workflow run does not
EXIST until its input is stored, so from the moment a form is submitted until
the last byte lands there is no run id, no status, and nothing for
<WorkflowProgress> to read — which for a 200 MB recording is minutes of a
page that looks stuck. useWorkflowSubmit reports the bytes as they go and
this is what draws them.
Three things it decides, so a page does not:
- It renders nothing when there is nothing to describe.
uploadis undefined before the first byte and again from the moment the last one lands, so<UploadProgressBar upload={upload} />is correct unguarded and a form with no files never shows a bar at all. - An unknown total is INDETERMINATE, not zero. A body whose length the
transport cannot state up front (see
UploadProgress.total) has no honest width, and a bar pinned at 0% reads as an upload that is not moving. - The file is NAMED, and counted when there is more than one. Files are sent one after another, so a single bar otherwise appears to restart from zero partway through with nothing to say why.
- A paused upload SAYS SO, rather than being a bar that stopped. Those look
identical, which is the whole reason
UploadStatus.pausedexists, and the fill stops animating so the difference is visible without reading.
The pause control appears only when a handler for it is passed. That is not
politeness about props: a button whose press does nothing is worse than no
button, and a page holding an upload it did not produce (a saved status, a
parent’s state) has nothing to pause.
Parameters
Section titled “Parameters”Progress-bar props.
className?
Section titled “className?”string
Replaces the default classes rather than extending them, so a custom chrome is not fighting a default it did not ask for.
onPause?
Section titled “onPause?”() => void
The hook’s pauseUpload. Pass it together with onResume to get the
pause control; pass neither for a bar that only reports. One without the
other is a one-way door drawn as a toggle, so the control is hidden unless
both are present.
onResume?
Section titled “onResume?”() => void
The hook’s resumeUpload. See onPause — the two travel together.
upload?
Section titled “upload?”What useWorkflowSubmit / useWorkflowStream report as upload.
undefined renders nothing, so a page may pass its state straight through
and never guard the element.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { Form, SubmitButton, UploadProgressBar, useWorkflowSubmit, WorkflowFields } from "@alexkroman1/aai-ui";import type { transcribe } from "./agent.ts";
function TranscribeForm() { const { submitForm, upload, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe"); return ( <Form onSubmit={submitForm} error={error}> <WorkflowFields workflow="transcribe" /> <UploadProgressBar upload={upload} /> <SubmitButton pending={pending}>Transcribe</SubmitButton> </Form> );}useAgentState()
Section titled “useAgentState()”Call Signature
Section titled “Call Signature”useAgentState():
Readonly<Record<string,unknown>> |null
The agent’s whole projected frame — { [slot]: view } for every slot its
syncState names — or null before the first push. Prefer naming the slot
(the overloads below): a component reading the whole frame re-renders when
any slot changes.
Returns
Section titled “Returns”Readonly<Record<string, unknown>> | null
Call Signature
Section titled “Call Signature”useAgentState<
V>(projection):V
One slot’s projected state, typed and defaulted by the SAME projection the
agent pushes — pass slot.projected and there is no type argument to
restate, no slot name to repeat and no empty frame to derive.
import { sessionSlot } from "@alexkroman1/aai";import { useAgentState } from "@alexkroman1/aai-ui";
// In a real project the slot is declared beside the agent and imported here;// `sync: true` is what puts it in the agent's `syncState`.const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }), { view: (cart) => ({ count: cart.items.length }), sync: true,});
function CartBadge() { const cart = useAgentState(cartSlot.projected); // reads state.cart return <span>{cart.count}</span>;}The projection carries its slot key, so this selects state[projection.key]
— the key the agent’s syncState frame is keyed by. Before the first
push it answers projection(), the slot’s DEFAULT through the same view,
memoized on the projection’s identity; slot.projected is built once with
the slot, so that identity is stable for the life of the component.
The one case that cannot use this overload is a slot whose declaring module is expensive to IMPORT (a seeded factory pulls its seed into the browser bundle): name the slot instead, with a fallback.
Type Parameters
Section titled “Type Parameters”V
Parameters
Section titled “Parameters”projection
Section titled “projection”Returns
Section titled “Returns”V
Call Signature
Section titled “Call Signature”useAgentState<
V>(slot):V|null
One slot’s projected state by NAME, or null before the agent pushed it.
Typed by the caller, as useToolResult is: the shape is the author’s own
projection, which this file cannot see.
Type Parameters
Section titled “Type Parameters”V = any
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”V | null
Call Signature
Section titled “Call Signature”useAgentState<
V>(slot,fallback):V
One slot’s projected state by NAME, falling back to fallback before the
first push — for a slot whose module the browser should not import. Build the
fallback by running the SAME view over an empty state, and hoist it to module
scope so it is a stable reference.
import { useAgentState } from "@alexkroman1/aai-ui";
type StoreView = { orders: string[] };// Hoisted, so it is one reference for the life of the component.const EMPTY: StoreView = { orders: [] };
function Orders() { const view = useAgentState("retail", EMPTY); return <p>{view.orders.length} orders</p>;}Type Parameters
Section titled “Type Parameters”V
Parameters
Section titled “Parameters”string
fallback
Section titled “fallback”V
Returns
Section titled “Returns”V
useClientId()
Section titled “useClientId()”useClientId():
string|undefined
The client id this session sends as ?client= — the id a tool reads with
sessionClientId(ctx) and the inbox socket is held under. With
mountClient({ client: "auto" }) it is this browser’s browserClientId();
undefined when the session sends none.
A client GETTER is read when the session notifies (each snapshot change),
so a page that changes what its getter answers sees the new id on the next
render the session causes.
Returns
Section titled “Returns”string | undefined
The client id, or undefined.
useClientRuns()
Section titled “useClientRuns()”useClientRuns(
path?,options?):UseClientRunsResult
The runs going on for this client, from a clientRunsRoutes() pair — see
this module’s doc.
Parameters
Section titled “Parameters”string
The path the routes were declared with, without /api.
Default "/tasks", clientRunsRoutes()’ own default.
options?
Section titled “options?”pollMs and client; see UseClientRunsOptions.
Returns
Section titled “Returns”The rows, the last error, reload, cancel and the id being
cancelled; see UseClientRunsResult.
Example
Section titled “Example”A “Running” panel with a Cancel on each live reminder
import { useClientRuns } from "@alexkroman1/aai-ui";
function Running() { const { runs, error, cancel, cancelling } = useClientRuns(); if (!runs) return error ? <p role="alert">{error}</p> : null; return ( <ul> {runs.map((r) => ( <li key={r.runId}> {r.title} — {r.status} {r.detail && <small>{r.detail}</small>} {r.status === "running" && ( <button type="button" disabled={cancelling === r.runId} onClick={() => void cancel(r.runId)}> Cancel </button> )} </li> ))} </ul> );}useClientTool()
Section titled “useClientTool()”useClientTool<
A>(toolName,handler):void
Run a clientTool in this page and answer the agent with its result.
When the model calls the tool named toolName, handler runs with the
call’s arguments. What it returns (or resolves to) is JSON-serialized and
becomes the tool’s result — the value the model reads. A handler that throws
or rejects fails the call, and the model is told the error’s message, as it
would be for a server tool.
The server waits only as long as the tool’s timeoutMs (default 30 s); an
answer after that, or while disconnected, is dropped. Mount the hook once per
tool: two mounted handlers both run, and only the first answer counts.
Type Parameters
Section titled “Type Parameters”A = Record<string, any>
The tool’s argument shape. Name it, or derive it with a
TYPE-ONLY import of the server tool: useClientTool<InferToolInput<typeof getLocation>>(…).
Parameters
Section titled “Parameters”toolName
Section titled “toolName”string
The name of the clientTool — its tools/<name>.ts file.
handler
Section titled “handler”(args, toolCall) => unknown
Runs the call; its return value is the result.
Returns
Section titled “Returns”void
Example
Section titled “Example”Let the agent ask for the caller’s location
import { useClientTool } from "@alexkroman1/aai-ui";
function LocationTool() { useClientTool("get_location", () => new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition( (p) => resolve({ lat: p.coords.latitude, lon: p.coords.longitude }), (err) => reject(new Error(err.message)), ), ), ); return null;}useConversation()
Section titled “useConversation()”useConversation():
UseConversationResult
Subscribe to the conversation: the interleaved exchange, the streaming utterance, the live transcript and the thinking rule — with no markup.
Must be used inside the provider mountClient() installs.
Returns
Section titled “Returns”Example
Section titled “Example”A custom bubble, keeping every rule `<MessageList>` knows
import { useConversation } from "@alexkroman1/aai-ui";
function Transcript() { const { items, streaming, transcript, thinking } = useConversation(); return ( <div> {items.map((item) => { switch (item.kind) { case "message": return ( <p key={item.message.id} data-role={item.message.role}> {item.message.content} </p> ); case "tool": return <code key={item.toolCall.callId}>{item.toolCall.name}</code>; default: return null; } })} {streaming !== null && <p data-role="assistant">{streaming}</p>} {transcript.speaking && <p data-role="user">{transcript.text}</p>} {thinking && <p>…</p>} </div> );}useConversationLog()
Section titled “useConversationLog()”useConversationLog(
options?):UseConversationLogResult
A transcript that outlives the session — see this module’s doc.
Must be used inside the provider mountClient() installs; call it once per
page, since two logs over one key would overwrite each other.
Parameters
Section titled “Parameters”options?
Section titled “options?”Where it is stored and how much; see UseConversationLogOptions.
Returns
Section titled “Returns”The entries and the ways to add to them; see UseConversationLogResult.
Example
Section titled “Example”The whole history, with a note per new session and a linked speaker mirrored
import { ConversationView, useConversationLog, useInbox } from "@alexkroman1/aai-ui";
function History() { const log = useConversationLog(); useInbox({ onEvent: log.mirror, onNotice: (n) => log.addNote(n.event) }); return ( <ConversationView log={log.entries} className="flex-1 min-h-0" renderMessage={(m) => <p data-role={m.role}>{m.content}</p>} /> );}useCopy()
Section titled “useCopy()”useCopy():
UseCopyResult
One copier for a group of copy buttons.
Returns
Section titled “Returns”Remarks
Section titled “Remarks”Call it ONCE per group and pass the UseCopyResult down, rather than once per button: the flash is shared, so clicking a second row clears the first row’s “Copied” — which is what makes a list of URLs readable, since two rows claiming to be on the clipboard is a lie about one of them.
A chip whose idle text is its own name rather than the word “Copy” passes that name to UseCopyResult.label; the two outcome words are fixed, for the reason that member’s own doc gives.
Example
Section titled “Example”import { useCopy } from "@alexkroman1/aai-ui";
function UrlList({ urls }: { urls: readonly string[] }) { const copier = useCopy(); return ( <ul> {urls.map((url) => ( <li key={url}> <code>{url}</code> <button type="button" onClick={() => copier.copy(url)}> {copier.label(url)} </button> </li> ))} </ul> );}useDictation()
Section titled “useDictation()”useDictation(
options):UseDictationResult
Record what the user says and hand it back as text — a microphone button for a text box.
Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”Example
Section titled “Example”import { appendDictation, dictationTranscriber, useDictation } from "@alexkroman1/aai-ui";import { useState } from "react";
// `POST /dictation` on your own server is `handleDictationRequest`// (`@alexkroman1/aai/experimental`), which holds the API key.const transcribe = dictationTranscriber("/dictation");
export function SpokenNote() { const [draft, setDraft] = useState(""); const dictation = useDictation({ transcribe, onText: (text) => setDraft((d) => appendDictation(d, text)), }); return ( <div> <textarea value={draft} onChange={(e) => setDraft(e.target.value)} /> {dictation.supported && ( <button type="button" {...dictation.buttonProps}> {dictation.phase === "recording" ? "Stop" : "Speak"} </button> )} </div> );}useDownloadUrl()
Section titled “useDownloadUrl()”useDownloadUrl(
uploadId,options?):UseDownloadUrlResult
Read an upload’s bytes and hand back a URL a DOM element can use.
Parameters
Section titled “Parameters”uploadId
Section titled “uploadId”string | undefined
The id a completed run reported, or undefined before one
exists — which is what a page passes straight through while it waits, and
reports as idle rather than pending.
options?
Section titled “options?”Returns
Section titled “Returns”See UseDownloadUrlResult.
Example
Section titled “Example”import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";import type { spokenSummary } from "./agent.ts";
function Playback() { const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary"); const output = run?.status === "completed" ? run.output : undefined; const audio = useDownloadUrl(output?.audio); if (audio.pending) return <p>Fetching audio…</p>; if (audio.error !== undefined) return <p role="alert">{audio.error}</p>; return audio.url === undefined ? null : ( <a href={audio.url} download="summary.mp3"> Download </a> );}useEvent()
Section titled “useEvent()”useEvent<
T>(event,callback):void
Subscribe to custom events emitted by agent tools via
ctx.send(event, data); the callback receives each event’s data.
This is the preferred way to drive UI from tools — an explicit event beats inferring state from tool results with useToolResult.
Type Parameters
Section titled “Type Parameters”T = unknown
Parameters
Section titled “Parameters”string
callback
Section titled “callback”(data) => void
Returns
Section titled “Returns”void
Example
Section titled “Example”import { useEvent } from "@alexkroman1/aai-ui";import { useState } from "react";
type Item = { sku: string; qty: number };
function Cart() { const [cart, setCart] = useState<Item[]>([]); // Tool: ctx.send("item_added", { sku, qty }) useEvent<Item>("item_added", (data) => { setCart((cart) => [...cart, data]); }); return <div>{cart.length} items</div>;}useFlash()
Section titled “useFlash()”useFlash<
T>(ms?):UseFlashResult<T>
A transient value: set it, and it clears itself after ms.
Type Parameters
Section titled “Type Parameters”T
What is being flashed.
Parameters
Section titled “Parameters”number
How long the value stays up. Defaults to 1500ms, which is what every caller wanted: long enough to read a word, short enough that the control is back to its real label before the reader looks again.
Returns
Section titled “Returns”Remarks
Section titled “Remarks”The two things this holds that a useState plus a setTimeout at the call
site reliably gets wrong:
- At most ONE live timer. A second flash while the first is still up re-arms rather than stacking, so the new value gets its full window instead of being cleared early by the previous click’s timeout.
- Nothing fires after unmount. The timer is cleared on teardown, so a
chip clicked and then scrolled out of the tree does not
setStateon a component React has already thrown away.
Example
Section titled “Example”import { useFlash } from "@alexkroman1/aai-ui";
function SaveNote({ onSave }: { onSave: () => Promise<void> }) { const { value: note, flash } = useFlash<string>(); return ( <button type="button" onClick={() => void onSave().then(() => flash("Saved"))}> {note ?? "Save"} </button> );}useInbox()
Section titled “useInbox()”useInbox(
options?):UseInboxResult
Hold this session’s WS /inbox socket open for the life of the component,
and play what arrives — see the module doc.
The session needs a client id: mountClient({ client: "auto" }), or a
client of your own. With none, no socket is opened (connected stays
false) until the session’s client getter answers one.
Parameters
Section titled “Parameters”options?
Section titled “options?”What to do with a notice or a live event; see UseInboxOptions.
Returns
Section titled “Returns”Whether the socket is open, and a way to stop playback.
Example
Section titled “Example”import { mountClient, useInbox } from "@alexkroman1/aai-ui";
function App() { const { connected } = useInbox({ onNotice: (notice) => console.log(notice.event, notice.data), }); return <p>{connected ? "Reminders on" : "Reminders offline"}</p>;}
mountClient({ client: "auto", component: App });usePushToTalk()
Section titled “usePushToTalk()”usePushToTalk(
options?):UsePushToTalkResult
Hold-to-speak over the session’s push-to-talk methods, with the four ways a turn gets stuck open handled — see this module’s doc.
Must be used inside the provider mountClient() installs, against an agent
declaring turnTaking: { detection: "manual" }; any other agent ignores the commands and
its server says so once.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”Example
Section titled “Example”A hold-to-talk button
import { usePushToTalk } from "@alexkroman1/aai-ui";
function TalkButton() { const { talking, buttonProps } = usePushToTalk(); return ( <button type="button" {...buttonProps}> {talking ? "Listening… release to send" : "Hold to talk (or hold Space)"} </button> );}useRoute()
Section titled “useRoute()”useRoute<
T>(path,options?):UseRouteResult<T>
GET one of the agent’s own routes, on mount, on reload() and every
pollMs until until says so — see this module’s doc.
Type Parameters
Section titled “Type Parameters”T = unknown
The shape the route answers. Not checked.
Parameters
Section titled “Parameters”string | null
The route’s path as declared, without /api. null reads nothing.
options?
Section titled “options?”pollMs, until, client and headers; see UseRouteOptions.
Returns
Section titled “Returns”The answer, the last error, and reload; see UseRouteResult.
Examples
Section titled “Examples”A list that re-reads after each delete
import { routeFetch, useClientId, useRoute } from "@alexkroman1/aai-ui";
type Memory = { id: string; text: string };
function Memories() { const client = useClientId(); const { data, error, reload } = useRoute<{ memories: Memory[] }>("/memories"); if (error) return <p role="alert">{error}</p>; return ( <ul> {data?.memories.map((m) => ( <li key={m.id}> {m.text} <button type="button" onClick={() => void routeFetch("DELETE", `/memories/${m.id}`, undefined, { client }).then(reload)} > Forget </button> </li> ))} </ul> );}A call followed until it is over
import { useRoute } from "@alexkroman1/aai-ui";
type Call = { status: string; transcript: { role: string; text: string }[] };
function LiveCall({ id }: { id: string }) { const { data } = useRoute<Call>(`/calls/${id}`, { pollMs: 2000, until: (call) => call.status === "completed", }); return <ol>{data?.transcript.map((line, i) => <li key={String(i)}>{line.text}</li>)}</ol>;}useRouteMutation()
Section titled “useRouteMutation()”useRouteMutation(
options?):UseRouteMutationResult
Write to the agent’s own routes, with a busy key, the last error and a re-read after — see this module’s doc.
Parameters
Section titled “Parameters”options?
Section titled “options?”client and onSettled; see UseRouteMutationOptions.
Returns
Section titled “Returns”run, the busy key, the last error and clearError; see
UseRouteMutationResult.
Example
Section titled “Example”A profile field that saves, then re-reads
import { useRoute, useRouteMutation } from "@alexkroman1/aai-ui";
function Name() { const { data, reload } = useRoute<{ name: string }>("/profile"); const { run, busy, error } = useRouteMutation({ onSettled: reload }); return ( <form onSubmit={(e) => { e.preventDefault(); const name = new FormData(e.currentTarget).get("name"); void run("PUT", "/profile", { name }, { key: "name" }); }} > <input name="name" defaultValue={data?.name} disabled={busy === "name"} /> {error && <p role="alert">{error}</p>} </form> );}useRunKey()
Section titled “useRunKey()”useRunKey(
options?):string
A lookup key for useWorkflowSubmit({ key }), stable across reloads.
Parameters
Section titled “Parameters”options?
Section titled “options?”See the module doc for the whole argument. The storage kind is read once, when the key is minted: a value that changed afterwards would be asking to move a key that has already been recorded with a run.
storage?
Section titled “storage?”"session" | "local"
Which store keeps the key between loads.
"session" (the default) dies with the tab; "local" survives the
browser closing, which is what a run that sleeps for days needs. See “The
storage is the caller’s decision”.
Returns
Section titled “Returns”string
The key to record runs under and to look them up by — the same one
for the life of the component, and for the next load in the same tab (or the
same browser, under "local").
useSession()
Section titled “useSession()”useSession():
Session
Return the live Session: the current snapshot fields plus the
control methods (start, toggle, reset, resetState, disconnect,
cancel, end).
Throws if used outside the provider mountClient() installs (the error names
<SessionProvider> — you only mount that yourself when bypassing
mountClient()). Re-renders the component on every snapshot change; for a
component that reads one field, prefer useSessionSelector for a
targeted subscription.
Returns
Section titled “Returns”Example
Section titled “Example”import { useSession } from "@alexkroman1/aai-ui";
function Controls() { const session = useSession(); if (!session.started) return <button onClick={session.start}>Start</button>; return <button onClick={session.toggle}>{session.running ? "Pause" : "Resume"}</button>;}useSessionActions()
Section titled “useSessionActions()”useSessionActions():
SessionActions
The session’s control methods — start, cancel, sendText,
setMicMuted, resetState, reset, restart, resume, disconnect,
toggle, end — with no snapshot subscription.
Push-to-talk is not among them: that is usePushToTalk.
This is the narrow half of useSession, and it is the half a custom
chrome could not reach. <Controls> and <StartScreen> in this package pair
a one-field useSessionSelector with this package’s own useSessionCore
(context.ts, unpublished); a client.tsx
could not, because that hook is not published — so a footer needing start
and toggle held a WHOLE-SNAPSHOT useSession(), and session/browser-session.ts
rebuilds the snapshot object on every change. Measured consequence: four
components across three templates re-rendered on every STT partial and every
streaming delta, in files whose every other component is narrowly subscribed
on purpose. One of them (text-adventure-agent’s TitleScreen) reads nothing
from the snapshot at all and subscribes to all of it for session.start.
Why publishing this does not reopen what /internal closed.
useSessionCore hands back the STORE — subscribe, getSnapshot,
connect, Symbol.dispose — which is the framework’s own plumbing, the same
category as the providers and buildAgentUrl that live on
@alexkroman1/aai-ui/internal. A client that holds it can subscribe out of
band of React, dial a socket the mount did not, and dispose the session under
the tree that is rendering it. What comes back from here is the SAME
methods useSession() already publishes on its result, built into a fresh
object rather than passed through, so the store is not reachable from it.
There is no new capability here — only the existing one without the
subscription tax.
Identity-stable per core, so it is safe in a dependency array and in a
memo() child’s props: the methods are closures created once by
createBrowserSession, and the object wrapping them is memoized on the core.
Throws outside the provider mountClient() installs, like every session hook.
Returns
Section titled “Returns”The control methods — see SessionActions.
Example
Section titled “Example”A footer that acts on the session without re-rendering with it
import { useSessionActions, useSessionSelector } from "@alexkroman1/aai-ui";
function Footer() { // Two narrow subscriptions and no snapshot read: this row re-renders when // `running` flips, and not on every transcript delta. const running = useSessionSelector((s) => s.running); const { toggle, end } = useSessionActions(); return ( <> <button onClick={toggle}>{running ? "Pause" : "Resume"}</button> <button onClick={end}>Hang up</button> </> );}useSessionControls()
Section titled “useSessionControls()”useSessionControls():
UseSessionControlsResult
The state and the actions a Start / Pause–Resume / New conversation / End row renders from, on two one-field subscriptions.
Must be used inside the provider mountClient() installs.
Returns
Section titled “Returns”Example
Section titled “Example”A footer that renders its own buttons
import { useSessionControls } from "@alexkroman1/aai-ui";
function Footer() { const { started, running, start, toggle, end } = useSessionControls(); if (!started) return <button type="button" onClick={start}>Begin</button>; return ( <> <button type="button" onClick={toggle}>{running ? "Hold" : "Resume"}</button> <button type="button" onClick={end}>Hang up</button> </> );}useSessionError()
Section titled “useSessionError()”useSessionError():
SessionError|null
The session’s current SessionError, or null when there is none, on
its own narrow subscription.
The other half of useSessionStatus — the second of the two fields a
custom chrome reads over and over, and the one whose absence is invisible:
per the fatalError latch in session/browser-session.ts the error is the ONLY
remaining signal that a session died, since the state beside it goes back to
reading like a live one.
A chrome rendering it owes role="alert" — which is what
SessionErrorBanner is for, and why reaching for that beats reaching
for this.
Returns
Section titled “Returns”SessionError | null
The current error, or null.
useSessionId()
Section titled “useSessionId()”useSessionId():
string|undefined
The server’s id for the current session: undefined before the session’s
first config frame and after end(); a resume or a new session updates it
from its own config frame. Key a page’s history by it.
Treat it as sensitive — whoever holds it can resume the session and read its
history (VoiceSessionOptions.onSessionId explains why).
Returns
Section titled “Returns”string | undefined
The current session id, or undefined.
Example
Section titled “Example”import { useSessionId } from "@alexkroman1/aai-ui";
function SessionTag() { const id = useSessionId(); return <small>{id ?? "no session yet"}</small>;}useSessionSelector()
Section titled “useSessionSelector()”useSessionSelector<
T>(selector,isEqual?):T
Subscribe to a narrow slice of the session snapshot.
Unlike useSession — which re-renders the component on every
snapshot change — this only triggers a re-render when the selected value
changes (per isEqual, default Object.is). Use it for components that
read a single field, e.g. useSessionSelector((s) => s.running).
The selector must be pure. It may run on every snapshot change, so keep it
cheap; when it returns a derived object, pass a custom isEqual to avoid
re-renders on referentially-new-but-equal results.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”selector
Section titled “selector”(snapshot) => T
Reads the slice out of the snapshot. Must be pure.
isEqual?
Section titled “isEqual?”(a, b) => boolean
Compares two selected values. Defaults to Object.is.
Returns
Section titled “Returns”T
The selected slice.
Example
Section titled “Example”import { useSessionSelector } from "@alexkroman1/aai-ui";
// Re-renders when `running` flips, and on nothing else — not on every// transcript delta the way `useSession()` would.function MicDot() { const running = useSessionSelector((snapshot) => snapshot.running); return <span>{running ? "●" : "○"}</span>;}useSessionStatus()
Section titled “useSessionStatus()”useSessionStatus():
AgentState
The agent’s live AgentState — disconnected, connecting, ready,
listening, thinking, speaking, error — on its own narrow
subscription.
useSessionSelector((s) => s.state) spelled once. It is one of exactly two
snapshot fields that more than one custom chrome ever selects (the other is
useSessionError), and it had been written inline at eight sites —
including inside this package and, worse, in ConsoleShell’s own @example,
which taught the inline form to everyone who read it.
Named useSessionStatus, not useSessionState. useAgentState is the
SLOT hook — the agent’s own synced application state, whatever a
sessionSlot() projects — and AgentState here is the phase of the CALL.
Two different concepts one letter apart, so the shorter-sounding name is the
one deliberately not taken.
Pair it with AGENT_STATE_LABELS for a rendered word; the raw member is a wire value, not a label.
Returns
Section titled “Returns”The current agent state.
Example
Section titled “Example”import { AGENT_STATE_LABELS, useSessionStatus } from "@alexkroman1/aai-ui";
function StatusDot() { const status = useSessionStatus(); return <span data-state={status}>{AGENT_STATE_LABELS[status] ?? status}</span>;}useStoredValue()
Section titled “useStoredValue()”useStoredValue(
source,initial?): [string, (value) =>void]
A remembered string as React state: the value, and a setter that writes it through to storage — see this module’s doc.
Pass the key (and an initial) for a value one component owns, or a
StoredValue shared with code outside React.
Parameters
Section titled “Parameters”source
Section titled “source”string | StoredValue
A storage key, or a StoredValue.
initial?
Section titled “initial?”string
With a key: what to answer while nothing is stored. Default "".
Ignored with a StoredValue, which carries its own.
Returns
Section titled “Returns”[string, (value) => void]
[value, setValue]; setValue("") forgets it.
Example
Section titled “Example”import { useStoredValue } from "@alexkroman1/aai-ui";
function Units() { const [units, setUnits] = useStoredValue("my-app:units", "metric"); return ( <button type="button" onClick={() => setUnits(units === "metric" ? "imperial" : "metric")}> {units} </button> );}useTapToTalk()
Section titled “useTapToTalk()”useTapToTalk(
options?):UseTapToTalkResult
Tap to go live, tap to hang up, over the session — see this module’s doc for what it handles beyond the tap.
Must be used inside the provider mountClient() installs. Call it ONCE per
page: it owns the session’s mute and hang-up clock.
Parameters
Section titled “Parameters”options?
Section titled “options?”The talk key and the three clocks; see UseTapToTalkOptions.
Returns
Section titled “Returns”The phase, the live flag and the controls; see UseTapToTalkResult.
Example
Section titled “Example”A talk button and a composer
import { useTapToTalk } from "@alexkroman1/aai-ui";
function Speaker() { const { live, phase, send, buttonProps } = useTapToTalk(); return ( <> <button type="button" {...buttonProps}> {phase === "connecting" ? "Connecting…" : live ? "Tap to hang up" : "Tap to talk"} </button> <form onSubmit={(e) => { e.preventDefault(); const input = e.currentTarget.elements.namedItem("say") as HTMLInputElement; send(input.value); input.value = ""; }} > <input name="say" placeholder="…or type" /> </form> </> );}useTheme()
Section titled “useTheme()”useTheme():
ResolvedClientTheme
Read the resolved theme (every ClientTheme field filled with its default) from the nearest theme context. Returns the default theme when no provider is present, so components can call it unconditionally.
This is how a custom component stays on the agent’s palette: a
mountClient({ theme }) override reaches it here, where a hardcoded colour or a
Tailwind class cannot see it.
Returns
Section titled “Returns”Every ClientTheme field, filled in — a ResolvedClientTheme.
Example
Section titled “Example”import { useTheme } from "@alexkroman1/aai-ui";
function Total({ amount }: { amount: string }) { const theme = useTheme(); return ( <strong style={{ color: theme.primary, background: theme.surface }}> {amount} </strong> );}useToolCallStart()
Section titled “useToolCallStart()”Call Signature
Section titled “Call Signature”useToolCallStart<
A>(toolName,callback):void
Fire a callback when ONE named tool starts, before its result arrives.
A start is a MOMENT rather than a value, so unlike useToolResult this never replays: a component that mounts mid-session learns nothing about calls that started before it.
Type Parameters
Section titled “Type Parameters”A = Record<string, any>
The tool’s ARGUMENT shape. Defaults to
ToolCallInfo["args"], which is Record<string, any> — so an
un-parameterized call behaves exactly as it always has, and
toolCall.args.totally_made_up_field still compiles. That default is a
property of ToolCallInfo rather than a choice made here (its doc
carries the argument, and the escape hatch it recommends —
args as { url: string } — is what this type parameter replaces); until
that field is tightened there is nothing stricter for this hook to fall
back to. What was missing was any way to opt IN: there was no type
parameter at all, so a custom client could not check args even when it
knew the shape. Name it — useToolCallStart<{ query: string }>(…) — or
derive it from the tool with a TYPE-ONLY import, which is erased and so
pulls no host code into the browser graph:
useToolCallStart<InferToolInput<typeof search>>("search", …).
Parameters
Section titled “Parameters”toolName
Section titled “toolName”string
Only calls of this tool fire the callback.
callback
Section titled “callback”(toolCall) => void
Called with the pending call.
Returns
Section titled “Returns”void
Example
Section titled “Example”import { useState } from "react";import { useToolCallStart } from "@alexkroman1/aai-ui";
function Searching() { const [busy, setBusy] = useState(false); useToolCallStart("search_catalog", () => setBusy(true)); return busy ? <p>Searching the catalog…</p> : null;}Call Signature
Section titled “Call Signature”useToolCallStart<
A>(callback):void
Fire a callback when ANY tool call starts — read the tool’s name off the
call itself (toolCall.name).
Type Parameters
Section titled “Type Parameters”A = Record<string, any>
The tool’s ARGUMENT shape; see the filtered overload. On the
unfiltered form every tool’s call arrives, so naming one shape here is
only right for a page that switches on toolCall.name and narrows it
itself — the default is the honest answer for a log renderer.
Parameters
Section titled “Parameters”callback
Section titled “callback”(toolCall) => void
Called with the pending call.
Returns
Section titled “Returns”void
Example
Section titled “Example”import { useState } from "react";import { useToolCallStart } from "@alexkroman1/aai-ui";
function Activity() { const [now, setNow] = useState<string>(); useToolCallStart((toolCall) => setNow(toolCall.name)); return now ? <p>Running {now}…</p> : null;}useToolResult()
Section titled “useToolResult()”Call Signature
Section titled “Call Signature”useToolResult<
R>(toolName,callback):void
Fire a callback when ONE named tool settles, with its parsed JSON result.
For new code prefer explicit events — ctx.send(event, data) in the tool
paired with useEvent here — over listening to tool results.
A component that mounts late still receives the results of calls that already completed, because a result is a value the UI is driven from rather than a moment. Each call fires exactly once per hook instance.
Type Parameters
Section titled “Type Parameters”R = unknown
The result shape. Defaults to unknown, NOT to
DefaultToolResult (any): the return type is inferred perfectly
at tool() and this hook is the one place a client reads it, so an any
default threw the whole inference away exactly where it was wanted —
useToolResult("get_order", (r) => r.a.b.c.d.e) reported nothing. It is
the tool’s own shape that belongs here, and the spelling that costs a
browser bundle nothing is a TYPE-ONLY import of the tool module:
import type getOrder from "./tools/get_order.ts" is erased, so
useToolResult<InferToolOutput<typeof getOrder>>(…) pulls no host code
into the client graph. useToolResult<Quote>(…) against a hand-written
shape is the other spelling. DefaultToolResult itself stays any
— see ToolCallInfo.args for why a value the framework cannot see is
typed that way at REST; the argument does not extend to a call site whose
whole job is to name the shape.
Parameters
Section titled “Parameters”toolName
Section titled “toolName”string
Only calls of this tool fire the callback.
callback
Section titled “callback”(result, toolCall) => void
Called with the parsed result and the call itself.
Returns
Section titled “Returns”void
Example
Section titled “Example”import { useState } from "react";import { useToolResult } from "@alexkroman1/aai-ui";
type Quote = { symbol: string; price: number };
function QuoteCard() { const [quote, setQuote] = useState<Quote>(); useToolResult<Quote>("get_quote", (result) => setQuote(result)); return quote ? <p>{quote.symbol}: {quote.price}</p> : null;}Call Signature
Section titled “Call Signature”useToolResult<
R>(callback):void
Fire a callback when ANY tool call settles — the tool’s name is the callback’s first argument.
The unfiltered form, for a chrome rendering a log of everything the agent did rather than reacting to one tool.
Type Parameters
Section titled “Type Parameters”R = unknown
The result shape. Defaults to unknown, for the reason the
filtered overload’s @typeParam gives. A log renderer is the one caller
that legitimately wants no shape, and unknown is what it should say:
JSON.stringify(result) takes it unchanged.
Parameters
Section titled “Parameters”callback
Section titled “callback”(name, result, toolCall) => void
Called with the tool’s name, the parsed result, and the call itself.
Returns
Section titled “Returns”void
Example
Section titled “Example”import { useState } from "react";import { useToolResult } from "@alexkroman1/aai-ui";
function ToolLog() { const [lines, setLines] = useState<string[]>([]); useToolResult((name, result) => { setLines((prev) => [...prev, `${name}: ${JSON.stringify(result)}`]); }); return <pre>{lines.join("\n")}</pre>;}useUserTranscript()
Section titled “useUserTranscript()”useUserTranscript():
UseUserTranscriptResult
Subscribe to the caller’s in-progress turn.
Narrowly subscribed — a component using this re-renders at STT-partial rate,
which is exactly what it is for and exactly what a whole-page useSession()
should not do.
Returns
Section titled “Returns”Example
Section titled “Example”import { useUserTranscript } from "@alexkroman1/aai-ui";
function LiveTranscript() { const { speaking, text } = useUserTranscript(); if (!speaking) return null; return <div className="italic opacity-60">{text}</div>;}useWorkflowProgress()
Section titled “useWorkflowProgress()”useWorkflowProgress<
T>(runId,options?):UseWorkflowProgressResult<T>
Follow one run’s progress stream.
Passing undefined (nothing started yet) costs nothing, and reading stops for
good once a read reports the run terminal — so a finished run costs one read.
Type Parameters
Section titled “Type Parameters”T = string
What the workflow writes. Defaults to string, which is what
a progress channel usually carries; a workflow writing objects names its own
shape. Nothing in the browser can verify it — the route describes no type —
so this is the page’s assertion about its own agent, narrowed once here
rather than at every read.
Parameters
Section titled “Parameters”string | undefined
options?
Section titled “options?”intervalMs?
Section titled “intervalMs?”number
namespace?
Section titled “namespace?”string
startIndex?
Section titled “startIndex?”number
Returns
Section titled “Returns”Example
Section titled “Example”import { useWorkflowProgress } from "@alexkroman1/aai-ui";
function Progress({ runId }: { runId?: string }) { const { progress, streaming, supported } = useWorkflowProgress(runId); if (!supported) return null; return ( <pre> {progress.join("\n")} {streaming && "\n…"} </pre> );}useWorkflowRun()
Section titled “useWorkflowRun()”useWorkflowRun<
R>(runId,options?):UseWorkflowRunResult<R>
Watch one run until it reaches a terminal status.
A watch rather than a subscription because a run is durable and the page is not: it can complete while the tab is closed, on a different sandbox, hours later. There is no session to reconnect — the id is the whole state.
Which is also the limit of what this hook can do on its own. An id is state a
RELOAD destroys, so a page holding nothing else comes back unable to name a
run that is still going. The durable handle is StartOptions.key, read back
with find(workflow, key), and the hook that owns the id is where that
belongs: useWorkflowSubmit({ key, recover: true }) adopts the key’s newest
run as it mounts and passes the id here. See _recover-run.ts — the reason
recovery is NOT in this hook is reset(), which leaves the owner holding no
id on purpose, and a watcher that re-resolved one from a key would undo it.
The stream (GET /runs/:id/events) is tried first and the poll is its
fallback, so an agent deployed before that route existed still works. Watching
STOPS on a terminal status, so a finished run costs nothing; passing
undefined (nothing started yet) also costs nothing.
Type Parameters
Section titled “Type Parameters”R = unknown
The workflow’s output type. Supplying it is what makes
run.status === "completed" narrow to a typed run.output instead of
unknown. Derive it with WorkflowOutputOf<typeof myWorkflow> — a
type-only import of agent.ts is erased, so it costs the bundle nothing.
Parameters
Section titled “Parameters”string | undefined
The run to watch. undefined costs nothing, so a page may
pass its state straight through before a run exists.
options?
Section titled “options?”api when the page holds its own client; intervalMs to
change the poll interval the stream falls back to.
intervalMs?
Section titled “intervalMs?”number
Returns
Section titled “Returns”The latest snapshot, the last read’s error, and whether the watch is still going — see UseWorkflowRunResult.
Example
Section titled “Example”import type { ToolInputSchema, WorkflowDef } from "@alexkroman1/aai";import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";import { useWorkflowRun } from "@alexkroman1/aai-ui";
// A real page writes `import type { digest } from "./agent.ts"`. Stood in// for here so the example compiles on its own.declare const digest: WorkflowDef<ToolInputSchema, Promise<{ points: string[] }>>;
type Digest = WorkflowOutputOf<typeof digest>;
function RunPanel({ runId }: { runId: string | undefined }) { // The type argument is what makes `run.output` a `Digest` below. const { run, error, polling } = useWorkflowRun<Digest>(runId); if (error !== undefined) return <p role="alert">{error}</p>; if (run?.status === "completed") { return ( <ul> {run.output.points.map((point) => ( <li key={point}>{point}</li> ))} </ul> ); } return <p>{polling ? "Working…" : "Nothing running."}</p>;}useWorkflowRuns()
Section titled “useWorkflowRuns()”Call Signature
Section titled “Call Signature”useWorkflowRuns<
App,Name>(workflow,options?):UseWorkflowRunsResult<WorkflowOutputOf<WorkflowDefOf<App,Name>>>
Read a workflow’s recent runs.
Type Parameters
Section titled “Type Parameters”App extends WorkflowAppLike = never
Name extends string = WorkflowNameOf<App>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”[App] extends [never] ? never : Name | undefined
options?
Section titled “options?”Returns
Section titled “Returns”UseWorkflowRunsResult<WorkflowOutputOf<WorkflowDefOf<App, Name>>>
Example
Section titled “Example”import { useWorkflowRuns } from "@alexkroman1/aai-ui";
function History() { const { runs } = useWorkflowRuns("transcribe", { limit: 10 }); return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;}Call Signature
Section titled “Call Signature”useWorkflowRuns<
R>(workflow,options?):UseWorkflowRunsResult<R>
Read a workflow’s recent runs.
Type Parameters
Section titled “Type Parameters”R = unknown
The workflow’s output type, so a completed run’s output is
typed rather than unknown. Derive it with WorkflowOutputOf — or name
the AGENT instead, useWorkflowRuns<typeof agentDef>("transcribe"), and
both the name and the output come off its workflows map (see
WorkflowNameOf).
Parameters
Section titled “Parameters”workflow
Section titled “workflow”[WorkflowsOf<R>] extends [never] ? string | undefined : never
options?
Section titled “options?”Returns
Section titled “Returns”Example
Section titled “Example”import { useWorkflowRuns } from "@alexkroman1/aai-ui";
function History() { const { runs } = useWorkflowRuns("transcribe", { limit: 10 }); return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;}useWorkflows()
Section titled “useWorkflows()”useWorkflows(
options?):UseWorkflowsResult
Read the agent’s declared workflows.
What <WorkflowFields> renders a form FROM: each summary carries the JSON
Schema of that workflow’s input, converted server-side precisely so a browser
can read it.
The failure is reported rather than swallowed, because the alternative is an empty list — which renders as a form with no fields and reads as “this agent declares no workflows” about an agent that was merely unreachable.
Parameters
Section titled “Parameters”options?
Section titled “options?”See UseWorkflowsOptions.
Returns
Section titled “Returns”The listing, its loading flag and its failure — see UseWorkflowsResult.
Example
Section titled “Example”import { useWorkflows } from "@alexkroman1/aai-ui";
// A page rendering its own chrome from the listing — a picker, say. A form// for ONE workflow wants `<WorkflowFields workflow="name" />` instead,// which does this lookup itself.function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) { const { workflows, loading, error } = useWorkflows(); if (loading) return <p>Loading…</p>; if (error !== undefined) return <p role="alert">{error}</p>; return ( <ul> {workflows.map((summary) => ( <li key={summary.name}> <button type="button" onClick={() => onPick(summary.name)}> {summary.description ?? summary.name} </button> </li> ))} </ul> );}useWorkflowStream()
Section titled “useWorkflowStream()”Call Signature
Section titled “Call Signature”useWorkflowStream<
App,Name>(workflow,options?):WorkflowStreamSubmission<WorkflowOutputOf<WorkflowDefOf<App,Name>>,SubmitInputOf<WorkflowDefOf<App,Name>>>
Start a workflow run and stream a file into it while it works.
The workflow declares which input property carries the upload
(workflow({ uploads: ["recording"] })) — the same declaration
useWorkflowSubmit reads, because what the property carries is an upload id
either way. What differs is only WHEN the id becomes valid.
Type Parameters
Section titled “Type Parameters”App extends WorkflowAppLike = never
Name extends string = WorkflowNameOf<App>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”Name
options?
Section titled “options?”Returns
Section titled “Returns”WorkflowStreamSubmission<WorkflowOutputOf<WorkflowDefOf<App, Name>>, SubmitInputOf<WorkflowDefOf<App, Name>>>
Example
Section titled “Example”const { submit, run, upload, pending, error } = useWorkflowStream("transcribe");
<Form onSubmit={(values) => submit(values)} error={error}> <WorkflowFields workflow="transcribe" /> <UploadProgressBar upload={upload} /> <SubmitButton pending={pending}>Transcribe</SubmitButton></Form>Call Signature
Section titled “Call Signature”useWorkflowStream<
D>(workflow,options?):WorkflowStreamSubmission<WorkflowOutputOf<D>,SubmitInputOf<D>>
Start a workflow run and stream a file into it while it works.
The workflow declares which input property carries the upload
(workflow({ uploads: ["recording"] })) — the same declaration
useWorkflowSubmit reads, because what the property carries is an upload id
either way. What differs is only WHEN the id becomes valid.
Type Parameters
Section titled “Type Parameters”D extends AnyWorkflowDef
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
options?
Section titled “options?”Returns
Section titled “Returns”WorkflowStreamSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>
Example
Section titled “Example”const { submit, run, upload, pending, error } = useWorkflowStream("transcribe");
<Form onSubmit={(values) => submit(values)} error={error}> <WorkflowFields workflow="transcribe" /> <UploadProgressBar upload={upload} /> <SubmitButton pending={pending}>Transcribe</SubmitButton></Form>useWorkflowSubmit()
Section titled “useWorkflowSubmit()”Call Signature
Section titled “Call Signature”useWorkflowSubmit<
App,Name>(workflow,options?):WorkflowSubmission<WorkflowOutputOf<WorkflowDefOf<App,Name>>,SubmitInputOf<WorkflowDefOf<App,Name>>>
Start a workflow from a form, and follow the run it creates.
Type Parameters
Section titled “Type Parameters”App extends WorkflowAppLike = never
Name extends string = WorkflowNameOf<App>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”Name
options?
Section titled “options?”Returns
Section titled “Returns”WorkflowSubmission<WorkflowOutputOf<WorkflowDefOf<App, Name>>, SubmitInputOf<WorkflowDefOf<App, Name>>>
Examples
Section titled “Examples”import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const digest = workflow({ input: z.object({ url: z.string() }), output: z.object({ title: z.string() }), run: async ({ url }) => ({ title: url }),});
export default agent({ name: "Digest", workflows: { digest } });
// file: client.tsximport { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";import type { digest } from "./agent.ts";
function DigestForm() { const { submitForm, run, pending, error } = useWorkflowSubmit<typeof digest>("digest"); return ( <Form onSubmit={(values) => submitForm(values)} error={error}> <TextField name="url" label="Link" type="url" required /> <SubmitButton pending={pending}>Digest</SubmitButton> {run?.status === "completed" && <p>{run.output.title}</p>} </Form> );}Named through the agent, so the name is checked
import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const digest = workflow({ input: z.object({ url: z.string() }), output: z.object({ title: z.string() }), run: async ({ url }) => ({ title: url }),});
export default agent({ name: "Digest", workflows: { digest } });
// file: client.tsximport { useWorkflowSubmit } from "@alexkroman1/aai-ui";import type agentDef from "./agent.ts";
export function DigestForm() { const { submit } = useWorkflowSubmit<typeof agentDef>("digest"); // "digset" would not compile return ( <button type="button" onClick={() => submit({ url: "https://example.com/a" })}> Digest </button> );}Call Signature
Section titled “Call Signature”useWorkflowSubmit<
D>(workflow,options?):WorkflowSubmission<WorkflowOutputOf<D>,SubmitInputOf<D>>
Start a workflow from a form, and follow the run it creates.
Type Parameters
Section titled “Type Parameters”D extends AnyWorkflowDef
The workflow DEFINITION, which types both halves of the
submission: submit(input) takes what the workflow’s schema parses to, and
run.status === "completed" narrows to a typed run.output.
It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
already wrote WorkflowOutputOf<typeof digest> to get the output, while
submit took unknown, so submit({ ur1: 42 }) compiled and arrived as a
400 in the browser. Naming the def instead types the input from the same
declaration — and import type is ERASED, so it costs the bundle nothing.
Passing an output type where a def belongs is now a compile error rather
than a silent loss of typing, which is the point.
Or the AGENT. useWorkflowSubmit<typeof agentDef>("digest") reads the
def off the agent’s own workflows map, and the NAME is then a literal the
compiler checks — a typo is an error naming the keys the agent declares,
where the def form accepts any string and 404s. See WorkflowNameOf; an
app with several workflows names the one it means as a second type
argument.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
options?
Section titled “options?”Returns
Section titled “Returns”WorkflowSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>
Examples
Section titled “Examples”import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const digest = workflow({ input: z.object({ url: z.string() }), output: z.object({ title: z.string() }), run: async ({ url }) => ({ title: url }),});
export default agent({ name: "Digest", workflows: { digest } });
// file: client.tsximport { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";import type { digest } from "./agent.ts";
function DigestForm() { const { submitForm, run, pending, error } = useWorkflowSubmit<typeof digest>("digest"); return ( <Form onSubmit={(values) => submitForm(values)} error={error}> <TextField name="url" label="Link" type="url" required /> <SubmitButton pending={pending}>Digest</SubmitButton> {run?.status === "completed" && <p>{run.output.title}</p>} </Form> );}Named through the agent, so the name is checked
import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const digest = workflow({ input: z.object({ url: z.string() }), output: z.object({ title: z.string() }), run: async ({ url }) => ({ title: url }),});
export default agent({ name: "Digest", workflows: { digest } });
// file: client.tsximport { useWorkflowSubmit } from "@alexkroman1/aai-ui";import type agentDef from "./agent.ts";
export function DigestForm() { const { submit } = useWorkflowSubmit<typeof agentDef>("digest"); // "digset" would not compile return ( <button type="button" onClick={() => submit({ url: "https://example.com/a" })}> Digest </button> );}WorkflowFields()
Section titled “WorkflowFields()”WorkflowFields<
App>(props):Element|null
Render one field per property of a workflow’s input schema that has an
honest control — every scalar, an array of strings, a { name, text } object.
Pass the workflow’s NAME and the schema is fetched here; pass a
WorkflowSummary you already hold and nothing is fetched. The name form
is the one a page usually wants — it is the same string the submit hook takes,
and the alternative is three lines (useWorkflows(), a .find() by name, and
folding that lookup’s error into the form’s) whose only product is this
component’s argument.
Renders nothing when the workflow declared no schema — a workflow with no declared input takes anything, and a form for “anything” is not a form — and nothing while a named lookup is still in flight, so the hand-written fields beside it are not reordered when the schema lands.
Type Parameters
Section titled “Type Parameters”App extends WorkflowAppLike = WorkflowAppLike
Parameters
Section titled “Parameters”WorkflowFieldsProps<App>
See WorkflowFieldsProps.
Returns
Section titled “Returns”Element | null
Example
Section titled “Example”import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit } from "@alexkroman1/aai-ui";import type { transcribe } from "./agent.ts";
function StartRun() { const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe"); return ( <Form onSubmit={submitForm} error={error}> <WorkflowFields workflow="transcribe" /> <SubmitButton pending={pending}>Transcribe</SubmitButton> </Form> );}WorkflowPendingNote()
Section titled “WorkflowPendingNote()”WorkflowPendingNote(
props):ReactNode
The one sentence a page says while a run is in flight — five situations, one line each — as a muted line under the form, and nothing otherwise.
Six template pages had each written the function under this, byte-identical in control flow and different only in the noun, under a doc arguing the same two things. Both survive here, once:
- The reload case gets its own words.
!startedHere && runis a run in front of somebody who did not press anything — a reload, or another tab on the same key — and they are owed an explanation for work appearing, plus the line that stops them starting it again. The sentence a page reaches for instead (“you can close this tab”) was true about the RUN and false about the page for as long as a reload could not find its run. - The lookup is a state, not an absence.
!startedHere && !runis the stretch on a reload where the key is being resolved and an empty form would read as “nothing is happening”. It is the same length as the request.
The render site had drifted too — three pages muted the line and two did not
— so the typography is the component’s, in the same text-sm opacity-70
every other muted line on these pages uses.
startedHere, run and pending are what WorkflowSubmission
reports, which is why the prop is the submission itself rather than three
booleans a page would re-derive. The two situations past those three read
two more of its fields, and used to be the one page that wrote its own note:
- A run nothing will find again (
scope: "none"—useWorkflowStream, whose run reads the recording as this page sends it and gives up on one that stops arriving, orrecover: false): “keep this tab open”, because a reload does not orphan that run, it ENDS it. A page telling a reader they can close the tab would be wrong in exactly that mode. - A PAUSED upload is that same fact arriving through a button, which is why it is tested first and why its two sentences differ: with no run yet a pause costs nothing, and in the streaming mode the run is already watching an upload it will give up on.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { useWorkflowSubmit, WorkflowPendingNote } from "@alexkroman1/aai-ui";
function App() { const submission = useWorkflowSubmit("redline"); return <WorkflowPendingNote submission={submission} subject="draft" />;}WorkflowProgress()
Section titled “WorkflowProgress()”WorkflowProgress(
props):ReactNode
What a run has said so far, rendered.
The complement of a status line, and the reason both exist: a run is
running for its whole life, so a one-round job and a ten-round one look
identical while they happen. These lines come from the run itself (stepReport()
in a "use step" body), which is the only channel a workflow has before it
produces an output.
Four rules are baked in, and they are why this is a component rather than three lines each page writes for itself — the two templates that had written it had written three of them, comments included:
- It renders nothing until there is something to render.
supportedis what keeps this from being an empty box forever on an agent deployed before progress streams existed: “wrote nothing yet” and “serves no stream” are indistinguishable from the chunk list alone. - The lines are TEXT, not elements. They are append-only and two rounds legitimately produce identical text, so there is no stable per-line key to give React. Joining sidesteps the question instead of suppressing the lint rule that asks it.
- They REPLAY. Chunks are retained with the run, so a reload mid-run —
or opening a finished run tomorrow — shows how it got there rather than an
empty box. That is
useWorkflowProgress’s doing; this is what makes it visible. - They are ANNOUNCED, for the reason the first paragraph gives: this is
the only channel a run has before it produces an output, and a
<pre>that grows is a silent one. A screen-reader user pressing “Digest” got nothing between the click and a terminal state minutes later — no “fetching”, no “summarising”, no evidence the button did anything. Seerole="log"below. The six pages that render this pass onlyclassName, so no template could have fixed it locally; that is what makes it this component’s job.
Parameters
Section titled “Parameters”Progress-log props.
The workflow API client, when the page holds its own. Defaults to the lazily-built one every workflow hook shares.
className?
Section titled “className?”string
Replaces the default classes rather than extending them, so a custom chrome is not fighting a default it did not ask for.
lines?
Section titled “lines?”number
How many of the newest lines to show. Undefined (the default) shows the
whole log; 1 is the newest line only.
A run’s narration is append-only and unbounded, so a page with a fixed slot
for it — a status strip, a card footer — wants a window rather than a log.
0 renders the placeholder, which is the consistent reading of “show none”
and the one that keeps a computed lines from silently rendering
everything.
placeholder?
Section titled “placeholder?”ReactNode
Rendered instead of nothing while the run has said nothing yet — for a page that would otherwise reflow when the first line lands.
runId?
Section titled “runId?”string
The run to read. undefined renders nothing, so a page may pass its state
straight through before a run exists.
Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { WorkflowProgress } from "@alexkroman1/aai-ui";
function RunPanel({ runId }: { runId: string }) { return <WorkflowProgress runId={runId} />;}WorkflowRunError()
Section titled “WorkflowRunError()”WorkflowRunError(
props):ReactNode
The announced line for a run that failed: its error, or nothing at all while the run is anything else.
Six workflow pages had written this paragraph by hand, and the one thing that
mattered about it was the part a reviewer cannot see is missing:
role="alert". A run fails minutes after the reader looked away — days, for
a scheduled one — and <Form> announces only the SUBMIT error, so without
the role a screen reader is never told the outcome it waited for arrived.
Three of the six also disagreed on the sentence (“That one failed”, “That run
failed”, the bare message), which is the drift a component ends.
Discriminated on status, so error is reachable without a cast — the
reason WorkflowRun is a union rather than a flat object with optional
fields.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { useWorkflowSubmit, WorkflowRunError } from "@alexkroman1/aai-ui";
function App() { const { run } = useWorkflowSubmit("digest"); return <WorkflowRunError run={run} />;}WorkflowRunPanel()
Section titled “WorkflowRunPanel()”WorkflowRunPanel<
O>(props):ReactNode
The bordered panel a workflow page shows one run in: a status line, a Clear button, the run’s own narration, the live slot while it works, the completed body once it has, and the announced error if it failed.
Two pages had written this shell — document-redline-workflow and transcription-workflow —
with the same header, the same text-xs underline Clear, the same
WorkflowProgress under it, the same run.status === "completed"
discrimination above the same WorkflowRunError, and each spread
WORKFLOW_STATUS_LABELS into a local map to change running. The
ORDER is the part worth owning: the narration is the complement of the status
line (a run is running for its whole life, so a one-round job and a
ten-round one look identical without it), and the error goes last because it
is the outcome the reader waited minutes for.
children is discriminated on the run for the caller, so the body reads
run.output typed, without a cast and without the page repeating the guard —
the reason WorkflowRun is a union rather than a flat object with
optional fields.
Type Parameters
Section titled “Type Parameters”O = unknown
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ReactNode
Example
Section titled “Example”import { useWorkflowRun, WorkflowRunPanel } from "@alexkroman1/aai-ui";
// `useWorkflowRun<R>` is where a page names the output's shape; a page that// started the run itself has it typed already, from `useWorkflowSubmit<D>`.function Panel({ runId, onClear }: { runId: string; onClear: () => void }) { const { run } = useWorkflowRun<{ draft: string }>(runId); if (!run) return null; return ( <WorkflowRunPanel run={run} statusLabels={{ running: "Writing…" }} onClear={onClear}> {(output) => <article className="whitespace-pre-wrap">{output.draft}</article>} </WorkflowRunPanel> );}Interfaces
Section titled “Interfaces”ClientConfigResponse
Section titled “ClientConfigResponse”Sealed
Parsed body of GET /client-config — the fields documented on
ClientConfigResponseSchema. Declared rather than inferred from the
schema, so zod’s types stay off the contract; the schema is typed against it.
Properties
Section titled “Properties”greeting?
Section titled “greeting?”
optionalgreeting?:string
The agent’s greeting.
optionalname?:string
Agent display name.
page:
"voice"|"static"
The front door: a voice page, or a static workflow-app page with no socket.
sessionToken?
Section titled “sessionToken?”
optionalsessionToken?:string
A ticket the session socket presents, when the server mints one.
sessionUrl?
Section titled “sessionUrl?”
optionalsessionUrl?:string
Absolute WebSocket URL of the live session endpoint, when the server brokers one.
tools?
Section titled “tools?”
optionaltools?:Record<string,ToolUi>
Each tool’s declared icon and label, by name — see ToolDef.ui.
ClientRun
Section titled “ClientRun”Sealed
One row of the list clientRunsRoutes answers — plain JSON, what a page renders a run from.
Properties
Section titled “Properties”createdAt
Section titled “createdAt”createdAt:
number
When the run was created, as epoch ms — the list is in this order, oldest first.
detail?
Section titled “detail?”
optionaldetail?:string
One line about it, when there is one: options.detail’s answer; else, for
a failed run, its error as spokenErrorReason says it (short, no URL, no
credential); else, for a running one, its newest progress line when that
is a string.
runId:
string
The run’s id — what the cancel route takes.
status
Section titled “status”status:
ClientRunStatus
Where it has got to; see ClientRunStatus.
title:
string
The run’s label (StartOptions.label), else its workflow’s key.
workflow
Section titled “workflow”workflow:
string
The workflow’s key in agent({ workflows }).
ClientRunsResponse
Section titled “ClientRunsResponse”Sealed
The body the list route answers.
Properties
Section titled “Properties”runs:
ClientRun[]
The client’s runs, oldest first.
ToolCallRowProps
Section titled “ToolCallRowProps”Input
Props for ToolCallRow.
Properties
Section titled “Properties”children?
Section titled “children?”
optionalchildren?:ReactNode
Expanded panel content. When present the row is expandable: a chevron is shown and clicking toggles the panel. When absent the row is inert (the button is disabled). Content provides its own padding and typography; the panel supplies the top border, surface background, and a max height.
className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the outer container.
detail?
Section titled “detail?”
optionaldetail?:ReactNode
One-line detail (typically an args preview), truncated to the row.
optionalicon?:ReactNode
Optional icon rendered in place of the outlined “TOOL” chip.
pending?
Section titled “pending?”
optionalpending?:boolean
True while the call is in flight — animates the title with a shimmer.
title:
ReactNode
Tool title, rendered in mono (shimmers while pending).
variant?
Section titled “variant?”
optionalvariant?:ToolCallRowVariant
Size preset; defaults to "default".
UseUserTranscriptResult
Section titled “UseUserTranscriptResult”Sealed
What useUserTranscript returns.
Properties
Section titled “Properties”partial
Section titled “partial”partial:
string|null
The raw partial: the words so far, "" while there are none, and null
when nobody is speaking. For a chrome that wants to render its own
placeholder (or none).
speaking
Section titled “speaking”speaking:
boolean
True while the caller holds the turn — from speech detection to the final transcript. This is the flag a live-transcript row renders on.
text:
string
The words so far, or a one-character ellipsis (…) while there are none.
Empty string when nobody is speaking.
Type Aliases
Section titled “Type Aliases”AgentClient
Section titled “AgentClient”AgentClient =
WorkflowApi&object
Sealed
Everything one agent answers: every WorkflowApi call, plus the front door.
An intersection rather than a redeclaration — the workflow half must not be describable twice.
Type Declaration
Section titled “Type Declaration”baseUrl
Section titled “baseUrl”
readonlybaseUrl:string
The agent’s base URL, normalized — no trailing slash.
Here because a caller that has this client should not also be threading the
string it was built from: a webhook to register, a link to print, a curl
to paste in a bug report all want it, and re-deriving it invites the
trailing-slash //workflows 404 this normalizes away.
config()
Section titled “config()”config():
Promise<ClientConfigResponse>
What the agent says it IS: { name?, greeting?, page?, sessionUrl? }.
The one read that works on EVERY agent, whatever shape it is, and the one a
caller starts with — page (absent reads as "voice") is how you know
whether there is a session to open at all, and sessionUrl is the current
one. Re-read it on every connect rather than storing it: on the platform
it names the agent’s sandbox, and that URL changes when the sandbox is
replaced by an idle reclaim or a redeploy.
Unauthenticated on a deployed agent, exactly like the page it describes — so
this call works with no token, and a workflow API closed by
AAI_WORKFLOW_API_TOKEN does not close it.
Returns
Section titled “Returns”Promise<ClientConfigResponse>
AgentCustomEvent
Section titled “AgentCustomEvent”AgentCustomEvent =
object
Sealed
A custom event emitted by the agent via ctx.send(event, data) — the
payload the session records in SessionSnapshot.customEvents (id is a
monotonic session-unique counter, event the name, data the payload).
Deliberately NOT the DOM CustomEvent: it shares nothing with that
interface, and the old name shadowed the global in .tsx files.
Properties
Section titled “Properties”
readonlydata:unknown
readonlyevent:string
readonlyid:number
AgentState
Section titled “AgentState”AgentState =
"disconnected"|"connecting"|"ready"|"listening"|"thinking"|"speaking"|"error"|string&object
Current state of the voice agent session — the state field of
SessionSnapshot, and what a chrome paints its status indicator from.
Remarks
Section titled “Remarks”The seven members, in the order a call passes through them:
"disconnected"— no socket. The state before the firststart()and afterdisconnect()/end()."connecting"— dialling. Covers the broker lookup and every automatic reconnect attempt, so a session flickers back through it mid-call."ready"— the socket is open and the handshake is done, but no turn has happened yet. The default chrome paints this with the same live indicator as"listening", which is deliberate — to a caller they are the same “the agent is there” — but they are not the same thing, and a session can wedge here (seesession/handshake.ts)."listening"— the microphone is open and the agent is waiting for the caller. Check SessionSnapshot.recording for whether the mic is actually live."thinking"— the caller’s turn is committed and the agent is working: the LLM step, and any tool calls under it."speaking"— the agent’s reply is playing. A caller may still barge in; the mic stays open throughout."error"— the session reported a failure. See SessionSnapshot.error for what it was. A FATAL error latches here until the next completed handshake, so a later frame cannot quietly paint over the banner explaining a dead call.
The set is OPEN (| (string & {})): a later release may add a state, so a
switch over it needs a default, and a lookup keyed by it needs a
fallback for a state it has no entry for.
AgentStateFrame
Section titled “AgentStateFrame”AgentStateFrame =
Readonly<Record<string,unknown>>
The agent’s projected state as the client holds it: one entry per slot the
agent’s syncState names, keyed by that slot’s name.
AudioResultCaptions
Section titled “AudioResultCaptions”AudioResultCaptions =
object
Input
A one-cue caption track for AudioResult: the words the clip speaks, spanning its whole length.
Properties
Section titled “Properties”durationMs
Section titled “durationMs”durationMs:
number
The clip’s length, which is where the cue ends.
label?
Section titled “label?”
optionallabel?:string
The track’s label. Defaults to the player’s label.
srcLang?
Section titled “srcLang?”
optionalsrcLang?:string
The track’s srcLang. Default "en".
text:
string
The spoken text — the one cue.
AudioResultProps
Section titled “AudioResultProps”AudioResultProps =
object
Input
Props of AudioResult.
Properties
Section titled “Properties”captions?
Section titled “captions?”
optionalcaptions?:AudioResultCaptions
The spoken text as a caption track. Omit it deliberately when the same words are rendered in full beside the player (see the component doc); pass it when they are not, or when a real track is what a page needs.
children?
Section titled “children?”
optionalchildren?:ReactNode
Rendered under the player — the spoken text, usually.
className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the wrapping <section>, appended to its own.
download
Section titled “download”download:
UseDownloadUrlResult
What useDownloadUrl returned for the run’s audio upload.
filename
Section titled “filename”filename:
string
The name the download link saves as — "summary.wav", "audit.mp3".
heading?
Section titled “heading?”
optionalheading?:ReactNode
The heading over the player — typically the duration and size, which the run’s output carries. Omitted, there is no heading.
label:
string
The player’s aria-label: what this audio IS — "Summary read aloud".
BrowserSession
Section titled “BrowserSession”BrowserSession =
object
Sealed
A framework-agnostic voice session that manages WebSocket communication, audio capture/playback, and agent state transitions.
Uses a subscribe/getSnapshot pattern (compatible with React’s
useSyncExternalStore). Implements Disposable for resource cleanup.
Only createBrowserSession produces one — see
browserSessionBrand. A test double for a component is a real
session with no socket, not an object literal.
Methods
Section titled “Methods”[dispose]()
Section titled “[dispose]()”[dispose]():
void
Alias for disconnect for use with using.
Returns
Section titled “Returns”void
cancel()
Section titled “cancel()”cancel():
void
Cancel the current agent turn and discard in-flight TTS audio.
Returns
Section titled “Returns”void
connect()
Section titled “connect()”connect(
options?):void
Open a WebSocket connection to the server and begin audio capture,
without touching the started/running flags — the low-level half of
start(). Most UIs call start() (first activation) or toggle()
(mute-style connect/disconnect) instead.
Parameters
Section titled “Parameters”options?
Section titled “options?”Optional. signal is an AbortSignal that, when aborted, disconnects the session.
signal?
Section titled “signal?”AbortSignal
Returns
Section titled “Returns”void
disconnect()
Section titled “disconnect()”disconnect():
void
Close the WebSocket and release all audio resources.
Returns
Section titled “Returns”void
end():
void
End the call: close the connection, clear the conversation, and return
to the not-started state (started flips back to false, so a
start-screen UI shows its Start control again). Unlike reset() —
which keeps the call live and only clears the conversation — the next
start() mints a brand-new session: a new session id, fresh
per-session tool state, greeting included.
Returns
Section titled “Returns”void
getSnapshot()
Section titled “getSnapshot()”getSnapshot():
SessionSnapshot
Return the current immutable state snapshot.
Returns
Section titled “Returns”reset()
Section titled “reset()”reset():
void
Reset the session: clear state as resetState() does, then drop and
reopen the connection for a fresh conversation.
Returns
Section titled “Returns”void
resetState()
Section titled “resetState()”resetState():
void
Clear messages, transcripts, and error state while keeping the current
connection (unlike reset(), which also reconnects).
Returns
Section titled “Returns”void
restart()
Section titled “restart()”restart():
void
End the current call and immediately begin a new one — end() then
start(), which is what “New Conversation” means for an agent that keeps
SESSION-SCOPED STATE.
reset() is the one whose name suggests this and it is not the same
thing: it clears the transcript and reconnects, but the reconnect carries
the same ?sessionId=, so every sessionSlot on the server survives —
the game world, the incident board, the cart. A caller who asked to start
over gets a blank transcript in front of the old state. This drops the
session id, so the next connect mints a fresh one and the greeting plays
again.
Three templates had each written session.end(); session.start(); with
the same paragraph explaining why reset() was wrong; the six on the
stock shell could not, because Controls called reset() for them.
Returns
Section titled “Returns”void
Example
Section titled “Example”declare const session: import("@alexkroman1/aai-ui").Session;session.restart();resume()
Section titled “resume()”resume(
sessionId):void
Continue an EARLIER session — one of this client’s past conversations, say,
picked from a list — without reloading the page: hang up the current call
if there is one, clear the transcript on screen, and connect presenting
sessionId as ?sessionId=, so the server resumes THAT session.
The server restores it the way a reload does: the session’s own turns come
back in history.restored (a session bound to a client keeps its events
after it ends), together with the client’s other prior sessions as seed —
never counting this one twice. The greeting is skipped, as on any resume.
The id is stored like a config frame’s, so a reload continues it too.
Throws a RangeError for an id the server would not honor (1-128 letters,
digits, - or _) BEFORE touching the current call. An id that names no
session the server has starts a fresh conversation under it, greeting
skipped — the same answer a stale bookmark gets.
Parameters
Section titled “Parameters”sessionId
Section titled “sessionId”string
Returns
Section titled “Returns”void
Example
Section titled “Example”declare const session: import("@alexkroman1/aai-ui").Session;declare const picked: { sessionId: string };session.resume(picked.sessionId);sendText()
Section titled “sendText()”sendText(
text,options?):void
Send a TYPED user turn: the agent answers text exactly as if the caller
had said it — aloud, with tools, under any turnTaking.detection.
Interrupts the agent if it is speaking or thinking (its queued audio is
discarded here at once, as cancel() does). The message is NOT echoed into
messages locally: the server reports it as the same
userTranscript.committed a spoken turn produces, and that is what adds
the row — so it is also what a resumed session replays.
text is trimmed; an empty message sends nothing, and so does a call
while disconnected — unless options.connect is set, which opens the
session (see SendTextOptions) and sends once it is up. Text longer than MAX_TRANSCRIPT_CHARS (100,000) is refused by
the server. A speech-to-speech agent cannot take a typed turn: the server
logs a warning once and the message is ignored.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”void
Example
Section titled “Example”declare const session: import("@alexkroman1/aai-ui").Session;session.sendText("What's the weather tomorrow?");sendToolResult()
Section titled “sendToolResult()”sendToolResult(
toolCallId,outcome):void
Answer a pending tool call the agent asked THIS PAGE to run — a
clientTool on the server. result is JSON-serialized and becomes the
value the model reads; error fails the call with that message. A no-op
while disconnected, and ignored by the server for a call nothing waits on.
Most pages use useClientTool(name, handler), which calls this for them.
Parameters
Section titled “Parameters”toolCallId
Section titled “toolCallId”string
outcome
Section titled “outcome”Returns
Section titled “Returns”void
setMicMuted()
Section titled “setMicMuted()”setMicMuted(
muted):void
Mute or unmute the microphone WITHOUT dropping the session — the gate a hold-to-talk button over an agent with automatic turn detection is built on (mute by default, unmute while held).
While muted the session keeps streaming frames of the same length and
cadence, zero-filled, so the server’s transcriber hears silence: its
automatic endpointing closes the turn on release, and the stream’s clock
stays true. Purely local — no command is sent and a speaking agent is not
interrupted (call cancel() for that).
UI state rather than connection state: it survives reconnects, resumes,
disconnect() → connect() and end(), and may be set before the first
connect() so a session opens already muted. Read it back as
micMuted on the snapshot.
Parameters
Section titled “Parameters”boolean
Returns
Section titled “Returns”void
Example
Section titled “Example”declare const session: import("@alexkroman1/aai-ui").Session;session.setMicMuted(true); // typed-only until the talk button is heldstart()
Section titled “start()”start():
void
Start the session for the first time: sets started and running, then
connects. Use this for the initial “start conversation” action;
afterwards toggle() is the pause/resume control.
Returns
Section titled “Returns”void
subscribe()
Section titled “subscribe()”subscribe(
callback): () =>void
Subscribe to state changes. Returns an unsubscribe function.
Parameters
Section titled “Parameters”callback
Section titled “callback”() => void
Returns
Section titled “Returns”() => void
toggle()
Section titled “toggle()”toggle():
void
Toggle between connected and disconnected states (after start()).
Returns
Section titled “Returns”void
Properties
Section titled “Properties”[browserSessionBrand]
Section titled “[browserSessionBrand]”
readonly[browserSessionBrand]:true
The seal — see browserSessionBrand.
identity
Section titled “identity”
readonlyidentity:SessionIdentity
Who this session is to its agent — the base URL it dials, the client id it
sends, this tab’s inbox holder id, and the server’s session id. What
useInbox(), useClientId() and useSessionId() read; see
SessionIdentity.
userTurn
Section titled “userTurn”
readonlyuserTurn:UserTurnControls
Push-to-talk’s three edges — see UserTurnControls.
browserSessionBrand
Section titled “browserSessionBrand”browserSessionBrand = typeof
browserSessionBrand
The seal on a BrowserSession.
TYPE-ONLY — there is no value at run time, so an object literal cannot carry
the key and only createBrowserSession mints a session. That is what lets
the handle grow a member without breaking anybody: push-to-talk added three
REQUIRED methods and broke every hand-written double, which is the failure a
handle a caller RECEIVES should not be able to cause.
BulletListProps
Section titled “BulletListProps”BulletListProps =
object
Input
Props for BulletList.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
ADDED to the list’s own classes rather than replacing them. There is no
tailwind-merge in this package, so a class that CONFLICTS with a base one
is not reliably the winner — use this for additions, not overrides.
items: readonly
string[]
The bullets, in the order they should read.
TEXT rather than ReactNode, deliberately: every list this replaced was a
string array straight off a run’s output, and taking strings is what lets
this component key them (see the component doc). A page that needs a link
inside a bullet wants its own <ul>, not a prop here.
optionalsize?:"sm"|"base"
"sm" adds text-sm, which two of the five copies carried and three did
not. "base" is the default and adds nothing.
title?
Section titled “title?”
optionaltitle?:ReactNode
Rendered as a heading above the list, inside a wrapping <section>.
Omitted (or null/false, so title={cond && "Risks"} means what it
looks like) renders the bare <ul> with no wrapper — which is what four of
the five lists this replaced were.
ButtonSize
Section titled “ButtonSize”ButtonSize =
"default"|"lg"
Size preset for a Button.
"default"— Compact control (height 36 px)."lg"— Primary CTA (height 44 px, generous padding).
ButtonVariant
Section titled “ButtonVariant”ButtonVariant =
"default"|"secondary"|"ghost"
Visual style of a Button (design-system “website refresh”: rectangular, ALL-CAPS, tracked labels).
"default"— Primary filled button (indigo background)."secondary"— Outlined primary (transparent background, primary border)."ghost"— Raised neutral (surface background with hairline border).
ChatMessage
Section titled “ChatMessage”ChatMessage =
object
Sealed
A chat message exchanged between user and assistant.
role is "user" | "assistant" only — unlike the SDK’s Message, there
is no "tool" role here. Tool activity never arrives as messages: it is
surfaced via SessionSnapshot.toolCalls (or useEvent for ctx.send
events).
Properties
Section titled “Properties”content
Section titled “content”content:
string
The text content of the message.
id:
number
Monotonically increasing, session-unique message id assigned at append time. Stable across snapshot updates and window slides — use it as a render key.
role:
"user"|"assistant"|string&object
The sender of the message. Open (| (string & {})) so a later sender is
not a break: render anything that is not "user" the way you render
"assistant", or skip it.
ClientConfig
Section titled “ClientConfig”ClientConfig =
Pick<VoiceSessionOptions,"onSessionId"|"resumeSessionId"|"location"|"phone"|"client"|"token"|"WebSocket"> &object
Input
Configuration passed to mountClient.
The session-forwarded fields are picked from VoiceSessionOptions
(one source of truth for types and docs) rather than re-declared — a
re-declared copy is exactly how doc comments drift. It is NOT the session’s
own options type: that is VoiceSessionOptions, which
createBrowserSession takes and which three of these fields come from.
Type Declaration
Section titled “Type Declaration”buttonText?
Section titled “buttonText?”
optionalbuttonText?:string
Label of the start CTA. Defaults to "Start Conversation".
component?
Section titled “component?”
optionalcomponent?:ComponentType
Full custom component to render instead of the default shell.
It is rendered inside the same providers the default shell gets, so every
session hook, useTheme and the tool display config work in it unchanged.
optionalcss?:string
A stylesheet for the PAGE, injected into <head> for the life of the
mount and removed on dispose.
For the rules a custom chrome has that no component prop reaches — a
@keyframes, a scrollbar palette, a font face — which otherwise meant a
<style> element written into index.html beside the mount, in the one
file the scaffold’s CSP is strict about. The five theme tokens are already
on :root as --aai-* when these rules run, so a sheet may use them.
optionalicon?:ReactNode
Element rendered in place of the AAI logo — on the start card, and in the shell header once the session begins.
Both, because they are one mark: an agent whose start screen shows a slice of pizza and whose header shows our logo reads as two products.
platformUrl?
Section titled “platformUrl?”
optionalplatformUrl?:string
Base URL of the AAI platform server. Derived from location.href by default.
sidebar?
Section titled “sidebar?”
optionalsidebar?:ComponentType
Optional sidebar component rendered alongside the main pane.
Beside a component it is the custom component that becomes the main pane,
in the same SidebarLayout the default shell uses.
sidebarPosition?
Section titled “sidebarPosition?”
optionalsidebarPosition?:"left"|"right"
Which side the sidebar sits on. Defaults to "left".
Routed through the same SidebarLayout whether the main pane is the
default shell or a component, for the reason sidebar itself is: the two
branches build the same layout and a field honoured by only one of them is
the shape this config used to have.
sidebarWidth?
Section titled “sidebarWidth?”
optionalsidebarWidth?:string
CSS width of the sidebar. Defaults to "18rem".
subtitle?
Section titled “subtitle?”
optionalsubtitle?:string
A line under the title on the start card.
target?
Section titled “target?”
optionaltarget?:string|HTMLElement
CSS selector or DOM element to render into. Defaults to "#app".
theme?
Section titled “theme?”
optionaltheme?:ClientTheme
Theme colors — the five tokens, or { primary, mode: "dark" | "light" }
with the rest derived. See ClientTheme.
Remarks
Section titled “Remarks”One flat type, not a union of tiers. component is what decides which
shell renders — absent, the default one (StartScreen + ChatView, optional
sidebar); present, the caller’s own component inside the same providers —
and that decision is made at runtime, where every field can be honoured. It
used to be a union whose two arms banned each other’s fields with ?: never,
and the failure that shape produces is recorded in this file’s history:
sidebar beside a component was the natural thing to write, was refused
with “Type ‘…’ is not assignable to type ‘undefined’”, and cost a build
round each time, for a combination mountClient can simply render.
The agent’s name and each tool’s icon and label are not options here: both
come from the agent’s own GET /client-config body — agent({ name }) and
tool({ ui }) — which the mount looks up, so a page never restates what the
agent already declared.
ClientHandle
Section titled “ClientHandle”ClientHandle =
object
Sealed
Handle returned by mountClient for cleanup.
Implements Disposable so it can be used with using.
Methods
Section titled “Methods”[dispose]()
Section titled “[dispose]()”[dispose]():
void
Alias for dispose for use with using.
Returns
Section titled “Returns”void
dispose()
Section titled “dispose()”dispose():
void
Unmount the UI and disconnect the session.
Returns
Section titled “Returns”void
Properties
Section titled “Properties”session
Section titled “session”session:
BrowserSession
The underlying session core.
ClientRunStatus
Section titled “ClientRunStatus”ClientRunStatus =
"waiting"|"running"|"completed"|"failed"|"cancelled"|string&object
A ClientRun’s status: a run’s own, with pending said as
waiting — the word a person reads for “queued, not started”.
ClientTheme
Section titled “ClientTheme”ClientTheme =
ClientThemeTokens&object|ClientThemePreset&object
Input
Theme color overrides for the AAI UI components: the five tokens (ClientThemeTokens), or an accent plus a light/dark mode (ClientThemePreset). Either resolves to a ResolvedClientTheme.
The two forms are EXCLUSIVE: a theme naming mode may name no token but
primary, and a theme naming a token may not name mode. Without the
guards a mixed { primary, mode: "dark", bg } compiled (an object literal is
checked against the union as a whole, so a key from either member passed)
and bg was silently dropped — the preset’s derived neutrals won. The guard
is ?: undefined rather than ?: never so an options bag that spells a hole
as undefined still fits. { primary } alone is both forms and resolves
alike.
ClientThemePreset
Section titled “ClientThemePreset”ClientThemePreset =
object
Input
The short form of ClientTheme: one accent and a light or dark
ground, from which bg, surface, text and border are DERIVED.
Most pages that themed at all set primary and nothing else — and the few
that wanted a dark page had to look up four neutrals that go together.
{ primary: "#e55", mode: "dark" } is that lookup done once
(resolveClientTheme, _theme.ts); mode defaults to "light", which is
the default palette, so { primary } means the same thing in both forms.
Properties
Section titled “Properties”
optionalmode?:"dark"|"light"
The ground the neutrals are derived for. Default: "light".
primary
Section titled “primary”primary:
string
Primary accent color.
ClientThemeTokens
Section titled “ClientThemeTokens”ClientThemeTokens =
object
Input
The five theme tokens, each optional — the long form of ClientTheme, for a page that names its own ground and surfaces.
Properties
Section titled “Properties”
optionalbg?:string
Background color, also painted on html/body. Default: #FAFAFA.
border?
Section titled “border?”
optionalborder?:string
Border color. Default: #E4E4E7.
primary?
Section titled “primary?”
optionalprimary?:string
Primary accent color. Default: #18181B.
surface?
Section titled “surface?”
optionalsurface?:string
Surface/card color. Default: #FFFFFF.
optionaltext?:string
Main text color. Default: #18181B.
ConsoleShellProps
Section titled “ConsoleShellProps”ConsoleShellProps =
object
Input
Props of ConsoleShell.
Properties
Section titled “Properties”children
Section titled “children”children:
ReactNode
Card content — normally a MessageList.
className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the root element, appended to its own.
footer
Section titled “footer”footer:
ReactNode
Row rendered beneath the card (controls).
optionalicon?:ReactNode
Element rendered before the title in the header. None by default.
pulsing
Section titled “pulsing”pulsing:
boolean
Whether the status dot pulses.
state:
AgentState
Live status shown in the header eyebrow.
title?
Section titled “title?”
optionaltitle?:string
Title string for the header.
ControlsProps
Section titled “ControlsProps”ControlsProps =
object
Input
Props of Controls.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names, appended to the container’s own layout classes rather than replacing them.
ConversationItem
Section titled “ConversationItem”ConversationItem = {
kind:"message";message:ChatMessage; } | {kind:"tool";toolCall:ToolCallInfo; } | {kind:"other";type:string; }
One row of the conversation: a finalized message, or a tool invocation.
A discriminated union rather than two arrays, because the ORDER between them
is the thing this hook computes — handing back two lists would hand back the
problem. kind is what a switch in a custom renderer narrows on.
"other" is the row this version cannot describe — today, an item a newer
page wrote into a stored conversation log (type is the kind it was
stored under). It is a member from the start so that a row kind added
later is a value an existing switch already has a branch for, not a
compile error: render nothing for it, or a placeholder.
ConversationLogEntry
Section titled “ConversationLogEntry”ConversationLogEntry = {
at:number;clientId?:string;items: readonlyConversationItem[];kind:"session";run:number;sessionId:string; } | {at:number;kind:"note";text:string; } | {at:number;kind:"spoken";text:string; }
One entry of a useConversationLog transcript.
session— a stretch of one server session’s conversation.runtells apart two conversations the server held under one id (a retired session that came back empty);clientIdis whose it was — this browser’s own or a linked device’s, since only the page’s CURRENT client can resume one. Tool calls are stored without theirresult, which is the agent’s to keep and can be large.note— a line the page logged itself (addNote): “New session”.spoken— what the agent said on its own, outside a session (a reminder read aloud): rendered as an assistant turn.
Union Members
Section titled “Union Members”Type Literal
Section titled “Type Literal”{ at: number; clientId?: string; items: readonly ConversationItem[]; kind: "session"; run: number; sessionId: string; }
readonlyat:number
When the entry was started, epoch ms.
clientId?
Section titled “clientId?”
readonlyoptionalclientId?:string
readonlyitems: readonlyConversationItem[]
readonlykind:"session"
readonlyrun:number
sessionId
Section titled “sessionId”
readonlysessionId:string
Type Literal
Section titled “Type Literal”{ at: number; kind: "note"; text: string; }
Type Literal
Section titled “Type Literal”{ at: number; kind: "spoken"; text: string; }
ConversationViewProps
Section titled “ConversationViewProps”ConversationViewProps =
object
Input
Props of ConversationView.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
Classes for the AutoScroll container. It must end up with a
bounded height (flex-1 min-h-0, h-full) or nothing pins.
contentClassName?
Section titled “contentClassName?”
optionalcontentClassName?:string
Classes for the scroll region’s content element — padding, gap, direction.
empty?
Section titled “empty?”
optionalempty?:ReactNode
Rendered inside the scroll region while there is nothing to show at all.
optionallog?: readonlyConversationLogEntry[]
A persisted transcript to render IN PLACE of the live session’s items —
useConversationLog().entries, whose newest session entry already is the
live conversation. Session entries render through renderMessage /
renderTool, each after renderSessionHeader; a spoken entry is
renderMessage over an assistant message (id -2); a note is
renderNote. The streaming reply, the transcript and the thinking row are
rendered after it as usual.
renderMessage
Section titled “renderMessage”renderMessage: (
message) =>ReactNode
One finalized message, in this chrome’s own markup.
Parameters
Section titled “Parameters”message
Section titled “message”Returns
Section titled “Returns”ReactNode
renderNote?
Section titled “renderNote?”
optionalrenderNote?: (entry) =>ReactNode
A log note. Absent, a small centred muted line.
Parameters
Section titled “Parameters”Extract<ConversationLogEntry, { kind: "note"; }>
Returns
Section titled “Returns”ReactNode
renderSessionHeader?
Section titled “renderSessionHeader?”
optionalrenderSessionHeader?: (entry) =>ReactNode
What goes above each log session entry — a timestamp, a “continue”
button. Absent, nothing.
Parameters
Section titled “Parameters”Extract<ConversationLogEntry, { kind: "session"; }>
Returns
Section titled “Returns”ReactNode
renderStreaming?
Section titled “renderStreaming?”
optionalrenderStreaming?: (text) =>ReactNode
The agent’s reply as it arrives. Absent, renderMessage is called with a
synthetic assistant message carrying the text so far (its id is -1,
which no real message has) — every chrome so far rendered the two the same
way, and this keeps them from drifting.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”ReactNode
renderTool?
Section titled “renderTool?”
optionalrenderTool?: (toolCall) =>ReactNode
One tool invocation. Absent, a compact ToolCallRow naming the tool, shimmering while it is pending.
Parameters
Section titled “Parameters”toolCall
Section titled “toolCall”Returns
Section titled “Returns”ReactNode
renderTranscript?
Section titled “renderTranscript?”
optionalrenderTranscript?: (transcript) =>ReactNode
The caller’s in-progress turn. Rendered only while transcript.speaking,
which is the null-vs-"" distinction useUserTranscript makes
("" is speech detected with no words yet — render on it, and read
transcript.text for the placeholder). Absent, a muted italic line.
Parameters
Section titled “Parameters”transcript
Section titled “transcript”Returns
Section titled “Returns”ReactNode
scrollClassName?
Section titled “scrollClassName?”
optionalscrollClassName?:string
Classes for the scrolling element itself. See AutoScroll.
style?
Section titled “style?”
optionalstyle?:CSSProperties
Inline styles for the scroll container.
thinkingClassName?
Section titled “thinkingClassName?”
optionalthinkingClassName?:string
CSS class names for the thinking row itself (the role="status" element).
thinkingIndicator?
Section titled “thinkingIndicator?”
optionalthinkingIndicator?:ReactNode
What the thinking row shows. Default: three pulsing dots.
thinkingLabel?
Section titled “thinkingLabel?”
optionalthinkingLabel?:string
The aria-label of the thinking row. Default "Thinking". Say who: the
dots are the only sign the agent is working, and to a screen reader they
are punctuation.
transcriptPosition?
Section titled “transcriptPosition?”
optionaltranscriptPosition?:"inline"|"below"
Where the transcript row goes. "inline" (the default) is the last row
inside the scroll region, as MessageList places it; "below" renders it
after the scroll region as a sibling — the strip a two-pane board pins to
the bottom of its conversation column, outside the scroll.
CreateInboxOptions
Section titled “CreateInboxOptions”CreateInboxOptions =
object
Input
Options for createInbox.
Properties
Section titled “Properties”
optionalbusy?: () =>boolean
Asked when a new notice arrives: true answers busy and the step sends it
again later. Default: never busy. Not asked for a repeat, which is acked.
Returns
Section titled “Returns”boolean
client
Section titled “client”client:
string| (() =>string|undefined)
This client’s id, as the session sends it: a string, or a getter asked on
every connection attempt. An answer that is not a valid client id (letters,
digits, -, _, at most 64) opens no socket that attempt.
events?
Section titled “events?”
optionalevents?:boolean
Ask for the client’s live conversation (?events=1). Default: whether an
onEvent was given.
holder
Section titled “holder”holder:
string
This page’s holder id among the client’s holders — per TAB (see
session.identity.holderId()). Same rule as a client id.
onEvent?
Section titled “onEvent?”
optionalonEvent?: (event) =>void
A frame of the client’s live conversation — only with events.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”void
onNotice?
Section titled “onNotice?”
optionalonNotice?: (notice) =>void
A notice arrived whole — once per delivery id; a repeat is acked, not delivered.
Parameters
Section titled “Parameters”notice
Section titled “notice”Returns
Section titled “Returns”void
platformUrl
Section titled “platformUrl”platformUrl:
string
The agent’s base URL — the socket is <platformUrl>/inbox.
token?
Section titled “token?”
optionaltoken?:VoiceSessionOptions["token"]
The session ticket to present, for a server that requires one — the same
option, with the same rules, as VoiceSessionOptions.token: a string, or a
getter asked on EVERY connection attempt (told sessionId: undefined; the
inbox resumes no session). A getter that throws or rejects presents none.
useInbox() fills it in from the session.
WebSocket?
Section titled “WebSocket?”
optionalWebSocket?:WebSocketConstructor
WebSocket constructor override, for tests. Default: the page’s WebSocket.
DictationTranscriberOptions
Section titled “DictationTranscriberOptions”DictationTranscriberOptions =
object
Input
Options for dictationTranscriber.
Properties
Section titled “Properties”fetch?
Section titled “fetch?”
optionalfetch?: typeoffetch
The transport. Defaults to the global fetch.
headers?
Section titled “headers?”
optionalheaders?:Record<string,string> | (() =>Record<string,string>)
Headers sent with each clip — an authorization for a route behind a
bearer. A FUNCTION is read per request, so a token that rotates is never
sent stale.
FactsProps
Section titled “FactsProps”FactsProps =
object
Input
Props for Facts.
Properties
Section titled “Properties”
optionalas?:"p"|"span"
The element to render. "p" by default; "span" for a line that sits
inside phrasing content, where a <p> is invalid nesting the browser will
reparent out from under React.
className?
Section titled “className?”
optionalclassName?:string
ADDED to the base classes rather than replacing them — tabular-nums,
uppercase tracking-[1.2px]. There is no tailwind-merge in this package,
so a class that CONFLICTS with a base one is not reliably the winner.
items: readonly (
string|number|false|null|undefined)[]
The facts, in reading order. Anything false, null, undefined or the
empty string is DROPPED, so a page writes cond && \${n} skipped`` inline
instead of splicing a separator into a conditional string.
0 is NOT dropped — it is a fact (“0 words”), and treating it as absent is
the bug a plain truthiness filter would ship.
They are TEXT rather than ReactNode: every one of the nine lines this
replaced was a string, and taking strings is what lets this JOIN them (see
the component doc) instead of interleaving keyed separator elements.
optionalsize?:"sm"|"xs"
Which of the two muted typographies the pages use. "sm" is
text-sm opacity-70, "xs" is text-xs opacity-60 — the size and the
muting move together, because that is the pair every site had.
FetchClientConfigOptions
Section titled “FetchClientConfigOptions”FetchClientConfigOptions =
object
Input
Options for fetchClientConfig.
Properties
Section titled “Properties”fetch?
Section titled “fetch?”
optionalfetch?: typeofglobalThis.fetch
Fetch implementation, for tests and for a caller that supplies its own
credentials. Defaults to the global fetch.
platformUrl?
Section titled “platformUrl?”
optionalplatformUrl?:string
The agent’s base URL. Defaults to the page’s own origin and path, which is the agent that served the page and the only case a browser has. Pass one for a page reading a DIFFERENT agent; the endpoint is resolved relative to it.
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
Abandons the lookup — a component that unmounts before the answer, say. An abandoned lookup resolves to the agent default like every other one that produced no answer; the built-in 10 s deadline applies either way.
FieldShell
Section titled “FieldShell”FieldShell =
object
Input
The props every field in form.tsx shares.
Public because it is part of each field’s own signature — a type reachable from a documented one has to be reachable from the entry point too, which the docs build enforces.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the field’s WRAPPER (label + control +
hint), appended to its own layout classes. The control itself takes the
shared field styling; pass style or a data- hook through the native
attributes to reach it.
optionalhint?:string
One line of guidance under the control.
label?
Section titled “label?”
optionallabel?:string
Visible label. Omitted leaves the control unlabelled — pass aria-label instead.
name:
string
Key this field contributes to FormValues.
FileReadMode
Section titled “FileReadMode”FileReadMode =
"none"|"text"|"dataUrl"|"upload"
How much of a chosen file a FileField reads.
"upload" is the odd one and the one a workflow input wants: the field
contributes the File ITSELF rather than a description of it, and
useWorkflowSubmit then stores it through POST /workflows/uploads and puts
the id in the run input. Bytes cannot travel in a run input — see
FileField — so this is how a form takes a file at all.
FileValue
Section titled “FileValue”FileValue =
object
Sealed
What a FileField contributes to FormValues.
FormValues is Record<string, unknown>, so a page narrows the field’s
value with isFileValue rather than casting it.
Properties
Section titled “Properties”content?
Section titled “content?”
optionalcontent?:string
The file’s contents, present only when the field asked for them — see
FileField’s read prop. A data: URL for "dataUrl", decoded text
for "text".
lastModified
Section titled “lastModified”lastModified:
number
Last modified, as epoch ms.
name:
string
size:
number
Size in bytes.
type:
string
MIME type the browser reported, or "" when it could not tell.
FileValueShape
Section titled “FileValueShape”FileValueShape =
"file"|"namedText"
The SHAPE a FileField contributes a read file as. "file" (the
default) is a FileValue; "namedText" is a NamedTextValue,
which implies read="text". Only "namedText" is a reshape today; the
option exists so a workflow’s object property has a declared control.
FormProps
Section titled “FormProps”FormProps =
object&Omit<FormHTMLAttributes<HTMLFormElement>,"onSubmit"|"className">
Input
Props of Form.
Type Declaration
Section titled “Type Declaration”children?
Section titled “children?”
optionalchildren?:ReactNode
className?
Section titled “className?”
optionalclassName?:string
error?
Section titled “error?”
optionalerror?:string
A failure to show above the fields. The caller owns it, because the
interesting failures are the server’s (useWorkflowSubmit’s error) and
this component never sees them.
onSubmit
Section titled “onSubmit”(
values) =>void|Promise<void>
Called with the collected values. May be async — the form stays disabled for the duration, so a double-click cannot submit twice.
FormValues
Section titled “FormValues”FormValues =
Record<string,unknown>
One submitted form, as a plain object keyed by field name.
unknown values rather than string: see the module doc — a number field
yields a number and a file field yields a FileValue.
Inbox =
object
Sealed
A held inbox socket — see createInbox.
Only createInbox produces one.
Methods
Section titled “Methods”close()
Section titled “close()”close():
void
Close the socket for good and stop reconnecting.
Returns
Section titled “Returns”void
connected()
Section titled “connected()”connected():
boolean
Whether the socket is open now.
Returns
Section titled “Returns”boolean
subscribe()
Section titled “subscribe()”subscribe(
callback): () =>void
Called whenever connected() changes. Returns the unsubscribe.
Parameters
Section titled “Parameters”callback
Section titled “callback”() => void
Returns
Section titled “Returns”() => void
InboxEvent
Section titled “InboxEvent”InboxEvent = {
event:object&Readonly<Record<string,unknown>>;sessionId:string;type:"session_event"; } | {sessionId:string;type:"session_ended"; } | {frameType:string;type:"other"; }
One frame of the client’s live conversation, sent to a holder that asked
for events: every session bound to the client, this tab’s own included —
filter on sessionId (useSessionId()) to show only the OTHER ones.
"other" is a frame this version does not know (frameType is the wire
type it arrived with) — a member from the start so that a frame the server
adds later reaches an existing switch’s default, not a compile error.
Ignore it.
Union Members
Section titled “Union Members”Type Literal
Section titled “Type Literal”{ event: object & Readonly<Record<string, unknown>>; sessionId: string; type: "session_event"; }
readonlyevent:object&Readonly<Record<string,unknown>>
The session event as the session socket would carry it (type, then its fields).
Type Declaration
Section titled “Type Declaration”
readonlytype:string
sessionId
Section titled “sessionId”
readonlysessionId:string
readonlytype:"session_event"
Type Literal
Section titled “Type Literal”{ sessionId: string; type: "session_ended"; }
Type Literal
Section titled “Type Literal”{ frameType: string; type: "other"; }
InboxNotice
Section titled “InboxNotice”InboxNotice =
object
Sealed
One notice from the agent — what a workflow step sent with
stepNotifyClient, e.g. a reminder coming due.
Properties
Section titled “Properties”
readonlyoptionaldata?:Readonly<Record<string,unknown>>
The step’s data, when it sent an object.
readonlyevent:string
What the notice is, as the step named it — "reminder", say.
readonlyid:string
Identifies the DELIVERY (usually the run id); a repeat carries the same one.
readonlypcm:Uint8Array
The notice’s audio as sent: stepSpeak’s PCM16LE mono, which useInbox’s
built-in playback plays at 16 kHz. Empty for a notice with no audio.
LinkedClient
Section titled “LinkedClient”LinkedClient =
object
Sealed
Which client a page is — see createLinkedClient.
Methods
Section titled “Methods”clear()
Section titled “clear()”clear():
void
Unlink: back to this browser’s own id.
Returns
Section titled “Returns”void
id():
string
The client this page is now: the linked one, else this browser’s own.
Returns
Section titled “Returns”string
linked()
Section titled “linked()”linked():
string|undefined
The linked client, or undefined while unlinked.
Returns
Section titled “Returns”string | undefined
own():
string
This browser’s own id, linked or not — what it asks for a link code AS.
Returns
Section titled “Returns”string
set(
id):void
Link to id (undefined unlinks). An invalid id unlinks too. Takes effect
on the next read — a mounted session’s next connection attempt, since
mountClient({ client }) asks per attempt; reload to move the inbox too.
Parameters
Section titled “Parameters”string | undefined
Returns
Section titled “Returns”void
LinkedClientOptions
Section titled “LinkedClientOptions”LinkedClientOptions =
object
Input
Options for createLinkedClient.
Properties
Section titled “Properties”fallback?
Section titled “fallback?”
optionalfallback?: () =>string
This browser’s own id, used while unlinked. Default: browserClientId for the page’s agent — pass your own to carry over an id minted before.
Returns
Section titled “Returns”string
key:
string
The localStorage key the link is kept under, used as given — choose one that is yours.
MarkdownProps
Section titled “MarkdownProps”MarkdownProps =
object
Input
Props of Markdown.
Properties
Section titled “Properties”text:
string
The Markdown source. Required — this is the prose to render, normally one agent message or the streaming tail of one.
variant?
Section titled “variant?”
optionalvariant?:MarkdownVariant
Type scale. Defaults to "default", the deployed agent UI’s scale; pass
"compact" for a denser surface. Colors are unaffected either way.
MarkdownVariant
Section titled “MarkdownVariant”MarkdownVariant =
"default"|"compact"
Type scale for Markdown: "default" is the deployed agent UI’s
scale, "compact" a notch smaller for denser surfaces (the studio’s chat
transcript). Colors are unaffected — they come from the theme either way.
MessageListProps
Section titled “MessageListProps”MessageListProps =
object
Input
Props of MessageList.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the outer scroll container, appended to its own rather than replacing them.
The container is an AutoScroll, so it must end up with a BOUNDED
height (flex-1 min-h-0, h-full, a fixed height). Unbounded, it grows
with the conversation and never scrolls, so nothing pins to the newest
message.
optionallog?: readonlyConversationLogEntry[]
A persisted transcript to show in place of the live session’s items —
useConversationLog().entries, rendered as ConversationView renders it.
NamedTextValue
Section titled “NamedTextValue”NamedTextValue =
object
Sealed
What a <FileField read="text" as="namedText"> contributes: the file’s name
and its text, and nothing else — the shape a workflow schema declares as
z.object({ name: z.string(), text: z.string() }) for a draft, a config, a
CSV of ids that belongs in the run’s input rather than in an upload. A
FileValue carries the browser’s metadata too, which a schema then
has to strip or refuse; this is the value a schema can take as it is.
Properties
Section titled “Properties”name:
string
text:
string
PageConfig
Section titled “PageConfig”PageConfig =
object
Input
Configuration for mountPage.
Properties
Section titled “Properties”component?
Section titled “component?”
optionalcomponent?:ComponentType
The root component, rendered instead of the generated shell.
Optional, the way mountClient()’s is: leave it out and mountPage()
renders a form per declared workflow, the run’s progress, its failure and
its output — see _page-shell.tsx for what that shell is composed of and
why it is deliberately functional rather than designed. It was REQUIRED,
because “a workflow app has no default shell to fall back to” — true of a
session and false of the page, and it cost the six shipped workflow
templates 220-511 lines each of the same composition.
optionalname?:string
Page title. Set only when given, so a title the HTML shell declared is never
clobbered — the same rule mountClient()’s custom-component tier follows.
target?
Section titled “target?”
optionaltarget?:string|HTMLElement
CSS selector or DOM element to render into. Defaults to "#app".
theme?
Section titled “theme?”
optionaltheme?:ClientTheme
Theme color overrides, read by the same tokens the voice components use.
PageHandle
Section titled “PageHandle”PageHandle =
object
Sealed
Handle returned by mountPage. Disposable, so using works.
Methods
Section titled “Methods”[dispose]()
Section titled “[dispose]()”[dispose]():
void
Alias for dispose for use with using.
Returns
Section titled “Returns”void
dispose()
Section titled “dispose()”dispose():
void
Unmount the React tree.
Returns
Section titled “Returns”void
PhoneE164Options
Section titled “PhoneE164Options”PhoneE164Options =
object
Input
Options for phoneE164.
Properties
Section titled “Properties”countryCode?
Section titled “countryCode?”
optionalcountryCode?:string
The country calling code to assume for a number typed WITHOUT one — "1"
for the US and Canada, "44" for the UK. Without it, only a number that
already starts with + (or the 00 international prefix) is accepted.
With "1", a 10-digit number takes +1, and an 11-digit one starting
with 1 is taken as already carrying it. With any other code, one leading
trunk 0 is dropped before the code is prepended (020 7946 0958 with
"44" is +442079460958).
ResolvedClientTheme
Section titled “ResolvedClientTheme”ResolvedClientTheme =
object
Sealed
The theme a component reads back from useTheme(): every
ClientTheme field, filled with its default where the client set
none.
Its own type rather than Required<ClientTheme>, because the two travel in
opposite directions — an author BUILDS a ClientTheme and only READS this —
so a token added later can be optional on one and present on the other.
Properties
Section titled “Properties”
readonlybg:string
Background color, also painted on html/body.
border
Section titled “border”
readonlyborder:string
Border color.
primary
Section titled “primary”
readonlyprimary:string
Primary accent color.
surface
Section titled “surface”
readonlysurface:string
Surface/card color.
readonlytext:string
Main text color.
RouteFetchOptions
Section titled “RouteFetchOptions”RouteFetchOptions =
object
Input
Options for routeFetch.
Properties
Section titled “Properties”baseUrl?
Section titled “baseUrl?”
optionalbaseUrl?:string
The agent’s base URL. Default: the page’s own directory.
client?
Section titled “client?”
optionalclient?:string
The client the call is about, sent as ?client= — the route’s
req.clientId. useClientId() is the session’s; absent or empty sends
none.
headers?
Section titled “headers?”
optionalheaders?:Readonly<Record<string,string>>
Extra request headers — an authorization for a route behind
apiKeyRoute, say. Names are case-insensitive, so a content-type (or
Content-Type) here beats the JSON one a body would set.
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
Abort the request.
RouteMethod
Section titled “RouteMethod”RouteMethod =
"GET"|"POST"|"PUT"|"PATCH"|"DELETE"
The HTTP methods an agent({ routes }) key can declare.
RouteMutationRunOptions
Section titled “RouteMutationRunOptions”RouteMutationRunOptions =
object
Input
Options for one UseRouteMutationResult.run call.
Properties
Section titled “Properties”
optionalkey?:string
What UseRouteMutationResult.busy names while this write is in
flight — the field being saved, the row being deleted. Default:
"<METHOD> <path>".
SendTextOptions
Section titled “SendTextOptions”SendTextOptions =
object
Input
Options for BrowserSession.sendText.
Properties
Section titled “Properties”connect?
Section titled “connect?”
optionalconnect?:boolean
Open the session for this message if it is not up: start() before the
first call, toggle() after a hang-up (so a resumable session RESUMES),
then send once the server has configured it. Messages typed meanwhile are
kept in order. They are DROPPED if the session stops before it is up — a
hang-up, end(), a failed connection — so a message typed into a call
that never came is not answered by a later one. Default false: sending
while disconnected does nothing.
Session
Section titled “Session”Session =
SessionSnapshot&SessionActions
What useSession returns: the live SessionSnapshot fields
(state, messages, toolCalls, agentState, live transcripts, error,
apiUrl, started/running/recording, …) merged with the session’s
control methods (start, toggle, reset, restart, resume, resetState,
disconnect, cancel, end, sendText, setMicMuted).
sendText(text) is the one input besides the microphone: a typed turn the
agent answers aloud, as if it had been spoken.
SessionActions
Section titled “SessionActions”SessionActions =
object
Sealed
The session’s control methods, and nothing else — what a client.tsx may
legitimately CALL on a session, as against what it may read.
Declared once and merged into Session rather than written out at both
places: the two lists have to be the same list, and a member added to one and
not the other is a hook that cannot do what useSession() can.
DECLARED here rather than picked out of BrowserSession: that handle is
sealed and grows, and a Pick made every member it gained a candidate for
this list without anyone deciding. Push-to-talk is the case in point — its
three edges are session.userTurn, reached through usePushToTalk, and not
actions every chrome is handed. createBrowserSession is what keeps each of
these assignable from the session’s own methods.
Methods
Section titled “Methods”cancel()
Section titled “cancel()”cancel():
void
Cancel the current agent turn and discard in-flight TTS audio.
Returns
Section titled “Returns”void
disconnect()
Section titled “disconnect()”disconnect():
void
Close the WebSocket and release all audio resources.
Returns
Section titled “Returns”void
end():
void
End the call and return to the not-started state — see BrowserSession.end.
Returns
Section titled “Returns”void
reset()
Section titled “reset()”reset():
void
Clear state and reopen the connection — the same session id.
Returns
Section titled “Returns”void
resetState()
Section titled “resetState()”resetState():
void
Clear messages, transcripts and error state, keeping the connection.
Returns
Section titled “Returns”void
restart()
Section titled “restart()”restart():
void
End the call and begin a fresh one — see BrowserSession.restart.
Returns
Section titled “Returns”void
resume()
Section titled “resume()”resume(
sessionId):void
Continue an earlier session by its id — see BrowserSession.resume.
Parameters
Section titled “Parameters”sessionId
Section titled “sessionId”string
Returns
Section titled “Returns”void
sendText()
Section titled “sendText()”sendText(
text,options?):void
Send a typed user turn, answered as if spoken — see BrowserSession.sendText.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”void
setMicMuted()
Section titled “setMicMuted()”setMicMuted(
muted):void
Mute or unmute the mic without dropping the session — see BrowserSession.setMicMuted.
Parameters
Section titled “Parameters”boolean
Returns
Section titled “Returns”void
start()
Section titled “start()”start():
void
Start the call for the first time — see BrowserSession.start.
Returns
Section titled “Returns”void
toggle()
Section titled “toggle()”toggle():
void
Toggle between connected and disconnected (after start()).
Returns
Section titled “Returns”void
SessionControlAction
Section titled “SessionControlAction”SessionControlAction =
"start"|"toggle"|"restart"|"end"|string&object
Which button a SessionControlButton is: one of the four today. Open
(| (string & {})) so a button added later is not a break — a renderButton
keeps a default look for an action it does not know.
SessionControlButton
Section titled “SessionControlButton”SessionControlButton =
object
Sealed
One button of SessionControls, as handed to renderButton.
Properties
Section titled “Properties”action
Section titled “action”action:
SessionControlAction
Which button this is. A custom renderer switches on it for its look.
label:
string
The label to show — the caller’s own word, or the default.
onClick
Section titled “onClick”onClick: () =>
void
The handler. Already bound; wire it to onClick as it is.
Returns
Section titled “Returns”void
running
Section titled “running”running:
boolean
Whether the call is live. Meaningful on toggle, whose label already says
which way it will flip, and handed to every button so a renderer can dim
the others while paused.
SessionControlsLabels
Section titled “SessionControlsLabels”SessionControlsLabels =
object
Input
The five words SessionControls renders, every one overridable.
Properties
Section titled “Properties”end:
string
Hang up. Default "End".
pause:
string
The toggle’s label while running. Default "Pause".
restart
Section titled “restart”restart:
string
Hang up and dial again. Default "New Conversation".
resume
Section titled “resume”resume:
string
The toggle’s label while paused. Default "Resume".
start:
string
The button shown before the call starts. Default "Start".
SessionControlsProps
Section titled “SessionControlsProps”SessionControlsProps =
object
Input
Props of SessionControls.
Properties
Section titled “Properties”children?
Section titled “children?”
optionalchildren?:ReactNode
Rendered after the buttons — a count, a spacer, a status line.
className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the row, appended to its own layout classes.
labels?
Section titled “labels?”
optionallabels?:Partial<SessionControlsLabels>
The words this chrome has its own term for; the rest keep the defaults.
renderButton?
Section titled “renderButton?”
optionalrenderButton?: (button) =>ReactNode
Renders one button. Absent, each is a stock Button. A chrome
with its own look renders its own <button> from the
SessionControlButton it is handed — the component still decides
WHICH buttons exist and what each one does.
Parameters
Section titled “Parameters”button
Section titled “button”Returns
Section titled “Returns”ReactNode
SessionError
Section titled “SessionError”SessionError =
object
Sealed
Error reported by the voice session.
Properties
Section titled “Properties”
readonlycode:SessionErrorCode
The category of the error.
readonlyfatal:boolean
Whether the session is OVER.
false means surface the message and keep the session interactive — a
turn-level failure over a server that kept running. true means the call
is dead and the microphone has been released.
Required rather than optional, because the wire always carries it
(error.reported declares fatal: z.boolean()) and a client that cannot
tell the two apart has to guess which banner to render. It was dropped one
line before reaching here for long enough that this type’s own doc, and
the reference page generated from it, described a field that did not
exist.
message
Section titled “message”
readonlymessage:string
A human-readable description of the error.
SessionErrorBannerProps
Section titled “SessionErrorBannerProps”SessionErrorBannerProps =
object
Input
Props of SessionErrorBanner.
Every field is optional, so <SessionErrorBanner /> is the whole call. It is
a NAMED type all the same, for the reason ControlsProps and
ConsoleShellProps are: an inline object literal in the signature leaves a
createElement(SessionErrorBanner, { className }) caller unable to infer the
props at all, and gives the reference page nothing to link to.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the banner, appended to its own.
SessionErrorCode
Section titled “SessionErrorCode”SessionErrorCode =
"stt"|"llm"|"tts"|"tool"|"protocol"|"connection"|"audio"|"internal"|string&object
What a session reports when a SUBSYSTEM fails — the code of an
error.reported event. Open: a new subsystem’s code is not a break.
SessionIdentity
Section titled “SessionIdentity”SessionIdentity =
object
Sealed
Who a session is to its agent — session.identity. Everything here is read
NOW (a getter client can change between attempts), and nothing is a
subscription: useClientId() and useSessionId() are the reactive reads.
Only createBrowserSession produces one.
Methods
Section titled “Methods”clientId()
Section titled “clientId()”clientId():
string|undefined
The client id the next connection attempt sends as ?client=, trimmed, or
undefined when it sends none. With client: "auto" it is
browserClientId; with a getter it is the getter’s answer now.
Returns
Section titled “Returns”string | undefined
holderId()
Section titled “holderId()”holderId():
string
This tab’s ?holder= for the client’s WS /inbox socket — per TAB, so two
tabs of one browser coexist instead of replacing each other’s socket.
Returns
Section titled “Returns”string
sessionId()
Section titled “sessionId()”sessionId():
string|undefined
The server’s id for the current session: undefined until the first
config frame of a session, and again after end(). A resume or a new
session sets it from its own config frame. Sensitive — see
VoiceSessionOptions.onSessionId.
Returns
Section titled “Returns”string | undefined
ticket()
Section titled “ticket()”ticket():
string|Promise<string|undefined> |undefined
A session ticket for ANOTHER socket on the same server — what useInbox()
presents on WS /inbox, which a gated server checks exactly like
/websocket. The session’s own token option when it has one; otherwise a
fresh one from the server’s client-config (aai dev with
AAI_SESSION_SECRET), until a lookup shows the server issues none.
undefined means “present none”. Fresh on every call: tickets are short-lived.
Returns
Section titled “Returns”string | Promise<string | undefined> | undefined
Properties
Section titled “Properties”platformUrl
Section titled “platformUrl”
readonlyplatformUrl:string
The agent’s base URL the session dials — VoiceSessionOptions.platformUrl.
SessionSnapshot
Section titled “SessionSnapshot”SessionSnapshot =
object
Sealed
Immutable snapshot of the session state.
Consumers (e.g. React hooks via useSyncExternalStore) read this to render.
A new object reference is created on every state change.
Remarks
Section titled “Remarks”Four fields describe liveness and they answer different questions. They are routinely collapsed into one truthy check, which is how a chrome ends up showing a live indicator over a call that has ended:
| Field | The question it answers |
|---|---|
started |
Has the caller pressed Start? end() puts it back to false; disconnect() does not. |
running |
Is the socket MEANT to be up? toggle() is what flips it. |
recording |
Is the microphone actually live right now? |
state |
What is the agent doing — see AgentState. |
Properties
Section titled “Properties”agentState
Section titled “agentState”
readonlyagentState:AgentStateFrame|null
Latest state the agent projected via syncState — keyed by slot name,
{ [slot]: view } — or null before the first push. A value, not a log —
a component that mounts mid-session reads current state rather than
replaying events it missed. A slot whose view did not change keeps its
previous value object, so a selector over one slot is stable across pushes.
agentTranscript
Section titled “agentTranscript”
readonlyagentTranscript:string|null
The agent’s reply as it streams, or null when it is not speaking.
Cleared when the reply is committed to messages, so a chrome renders
this row and the finished message, never both.
apiUrl
Section titled “apiUrl”
readonlyapiUrl:string
The WebSocket URL a program can connect to directly — the long-living
platform endpoint, e.g. wss://host/my-agent/websocket. Derived from
platformUrl at construction — available before connecting — and never
replaced by the brokered sandbox tunnel URL the session may actually be
connected to: that URL is ephemeral (it dies when the sandbox is
replaced), while the platform endpoint is stable and upgrades
programmatic clients to the current sandbox endpoint itself.
contentVersion
Section titled “contentVersion”
readonlycontentVersion:number
Monotonically increasing counter bumped whenever rendered conversation
content changes (messages, toolCalls, or either live transcript).
Cheap dependency for scroll-to-bottom effects — unlike summed lengths it
never collides when the capped arrays slide.
customEvents
Section titled “customEvents”
readonlycustomEvents:AgentCustomEvent[]
Custom events the agent pushed with ctx.send(event, data), in order.
A LOG rather than a value — useEvent(name, cb) is the reader that
delivers each one exactly once; reading this array directly means owning
the cursor yourself.
readonlyerror:SessionError|null
The session’s current failure, or null. Carries a code
(SessionErrorCode), a message, and whether it was FATAL.
A fatal error LATCHES: nothing clears it but the next completed handshake, because the frame announcing a session’s death is also the frame that used to wipe the message explaining it. A non-fatal one is retired by later activity, which is what the recovery was written for.
messages
Section titled “messages”
readonlymessages:ChatMessage[]
The conversation so far, oldest first — user and assistant turns only.
Tool activity is NOT in here; it is in toolCalls. Capped, so the oldest
entries slide off a long call.
micMuted
Section titled “micMuted”
readonlymicMuted:boolean
Whether the caller has MUTED the microphone — see
BrowserSession.setMicMuted. Independent of recording: a muted
mic stays open and keeps streaming (as silence), so recording stays
true. UI state, so it survives reconnects, disconnect() and end().
recording
Section titled “recording”
readonlyrecording:boolean
True while the microphone is live and streaming to the server.
This is the mic, not the session: a session can be running with the mic
still opening, and a failure to acquire it leaves this false with the
socket up.
running
Section titled “running”
readonlyrunning:boolean
Whether the session is MEANT to be connected — the pause/resume state
toggle() flips, not a report of the socket. A reconnecting session is
still running.
started
Section titled “started”
readonlystarted:boolean
Whether the caller has pressed Start. false until the first start(),
and back to false after end() — which is what makes a start-screen
chrome show its Start control again. disconnect() leaves it true.
readonlystate:AgentState
What the agent is doing. See AgentState for the seven members.
toolCalls
Section titled “toolCalls”
readonlytoolCalls:ToolCallInfo[]
Every tool call the agent has made this session, in order, each carrying
its own pending/settled state. Capped like messages. useToolResult and
useToolCallStart are the narrow readers; this is the whole log.
userTranscript
Section titled “userTranscript”
readonlyuserTranscript:string|null
The caller’s in-progress turn, as STT reports it.
null and "" are different turns, and collapsing them is the mistake
this field invites. null is silence; "" is speech DETECTED with no
words back yet — where a live session sits for a few hundred milliseconds
at the start of every turn. Read as one falsy check, the live-transcript
row appears a beat late, on the first word rather than on the first sound,
which is the moment it exists for.
Prefer useUserTranscript, which returns the distinction as two
named things (speaking to render on, text with a placeholder) rather
than leaving each chrome to re-derive the ternary.
Cleared when the turn is committed to messages.
SessionStateDotColors
Section titled “SessionStateDotColors”SessionStateDotColors =
Partial<Readonly<Record<AgentState|SessionStateDotGroup,string>>>
Input
What SessionStateDot takes as colors: a colour per
AgentState, per SessionStateDotGroup, or any mix. An exact
state wins over its group; a state neither names is painted from the theme.
SessionStateDotGroup
Section titled “SessionStateDotGroup”SessionStateDotGroup =
"idle"|"live"|"busy"
The coarse colour groups SessionStateDotColors accepts beside the
exact states: idle (disconnected, connecting, and any state this version
does not know), live (ready, listening, speaking) and busy (thinking).
error has no group: its red is semantic and is only replaced by name.
SessionStateDotProps
Section titled “SessionStateDotProps”SessionStateDotProps =
object
Input
Props of SessionStateDot.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the wrapping <span>, appended to its own.
colors?
Section titled “colors?”
optionalcolors?:SessionStateDotColors
The dot’s colour per AgentState, per coarse group (idle, live,
busy), or both — the chrome’s own palette, see
SessionStateDotColors. Any state not named either way (including
one a later release adds) is painted from the mounted theme: the primary
colour while live, the text colour at half strength while idle, a fixed
amber while thinking, red on error.
dotClassName?
Section titled “dotClassName?”
optionaldotClassName?:string
REPLACES the dot’s default size (w-2 h-2) rather than adding to it —
there is no tailwind-merge in this package, so two conflicting width
utilities would not have a reliable winner. The dot’s shape classes stay.
labelClassName?
Section titled “labelClassName?”
optionallabelClassName?:string
Additional CSS class names for the label <span>.
labels?
Section titled “labels?”
optionallabels?:Partial<Readonly<Record<AgentState,string>>>
The words this chrome has a better term for. Anything not named falls back
to AGENT_STATE_LABELS, so a page overrides one member (speaking: "Narrating") without restating the union; a state neither names is shown
as its raw value.
pulse?
Section titled “pulse?”
optionalpulse?:boolean
Whether the dot pulses while listening (slowly) and thinking (faster).
Defaults to true; a chrome whose dot glows rather than beats passes
false.
StoredValue
Section titled “StoredValue”StoredValue =
object
Sealed
One remembered string — see createStoredValue.
Methods
Section titled “Methods”get():
string
The value now: what is stored, else the initial.
Returns
Section titled “Returns”string
set(
value):void
Remember value; "" or undefined forgets it. Every holder of the key is told.
Parameters
Section titled “Parameters”string | undefined
Returns
Section titled “Returns”void
subscribe()
Section titled “subscribe()”subscribe(
listener): () =>void
Be told when the value changes — here, in another holder, or in another tab.
Parameters
Section titled “Parameters”listener
Section titled “listener”() => void
Returns
Section titled “Returns”() => void
Properties
Section titled “Properties”
readonlykey:string
The storage key, as given.
StoredValueOptions
Section titled “StoredValueOptions”StoredValueOptions =
object
Input
Options for createStoredValue.
Properties
Section titled “Properties”initial?
Section titled “initial?”
optionalinitial?:string
What a read answers while nothing is stored. Default "".
storage?
Section titled “storage?”
optionalstorage?:"local"|"session"
"local" (the default) outlives the tab; "session" does not.
SubmitInputOf
Section titled “SubmitInputOf”SubmitInputOf<
D> = [WorkflowInputOf<D>] extends [never] ?undefined:WorkflowInputOf<D>
What submit() takes for D — undefined when D declares no schema.
This is what WorkflowInputOf cannot say on its own: a def whose
input schema is absent has no parsed input, and never as a parameter type
accepts nothing at all — not even undefined — so a workflow that declares
no schema would have an uncallable submit. It gets undefined instead, i.e.
submit(undefined). Explicit rather than void, which would let the argument
be omitted and which Biome’s noConfusingVoidType rejects outside a return or
type-parameter position.
The [T] extends [never] spelling is deliberate: a bare T extends never
distributes over a naked type parameter and answers never for a union.
Type Parameters
Section titled “Type Parameters”D
TextSplitMode
Section titled “TextSplitMode”TextSplitMode =
"none"|"lines"
How a TextAreaField contributes its text. "none" (the default) is
the string; "lines" is a string[] — one entry per non-blank line,
trimmed — the control for a schema’s z.array(z.string()).
ToolCallInfo
Section titled “ToolCallInfo”ToolCallInfo =
object
Sealed
Info about a tool call for display in the UI.
Properties
Section titled “Properties”afterMessageId
Section titled “afterMessageId”afterMessageId:
number
id of the last ChatMessage present when this tool call was
inserted (-1 when there were none). The tool call renders immediately
after that message; if the anchor message has slid out of the retained
window, the tool call renders before all messages.
args:
Record<string,DefaultToolResult>
The tool’s arguments, as the model sent them.
Values are DefaultToolResult — any — for the same reason a tool
result is: the shape is the author’s own Zod schema, which the framework
cannot see from here. As Record<string, unknown> the ordinary
toolCall.args.url was a compile error in a client that runs correctly,
and the escape hatch agents reached for next (args as FetchJsonArgs) is
itself an error — TypeScript rejects the cast as insufficiently
overlapping. That pair cost two build rounds in one run.
Annotate at the read site for real checking:
const { url } = toolCall.args as { url: string } is still available, and
now actually compiles.
callId
Section titled “callId”callId:
string
name:
string
result?
Section titled “result?”
optionalresult?:string
seq:
number
Monotonically increasing, session-unique insertion sequence number.
Tool calls in a snapshot are always sorted ascending by seq.
status
Section titled “status”status:
"pending"|"done"|string&object
"pending" until the tool returns, then "done". A tool that FAILED is
"done" too: the wire reports a failure as a completion whose result is
the error text the model read. Open (| (string & {})) so a later status
is not a break — treat anything but "pending" as settled.
ToolCallOutcome
Section titled “ToolCallOutcome”ToolCallOutcome = {
result:unknown; } | {error:string; }
How a page answers a tool call it ran — see BrowserSession.sendToolResult.
ToolCallRowVariant
Section titled “ToolCallRowVariant”ToolCallRowVariant =
"default"|"compact"
Size preset for ToolCallRow: "default" is the deployed agent
UI’s scale, "compact" the studio transcript’s denser one.
UploadStatus
Section titled “UploadStatus”UploadStatus =
UploadProgress&object
Sealed
What WorkflowSubmission.upload reports while the bytes are going.
The SDK’s per-request UploadProgress plus WHICH file it describes, because a form is allowed more than one and a bar over “the upload” would restart at zero partway through with nothing to say why.
Type Declaration
Section titled “Type Declaration”count:
number
How many files this submission sends in total.
index:
number
Which file of the submission this is, counting from 1.
name:
string
The file being sent, by the name the picker gave it.
paused
Section titled “paused”paused:
boolean
Whether the person has parked this upload.
A paused upload is not a stopped one: the windows already stored stay stored,
loaded holds where it got to, and resuming sends what is missing rather than
the file. So a bar rendering this reads “Paused at 62%”, never “62% and
frozen” — which is what a page could otherwise only guess from a number that
stopped moving, the same ambiguity complete exists to remove on the run side.
UseClientRunsOptions
Section titled “UseClientRunsOptions”UseClientRunsOptions =
object
Input
Options for useClientRuns.
Properties
Section titled “Properties”client?
Section titled “client?”
optionalclient?:string
The ?client= to send. Default: the session’s client inside
mountClient(), none on a page with no session.
pollMs?
Section titled “pollMs?”
optionalpollMs?:number
Read the list again every this many ms, while mounted. Default 5000; 0 never.
UseClientRunsResult
Section titled “UseClientRunsResult”UseClientRunsResult =
object
Sealed
What useClientRuns returns.
Properties
Section titled “Properties”cancel
Section titled “cancel”cancel: (
runId) =>Promise<boolean>
Cancel one run, then re-read the list. Resolves true when this call
ended it, false when it had already finished or the cancel failed (the
reason is in error). Never rejects.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Promise<boolean>
cancelling
Section titled “cancelling”cancelling:
string|undefined
The run id whose cancel is in flight, or undefined — to disable its button.
error:
string|undefined
Why the last read or cancel failed — the route’s { error } when it said — or undefined.
reload
Section titled “reload”reload: () =>
void
Read the list again now.
Returns
Section titled “Returns”void
runs:
ClientRun[] |undefined
The client’s runs, oldest first — undefined until the first read lands.
UseConversationLogOptions
Section titled “UseConversationLogOptions”UseConversationLogOptions =
object
Input
Options for useConversationLog.
Properties
Section titled “Properties”
optionalmax?:number
Entries kept; the oldest go first. Default 300 — localStorage holds a few MB per origin.
storageKey?
Section titled “storageKey?”
optionalstorageKey?:string
The localStorage key, read once on mount. Default: one per agent URL, so
two agents on one origin keep separate logs.
UseConversationLogResult
Section titled “UseConversationLogResult”UseConversationLogResult =
object
Sealed
What useConversationLog returns.
Methods
Section titled “Methods”addNote()
Section titled “addNote()”addNote(
text):void
Log a line of the page’s own: “New session”, “Continuing an earlier conversation”.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”void
addSpoken()
Section titled “addSpoken()”addSpoken(
text):void
Log what the agent said outside a session — a reminder read aloud — as an assistant turn.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”void
clear()
Section titled “clear()”clear():
void
Forget the whole log, stored copy included.
Returns
Section titled “Returns”void
mirror()
Section titled “mirror()”mirror(
event):void
Mirror a frame of the client’s live conversation into the log — pass it as
useInbox({ onEvent: log.mirror }). The page’s own session is skipped (it
logs itself); every other session of the client is logged as it happens.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”void
Properties
Section titled “Properties”entries
Section titled “entries”
readonlyentries: readonlyConversationLogEntry[]
The log, oldest first; the live session is its newest session entry.
UseConversationResult
Section titled “UseConversationResult”UseConversationResult =
object
Sealed
What useConversation returns.
Properties
Section titled “Properties”items: readonly
ConversationItem[]
Messages and tool calls in one list, in the order they happened.
Referentially stable while neither array changes, so a consumer may map it
inside a useMemo keyed on it, or hand rows to memo()ed components,
without rebuilding the list on unrelated snapshot updates.
streaming
Section titled “streaming”streaming:
string|null
The agent’s utterance as it arrives, or null between turns.
Not yet a member of items: it has no id and it is replaced wholesale on
every delta, so it is rendered as its own trailing row and disappears when
the finalized message takes its place.
thinking
Section titled “thinking”thinking:
boolean
Whether to show a thinking indicator.
The suppression rule, and it is why this is a field rather than
state === "thinking": the agent is thinking for a stretch during which
something ELSE is already saying so. A pending tool call draws its own
spinner, and a trailing agent message means the reply has begun landing —
in both cases a second indicator underneath reads as a second thing
happening. So it is on only while thinking with no pending tool call, and
either no messages yet, a trailing USER message, or a settled tool call
after it.
transcript
Section titled “transcript”transcript:
UseUserTranscriptResult
The caller’s in-progress turn — useUserTranscript’s result,
forwarded rather than re-derived, so the null-vs-"" distinction is made
in exactly one place.
UseCopyResult
Section titled “UseCopyResult”UseCopyResult =
object
Sealed
What useCopy hands back — the click handler and the two readings a button needs off one shared flash.
Properties
Section titled “Properties”copy: (
text) =>void
Copy text, flashing the button that owns it.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”void
didCopy
Section titled “didCopy”didCopy: (
text) =>boolean
True when text was the last thing copied, successfully.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
label: (
text,idle?) =>string
The button label for text — idle until it is clicked, then "Copied"
or "Failed" for the length of the flash.
Only the idle word is the caller’s: it is the button’s NAME ("Copy",
"UI", "Webhook URL"), where the other two are the OUTCOME and are the
one bit of state a reader can see. A caller wanting other words for those
reads UseCopyResult.didCopy and writes its own.
Parameters
Section titled “Parameters”string
string
Returns
Section titled “Returns”string
UseDictationOptions
Section titled “UseDictationOptions”UseDictationOptions =
object
Input
Options for useDictation.
Properties
Section titled “Properties”disabled?
Section titled “disabled?”
optionaldisabled?:boolean
Refuse to START a recording — the text box is not accepting input right
now. A recording already running can still be stopped and transcribed, so
the words said before the box went dead are not lost. Default false.
maxSeconds?
Section titled “maxSeconds?”
optionalmaxSeconds?:number
The longest recording, in seconds: it stops and is transcribed by itself then. Defaults to the dictation API’s own ceiling, 120.
onText
Section titled “onText”onText: (
text) =>void
The words, once a recording is transcribed. Not called for an empty transcript (the user said nothing) or for a recording that was cancelled.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”void
transcribe
Section titled “transcribe”transcribe: (
audio,options) =>Promise<string>
Turn one clip — a mono 16-bit WAV — into text. Called once per recording,
with a signal that aborts when the result would no longer be used. Usually
a POST to the page’s own server, which forwards the clip to the
dictation API under its key. A rejection is shown as error.
Parameters
Section titled “Parameters”Blob
options
Section titled “options”signal
Section titled “signal”AbortSignal
Returns
Section titled “Returns”Promise<string>
UseDictationResult
Section titled “UseDictationResult”UseDictationResult =
object
Sealed
What useDictation returns.
Properties
Section titled “Properties”buttonProps
Section titled “buttonProps”buttonProps:
object
Spread onto a <button>: toggle, and the two rules a hand-written one
gets wrong — a recording can ALWAYS be stopped (even with disabled set),
and nothing can be pressed while the microphone opens or the clip is
transcribing. Label and style it however you like.
aria-pressed
Section titled “aria-pressed”aria-pressed:
boolean
disabled
Section titled “disabled”disabled:
boolean
onClick
Section titled “onClick”() =>
void
cancel
Section titled “cancel”cancel: () =>
void
Throw the recording (or the transcription in flight) away.
Returns
Section titled “Returns”void
error:
string|null
Why the last recording produced no text, or null. Cleared by start.
phase:
"idle"|"starting"|"recording"|"transcribing"|string&object
Where the hook is: "idle", "starting" (asking for the microphone),
"recording" or "transcribing".
start: () =>
void
Ask for the microphone and start recording. Ignored unless idle, and while disabled.
Returns
Section titled “Returns”void
stop: () =>
void
Stop recording and transcribe what was said. Ignored unless recording.
Returns
Section titled “Returns”void
supported
Section titled “supported”supported:
boolean
Whether this browser can record at all — a microphone API, an
AudioContext and audio worklets. A button should not render without it.
toggle
Section titled “toggle”toggle: () =>
void
start when idle, stop when recording — one button.
Returns
Section titled “Returns”void
UseDownloadUrlOptions
Section titled “UseDownloadUrlOptions”UseDownloadUrlOptions =
object
Input
Options for useDownloadUrl.
Properties
Section titled “Properties”
optionalapi?:WorkflowApi
The client to read the bytes with. Defaults to one for the page’s own agent.
UseDownloadUrlResult
Section titled “UseDownloadUrlResult”UseDownloadUrlResult =
object
Sealed
What useDownloadUrl reports.
Properties
Section titled “Properties”error?
Section titled “error?”
optionalerror?:string
The read’s failure, as the agent’s own sentence where it gave one.
pending
Section titled “pending”pending:
boolean
True while the bytes are on their way.
Its own field rather than “neither url nor error”, which cannot tell a
download in flight from no id to download — the two states a page most
wants to render differently (a spinner, and nothing at all).
optionalurl?:string
An object URL for the stored bytes, once they are here. Valid until the id changes or the component unmounts — do not stash it anywhere that outlives the render that read it.
UseFlashResult
Section titled “UseFlashResult”UseFlashResult<
T> =object
Sealed
What useFlash hands back.
Type Parameters
Section titled “Type Parameters”T
What is being flashed. A string for a label; a record for a
flash that has to say WHICH thing it belongs to, as useCopy does.
Properties
Section titled “Properties”
readonlyflash: (value) =>void
Show value for the hook’s duration, replacing any flash already up.
Parameters
Section titled “Parameters”T
Returns
Section titled “Returns”void
readonlyvalue:T|null
What is being shown right now, or null between flashes.
UseInboxOptions
Section titled “UseInboxOptions”UseInboxOptions =
object
Input
Options for useInbox.
Properties
Section titled “Properties”
optionalbusy?: () =>boolean
Whether to refuse a notice for now (busy; it comes back later). Default:
the session is running — connecting, listening, thinking or speaking.
Returns
Section titled “Returns”boolean
events?
Section titled “events?”
optionalevents?:boolean
Ask for the client’s live conversation (?events=1). Default: whether onEvent is set.
onEvent?
Section titled “onEvent?”
optionalonEvent?: (event) =>void
A frame of the client’s live conversation — every session of this client,
this page’s own included (compare sessionId with useSessionId() to skip
it). Giving one asks for the feed, unless events says otherwise.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”void
onNotice?
Section titled “onNotice?”
optionalonNotice?: (notice) =>void
A notice arrived whole — once per delivery, after playback has started
(when play is on). Log it, show it, or play it yourself with play: false.
Parameters
Section titled “Parameters”notice
Section titled “notice”Returns
Section titled “Returns”void
optionalplay?:boolean
Play each notice’s audio (PCM16LE mono, 16 kHz) through built-in playback.
Default true.
UseInboxResult
Section titled “UseInboxResult”UseInboxResult =
object
Sealed
What useInbox returns.
Methods
Section titled “Methods”stopPlayback()
Section titled “stopPlayback()”stopPlayback():
void
Silence a notice that is playing — e.g. when the user starts talking.
Returns
Section titled “Returns”void
Properties
Section titled “Properties”connected
Section titled “connected”
readonlyconnected:boolean
Whether the inbox socket is open now.
UsePushToTalkOptions
Section titled “UsePushToTalkOptions”UsePushToTalkOptions =
object
Input
Options for usePushToTalk.
Properties
Section titled “Properties”holdKey?
Section titled “holdKey?”
optionalholdKey?:string|false
The keyboard key that holds the turn open, as a KeyboardEvent.code —
"Space" by default, so the whole page is a walkie-talkie. false turns
the global key off; the button itself still answers Space and Enter while
it has focus.
UsePushToTalkResult
Section titled “UsePushToTalkResult”UsePushToTalkResult =
object
Sealed
What usePushToTalk returns.
Properties
Section titled “Properties”buttonProps
Section titled “buttonProps”buttonProps:
object
Spread onto a <button>: pointer capture, the keyboard pair, and
aria-pressed. The handlers are the whole contract — style it however you
like.
aria-pressed
Section titled “aria-pressed”aria-pressed:
boolean
disabled
Section titled “disabled”disabled:
boolean
onContextMenu
Section titled “onContextMenu”(
event) =>void
onKeyDown
Section titled “onKeyDown”(
event) =>void
onKeyUp
Section titled “onKeyUp”(
event) =>void
onPointerCancel
Section titled “onPointerCancel”() =>
void
onPointerDown
Section titled “onPointerDown”(
event) =>void
onPointerUp
Section titled “onPointerUp”() =>
void
cancel
Section titled “cancel”cancel: () =>
void
Close the turn and discard it — nothing is answered. Ignored unless held.
Returns
Section titled “Returns”void
press: () =>
void
Open a turn. Interrupts the agent if it is speaking. Ignored while held.
Returns
Section titled “Returns”void
ready:
boolean
Whether pressing would do anything: the call is live. False before Start and while paused, which is when a button should render disabled.
release
Section titled “release”release: () =>
void
Close the turn and have the agent answer it. Ignored unless held.
Returns
Section titled “Returns”void
talking
Section titled “talking”talking:
boolean
Whether a turn is being held open right now — the button is DOWN.
UseRouteMutationOptions
Section titled “UseRouteMutationOptions”UseRouteMutationOptions =
object
Input
Options for useRouteMutation.
Properties
Section titled “Properties”client?
Section titled “client?”
optionalclient?:string
The ?client= to send. Default: the session’s client
(session.identity.clientId(), read per write) inside mountClient(),
none on a page with no session.
onSettled?
Section titled “onSettled?”
optionalonSettled?: () =>void
Called after every write settles, success or failure — a useRoute’s
reload, so the list shows what the write did. Not called after unmount.
Read at settle time, so an inline arrow is fine.
Returns
Section titled “Returns”void
UseRouteMutationResult
Section titled “UseRouteMutationResult”UseRouteMutationResult =
object
Sealed
What useRouteMutation returns.
Properties
Section titled “Properties”busy:
string|undefined
The key of the newest write still in flight (see
RouteMutationRunOptions.key), or undefined when none is —
busy === "email" for one field, busy !== undefined for any.
clearError
Section titled “clearError”clearError: () =>
void
Forget error — when the reader dismissed it, or edited the field again.
Returns
Section titled “Returns”void
error:
string|undefined
Why the newest settled write failed — the route’s { error } when it said — or undefined.
run: <
T>(method,path,body?,options?) =>Promise<T|undefined>
Send one write: routeFetch(method, path, body, { client }). Resolves the
route’s answer, or undefined when it failed (the reason is in error) —
never rejects.
Type Parameters
Section titled “Type Parameters”T = unknown
Parameters
Section titled “Parameters”method
Section titled “method”string
unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<T | undefined>
UseRouteOptions
Section titled “UseRouteOptions”UseRouteOptions<
T> =object
Input
Options for useRoute.
Type Parameters
Section titled “Type Parameters”T = unknown
Properties
Section titled “Properties”client?
Section titled “client?”
optionalclient?:string
The ?client= to send. Default: the session’s client
(session.identity.clientId(), read per request) inside mountClient(),
none on a page with no session.
headers?
Section titled “headers?”
optionalheaders?:Readonly<Record<string,string>>
Extra request headers, as routeFetch’s — an authorization for a route
behind apiKeyRoute. Read per request, so an inline object does not
re-trigger the read.
pollMs?
Section titled “pollMs?”
optionalpollMs?:number
Read again every this many ms, while mounted. 0 or unset: never. A
negative or NaN value throws rather than polling as fast as the route
answers.
until?
Section titled “until?”
optionaluntil?: (value) =>boolean
Stop polling once an answer satisfies this — a call that is over, a job
that has settled. Judged on every read, the first included; reload()
still works afterwards. Read per request, so an inline function is fine.
Parameters
Section titled “Parameters”T
Returns
Section titled “Returns”boolean
UseRouteResult
Section titled “UseRouteResult”UseRouteResult<
T> =object
Sealed
What useRoute returns.
Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”data:
T|undefined
The last answer for THIS path, kept through a failed re-read.
undefined until the first, and again whenever path changes.
error:
string|undefined
Why the LAST read of this path failed — the route’s { error } when it said — or undefined.
loading
Section titled “loading”loading:
boolean
Whether a read is in flight.
reload
Section titled “reload”reload: () =>
void
Read again now — after a write, say.
Returns
Section titled “Returns”void
UserTurnControls
Section titled “UserTurnControls”UserTurnControls =
object
Sealed
Push-to-talk’s three edges on a BrowserSession — session.userTurn.
Only an agent declaring turnTaking: { detection: "manual" } honours them; any other
agent logs once and ignores them, because its transcriber already ends each
turn on a pause. usePushToTalk is the hook a button is built on, and the
way a client.tsx reaches these: a sub-handle rather than three methods on
the session, so a feature one agent in many declares is not three members
every session carries in its autocomplete.
Methods
Section titled “Methods”clear()
Section titled “clear()”clear():
void
Close the turn and THROW AWAY what was said in it — a cancelled press (the pointer left the button, Escape). The agent answers nothing.
Returns
Section titled “Returns”void
commit()
Section titled “commit()”commit():
void
CLOSE the turn and have the agent answer everything said since start() — the button came up.
Returns
Section titled “Returns”void
start()
Section titled “start()”start():
void
OPEN a turn — the button went down. Stops the agent if it is speaking (discarding its queued audio here at once, rather than a round trip later) and lets the microphone through to the transcriber.
Returns
Section titled “Returns”void
UseSessionControlsResult
Section titled “UseSessionControlsResult”UseSessionControlsResult =
object
Sealed
What useSessionControls returns.
Properties
Section titled “Properties”end: () =>
void
Hang up. Flips started back, so a chrome returns to its Start button and
the next start() is a new session. reset() would keep the call live.
Returns
Section titled “Returns”void
restart
Section titled “restart”restart: () =>
void
Hang up AND dial again — a brand-new session with fresh session-scoped
state, a fresh greeting, and the chrome kept on the call. end() then
start(), never reset(): reset() clears the conversation and reconnects
carrying the same session id, so every sessionSlot on the agent survives
and the next tool call repopulates the board, cart or game that was just
abandoned. Three chromes each found this and wrote the pair by hand.
Returns
Section titled “Returns”void
running
Section titled “running”running:
boolean
Whether the started call is live rather than paused — the Pause/Resume hinge.
start: () =>
void
Dial. What the button before started presses.
Returns
Section titled “Returns”void
started
Section titled “started”started:
boolean
Whether a call has been started and not yet ended — the Start/End hinge.
toggle
Section titled “toggle”toggle: () =>
void
Pause a running call, or resume a paused one.
Returns
Section titled “Returns”void
UseTapToTalkOptions
Section titled “UseTapToTalkOptions”UseTapToTalkOptions =
object
Input
Options for useTapToTalk.
Properties
Section titled “Properties”connectTimeoutMs?
Section titled “connectTimeoutMs?”
optionalconnectTimeoutMs?:number
Give up on a connection attempt after this long, and report failed. Default 8000 ms.
idleHangupMs?
Section titled “idleHangupMs?”
optionalidleHangupMs?:number
Hang up a call that is NOT live after this long with nothing happening — the device’s follow-up window. Default 3000 ms.
optionalkey?:string|false
The keyboard key that taps, as a KeyboardEvent.code — "Space" by
default. false turns the page-wide key off; the button itself still
answers a click, Space and Enter.
thinkingHangupMs?
Section titled “thinkingHangupMs?”
optionalthinkingHangupMs?:number
Hang up a call that is not live when the agent has been thinking this long. Default 60,000 ms — long enough for a slow tool.
UseTapToTalkResult
Section titled “UseTapToTalkResult”UseTapToTalkResult =
object
Sealed
What useTapToTalk returns.
Properties
Section titled “Properties”buttonProps
Section titled “buttonProps”buttonProps:
object
Spread onto a <button>: the click, the keyboard guard against a double
tap, and aria-pressed. Style it however you like.
aria-pressed
Section titled “aria-pressed”aria-pressed:
boolean
onClick
Section titled “onClick”() =>
void
onKeyDown
Section titled “onKeyDown”(
event) =>void
onKeyUp
Section titled “onKeyUp”(
event) =>void
failed
Section titled “failed”failed:
boolean
Whether the last attempt failed: the connection timed out or the session reported an error. Cleared by the next tap or typed turn.
hangUp
Section titled “hangUp”hangUp: () =>
void
Hang up now — resumably (disconnect()), live or not.
Returns
Section titled “Returns”void
live:
boolean
Whether the caller is live — mic open, in a realtime conversation. The button is ON.
phase:
"idle"|"connecting"|"active"|string&object
The call: "idle" (not meant to be running), "connecting", or
"active" — up, whatever the agent is doing. Open (| (string & {})) so
a phase added later is not a break.
send: (
text) =>void
A typed turn. Sent at once while the call is up; while idle it opens the session WITHOUT the mic and is sent once connected. Blank text is ignored.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”void
toggle
Section titled “toggle”toggle: () =>
void
Tap: go live (connecting first if idle), or hang up if live.
Returns
Section titled “Returns”void
UseWorkflowProgressResult
Section titled “UseWorkflowProgressResult”UseWorkflowProgressResult<
T> =object
Sealed
What useWorkflowProgress reports.
Type Parameters
Section titled “Type Parameters”T = string
Properties
Section titled “Properties”latest
Section titled “latest”latest:
T|undefined
The newest chunk, or undefined before the first one lands.
progress
Section titled “progress”progress:
T[]
Every chunk the run has written, oldest first.
streaming
Section titled “streaming”streaming:
boolean
True while the run is still being read — it may yet say more.
supported
Section titled “supported”supported:
boolean
False once the agent has answered that it does not serve this route.
Distinguishes “this deploy predates progress streams” from “the run has not
written anything yet”, which look identical from progress alone. A page
uses it to hide the section rather than show an empty one forever.
UseWorkflowRunResult
Section titled “UseWorkflowRunResult”UseWorkflowRunResult<
R> =object
Sealed
What useWorkflowRun reports.
Type Parameters
Section titled “Type Parameters”R = unknown
Properties
Section titled “Properties”error:
string|undefined
The last read’s failure, cleared by the next successful one.
polling
Section titled “polling”polling:
boolean
True while a non-terminal run is still being watched.
run:
WorkflowRun<R> |undefined
Latest snapshot, or undefined before the first read lands.
UseWorkflowRunsOptions
Section titled “UseWorkflowRunsOptions”UseWorkflowRunsOptions =
object
Input
Options for useWorkflowRuns.
Properties
Section titled “Properties”
optionalapi?:WorkflowApi
The client to read with. Defaults to one for the page’s own agent.
optionalkey?:string
Narrow to one correlation key — the key a run was started with.
Omitted, the list is every recent run of the workflow, which is what an operator’s page wants. A page showing “your” runs passes the key it started them with; there is no per-user filtering behind this, so the key IS the scoping mechanism.
limit?
Section titled “limit?”
optionallimit?:number
Most runs to return, newest first. The agent clamps its own ceiling.
optionalskip?:boolean
Skip the read entirely — for a page that does not know its workflow yet.
UseWorkflowRunsResult
Section titled “UseWorkflowRunsResult”UseWorkflowRunsResult<
R> =object
Sealed
What useWorkflowRuns reports.
Type Parameters
Section titled “Type Parameters”R = unknown
Properties
Section titled “Properties”error:
string|undefined
The read’s failure, alongside an empty list — which is why it exists.
loading
Section titled “loading”loading:
boolean
True until the first read settles, and during an explicit refresh.
refresh
Section titled “refresh”refresh: () =>
void
Re-read now. Call it when a run this page started reaches a terminal status.
Returns
Section titled “Returns”void
runs:
WorkflowRun<R>[]
The runs, newest first. Empty until the first read lands.
UseWorkflowsOptions
Section titled “UseWorkflowsOptions”UseWorkflowsOptions =
object
Input
Options for useWorkflows.
Properties
Section titled “Properties”
optionalapi?:WorkflowApi
The client to read the listing with. Defaults to one for the page’s own agent.
optionalskip?:boolean
Skip the lookup entirely, reporting an empty listing that is not loading.
For a caller that may or may not need the listing and cannot decide with a
conditional hook — <WorkflowFields> handed a summary rather than a name is
the one in this package. It reports loading: false, because a skipped
lookup is finished rather than pending.
UseWorkflowsResult
Section titled “UseWorkflowsResult”UseWorkflowsResult =
object
Sealed
What useWorkflows reports.
Properties
Section titled “Properties”error:
string|undefined
The lookup’s failure. Set alongside an EMPTY list, which is why it exists.
loading
Section titled “loading”loading:
boolean
True until the listing lands, so a form can hold its fields back.
workflows
Section titled “workflows”workflows:
WorkflowSummary[]
The agent’s declared workflows, each with the JSON Schema of its input.
UseWorkflowStreamOptions
Section titled “UseWorkflowStreamOptions”UseWorkflowStreamOptions =
object
Input
Options for useWorkflowStream.
UseWorkflowSubmitOptions without wait, which is the synchronous
mode: it holds the POST open until the run settles, and here the run is
started before its bytes are, so there is nothing left to hold it for.
Without recover either, and REFUSED rather than ignored: an option a hook
accepts and does nothing with is the silent-no-op failure this repo keeps
paying for. Adopting an earlier run by key would hand this hook a run whose
input names an upload id it did not mint and is not filling — so the run
would sit waiting for bytes nobody is sending until its own abandonment
bound. That is the same reason upload/recall.ts deliberately does not
recall for this hook, one layer up: here the id is part of a run’s INPUT.
key itself still works, and still makes the run findable — but it is NOT
defaulted here the way useWorkflowSubmit defaults it, because the whole
value of that default is the lookup this hook refuses, and minting a key
nothing will ever read back is a slot left in storage for no one.
parallel COMPOSES with what this hook is for rather than competing with it.
The run still starts before the bytes, and the store still publishes how far
the file is readable — that number is the CONTIGUOUS prefix, so a run reading
ahead of the uplink sees the same growing file whether one connection or four
are filling it. What changes is only how fast it grows.
Declared on its own rather than Omitted from the submit options, so an
option added to one hook is a decision about the other, not a side effect.
Properties
Section titled “Properties”
optionalapi?:WorkflowApi
The client to start runs with. Defaults to one for the page’s own agent.
intervalMs?
Section titled “intervalMs?”
optionalintervalMs?:number
How often the fallback poll re-reads a live run.
optionalkey?:string
Correlation key recorded with the run, for finding it again without the id. Not defaulted here — see above.
parallel?
Section titled “parallel?”
optionalparallel?:UploadParallelOption
Send the file as concurrent parts instead of in one request — on by
default, false opts out, { partBytes, concurrency } tunes it. See
UploadOptions.parallel.
UseWorkflowSubmitOptions
Section titled “UseWorkflowSubmitOptions”UseWorkflowSubmitOptions =
object
Input
Options for useWorkflowSubmit.
Properties
Section titled “Properties”
optionalapi?:WorkflowApi
The client to start runs with. Defaults to one for the page’s own agent.
intervalMs?
Section titled “intervalMs?”
optionalintervalMs?:number
How often the fallback poll re-reads a live run.
optionalkey?:string| {storage?:"session"|"local"; }
Correlation key recorded with the run, for finding it again without the id.
Defaulted, to an opaque per-page key in sessionStorage that the next
load produces again — useRunKey()’s, minted by the hook. Pass one to
scope runs to something the page knows better: an ACCOUNT’s own id, which
is what makes a run follow the person to a new device, or
{ storage: "local" } — useRunKey({ storage: "local" })’s key, minted
here — for a run that outlives the tab by design. The key is a lookup
CAPABILITY (there is no per-user filtering behind find), it must fit the
route’s 256-character bound, and anything derived from a person’s own
input both collides and carries what they typed — use-run-key.ts argues
every alternative. What was chosen is reported back as
WorkflowSubmission.scope.
An empty or whitespace key THROWS at the hook (and the route answers
400): "" is not “no key” but one key shared by every visitor whose key
computed to it — user?.id ?? "" before login. Pass undefined until the
key is known.
parallel?
Section titled “parallel?”
optionalparallel?:UploadParallelOption
Send each chosen file as concurrent parts instead of in one request.
On by default. false opts out, { partBytes, concurrency } tunes it.
This is the wait a form with a recording in it actually spends: the run does
not exist until its input is stored, so until the last byte lands there is no
run to watch and nothing for <WorkflowProgress> to say. Splitting the file
across connections is what makes that stretch shorter, and it degrades to the
single request wherever it would not help — a small file, an older agent — so
the default costs nothing where it would not have paid. See
UploadOptions.parallel.
recover?
Section titled “recover?”
optionalrecover?:boolean
On mount, adopt the newest run this key already has.
This is what makes a reload survivable, and it is ON. The run id is
this hook’s own state, so a refresh loses it while the run carries on — and
a page that cannot name a run cannot show it, cancel it or wake it. The
hook asks find(workflow, key) once as it mounts and follows whatever
comes back, so the answer, the progress and the controls are all there
again.
It used to be opt-in, on the argument that a key alone means only “record
this with the run” — true of ctx.workflows.start({ key }), where there is
no page to put a run back on, and not of a form: six of six page templates
passed useRunKey() and recover: true together, which is a default in
the wrong place. false is the opt-out, and what it buys is a form that
always opens empty — no lookup on mount, and a live run reachable only by
an id the page has already lost.
optionalwait?:number
Hold the POST open until the run settles, up to this many ms — the
synchronous mode. Omitted (the default) returns as soon as the run exists.
VoiceSessionOptions
Section titled “VoiceSessionOptions”VoiceSessionOptions =
object
Input
Options for creating a voice session — the shared field set accepted by
both mountClient() and createBrowserSession. The one difference: mountClient()
defaults platformUrl from location.href, while createBrowserSession
requires it.
Properties
Section titled “Properties”client?
Section titled “client?”
optionalclient?:string| (() =>string|undefined)
The name of THIS client — a device id — sent as ?client= on every
connection attempt, with the same rules as location: a string or a
getter asked per attempt, trimmed, an empty answer sends none.
"auto" has the SDK mint and keep one: this browser’s
browserClientId() — browser-<32 hex>, stored in localStorage per
agent URL (for this tab only where storage is unavailable). It is the id
useInbox() holds the inbox under too, with a holder id per TAB so two tabs
of one browser do not replace each other’s inbox socket. Read it back with
useClientId() or session.identity.clientId(). (So "auto" itself can
never be a client id.)
It is the id a tool reads with sessionClientId(ctx) and the one a client
holds its WS /inbox?client= socket open under, so a tool can hand it to a
workflow run and the run’s stepNotifyClient reaches this client AFTER the
voice session has ended (a reminder, a finished research job). Send the
same id the inbox socket uses, or the run delivers to a client that is not
listening.
Letters, digits, - and _, at most 64 characters: the server checks it
against the same pattern the inbox uses and ignores one that does not
match — the session still opens, it just has no client id.
It is also the key of this client’s DURABLE conversation: every connection that sends it is seeded with that client’s earlier sessions. The server takes it on the client’s word and authenticates nothing, so on a server reachable from a network (a LAN-listening dev server, a self-hosted one) the id is the ONLY credential for that history — anyone who can reach the server and knows the id reads what was said. Use an unguessable id.
location?
Section titled “location?”
optionallocation?:string| (() =>string|undefined)
Where the client is — e.g. a street address or "Portland, Oregon" — sent
as ?location= on every connection attempt (the first, a resume and each
reconnect, brokered or not). Location-aware builtins read it: open_meteo
falls back to it when the model names no place, and google_places biases
its searches toward it.
A string, or a getter asked on EVERY attempt — so a UI whose location can
change (a settings field) passes () => current and the next reconnect
carries the new value without a remount. An empty or undefined answer
sends none. The server strips control characters and ignores a value
longer than 200 characters.
Treat it as personal data: like the session id it travels as a query parameter (browsers cannot set WebSocket headers), so it may appear in proxy and access logs.
onSessionId?
Section titled “onSessionId?”
optionalonSessionId?: (sessionId) =>void
Called when the server sends a session ID in the config message.
Use this to store the ID (e.g. in localStorage) for reconnection
via resumeSessionId.
Treat session IDs as sensitive: whoever holds one can resume the session and read its replayed history. They travel as a WebSocket query parameter (browsers cannot set WS headers), so they may appear in proxy and server access logs — don’t put them in shared URLs.
Parameters
Section titled “Parameters”sessionId
Section titled “sessionId”string
Returns
Section titled “Returns”void
phone?
Section titled “phone?”
optionalphone?:string| (() =>string|undefined)
The phone number of whoever owns this client — sent as ?phone= on every
connection attempt, with the same rules as location: a string or a
getter asked per attempt, trimmed, an empty answer sends none. A tool reads
it with sessionClientPhone(ctx), e.g. to text the caller a link.
Give it in E.164 form, with the + and country code ("+1 503 555 0123"):
the server strips spaces, dashes, dots and parentheses and then needs a +
and 8–15 digits. A bare "5035550123" is NOT assumed to be North American
— it is dropped (the server logs that it was, never the value).
The server takes it on the client’s word. Personal data: it travels as a query parameter, so it may appear in proxy and access logs.
platformUrl
Section titled “platformUrl”platformUrl:
string
Base URL of the AAI platform server.
preConnectAudio?
Section titled “preConnectAudio?”
optionalpreConnectAudio?:boolean
Open the microphone when the session connects, rather than once the server has configured it, and send what the caller said in between ahead of the live audio — so an opener spoken while the agent is still joining (a handshake, or a sandbox boot on the platform) reaches it instead of being lost. The latest 10 seconds are kept.
Default true. false asks for the microphone only after the server’s
config frame, as before; a UI that wants the permission prompt to
follow its own greeting might.
resumeSessionId?
Section titled “resumeSessionId?”
optionalresumeSessionId?:string
Session ID from a previous connection. When set, the server resumes
that session if its per-session state is still within the resume grace
window (SESSION_RESUME_GRACE_MS), replaying history into the new
connection. Sensitive — see onSessionId.
token?
Section titled “token?”
optionaltoken?:string| ((attempt) =>string|undefined|Promise<string|undefined>)
The session ticket this client presents — for a server that requires one
(AAI_SESSION_SECRET, or createSessionAuth() on
@alexkroman1/aai-runtime/auth).
A string, or a getter asked on EVERY connection attempt — the first, each
reconnect and each resume — so a short-lived ticket (60 s by default) is
fresh each time. Fetch it from your own backend, which checks its own login
and mints with createSessionToken(); the secret must never reach the
browser. The getter is told the session the attempt RESUMES (undefined
for a new one), so a backend can bind a resume ticket to it
(createSessionToken({ sessionId })) — what a resume needs after the
server restarted.
It travels in Sec-WebSocket-Protocol as aai.auth.<ticket> beside the
plain aai.session protocol, never in the URL, so it stays out of access
logs (a value that is not a valid protocol token falls back to ?token=).
An empty or undefined answer sends none, and so does a getter that throws
or rejects: the attempt still dials, and a server that requires a ticket
refuses it with a reason. Give a fetch inside the getter a deadline — the
attempt waits for it. With an injected WebSocket the getter must
answer synchronously.
When omitted, a ticket the server’s client-config issued is used — which
aai dev does for its own client when AAI_SESSION_SECRET is set.
Example
Section titled “Example”import { mountClient } from "@alexkroman1/aai-ui";
mountClient({ token: async ({ sessionId }) => { const res = await fetch("/my-backend/session-ticket", { method: "POST", body: JSON.stringify({ sessionId }), signal: AbortSignal.timeout(5000), }); return (await res.json()).token; },});WebSocket?
Section titled “WebSocket?”
optionalWebSocket?:WebSocketConstructor
WebSocket constructor override. Primarily useful for testing with a mock WebSocket. When omitted, the session uses a reconnecting WebSocket (partysocket) that retries with exponential backoff after an unexpected close and resumes the session; an injected constructor is used as-is and never reconnects on its own.
WebSocketConstructor()
Section titled “WebSocketConstructor()”WebSocketConstructor =
WebSocket
Input
Minimal WebSocket constructor type accepted by VoiceSessionOptions.
new WebSocketConstructor(
url,protocols?):WebSocket
Input
Minimal WebSocket constructor type accepted by VoiceSessionOptions.
Parameters
Section titled “Parameters”string | URL
protocols?
Section titled “protocols?”string | string[]
Returns
Section titled “Returns”WebSocket
Properties
Section titled “Properties”
readonlyOPEN:number
WorkflowApi
Section titled “WorkflowApi”WorkflowApi =
object
Sealed
The calls the API offers — one method per route, and nothing beyond them.
The width is the constraint: a route needing more than a tool can do is the
signal to add a WorkflowClient method server-side, never to grow this
into an engine with reads of its own: this surface dispatches, it does not
query.
Methods
Section titled “Methods”cancel()
Section titled “cancel()”cancel(
runId,options?):Promise<boolean>
Stop a run, resolving whether this call is what ended it. A run that had already finished answers false rather than failing — two tabs pressing Stop is ordinary.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<boolean>
download()
Section titled “download()”download(
id,options?):Promise<Blob>
Read an upload’s BYTES, as a Blob — the other end of a run that PRODUCED
a file (stepWriteUpload stores it, the output carries the id). A Blob
rather than a URL because the byte route takes the same bearer every route
here does and neither <audio src> nor <a href> can send one;
downloadUpload carries the rest.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<Blob>
find()
Section titled “find()”find(
workflow,key,options?):Promise<WorkflowRunSnapshot[]>
Runs of workflow started with key, newest first.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot[]>
follow()
Section titled “follow()”follow(
runId,options?):AsyncIterable<WorkflowRunSnapshot>
Every snapshot of a run, until it settles — the call watch is the raw
material for.
import { createAgentClient } from "@alexkroman1/aai/workflow-api";
const agent = createAgentClient({ baseUrl: "https://agents.example/my-agent" });for await (const run of agent.follow("wrun_1")) console.log(run.status);The last value is the TERMINAL snapshot, and reaching it is what ends the
iteration, so a caller that only wants the answer keeps the last one it saw.
The two protocol rules a hand-written loop gets wrong are honoured inside:
the stream hands the client back with an idle frame after its own duration
cap (a run may sleep for hours) and this re-opens, and a stream that ends
with the run unsettled THROWS rather than looking like a run that finished.
There is no polling fallback, deliberately — an agent that does not serve the route fails here with its own sentence, and a caller who wants to poll instead is the caller WorkflowApi.watch exists for.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”AsyncIterable<WorkflowRunSnapshot>
followOutput()
Section titled “followOutput()”followOutput(
runId,options?):AsyncIterable<unknown>
Everything a run WRITES, in order, until it settles.
import { createAgentClient } from "@alexkroman1/aai/workflow-api";
const agent = createAgentClient({ baseUrl: "https://agents.example/my-agent" });for await (const chunk of agent.followOutput("wrun_1")) console.log(chunk);One read of the route is bounded by the tail it saw, so this re-opens from
the next unread chunk until the run is finished — which is the rule that
makes a single for await cover a live run’s whole log. Chunks are retained
with the run, so it is a replay as much as a tail and starts at the
beginning by default; fromIndex is ABSOLUTE, and the raw route’s negative
“last N” form is left on WorkflowApi.streamOutput because it names
no position a re-open could resume from.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”AsyncIterable<unknown>
get(
runId,options?):Promise<WorkflowRunSnapshot|undefined>
Read a run’s state. Resolves undefined for an unknown id.
Deliberately NOT generic on the output, even though a caller wants it typed:
a generic METHOD has to be implemented generically, which would make every
test double and every hand-written stub of this client generic too. The type
parameter belongs on whatever a caller states its expectation with —
useWorkflowRun<R> in the browser client, or a cast at the one place a
script reads output.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot | undefined>
list()
Section titled “list()”list(
options?):Promise<WorkflowSummary[]>
Declared workflows: name, description, and the input schema to render.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowSummary[]>
recent()
Section titled “recent()”recent(
workflow,options?):Promise<WorkflowRunSnapshot[]>
Runs of workflow, newest first, whatever key they carry.
The operator’s read where WorkflowApi.find is the app’s — a console
has no correlation key to ask about, and most runs carry none (a page holds
its own runId). Two methods rather than one nullable key, so a caller
meaning “this user’s runs” cannot silently widen to every user’s.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot[]>
start()
Section titled “start()”start(
workflow,input?,options?):Promise<string>
Start a run and resolve its id WITHOUT waiting for it — the point of the mechanism. Rejects when the name is not declared or the input fails the workflow’s schema, both of which are 400s carrying the reason.
key is a correlation handle the caller chooses, so the run can be found
again later without the id — a signed-in user, an upload, a device. Pass one
when the caller might be gone before the run finishes and you would rather
look it up than remember the id.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
input?
Section titled “input?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<string>
startAndWait()
Section titled “startAndWait()”startAndWait(
workflow,input?,options?):Promise<WorkflowRunSnapshot>
Start a run and resolve the FINISHED one — the synchronous call.
What a form or a shell script wants, and what WorkflowApi.start
deliberately is not: one request in, one result out, with no watch to wire
up. The agent holds the request open until the run settles or its own budget
expires, so a run that is still going when the wait runs out resolves
NON-terminal — check isTerminal, or keep the id and read it back later.
wait is clamped to MAX_WORKFLOW_WAIT_MS at both ends, by the same
function, so this can never be waiting on a request the agent already
answered.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
input?
Section titled “input?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot>
streamOutput()
Section titled “streamOutput()”streamOutput(
runId,options?):Promise<Response>
Open a server-sent-event stream of what the run has WRITTEN — its progress, as opposed to WorkflowApi.watch’s status transitions.
Resolves the raw Response for the same reason watch does: an agent
deployed before this route existed answers 404, which a caller has to be able
to see rather than have raised at it. Frames are chunk then done.
Chunks are retained with the run, so this is a replay as much as a live tail:
a caller that reloads gets the whole stream by default, and startIndex
(negative counts back from the end) is for a reader resuming from a known
position.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<Response>
upload()
Section titled “upload()”upload(
file,options?):Promise<UploadRef>
Store a file and resolve the handle a run input carries.
The other half of WorkflowDef.uploads: a workflow’s input is journaled and
replayed on every resume, so bytes may not travel in it — they go here once,
and the run carries UploadRef.id, which a step reads windows of with
stepReadUpload.
A File from an <input type="file"> needs no second argument: its own
name and type are what get stored. Anything else — a Blob, a
Uint8Array — should name the file it is, since a step’s failure messages
and the download link are all the name it will ever have.
One request for the whole body, so a file past MAX_WORKFLOW_UPLOAD_BYTES is
a 413 rather than a truncation; UploadOptions.onProgress draws a bar.
{ parallel: true } sends it as concurrent parts instead, which is what a
recording over a long link wants — see UploadOptions.parallel.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”Promise<UploadRef>
uploadInfo()
Section titled “uploadInfo()”uploadInfo(
id,options?):Promise<UploadRecord>
Read an upload’s record: its name, how much has ARRIVED, and complete.
What a page watches a streamed upload with. complete is the field to branch
on — a size that stopped growing means only that nothing arrived recently,
which a slow link and a dead client both produce.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<UploadRecord>
uploadStream()
Section titled “uploadStream()”uploadStream(
id,file,options?):Promise<UploadRef>
Store a file under an id YOU chose, so a run can start before it is all in.
The counterpart of WorkflowApi.upload, and the difference is the order
it makes possible: upload answers with an id once the last byte is stored, so
a run that needs the id in its input has to wait for the whole upload. Here the
caller already has the id.
id must be 1-64 characters of letters, digits, - and _ (a
crypto.randomUUID() qualifies) and must not already exist — a second call on
one id is a 409, never an append.
{ parallel: true } applies here too, and composes with the ORDER this method
exists for: the run reads the contiguous prefix as the parts fill it in,
exactly as it reads a single streaming PUT.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<UploadRef>
wake()
Section titled “wake()”wake(
runId,options?):Promise<number>
End a run’s sleep() early, resolving how many pending sleeps were
interrupted.
0 is an answer, not a failure — the run finished, was never sleeping, or is
gone. Same shape as WorkflowApi.cancel answering false, and for the
same reason: two tabs pressing “send it now” is ordinary.
WakeUpOptions.correlationIds narrows it to the waits declared with
those ids, which is the same bag ctx.workflows.wakeUp takes and reaches the
route’s repeatable ?correlationId=. Reach for it when the caller means one
particular wait rather than “everything this run is waiting on” — and note it
is the ONLY spelling that can end a hook’s approval deadline, since a bare
wake deliberately cannot (the journal filters a hookTimeout out of one).
An id that is blank, or longer than 256 characters, REJECTS here without a request being sent. The route answers 400 for both, and there is nothing a caller can do with that answer that it could not do with a rejection it never had to make a round trip for.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”WakeUpOptions & WorkflowApiCallOptions
Returns
Section titled “Returns”Promise<number>
watch()
Section titled “watch()”watch(
runId,signal?):Promise<Response>
Open a server-sent-event stream of one run’s state.
Resolves the raw Response rather than parsed frames, because what a caller
needs to decide first is whether the agent SERVES this at all — an older
deploy answers 404 and the caller falls back to polling, which is a normal
path rather than an error.
Parameters
Section titled “Parameters”string
signal?
Section titled “signal?”AbortSignal
Returns
Section titled “Returns”Promise<Response>
WorkflowApiOptions
Section titled “WorkflowApiOptions”WorkflowApiOptions =
object
Input
Options for createWorkflowApi.
Properties
Section titled “Properties”baseUrl?
Section titled “baseUrl?”
optionalbaseUrl?:string
Base URL of the agent. Defaults to the page’s own origin + path, which is
right for a page the agent itself serves — the only case that exists today,
and the reason this wrapper exists at all: the SDK client requires a base
URL, because location does not exist in that half of the SDK.
token?
Section titled “token?”
optionaltoken?:string
Bearer for an agent whose operator set AAI_WORKFLOW_API_TOKEN. A page
served to the public has nothing to put here (and should not — it would be
readable in the bundle); this exists for a programmatic caller written
against the same client.
WorkflowAppLike
Section titled “WorkflowAppLike”WorkflowAppLike =
object
Input
An agent with a workflows map — what agent({ workflows }) and
workflowApp() return, and what the agent form of the workflow hooks and of
runWorkflow is parameterized by (useWorkflowSubmit<typeof agentDef>("digest"), runWorkflow(agentDef, "digest", input)).
Properties
Section titled “Properties”workflows?
Section titled “workflows?”
readonlyoptionalworkflows?:Readonly<Record<string,AnyWorkflowDef>>
WorkflowDefOf
Section titled “WorkflowDefOf”WorkflowDefOf<
App,Name> =WorkflowsOf<App>[Name& keyofWorkflowsOf<App>]
The def App declares under Name — what the agent form of a hook derives
its input and output from: WorkflowOutputOf<WorkflowDefOf<App, Name>>.
With Name the whole WorkflowNameOf union it is the union of the
agent’s defs, so an app with ONE workflow — most of them — gets exactly that
workflow’s types from <typeof agentDef> alone, and an app with several
names the one it means as a second type argument:
useWorkflowSubmit<typeof agentDef, "digest">("digest").
Type Parameters
Section titled “Type Parameters”App
Name extends string
WorkflowFieldKind
Section titled “WorkflowFieldKind”WorkflowFieldKind =
"text"|"number"|"select"|"checkbox"|"file"|"lines"|"namedText"|"none"|string&object
The control a schema property renders as inside <WorkflowFields>.
"none" is not a control: it is the shape the generated form declines to
guess at — a nested object, an array — because every choice (a JSON
textarea, a repeater, a comma-separated string) is a guess about what the
author meant, and a guess producing a value the schema then rejects is worse
than no field. The API takes those shapes perfectly well; only the generated
form has nothing honest to draw, so the field is written by hand and
composes with the generated ones inside the same <Form>.
There is deliberately no "textarea" member. A textarea is a string like a
text field is — the same value over the wire — so it is a hand-written swap
rather than a shape the schema can ask for.
Two NON-scalar shapes do have an honest control, and are the two members
past the scalars: "lines" is <TextAreaField split="lines"> for an array
of strings (one per line), and "namedText" is <FileField read="text" as="namedText"> for an object of exactly { name: string, text: string } —
a draft, a config, read in the browser and journaled with the input. Both
were the hand-written half of document-redline-workflow’s mixed form; a
page may also ASK for either by name through render.
Open (| (string & {})): a control added later is a new value, so read an
unknown kind as "none".
WorkflowFieldRender
Section titled “WorkflowFieldRender”WorkflowFieldRender =
"lines"|"namedText"
The kinds a page may ask <WorkflowFields render> for by property name —
the two non-scalar controls, which a schema cannot always say it wants.
WorkflowFieldsProps
Section titled “WorkflowFieldsProps”WorkflowFieldsProps<
App> =object
Input
Props for WorkflowFields.
Type Parameters
Section titled “Type Parameters”App extends WorkflowAppLike = WorkflowAppLike
The agent, when the page names it
(<WorkflowFields<typeof agentDef> workflow="digest" />): workflow is
then a name the agent declares, checked. Left to its default, any string:
WorkflowNameOf<WorkflowAppLike> is string. The default is the
constraint rather than the hooks’ never because this is ONE signature —
a form has no def form to fall to, so there is no second overload for a
never to hand the call on to.
Properties
Section titled “Properties”render?
Section titled “render?”
optionalrender?:Readonly<Record<string,WorkflowFieldRender>>
The control to draw for a property, by name, where the schema alone does
not say — { mustCover: "lines", source: "namedText" }. Beats the schema
for that property, never a declared upload. See WorkflowFieldKind.
workflow?
Section titled “workflow?”
optionalworkflow?:WorkflowSummary|WorkflowNameOf<App>
The workflow whose input schema to render. A NAME is looked up here (one
GET workflows); a WorkflowSummary the page already holds fetches
nothing. undefined renders nothing, so a page may pass a selection
straight through before one is made.
WorkflowInputOf
Section titled “WorkflowInputOf”WorkflowInputOf<
D> =Dextendsobject?I:DextendsStandardSchemaV1<unknown, infer O> ?O:Dextendsobject?I:never
A workflow’s INPUT type — what its declared schema parses to, which is exactly what the body’s parameter should be.
The reason it exists is that nothing checks a hand-written parameter.
WorkflowBody takes its input as a function PARAMETER, so it is
contravariant: a body declaring a WIDER shape than the schema produces is
assignable, and a body declaring the same shape with a field’s optionality or
a default’s type subtly different is assignable too. Both compile. A
z.number().default(5) against a body that writes input.limit ?? 3 is the
sharp version — the schema guarantees limit is present, the ?? is dead,
and the two numbers disagree with nothing to report it.
It reads the parameter WorkflowDef.run declares, which IS the schema’s
output (InferSchemaOutput<P>), by matching run’s shape — see
WorkflowOutputOf for why a reading matches a shape.
Two details a restated shape gets wrong by hand, both of which this gets
right for free. A zod .optional() infers a property that may be PRESENT AND
undefined, which under exactOptionalPropertyTypes is ?: T | undefined
and not ?: T — two templates carry the same four-line comment explaining
that, which is a comment z.infer makes unnecessary. And a .default() makes
the OUTPUT property required while the input stays optional, so a body reading
it needs no fallback at all.
Like WorkflowOutputOf, it needs no build step: import type is
erased, so a body in workflows/ naming WorkflowInputOf<typeof theDef>
through a type-only import of ../agent.ts drags no runtime cycle behind it.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”import { agent, workflow, type WorkflowDef } from "@alexkroman1/aai";import { z } from "zod";import { digestFlow } from "./workflows/digest.ts";
const digestInput = z.object({ topic: z.string(), limit: z.number().default(5) });
// ANNOTATED, so the body may name `typeof digest` without a cycle (below).export const digest: WorkflowDef<typeof digestInput, string> = workflow({ description: "Digest a topic", input: digestInput, run: digestFlow,});
export default agent({ name: "Digest", workflows: { digest } });
// file: workflows/digest.ts// `import type` is erased, so there is no cycle.import type { WorkflowContext, WorkflowInputOf } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";import type { digest } from "../agent.ts";
export async function digestFlow(input: WorkflowInputOf<typeof digest>, ctx: WorkflowContext) { // `limit` is `number`, not `number | undefined` — the default already ran. return await ctx.step("research", () => stepGenerate(`${input.limit} facts about ${input.topic}`), );}It also reads a SCHEMA, and a bodyless declaration, for the cycle’s sake
Section titled “It also reads a SCHEMA, and a bodyless declaration, for the cycle’s sake”WorkflowInputOf<typeof def> inside the body’s own module is the obvious
spelling and it is TS7022: typeof def needs the body’s signature when the
body is what workflow({ run }) infers from, and the body’s parameter names
typeof def. Two spellings avoid it, and both are read here:
- The input schema itself —
WorkflowInputOf<typeof digestInput>for a namedz.object(…)const, which is the schema’s OUTPUT type exactly as the def reading answers it. The schema is what the def reads anyway, so the body names the same declaration one step earlier. - A two-phase declaration —
workflow({ input, output }).body(digestFlow).typeof defthere is fixed by the declaration alone (seeWorkflowDeclaration), so a body annotatedWorkflowInputOf<typeof def>resolves; and the declaration BEFORE.bodyreads here too, as what itsbodywill accept.
Published from @alexkroman1/aai as well as @alexkroman1/aai/workflow-api.
The root is the one an author wants: this annotation lives in a
workflows/*.ts body, next to the workflow() that declared it.
WorkflowNameOf
Section titled “WorkflowNameOf”WorkflowNameOf<
App> = [WorkflowsOf<App>] extends [never] ?never: keyofWorkflowsOf<App> &string
The names App declares under workflows — the literal union a page passes
to useWorkflowSubmit<typeof agentDef>(…), useWorkflowStream,
useWorkflowRuns and <WorkflowFields>, and a spec to runWorkflow.
never for a type with no workflows map (a def, an unrelated object), so
the agent form of each overloaded hook declines it and the def form is tried.
The [T] extends [never] guard is load-bearing: keyof never is every key.
Type Parameters
Section titled “Type Parameters”App
WorkflowOutputOf
Section titled “WorkflowOutputOf”WorkflowOutputOf<
D> =Dextendsobject?Awaited<unknownextendsO?R:O> :never
A workflow’s OUTPUT type, for a page that polls its runs.
This is the end-to-end typing a static page would otherwise be missing.
useWorkflowRun<R> makes run.status === "completed" narrow to a typed
run.output, and without this the page has to name R by hand — restating a
shape the agent module already declares, with nothing checking the two agree.
It needs no build step and no generated .d.ts, because the reason a page
“cannot import the agent” does not survive contact with import type: a
type-only import is ERASED, so it drags no server graph into the browser
bundle.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const transcribe = workflow({ description: "Transcribe one upload", input: z.object({ uploadId: z.string() }), output: z.object({ text: z.string() }), run: async (input, ctx) => ({ text: await ctx.step("transcribe", () => `(transcript of ${input.uploadId})`), }),});
export default agent({ name: "Transcriber", workflows: { transcribe } });
// file: client.tsx// `import type` is erased, so nothing server-side is bundled.import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";import { useWorkflowRun } from "@alexkroman1/aai-ui";import type { transcribe } from "./agent.ts";
export function Transcript({ runId }: { runId: string }) { const { run } = useWorkflowRun<WorkflowOutputOf<typeof transcribe>>(runId); return run?.status === "completed" ? <p>{run.output.text}</p> : null; // typed}It reads the declared SCHEMA first, and that is what breaks a cycle
Section titled “It reads the declared SCHEMA first, and that is what breaks a cycle”The DECLARATION is the better source of this type, and the worse one used to
be the only one. Deriving R from the body means typeof theDef needs the
body’s signature — while a body annotated WorkflowInputOf<typeof theDef>
needs typeof theDef, which is TS7022 reported against agent.ts. The
documented way out is to ANNOTATE the declaration, and an annotation whose
R comes from a schema (WorkflowDef<typeof digestInput, z.infer<typeof digestOutput>>) states the output type once, in the schema, rather than
naming it a second time by hand.
That annotated shape is also what the second reading gets WRONG, which is
the other half of this rewrite. D extends WorkflowDef<ToolInputSchema, infer R> is an assignability test over the whole def, and run’s input is a
function PARAMETER — so a def carrying an input schema is not assignable to
one taking the open Record<string, unknown>, and the conditional silently
fell to never. It is the same contravariance AnyWorkflowDef was
written for, reached by the other route, and it is why the test below matches
run as (input: never, ctx: never) => infer R — never is assignable to
every parameter type.
It matches a SHAPE, not a named declaration
Section titled “It matches a SHAPE, not a named declaration”Both readings test run’s signature structurally rather than naming
WorkflowDef, WorkflowBody or WorkflowContext. A reading answers the
same type either way — WorkflowDef.run IS (input: InferSchemaOutput<P>, ctx: WorkflowContext) => … — but a reading that names the declaration
carries it (and everything WorkflowContext reaches) into the contract of
every capability that publishes the reading, so a new member on the context
a body receives moved a PAGE’s type.
unknown extends O is how “declared nothing” is told from “declared a
schema”: a def with no output schema still HAS the optional property in its
type, carrying R — so the two readings agree, and the fallback only ever
fires for a def-shaped object that names no output at all.
Awaited because a body may be sync or async and the snapshot always holds
the settled value.
On @alexkroman1/aai/workflow-api only, unlike its two siblings: its reader
is a page. Both templates that name it are a client.tsx parameterizing
useWorkflowRun<…>, and a *_status tool wants WorkflowRunOf, which
composes this in already.
WorkflowPendingNoteProps
Section titled “WorkflowPendingNoteProps”WorkflowPendingNoteProps =
object
Input
Props for WorkflowPendingNote.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
ADDED to the note’s own text-sm opacity-70 rather than replacing it.
There is no tailwind-merge in this package, so a class that CONFLICTS with
a base one is not reliably the winner.
scope?
Section titled “scope?”
optionalscope?:WorkflowSubmissionScope
Where the key that finds the run again lives, overriding what the
submission reports. "tab" is useWorkflowSubmit’s own sessionStorage
key; "browser" is a page that passed useRunKey({ storage: "local" }),
whose runs any tab on this browser can find — so the sentence says “this
browser” and stops promising that closing the tab loses anything;
"none" is a run nothing will find again (recover: false,
useWorkflowStream), which the sentence has to say instead. Default: the
submission’s own scope, else "tab".
subject?
Section titled “subject?”
optionalsubject?:string
What the run produces, as the noun the sentences name: "draft",
"summary", "transcript". Default "run".
submission
Section titled “submission”submission:
object
The submission the page is rendering — useWorkflowSubmit’s or
useWorkflowStream’s result, or any object carrying the three required
fields. Nothing renders while pending is false. scope and upload,
when present, choose two more sentences — see below.
pending
Section titled “pending”
readonlypending:boolean
readonlyrun:WorkflowRun|undefined
scope?
Section titled “scope?”
readonlyoptionalscope?:WorkflowSubmissionScope
Who finds the run again — WorkflowSubmission.scope; the scope prop overrides it.
startedHere
Section titled “startedHere”
readonlystartedHere:boolean
upload?
Section titled “upload?”
readonlyoptionalupload?:UploadStatus
The bytes in flight — WorkflowSubmission.upload; a PAUSED one gets its own line.
WorkflowRun
Section titled “WorkflowRun”WorkflowRun<
R> =WorkflowRunSnapshot<R>
A run’s observable state.
Aliased from the SDK rather than restated. import type is erased entirely,
so a second definition of the fields and the five-member status union would
buy nothing and cost the one thing that matters — nothing would assert the two
agree, so a status added to the SDK would never reach the browser type.
WorkflowRun keeps the shorter name because it is what a page’s own code
writes; nothing in a browser needs the word “snapshot” to know a read returns
one.
It is GENERIC on the run’s output, and a page supplies it — see
useWorkflowRun. It does NOT have to restate that type: a page can name
its own workflow and derive the rest with WorkflowOutputOf, pulling no
server graph into the bundle.
Type Parameters
Section titled “Type Parameters”R = unknown
WorkflowRunErrorProps
Section titled “WorkflowRunErrorProps”WorkflowRunErrorProps =
object
Input
Props for WorkflowRunError.
Properties
Section titled “Properties”className?
Section titled “className?”
optionalclassName?:string
ADDED to the alert’s own text-red-600 rather than replacing it. There is
no tailwind-merge in this package, so a class that CONFLICTS with a base
one is not reliably the winner.
run:
WorkflowRun|undefined
The run the page is following. Nothing renders unless it has FAILED.
WorkflowRunPanelProps
Section titled “WorkflowRunPanelProps”WorkflowRunPanelProps<
O> =object
Input
Props of WorkflowRunPanel.
Type Parameters
Section titled “Type Parameters”O
The run’s output type; run.output narrows to it in the
completed slot.
Properties
Section titled “Properties”
optionalapi?:WorkflowApi
The workflow API client, when the page holds its own.
children?
Section titled “children?”
optionalchildren?:ReactNode| ((output) =>ReactNode)
The completed body: what the run PRODUCED. A function receives the typed
output; a node is rendered as it is. Either appears only while
run.status === "completed".
className?
Section titled “className?”
optionalclassName?:string
Additional CSS class names for the wrapping <section>, appended to its own.
optionallive?:ReactNode
Rendered beneath the narration while the run is NOT terminal — a live transcript, a partial result. Nothing once it settles.
onClear?
Section titled “onClear?”
optionalonClear?: () =>void
Renders a Clear button in the header that calls this. Absent, no button.
Returns
Section titled “Returns”void
run:
WorkflowRun<O>
The run to show. Nothing here handles undefined — a page renders the panel once it has one.
statusLabels?
Section titled “statusLabels?”
optionalstatusLabels?:Partial<Readonly<Record<WorkflowRunStatus,string>>>
The status lines this page has a better word for — { running: "Writing…" }. The rest come from WORKFLOW_STATUS_LABELS.
WorkflowRunStatus
Section titled “WorkflowRunStatus”WorkflowRunStatus =
"pending"|"running"|"completed"|"failed"|"cancelled"
Lifecycle of one workflow run.
pending— created, not yet picked up by the queue.running— executing, or suspended at asleep/hook waiting to resume.completed/failed/cancelled— terminal.
WorkflowsOf
Section titled “WorkflowsOf”WorkflowsOf<
App> =Appextendsobject?Extract<W,Record<string,unknown>> :never
The workflows map of an app def, or never when it declares none — the
reading WorkflowNameOf and WorkflowDefOf are spelled in.
Extract rather than NonNullable: an absent key infers unknown, which
NonNullable would keep.
Type Parameters
Section titled “Type Parameters”App
WorkflowStreamSubmission
Section titled “WorkflowStreamSubmission”WorkflowStreamSubmission<
R,I> =WorkflowSubmission<R,I>
What useWorkflowStream returns: a WorkflowSubmission, exactly.
The same fields useWorkflowSubmit returns, of which this hook is a
drop-in sibling — same <Form>, same <UploadProgressBar>, same
<WorkflowProgress>. An ALIAS rather than a second declaration of them:
the two have to agree field for field to be drop-in, and two copies of a type
that have to agree are two copies that can stop agreeing.
Exactly three of the fields mean something different here, and all three follow from WHEN the run is created — it exists before its bytes do:
submit()resolves when the UPLOAD finishes, not when the run is accepted; the run’s own progress arrives throughrun. It still resolves rather than rejecting on a failed upload — the failure is reported througherror, the way a form expects.runis set from the moment the run EXISTS, which here is before the bytes are in. That is what lets a page render<WorkflowProgress>beside the upload bar rather than after it.scopeis always"none": the run is reading the bytes THIS page sends, so a reload does not orphan it, it ENDS it — and<WorkflowPendingNote>says so instead of promising the run will be found again.
Type Parameters
Section titled “Type Parameters”R = unknown
I = unknown
WorkflowSubmission
Section titled “WorkflowSubmission”WorkflowSubmission<
R,I> =object
Sealed
What useWorkflowSubmit returns.
WorkflowStreamSubmission — an ALIAS of this type, returned by
useWorkflowStream, which is a drop-in sibling. Exactly two fields MEAN
something different there, and both differences follow from WHEN the run is
created: there, submit() resolves when the UPLOAD finishes rather than when
the run is accepted, and run is non-undefined from before the bytes are
in, so a page can render <WorkflowProgress> beside the upload bar instead
of after it. Here the run does not exist until the last byte lands.
Type Parameters
Section titled “Type Parameters”R = unknown
I = unknown
Properties
Section titled “Properties”cancel
Section titled “cancel”cancel: () =>
Promise<boolean>
Stop the current run, resolving whether this call is what ended it.
false for a run that had already finished, and for no run at all — the
SDK’s contract, because two tabs pressing Stop is ordinary. Distinct from
reset(), which puts the FORM back and leaves the run running.
Returns
Section titled “Returns”Promise<boolean>
error:
string|undefined
The submit’s own failure (a rejected input), or the watch’s.
pauseUpload
Section titled “pauseUpload”pauseUpload: () =>
void
Park the upload where it is, stopping the bytes in flight.
The windows already stored stay stored, so resumeUpload() sends what is
missing rather than the file — which is the difference between a pause a
person will actually use on a 200 MB recording and a cancel dressed up as one.
submit()’s promise stays unresolved across a pause, because the submission
genuinely has not finished: the run does not exist until the last byte lands,
so resolving here would tell a <Form> the work was accepted when nothing has
been started. A no-op when there is no upload in flight.
Returns
Section titled “Returns”void
pending
Section titled “pending”pending:
boolean
True from submit() until the run reaches a terminal status.
The WORK, not the request: a run outlives its POST, and a submit button
that re-enabled on the response would invite a second submission of work
already in flight.
reset: () =>
void
Clear the run and any error, putting the form back to its initial state.
Returns
Section titled “Returns”void
resumeUpload
Section titled “resumeUpload”resumeUpload: () =>
void
Continue a paused upload, sending only the windows the store does not have.
Returns
Section titled “Returns”void
run:
WorkflowRun<R> |undefined
The run, once started, followed to completion.
scope:
WorkflowSubmissionScope
Who can find the run again after this page is gone — see
WorkflowSubmissionScope. Derived from the hook’s options (key,
recover), so a page never restates it; <WorkflowPendingNote> reads it.
startedHere
Section titled “startedHere”startedHere:
boolean
True from submit() on this mount until reset() — did THIS page start
the run it is showing?
A page needs it to say the right sentence and cannot derive it: a run
ADOPTED by the mount-time lookup after a reload looks exactly like one this
page started. Six templates kept a useState(false) next to this hook, set
it in their onSubmit and mirrored it in their onClear — shadow state
for a fact only this hook can know, since it is the thing that decides
between submit() and the recovery lookup. One of the six grew a fourth
branch and had to move the whole note into its own module with its own
spec, which is what a seam missing one layer down looks like.
The RAW fact rather than a derived “recovered”, deliberately: with run
these are three states, not two, and the third is the one a page most needs
to explain. startedHere is “you pressed the button”; !startedHere && !run is the mount-time lookup still going; !startedHere && run is a run
this browser started earlier, now in front of somebody who did not press
anything. A boolean meaning only the last of those cannot express the
middle one.
Example
Section titled “Example”declare const submission: import("@alexkroman1/aai-ui").WorkflowSubmission;const note = submission.startedHere ? "You can close this tab or reload it — this page will find the run again." : submission.run === undefined ? "Looking for a run this tab started earlier…" : "Still working on the run this tab started earlier. Reloading is safe.";submit
Section titled “submit”submit: (
input) =>Promise<void>
Start a run with this input. Resolves once the run EXISTS — progress
arrives through run — so a <Form>’s handler can await it to know the
submission was accepted.
Parameters
Section titled “Parameters”I
Returns
Section titled “Returns”Promise<void>
submitForm
Section titled “submitForm”submitForm: (
values) =>Promise<void>
Start a run from a <Form>’s values, which are UNVALIDATED.
The same function as WorkflowSubmission.submit, with the type the
form path can honestly offer. FormValues is Record<string, unknown>
scraped off the DOM at submit time — a <TextField name="limit">
contributes a string whatever the schema says — so the shape is not known
here and the SERVER is what checks it against the workflow’s schema.
Two doors rather than one loose one: submit takes the workflow’s own
input type, so a hand-built object is checked at compile time and
submit({ ur1: 42 }) is an error; widening it to accept FormValues would
have made every object satisfy it and given the typing back. Reaching for
this one is the author saying “these came from a form”, which is a fact
about the values and not a cast.
Parameters
Section titled “Parameters”values
Section titled “values”Returns
Section titled “Returns”Promise<void>
timing
Section titled “timing”timing:
WorkflowSubmissionTiming
Press-to-settle marks for the submission THIS mount made — see
WorkflowSubmissionTiming. Both undefined before the first
submit() and again after reset(); a run adopted by the mount-time
lookup has no press to time from and sets neither.
upload
Section titled “upload”upload:
UploadStatus|undefined
How far the submission’s files have got, while any are still going.
Undefined before the first byte and again from the moment the last one
lands, so a page can render {upload && <UploadProgressBar upload={upload} />}
and the bar exists exactly for as long as there is an upload to describe. A
form with no files never sets it at all.
The wait it covers is the one run cannot: a run does not EXIST until its
input is stored, so pending is true and there is nothing to poll — which
for a 200 MB recording is minutes of a page that looks stuck.
wake: () =>
Promise<number>
End the current run’s sleep() early — “file it now” — resolving how many
pending sleeps were interrupted.
Bound to the run this submission is following, which is the point: it is
the only reason a page holding one of these hooks needed an api of its
own. 0 is an answer rather than a failure (the run had already moved past
its wait, or there is no run yet), so nothing here has to be guarded.
Returns
Section titled “Returns”Promise<number>
WorkflowSubmissionOf
Section titled “WorkflowSubmissionOf”WorkflowSubmissionOf<
D> =WorkflowSubmission<WorkflowOutputOf<D>,SubmitInputOf<D>>
The submission useWorkflowSubmit<D> / useWorkflowStream<D> hands back for
the workflow def D, named so a component can take one as a prop.
The hook’s own return is inferred, so a page that renders everything in one
function never needs this. A page that splits does, and there was no way to
write it from the outside without three names — WorkflowSubmission,
WorkflowOutputOf and SubmitInputOf — composed by hand, which is what a
ReturnType<typeof useWorkflowSubmit> cannot say because the hook is
generic. podcast-digest-workflow’s client.tsx composed exactly that.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const dailyDigest = workflow({ input: z.object({ hour: z.number() }), output: z.object({ posted: z.string() }), run: async ({ hour }) => ({ posted: `daily at ${hour}:00` }),});
export default agent({ name: "Digest", workflows: { dailyDigest } });
// file: client.tsximport type { WorkflowSubmissionOf } from "@alexkroman1/aai-ui";import type { dailyDigest } from "./agent.ts";
function ScheduleControls({ submission }: { submission: WorkflowSubmissionOf<typeof dailyDigest> }) { return submission.run?.status === "completed" ? <p>{submission.run.output.posted}</p> : null;}WorkflowSubmissionScope
Section titled “WorkflowSubmissionScope”WorkflowSubmissionScope =
"tab"|"browser"|"none"
Who can find the run again — the lifetime of the key it was recorded under.
"tab"—useWorkflowSubmit’s ownsessionStoragekey: a reload or a same-tab navigation finds the run; a new tab does not."browser"— alocalStoragekey (key: { storage: "local" }, oruseRunKey({ storage: "local" })), or a key the page supplied itself (an account’s id): any tab on this browser finds it, so closing the tab loses nothing."none"— nothing will find it:recover: false, oruseWorkflowStream, whose run is reading the bytes THIS page is sending and is ended, not orphaned, by a reload.
<WorkflowPendingNote> reads it to say the right sentence; the raw fact is
published so a page with its own note can too.
WorkflowSubmissionTiming
Section titled “WorkflowSubmissionTiming”WorkflowSubmissionTiming =
object
Sealed
The two marks a submission’s wall clock is read from — press to settle,
across the upload, the run, and the gap between them, which no server-side
number can give: output.elapsedMs is the RUN’s own clock and begins after
the upload. Only the browser holds both ends.
performance.now() marks rather than elapsed durations, and that is the
load-bearing part: an elapsed number is stale the instant it is computed, so
a page holding one has to re-render to keep it true. A mark lets the one
section that prints a running clock re-render on its own interval and read
performance.now() - submittedAt at render. Monotonic, so a clock
correction cannot make a run look instant, or negative.
Properties
Section titled “Properties”settledAt?
Section titled “settledAt?”
optionalsettledAt?:number
When the submission settled — the run reached a terminal status, or the
submit itself failed — measured at that moment rather than at the next
render. undefined while it is in flight, and for a run this page did
not start (there was no press to time from).
submittedAt?
Section titled “submittedAt?”
optionalsubmittedAt?:number
When submit() was called on this mount, or undefined before the first.
WorkflowSummary
Section titled “WorkflowSummary”WorkflowSummary =
object
Sealed
One declared workflow, as GET /workflows lists it.
Here rather than in host/ because both ends need it and only one of them is
a Node process: the API serves it, and a static page’s client renders a form
from it.
Properties
Section titled “Properties”description?
Section titled “description?”
optionaldescription?:string
The workflow’s own description, when it declared one.
inputSchema?
Section titled “inputSchema?”
optionalinputSchema?:unknown
JSON Schema for the run input, when the workflow declared one — what a page renders its form from. Converted at declaration-listing time rather than shipped as the Standard Schema itself, because the reader is a browser.
name:
string
Key the workflow is declared under in agent({ workflows }).
outputSchema?
Section titled “outputSchema?”
optionaloutputSchema?:unknown
JSON Schema for what a completed run answers with, when the workflow
declared an output — what a page renders its RESULTS from, the way
inputSchema is what it renders its form from.
Converted at declaration-listing time for the same stated reason: the reader is a browser, and a Standard Schema does not survive the wire.
The two are converted in opposite DIRECTIONS and the asymmetry is not an
oversight — see the converter in the runtime’s workflow-client.ts. An
input schema is described as what a caller may SEND (a .default() field
is optional); an output schema as what the run PRODUCES, which is the
parsed value, where that same field is always present.
uploads?
Section titled “uploads?”
optionaluploads?: readonlystring[]
Input properties that carry an upload id — see WorkflowDef.uploads.
Served alongside the schema because a form is rendered from BOTH: the schema says the property is a string, and this says the string is a file the page has to upload first.
Variables
Section titled “Variables”AGENT_STATE_LABELS
Section titled “AGENT_STATE_LABELS”
constAGENT_STATE_LABELS:Readonly<Record<AgentState,string>>
The default label per AgentState.
AgentState is open, so a lookup by a live state is string | undefined: fall back to the raw state for one this version has no word for.
Override the ones your page has a better word for and keep the rest:
import type { AgentState } from "@alexkroman1/aai-ui";import { AGENT_STATE_LABELS } from "@alexkroman1/aai-ui";
// A dispatch board that shouts, and renames one state.const STATE_LABEL: Readonly<Record<AgentState, string>> = { ...AGENT_STATE_LABELS, thinking: "Processing",};const shout = (s: AgentState) => (STATE_LABEL[s] ?? s).toUpperCase();Sentence case, deliberately. A template that wants caps applies its own
.toUpperCase(), and a template that wants Title Case is already there;
shipping the shouted form instead would leave the two chromes that do not
shout with a string they have to un-shout, which no case transform does
correctly.
Two wordings are decisions rather than transliterations of the member name:
disconnectedis “Idle”. It is the state a session is in BEFORE it has ever started as well as after it ends, so it is the first word most callers see; “Disconnected” reads as a fault on a page where nothing has gone wrong yet. Both chromes that mapped this state by hand chose “Idle” too.connectingandthinkingcarry an ellipsis,listeningandspeakingdo not. The first two are waits with nothing for the caller to do; the other two describe someone actually talking. Same distinctionWORKFLOW_STATUS_LABELSdraws with its one “Working…”.
Controls
Section titled “Controls”
constControls:MemoExoticComponent<FunctionComponent<ControlsProps>>
Session control buttons: Stop / Resume, New Conversation and End Call.
End Call is end(): it hangs up, drops the resume identity and returns the
shell to its start screen, so the provider sockets close and the next start
is a new session. Stop only pauses — the session is kept for a resume.
Reads session state from useSession. Must be rendered inside a
SessionProvider.
Example
Section titled “Example”import { Controls } from "@alexkroman1/aai-ui";
function Footer() { return <Controls className="justify-end" />;}props
Container props.
Markdown
Section titled “Markdown”
constMarkdown:MemoExoticComponent<FunctionComponent<MarkdownProps>>
Agent prose, rendered as Markdown.
Pipeline and S2S models write emphasis, lists, code, and links; before
this they arrived as literal asterisks and backticks. Styling is
per-element (theme colors via inline styles, spacing via Tailwind) so it
stays on the default client’s type scale and follows custom themes.
The optional variant selects the type scale (see
MarkdownVariant).
GFM is on for tables and strikethrough. react-markdown does not render
raw HTML unless rehype-raw is added — keep it that way, this text comes
from a model.
Memoized alongside MessageBubble: message content is referentially
stable across snapshots, so only the streaming row re-parses.
Example
Section titled “Example”import { Markdown, useSessionSelector } from "@alexkroman1/aai-ui";
// The agent's reply as it streams, rendered rather than shown as literal// asterisks and backticks.function LiveReply() { const text = useSessionSelector((snapshot) => snapshot.agentTranscript); return text === null ? null : <Markdown text={text} variant="compact" />;}MessageList
Section titled “MessageList”
constMessageList:MemoExoticComponent<FunctionComponent<MessageListProps>>
Scrollable list of all chat messages, tool-call blocks, live transcript, streaming agent utterance, and a thinking indicator.
Messages and tool calls are interleaved in the correct order. The list auto-scrolls to the latest content.
Must be rendered inside a SessionProvider.
Example
Section titled “Example”import { MessageList } from "@alexkroman1/aai-ui";
function Conversation() { return <MessageList className="flex-1" />;}props
Container props.
WORKFLOW_STATUS_LABELS
Section titled “WORKFLOW_STATUS_LABELS”
constWORKFLOW_STATUS_LABELS:Readonly<Record<WorkflowRunStatus,string>>
The default status line per WorkflowRunStatus.
Override the ones your page has a better word for and keep the rest:
import { WORKFLOW_STATUS_LABELS } from "@alexkroman1/aai-ui";
const STATUS_LINE = { ...WORKFLOW_STATUS_LABELS, running: "Writing…" };The wording is deliberately about the RUN rather than about the work — a page
knows what its workflow does and this does not, so running is the neutral
“Working…” and every page that cares replaces exactly that key.