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({ page: "static" }) |
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 |
| what was said | useConversation, useUserTranscript |
| what the agent projects | useAgentState — pass the slot.projected the agent declared as syncState, and it types the state AND supplies the frame rendered before the first push |
| 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”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> );}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 cream page with a header (logo + live-status eyebrow), the conversation on a raised white 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 in place of the logo in the header.
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>, "name" | "className" | "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) => ( <li key={item.kind === "message" ? item.message.id : item.toolCall.callId}> {item.kind === "message" ? item.message.content : item.toolCall.name} </li> ))} </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();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> );}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(
platformUrl?,fetchFn?):Promise<{greeting?:string;name?:string;page:"static"|"voice";sessionUrl?:string; }>
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”platformUrl?
Section titled “platformUrl?”string
The agent’s base URL. Defaults to the page’s own
origin and path (pageBaseUrl()), which is the agent that served the page
and the only case a browser has — the argument was required while the default
sat one module away in this same package, so every caller wrote
location.origin + location.pathname for itself. Pass one for a page reading
a DIFFERENT agent. The endpoint is resolved relative to it.
fetchFn?
Section titled “fetchFn?”{(input, init?): Promise<Response>; (input, init?): Promise<Response>; }
Fetch implementation, for tests and for a caller that
supplies its own credentials. Defaults to the global fetch.
Returns
Section titled “Returns”Promise<{ greeting?: string; name?: string; page: "static" | "voice"; sessionUrl?: string; }>
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.
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 non-empty
enum, before the type switch: an enum of strings is astringtoo, and a select is the narrower, better control. - Then the type —
boolean,number/integer,string. - 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".
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>, "name" | "className" | "type">
FieldShell props, read/upload, 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> );}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. A provided name then also sets
document.title, there being no shell header to show it in.
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({ name: "Pizza Ordering", theme: { bg: "#1a1a1a", primary: "#e55" }, sidebar: OrderPanel, tools: { add_pizza: { icon: "🍕", label: "Adding pizza" } },});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>, "name" | "className" | "type">
FieldShell props plus <input> attributes.
Returns
Section titled “Returns”Element
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>, "name" | "className">
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 and New
Conversation, with the URL chips — and it has no Start branch and no End,
because the default shell shows a start SCREEN and a session there ends by
closing the tab. 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-core.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 exhaustive colour lookup, 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: its own colours, three of its own words
import type { AgentState } from "@alexkroman1/aai-ui";import { SessionStateDot } from "@alexkroman1/aai-ui";
const STATE_COLORS = { disconnected: "#6b7280", connecting: "#6b7280", ready: "#22c55e", listening: "#22c55e", thinking: "#eab308", speaking: "#3b82f6", error: "#6b7280",} satisfies Record<AgentState, string>;
function StatusReadout() { return ( <SessionStateDot colors={STATE_COLORS} labels={{ listening: "LISTENING", thinking: "PROCESSING", speaking: "TRANSMITTING" }} labelClassName="text-[11px] uppercase" /> );}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 white card on the cream page with the logo, an
eyebrow label, a serif 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 in place of the logo on the card.
subtitle?
Section titled “subtitle?”string
A line under the title.
title?
Section titled “title?”string
The card’s serif 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.
Parameters
Section titled “Parameters”FieldShell & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "name" | "className">
FieldShell props plus <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>, "name" | "className">
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<
S>():S|null
The agent’s projected session state, or null before the first push.
The counterpart to syncState on the agent: whatever that projection
returns is what arrives here — no per-tool result mirroring needed.
import { useAgentState } from "@alexkroman1/aai-ui";
type Item = { sku: string; qty: number };
function Cart() { const state = useAgentState<{ cart: Item[] }>(); return <ul>{state?.cart.map((item) => <li key={item.sku}>{item.qty}</li>)}</ul>;}Typed by the caller for the same reason useToolResult is: the shape is
the author’s own projection, which the framework cannot see. It is
nullable on purpose — nothing has been pushed before the first tool call,
and a UI has to render that moment.
Type Parameters
Section titled “Type Parameters”S = any
Returns
Section titled “Returns”S | null
Call Signature
Section titled “Call Signature”useAgentState<
V>(projection):V
The agent’s projected session state, typed and defaulted by the SAME
projection the agent pushes — pass slot.projected and there is no type
argument to restate and no empty frame to derive.
This is the overload to reach for whenever syncState is a slot projection,
because it closes the round-trip the other two leave open. A projection is
callable, so the pre-first-push frame is what projection() returns — the
fallback overload’s own doc tells you to build it that way — and the
projection’s return type is the state’s type, so useAgentState<CartView>
was restating what cartView already knew. Both halves came out of the same
declaration and both were written by hand:
// `no-check`: the slot lives with the agent, in another file.// Before — the empty frame derived by hand, the type named three times:const EMPTY: CartView = cartSlot.projection(cartView)(undefined);const cart = useAgentState<CartView>(EMPTY);
// After — the slot declares its `view`, and both ends pass the one object// it built at declaration:const cart = useAgentState(cartSlot.projected);slot.projected is the spelling to prefer, and it retires the caveat
below. Declare the view on the slot (sessionSlot(key, create, { view }))
and the projection is built ONCE where the slot is, so agent({ syncState })
and this hook are handed the same object and nothing has to arrange for that.
slot.projection(view) composes a NEW projection per call, which is what
leaves both halves below to a convention.
The empty frame is memoized on the projection’s identity, so a module-scope
projection (the normal case) produces ONE frame for the life of the
component — which the fallback overload can only ask you to arrange by
hoisting, and which a slot.projection(view) spelled inline in the render
body silently got wrong. A slot.projected cannot be spelled inline: it is
the slot’s own field.
The one case that cannot use this overload is a slot whose declaring module
is expensive to IMPORT. A projection is built from the slot, so the browser
bundle gets whatever that module pulls in — and the cost is the static import
graph rather than the create() call, so no option on the slot can avoid it.
retail-orders-agent is the worked example: its slot lives beside a 107 KB seed, so the
page passes a fallback built by running the same view over a cheap empty
state, from a module that imports no seed. Reach for this overload
everywhere the slot’s module is cheap, which is every other stateful
template.
Type Parameters
Section titled “Type Parameters”V
Parameters
Section titled “Parameters”projection
Section titled “projection”The same projection the agent declares as syncState.
slot.projected is that object by construction; a slot.projection(view)
has to be exported from the module that declares the slot so the two ends
cannot drift.
Returns
Section titled “Returns”V
Call Signature
Section titled “Call Signature”useAgentState<
S>(fallback):S
The agent’s projected session state, falling back to fallback before the
first push — so the return is never null and a sidebar needs no branch for
the pre-first-tool-call moment.
Build the fallback by running the SAME projection over an empty state, not
by hand-writing an empty-looking literal: a field added to the projection
then reaches the first render too, instead of being undefined only in
that one frame.
// `no-check`: the projection lives with the agent, in another file.import { useAgentState } from "@alexkroman1/aai-ui";import { cartSlot, cartView, type CartView } from "./shared.ts";
const EMPTY: CartView = cartSlot.projection(cartView)(undefined);
function Cart() { const cart = useAgentState<CartView>(EMPTY); return <ul>{cart.items.map((item) => <li key={item.sku}>{item.qty}</li>)}</ul>;}Type Parameters
Section titled “Type Parameters”S = any
Parameters
Section titled “Parameters”fallback
Section titled “fallback”S
Returned while the agent has pushed nothing. Not memoized here — hoist it to module scope (or memoize it) so it is a stable reference across renders.
Returns
Section titled “Returns”S
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) => item.kind === "message" ? ( <p key={item.message.id} data-role={item.message.role}> {item.message.content} </p> ) : ( <code key={item.toolCall.callId}>{item.toolCall.name}</code> ), )} {streaming !== null && <p data-role="assistant">{streaming}</p>} {transcript.speaking && <p data-role="user">{transcript.text}</p>} {thinking && <p>…</p>} </div> );}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> );}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> );}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 turnDetection: "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> );}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, resetState, reset,
restart, 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-core.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 eight
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-core.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.
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]}</span>;}useTheme()
Section titled “useTheme()”useTheme():
Required<ClientTheme>
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”Required<ClientTheme>
Every ClientTheme field, filled in.
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()”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.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string | undefined
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()”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()”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.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
options?
Section titled “options?”Returns
Section titled “Returns”WorkflowSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>
Example
Section titled “Example”import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";import type { digest } from "./agent.ts";
function DigestForm() { const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest"); return ( <Form onSubmit={(values) => submit(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> );}WorkflowFields()
Section titled “WorkflowFields()”WorkflowFields(
props):Element|null
Render one field per scalar property of a workflow’s input schema.
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.
Parameters
Section titled “Parameters”Field-set props.
workflow?
Section titled “workflow?”string | WorkflowSummary
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.
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 — three 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. A page with a FOURTH situation — a run that a
reload does not recover but ENDS, as transcription-workflow’s streaming
flow has — writes its own sentence, and that template is the one doing so.
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”ToolCallRowProps
Section titled “ToolCallRowProps”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”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<{greeting?:z.ZodOptional<z.ZodString>;name?:z.ZodOptional<z.ZodString>;page:z.ZodEnum<{static:"static";voice:"voice"; }>;sessionUrl?:z.ZodOptional<z.ZodString>; }>
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<{ greeting?: z.ZodOptional<z.ZodString>; name?: z.ZodOptional<z.ZodString>; page: z.ZodEnum<{ static: "static"; voice: "voice"; }>; sessionUrl?: z.ZodOptional<z.ZodString>; }>
AgentCustomEvent
Section titled “AgentCustomEvent”AgentCustomEvent =
object
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"
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-core-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.
AudioResultCaptions
Section titled “AudioResultCaptions”AudioResultCaptions =
object
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
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();start()
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.
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
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
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"
The sender of the message.
ClientConfig
Section titled “ClientConfig”ClientConfig =
Pick<VoiceSessionOptions,"onSessionId"|"resumeSessionId"|"WebSocket"> &object
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.
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.
optionalname?:string
Agent name shown in the header and start screen — and, with a component,
the page title, there being no shell header to put it in. Left out, the
default shell asks the agent for its own declared name.
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 color overrides.
tools?
Section titled “tools?”
optionaltools?:ToolDisplayConfig
Tool display config: icon and label overrides keyed by tool name.
Honoured with a custom component too: mountClient installs it into
ToolConfigContext, and the consumer is ToolCallBlock — which a custom
component renders as soon as it uses MessageList or ChatView, the usual
way to build one.
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 twice in this file’s history:
mountClient({ name, component }) and mountClient({ component, tools }) were both
the natural thing to write, both were refused with “Type ‘string’ is not
assignable to type ‘undefined’”, and both cost a build round each time
before the ban was lifted. What was left banned was sidebar beside a
component, which invited the identical failure for a combination
mountClient can simply render.
ClientConfigResponse
Section titled “ClientConfigResponse”ClientConfigResponse =
z.infer<typeofClientConfigResponseSchema>
Parsed body of GET /client-config.
ClientHandle
Section titled “ClientHandle”ClientHandle =
object
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.
ClientTheme
Section titled “ClientTheme”ClientTheme =
object
Theme color overrides for the AAI UI components.
Properties
Section titled “Properties”
optionalbg?:string
Background color, also painted on html/body. Default: #FBF8F2.
border?
Section titled “border?”
optionalborder?:string
Border color. Default: #DCD7CC.
primary?
Section titled “primary?”
optionalprimary?:string
Primary accent color. Default: #3F2BC1.
surface?
Section titled “surface?”
optionalsurface?:string
Surface/card color. Default: #FFFFFF.
optionaltext?:string
Main text color. Default: #1B1A18.
ConsoleShellProps
Section titled “ConsoleShellProps”ConsoleShellProps =
object
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 in place of the logo in the header.
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
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; }
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.
ConversationViewProps
Section titled “ConversationViewProps”ConversationViewProps =
object
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.
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
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.
FactsProps
Section titled “FactsProps”FactsProps =
object
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.
FieldShell
Section titled “FieldShell”FieldShell =
object
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
What a FileField contributes to FormValues.
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.
FormProps
Section titled “FormProps”FormProps =
object&Omit<FormHTMLAttributes<HTMLFormElement>,"onSubmit"|"className">
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.
MarkdownProps
Section titled “MarkdownProps”MarkdownProps =
object
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
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.
PageConfig
Section titled “PageConfig”PageConfig =
object
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
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
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, resetState,
disconnect, cancel, end).
Note there is no text-send method — sessions are voice-only; the only client→server inputs are audio and the control methods above.
SessionActions
Section titled “SessionActions”SessionActions =
object
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
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"
Which of the four buttons a SessionControlButton is.
SessionControlButton
Section titled “SessionControlButton”SessionControlButton =
object
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
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
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
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
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 =
z.infer<typeofSessionErrorCodeSchema>
Error codes for categorizing session errors on the wire.
Remarks
Section titled “Remarks”The field a client renders its error banner from (error.reported.code, and
SessionError.code in @alexkroman1/aai-ui). Eight values, by where the
failure came from:
stt— speech-to-text: the provider refused the connection, or its stream failed mid-utterance.llm— the model call for a reply failed. In pipeline mode the caller also hearserrorPhrase, so the turn is handed back rather than going silent.tts— synthesis failed, which is the one the caller cannot hear.tool— a tool threw and the failure could not be given to the model.protocol— a frame that does not parse, or one sent in a state that has no answer for it.connection— the session’s own link, or a provider’s, went away.audio— the audio path: a rate the transport cannot honour, a decode.internal— anything the runtime could not classify.
Severity is fatal, not the code, and the two are independent: any of
these can arrive on a session that continues. fatal: false means surface
the message and keep the session interactive. It is REQUIRED: a fatal frame
is not a banner — aai-ui answers one by releasing the microphone and ending
the call — so every emitter states which it means rather than inheriting a
default that takes the whole session down.
SessionSnapshot
Section titled “SessionSnapshot”SessionSnapshot =
object
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:unknown
Latest state the agent projected via syncState, 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.
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.
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.
SessionStateDotProps
Section titled “SessionStateDotProps”SessionStateDotProps =
object
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”colors:
Readonly<Record<AgentState,string>>
The dot’s colour per AgentState — the chrome’s own palette. A
complete record, so a state added upstream is a compile error here rather
than a silently unpainted dot; satisfies Record<AgentState, string> on the
caller’s literal is the shape to write it in.
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.
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.
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
ToolCallInfo
Section titled “ToolCallInfo”ToolCallInfo =
object
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"
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.
ToolDisplayConfig
Section titled “ToolDisplayConfig”ToolDisplayConfig =
Record<string, {icon?:string;label?:string; }>
Display configuration for a tool call in the UI.
UploadStatus
Section titled “UploadStatus”UploadStatus =
UploadProgress&object
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.
UseConversationResult
Section titled “UseConversationResult”UseConversationResult =
object
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
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
UseDownloadUrlOptions
Section titled “UseDownloadUrlOptions”UseDownloadUrlOptions =
object
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
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
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.
UsePushToTalkOptions
Section titled “UsePushToTalkOptions”UsePushToTalkOptions =
object
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
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.
UserTurnControls
Section titled “UserTurnControls”UserTurnControls =
object
Push-to-talk’s three edges on a BrowserSession — session.userTurn.
Only an agent declaring turnDetection: "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
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
UseWorkflowProgressResult
Section titled “UseWorkflowProgressResult”UseWorkflowProgressResult<
T> =object
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
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
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
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
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
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 =
Omit<UseWorkflowSubmitOptions,"wait"|"recover">
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.
UseWorkflowSubmitOptions
Section titled “UseWorkflowSubmitOptions”UseWorkflowSubmitOptions =
object
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
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
useRunKey({ storage: "local" }) 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.
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
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”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
platformUrl
Section titled “platformUrl”platformUrl:
string
Base URL of the AAI platform server.
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.
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
Minimal WebSocket constructor type accepted by VoiceSessionOptions.
new WebSocketConstructor(
url,protocols?):WebSocket
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<UploadInfo>
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<UploadInfo>
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
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.
WorkflowFieldKind
Section titled “WorkflowFieldKind”WorkflowFieldKind =
"text"|"number"|"select"|"checkbox"|"file"|"none"
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.
WorkflowInputOf
Section titled “WorkflowInputOf”WorkflowInputOf<
D> =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”export const digest = workflow({ input: z.object({ topic: z.string(), limit: z.number().default(5) }), run: digestFlow,});
// workflows/digest.ts — `import type` is erased, so there is no cycle.import type { WorkflowInputOf } from "@alexkroman1/aai";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 research(input.topic, input.limit);}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.
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”export const transcribe = workflow({ input: …, output: transcriptSchema, run: transcribeFlow });
// client.tsx — `import type` is erased, so nothing server-side is bundled.import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";import type { transcribe } from "./agent.ts";
const run = useWorkflowRun<WorkflowOutputOf<typeof transcribe>>(runId, { api });if (run?.status === "completed") console.log(run.output.text); // typedIt 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
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?:"tab"|"browser"
Where the key that finds the run again lives. "tab" (the default) 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.
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 these three fields.
Nothing renders while pending is false.
pending
Section titled “pending”
readonlypending:boolean
readonlyrun:WorkflowRun|undefined
startedHere
Section titled “startedHere”
readonlystartedHere:boolean
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
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
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.
WorkflowStreamSubmission
Section titled “WorkflowStreamSubmission”WorkflowStreamSubmission<
R,I> =WorkflowSubmission<R,I>
What useWorkflowStream returns: a WorkflowSubmission, exactly.
The same eight 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 the eight:
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 two of the fields mean something different here, and both differences 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.
Type Parameters
Section titled “Type Parameters”R = unknown
I = unknown
WorkflowSubmission
Section titled “WorkflowSubmission”WorkflowSubmission<
R,I> =object
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.
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>
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>
WorkflowSummary
Section titled “WorkflowSummary”WorkflowSummary =
object
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.
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 = { ...AGENT_STATE_LABELS, thinking: "Processing" };const shout = (s: AgentState) => STATE_LABEL[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 and New Conversation.
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.