Skip to content

index

The browser client for aai agents — React 19 hooks and components over a framework-agnostic session core (WebSocket + microphone + playback).

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.

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/aaiWorkflowInputOf, 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.)

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.

AudioResultProps

See AudioResultProps.

ReactNode

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(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.

Scroll container props.

ReactNode

The scrollable content.

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.

string

Classes for the inner content element, where padding and the children’s own layout belong.

"instant" | "smooth"

Scroll behavior on mount. Defaults to "instant" — start at the latest content without animating a scroll the reader did not ask for.

"instant" | "smooth"

Scroll behavior when pinned content grows. Defaults to "smooth".

string

Classes for the scrolling element itself. Defaults to hiding the scrollbar; pass "overflow-y-auto" to show a native one.

CSSProperties

Inline styles for the outer container.

ReactNode

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.

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(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 what noArrayIndexKey exists to talk you out of, and a unique key is one Map away, 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-rolled if (items.length === 0) return null and 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, title included: a heading over no bullets is a claim the run did not make.

BulletListProps

Bullet-list props.

ReactNode

import { BulletList } from "@alexkroman1/aai-ui";
function Findings({ risks }: { risks: string[] }) {
return <BulletList title="Risks" items={risks} size="sm" />;
}

Button(props): Element

A styled button with variant and size presets.

Accepts all standard <button> HTML attributes in addition to the props listed below.

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.

Element

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(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.

Chat surface props.

string

Additional CSS class names for the root element, appended to its own.

ReactNode

Element rendered in place of the logo in the header.

string

Title string for the header. Defaults to the agent’s declared name.

ReactNode

import { ChatView, StartScreen } from "@alexkroman1/aai-ui";
function App() {
return (
<StartScreen icon="🍕" title="Pizza Palace">
<ChatView />
</StartScreen>
);
}

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.

FieldShell & Omit<InputHTMLAttributes<HTMLInputElement>, "name" | "className" | "type">

FieldShell props plus <input> attributes.

Element


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.

ConsoleShellProps

See ConsoleShellProps.

ReactNode

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(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.

ConversationViewProps

See ConversationViewProps.

ReactNode

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(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).

VoiceSessionOptions

Session configuration including the platform server URL.

BrowserSession

A BrowserSession handle for controlling the session.

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(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.

WorkflowApiOptions

See WorkflowApiOptions. Both fields are optional; the default base URL is the page’s own origin and path.

AgentClient

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.

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(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.

FactsProps

Facts-line props.

ReactNode

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(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.

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.

{(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.

Promise<{ greeting?: string; name?: string; page: "static" | "voice"; sessionUrl?: string; }>

The agent’s config, or {} when the lookup produced no answer.

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(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.

Field-shell props.

ReactNode

The control itself.

string

Additional CSS class names for the wrapper, appended to its own.

string

One line of guidance under the control.

string

Id of the control this labels.

string

Visible label. Omitted leaves the control unlabelled.

Element

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(schema, options?): WorkflowFieldKind

Which control <WorkflowFields> renders for one property of an input schema.

unknown

The property’s JSON Schema, as GET workflows reports it. Anything that is not an object reads as "none".

upload says the property is named in the workflow’s own uploads declaration, which is what step 1 above tests.

boolean

WorkflowFieldKind

The ORDER is the contract, and it is the half a reader cannot infer:

  1. A declared upload wins outright. It is a plain string in 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.
  2. Then a non-empty enum, before the type switch: an enum of strings is a string too, and a select is the narrower, better control.
  3. Then the typeboolean, number/integer, string.
  4. Anything else is "none" rather than a guess. See WorkflowFieldKind.
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(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.

FieldShell & object & Omit<InputHTMLAttributes<HTMLInputElement>, "name" | "className" | "type">

FieldShell props, read/upload, and <input> attributes.

Element


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.

FormProps

See FormProps. Every <form> attribute except onSubmit and className is passed through.

Element

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<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.

R

WorkflowRunSnapshot<R> | undefined

run is TerminalWorkflowRun<R>


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.

ClientConfig

ClientHandle

A ClientHandle for cleanup.

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 });

If the target element is not found in the DOM.


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.

PageConfig

PageHandle

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 });

If the target element is not found in the DOM.


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.

FieldShell & Omit<InputHTMLAttributes<HTMLInputElement>, "name" | "className" | "type">

FieldShell props plus <input> attributes.

Element


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).

FieldShell & object & Omit<SelectHTMLAttributes<HTMLSelectElement>, "name" | "className">

FieldShell props, options, and <select> attributes.

Element


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.

SessionControlsProps

See SessionControlsProps.

ReactNode

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(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.

SessionErrorBannerProps

See SessionErrorBannerProps.

ReactNode

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(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.

SessionStateDotProps

See SessionStateDotProps.

ReactNode

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(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 />.

Layout props.

ReactNode

The main pane, normally a <ChatView />.

string

Additional CSS class names for the root element, appended to its own.

ReactNode

The sidebar pane — a cart, a dashboard, a run history.

"left" | "right"

Which side the sidebar sits on. Defaults to "left".

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.

Element

import { ChatView, SidebarLayout } from "@alexkroman1/aai-ui";
function OrderPanel() {
return <div>Cart</div>;
}
function App() {
return (
<SidebarLayout sidebar={<OrderPanel />}>
<ChatView />
</SidebarLayout>
);
}

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.

Start-screen props.

string

Label of the start CTA. Defaults to "Start Conversation".

ReactNode

The app, rendered once the session has started.

string

Additional CSS class names for the root element, appended to its own.

ReactNode

Element rendered in place of the logo on the card.

string

A line under the title.

string

The card’s serif title. Defaults to the agent’s declared name.

ReactNode

import { ChatView, StartScreen } from "@alexkroman1/aai-ui";
function MyAgent() {
return (
<StartScreen icon="🍕" title="Pizza Palace" subtitle="Voice-powered ordering">
<ChatView />
</StartScreen>
);
}

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.

object & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "disabled" | "type">

Button props.

Element

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(props): Element

A multi-line text input.

Accepts every <textarea> attribute except name and className, plus the shared FieldShell props. rows defaults to 4.

FieldShell & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "name" | "className">

FieldShell props plus <textarea> attributes.

Element


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.

FieldShell & Omit<InputHTMLAttributes<HTMLInputElement>, "name" | "className">

FieldShell props plus <input> attributes.

Element


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.

ToolCallRowProps

See ToolCallRowProps.

ReactNode

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(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. upload is 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.paused exists, 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.

Progress-bar props.

string

Replaces the default classes rather than extending them, so a custom chrome is not fighting a default it did not ask for.

() => 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.

() => void

The hook’s resumeUpload. See onPause — the two travel together.

UploadStatus

What useWorkflowSubmit / useWorkflowStream report as upload. undefined renders nothing, so a page may pass its state straight through and never guard the element.

ReactNode

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<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.

S = any

S | null

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.

V

StateProjection<V>

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.

V

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>;
}

S = any

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.

S


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.

UseConversationResult

See UseConversationResult.

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(): UseCopyResult

One copier for a group of copy buttons.

UseCopyResult

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.

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(uploadId, options?): UseDownloadUrlResult

Read an upload’s bytes and hand back a URL a DOM element can use.

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.

UseDownloadUrlOptions

See UseDownloadUrlOptions.

UseDownloadUrlResult

See UseDownloadUrlResult.

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<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.

T = unknown

string

(data) => void

void

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<T>(ms?): UseFlashResult<T>

A transient value: set it, and it clears itself after ms.

T

What is being flashed.

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.

UseFlashResult<T>

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 setState on a component React has already thrown away.
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(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.

UsePushToTalkOptions

UsePushToTalkResult

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(options?): string

A lookup key for useWorkflowSubmit({ key }), stable across reloads.

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.

"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”.

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(): 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.

Session

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(): 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.

SessionActions

The control methods — see SessionActions.

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(): 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.

UseSessionControlsResult

See UseSessionControlsResult.

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(): 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.

SessionError | null

The current error, or null.


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.

T

(snapshot) => T

Reads the slice out of the snapshot. Must be pure.

(a, b) => boolean

Compares two selected values. Defaults to Object.is.

T

The selected slice.

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(): AgentState

The agent’s live AgentStatedisconnected, 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.

AgentState

The current agent state.

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(): 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.

Required<ClientTheme>

Every ClientTheme field, filled in.

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<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.

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", …).

string

Only calls of this tool fire the callback.

(toolCall) => void

Called with the pending call.

void

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;
}

useToolCallStart<A>(callback): void

Fire a callback when ANY tool call starts — read the tool’s name off the call itself (toolCall.name).

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.

(toolCall) => void

Called with the pending call.

void

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<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.

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.

string

Only calls of this tool fire the callback.

(result, toolCall) => void

Called with the parsed result and the call itself.

void

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;
}

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.

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.

(name, result, toolCall) => void

Called with the tool’s name, the parsed result, and the call itself.

void

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(): 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.

UseUserTranscriptResult

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<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.

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.

string | undefined

WorkflowApi

number

string

number

UseWorkflowProgressResult<T>

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<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.

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.

string | undefined

The run to watch. undefined costs nothing, so a page may pass its state straight through before a run exists.

api when the page holds its own client; intervalMs to change the poll interval the stream falls back to.

WorkflowApi

number

UseWorkflowRunResult<R>

The latest snapshot, the last read’s error, and whether the watch is still going — see UseWorkflowRunResult.

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<R>(workflow, options?): UseWorkflowRunsResult<R>

Read a workflow’s recent runs.

R = unknown

The workflow’s output type, so a completed run’s output is typed rather than unknown. Derive it with WorkflowOutputOf.

string | undefined

UseWorkflowRunsOptions

UseWorkflowRunsResult<R>

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(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.

UseWorkflowsOptions

See UseWorkflowsOptions.

UseWorkflowsResult

The listing, its loading flag and its failure — see UseWorkflowsResult.

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<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.

D extends AnyWorkflowDef

string

UseWorkflowStreamOptions

WorkflowStreamSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>

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<D>(workflow, options?): WorkflowSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>

Start a workflow from a form, and follow the run it creates.

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.

string

UseWorkflowSubmitOptions

WorkflowSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>

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(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.

Field-set props.

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.

Element | null

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(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 && run is 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 && !run is 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.

WorkflowPendingNoteProps

See WorkflowPendingNoteProps.

ReactNode

import { useWorkflowSubmit, WorkflowPendingNote } from "@alexkroman1/aai-ui";
function App() {
const submission = useWorkflowSubmit("redline");
return <WorkflowPendingNote submission={submission} subject="draft" />;
}

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. supported is 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. See role="log" below. The six pages that render this pass only className, so no template could have fixed it locally; that is what makes it this component’s job.

Progress-log props.

WorkflowApi

The workflow API client, when the page holds its own. Defaults to the lazily-built one every workflow hook shares.

string

Replaces the default classes rather than extending them, so a custom chrome is not fighting a default it did not ask for.

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.

ReactNode

Rendered instead of nothing while the run has said nothing yet — for a page that would otherwise reflow when the first line lands.

string

The run to read. undefined renders nothing, so a page may pass its state straight through before a run exists.

ReactNode

import { WorkflowProgress } from "@alexkroman1/aai-ui";
function RunPanel({ runId }: { runId: string }) {
return <WorkflowProgress runId={runId} />;
}

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.

WorkflowRunErrorProps

See WorkflowRunErrorProps.

ReactNode

import { useWorkflowSubmit, WorkflowRunError } from "@alexkroman1/aai-ui";
function App() {
const { run } = useWorkflowSubmit("digest");
return <WorkflowRunError run={run} />;
}

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.

O = unknown

WorkflowRunPanelProps<O>

See WorkflowRunPanelProps.

ReactNode

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>
);
}

Props for ToolCallRow.

optional children?: 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.

optional className?: string

Additional CSS class names for the outer container.

optional detail?: ReactNode

One-line detail (typically an args preview), truncated to the row.

optional icon?: ReactNode

Optional icon rendered in place of the outlined “TOOL” chip.

optional pending?: boolean

True while the call is in flight — animates the title with a shimmer.

title: ReactNode

Tool title, rendered in mono (shimmers while pending).

optional variant?: ToolCallRowVariant

Size preset; defaults to "default".


What useUserTranscript returns.

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: 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.

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.

readonly baseUrl: 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(): 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.

Promise<{ greeting?: z.ZodOptional<z.ZodString>; name?: z.ZodOptional<z.ZodString>; page: z.ZodEnum<{ static: "static"; voice: "voice"; }>; sessionUrl?: z.ZodOptional<z.ZodString>; }>


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.

readonly data: unknown

readonly event: string

readonly id: number


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.

The seven members, in the order a call passes through them:

  • "disconnected" — no socket. The state before the first start() and after disconnect() / 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 (see session-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 = object

A one-cue caption track for AudioResult: the words the clip speaks, spanning its whole length.

durationMs: number

The clip’s length, which is where the cue ends.

optional label?: string

The track’s label. Defaults to the player’s label.

optional srcLang?: string

The track’s srcLang. Default "en".

text: string

The spoken text — the one cue.


AudioResultProps = object

Props of AudioResult.

optional captions?: 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.

optional children?: ReactNode

Rendered under the player — the spoken text, usually.

optional className?: string

Additional CSS class names for the wrapping <section>, appended to its own.

download: UseDownloadUrlResult

What useDownloadUrl returned for the run’s audio upload.

filename: string

The name the download link saves as — "summary.wav", "audit.mp3".

optional heading?: 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 = 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.

[dispose](): void

Alias for disconnect for use with using.

void

cancel(): void

Cancel the current agent turn and discard in-flight TTS audio.

void

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.

Optional. signal is an AbortSignal that, when aborted, disconnects the session.

AbortSignal

void

disconnect(): void

Close the WebSocket and release all audio resources.

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.

void

getSnapshot(): SessionSnapshot

Return the current immutable state snapshot.

SessionSnapshot

reset(): void

Reset the session: clear state as resetState() does, then drop and reopen the connection for a fresh conversation.

void

resetState(): void

Clear messages, transcripts, and error state while keeping the current connection (unlike reset(), which also reconnects).

void

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.

void

declare const session: import("@alexkroman1/aai-ui").Session;
session.restart();

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.

void

subscribe(callback): () => void

Subscribe to state changes. Returns an unsubscribe function.

() => void

() => void

toggle(): void

Toggle between connected and disconnected states (after start()).

void

readonly [browserSessionBrand]: true

The seal — see browserSessionBrand.

readonly userTurn: UserTurnControls

Push-to-talk’s three edges — see UserTurnControls.


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 = object

Props for BulletList.

optional className?: 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.

optional size?: "sm" | "base"

"sm" adds text-sm, which two of the five copies carried and three did not. "base" is the default and adds nothing.

optional title?: 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 = "default" | "lg"

Size preset for a Button.

  • "default" — Compact control (height 36 px).
  • "lg" — Primary CTA (height 44 px, generous padding).

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 = 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).

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 = 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.

optional buttonText?: string

Label of the start CTA. Defaults to "Start Conversation".

optional component?: 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.

optional icon?: 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.

optional name?: 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.

optional platformUrl?: string

Base URL of the AAI platform server. Derived from location.href by default.

optional sidebar?: 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.

optional sidebarPosition?: "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.

optional sidebarWidth?: string

CSS width of the sidebar. Defaults to "18rem".

optional subtitle?: string

A line under the title on the start card.

optional target?: string | HTMLElement

CSS selector or DOM element to render into. Defaults to "#app".

optional theme?: ClientTheme

Theme color overrides.

optional tools?: 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.

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 = z.infer<typeof ClientConfigResponseSchema>

Parsed body of GET /client-config.


ClientHandle = object

Handle returned by mountClient for cleanup.

Implements Disposable so it can be used with using.

[dispose](): void

Alias for dispose for use with using.

void

dispose(): void

Unmount the UI and disconnect the session.

void

session: BrowserSession

The underlying session core.


ClientTheme = object

Theme color overrides for the AAI UI components.

optional bg?: string

Background color, also painted on html/body. Default: #FBF8F2.

optional border?: string

Border color. Default: #DCD7CC.

optional primary?: string

Primary accent color. Default: #3F2BC1.

optional surface?: string

Surface/card color. Default: #FFFFFF.

optional text?: string

Main text color. Default: #1B1A18.


ConsoleShellProps = object

Props of ConsoleShell.

children: ReactNode

Card content — normally a MessageList.

optional className?: string

Additional CSS class names for the root element, appended to its own.

footer: ReactNode

Row rendered beneath the card (controls).

optional icon?: ReactNode

Element rendered in place of the logo in the header.

pulsing: boolean

Whether the status dot pulses.

state: AgentState

Live status shown in the header eyebrow.

optional title?: string

Title string for the header.


ControlsProps = object

Props of Controls.

optional className?: string

Additional CSS class names, appended to the container’s own layout classes rather than replacing them.


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 = object

Props of ConversationView.

optional className?: string

Classes for the AutoScroll container. It must end up with a bounded height (flex-1 min-h-0, h-full) or nothing pins.

optional contentClassName?: string

Classes for the scroll region’s content element — padding, gap, direction.

optional empty?: ReactNode

Rendered inside the scroll region while there is nothing to show at all.

renderMessage: (message) => ReactNode

One finalized message, in this chrome’s own markup.

ChatMessage

ReactNode

optional renderStreaming?: (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.

string

ReactNode

optional renderTool?: (toolCall) => ReactNode

One tool invocation. Absent, a compact ToolCallRow naming the tool, shimmering while it is pending.

ToolCallInfo

ReactNode

optional renderTranscript?: (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.

UseUserTranscriptResult

ReactNode

optional scrollClassName?: string

Classes for the scrolling element itself. See AutoScroll.

optional style?: CSSProperties

Inline styles for the scroll container.

optional thinkingClassName?: string

CSS class names for the thinking row itself (the role="status" element).

optional thinkingIndicator?: ReactNode

What the thinking row shows. Default: three pulsing dots.

optional thinkingLabel?: 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.

optional transcriptPosition?: "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 = object

Props for Facts.

optional as?: "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.

optional className?: 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.

optional size?: "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 = 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.

optional className?: 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.

optional hint?: string

One line of guidance under the control.

optional label?: string

Visible label. Omitted leaves the control unlabelled — pass aria-label instead.

name: string

Key this field contributes to FormValues.


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 = object

What a FileField contributes to FormValues.

optional content?: 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: 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 = object & Omit<FormHTMLAttributes<HTMLFormElement>, "onSubmit" | "className">

Props of Form.

optional children?: ReactNode

optional className?: string

optional error?: 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.

(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 = 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 = object

Props of Markdown.

text: string

The Markdown source. Required — this is the prose to render, normally one agent message or the streaming tail of one.

optional variant?: MarkdownVariant

Type scale. Defaults to "default", the deployed agent UI’s scale; pass "compact" for a denser surface. Colors are unaffected either way.


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 = object

Props of MessageList.

optional className?: 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 = object

Configuration for mountPage.

optional component?: 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.

optional name?: 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.

optional target?: string | HTMLElement

CSS selector or DOM element to render into. Defaults to "#app".

optional theme?: ClientTheme

Theme color overrides, read by the same tokens the voice components use.


PageHandle = object

Handle returned by mountPage. Disposable, so using works.

[dispose](): void

Alias for dispose for use with using.

void

dispose(): void

Unmount the React tree.

void


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 = 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.

cancel(): void

Cancel the current agent turn and discard in-flight TTS audio.

void

disconnect(): void

Close the WebSocket and release all audio resources.

void

end(): void

End the call and return to the not-started state — see BrowserSession.end.

void

reset(): void

Clear state and reopen the connection — the same session id.

void

resetState(): void

Clear messages, transcripts and error state, keeping the connection.

void

restart(): void

End the call and begin a fresh one — see BrowserSession.restart.

void

start(): void

Start the call for the first time — see BrowserSession.start.

void

toggle(): void

Toggle between connected and disconnected (after start()).

void


SessionControlAction = "start" | "toggle" | "restart" | "end"

Which of the four buttons a SessionControlButton is.


SessionControlButton = object

One button of SessionControls, as handed to renderButton.

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: () => void

The handler. Already bound; wire it to onClick as it is.

void

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 = object

The five words SessionControls renders, every one overridable.

end: string

Hang up. Default "End".

pause: string

The toggle’s label while running. Default "Pause".

restart: string

Hang up and dial again. Default "New Conversation".

resume: string

The toggle’s label while paused. Default "Resume".

start: string

The button shown before the call starts. Default "Start".


SessionControlsProps = object

Props of SessionControls.

optional children?: ReactNode

Rendered after the buttons — a count, a spacer, a status line.

optional className?: string

Additional CSS class names for the row, appended to its own layout classes.

optional labels?: Partial<SessionControlsLabels>

The words this chrome has its own term for; the rest keep the defaults.

optional renderButton?: (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.

SessionControlButton

ReactNode


SessionError = object

Error reported by the voice session.

readonly code: SessionErrorCode

The category of the error.

readonly fatal: 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.

readonly message: string

A human-readable description of the error.


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.

optional className?: string

Additional CSS class names for the banner, appended to its own.


SessionErrorCode = z.infer<typeof SessionErrorCodeSchema>

Error codes for categorizing session errors on the wire.

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 hears errorPhrase, 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 = 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.

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.

readonly agentState: 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.

readonly agentTranscript: 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.

readonly apiUrl: 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.

readonly contentVersion: 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.

readonly customEvents: 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.

readonly error: 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.

readonly messages: 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.

readonly recording: 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.

readonly running: 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.

readonly started: 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.

readonly state: AgentState

What the agent is doing. See AgentState for the seven members.

readonly toolCalls: 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.

readonly userTranscript: 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 = object

Props of SessionStateDot.

optional className?: string

Additional CSS class names for the wrapping <span>, appended to its own.

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.

optional dotClassName?: 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.

optional labelClassName?: string

Additional CSS class names for the label <span>.

optional labels?: 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.

optional pulse?: 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<D> = [WorkflowInputOf<D>] extends [never] ? undefined : WorkflowInputOf<D>

What submit() takes for Dundefined 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.

D


ToolCallInfo = object

Info about a tool call for display in the UI.

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 DefaultToolResultany — 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: string

name: string

optional result?: string

seq: number

Monotonically increasing, session-unique insertion sequence number. Tool calls in a snapshot are always sorted ascending by seq.

status: "pending" | "done"


ToolCallRowVariant = "default" | "compact"

Size preset for ToolCallRow: "default" is the deployed agent UI’s scale, "compact" the studio transcript’s denser one.


ToolDisplayConfig = Record<string, { icon?: string; label?: string; }>

Display configuration for a tool call in the UI.


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.

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: 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 = object

What useConversation returns.

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: 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: 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: 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 = object

What useCopy hands back — the click handler and the two readings a button needs off one shared flash.

copy: (text) => void

Copy text, flashing the button that owns it.

string

void

didCopy: (text) => boolean

True when text was the last thing copied, successfully.

string

boolean

label: (text, idle?) => string

The button label for textidle 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.

string

string

string


UseDownloadUrlOptions = object

Options for useDownloadUrl.

optional api?: WorkflowApi

The client to read the bytes with. Defaults to one for the page’s own agent.


UseDownloadUrlResult = object

What useDownloadUrl reports.

optional error?: string

The read’s failure, as the agent’s own sentence where it gave one.

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).

optional url?: 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<T> = object

What useFlash hands back.

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.

readonly flash: (value) => void

Show value for the hook’s duration, replacing any flash already up.

T

void

readonly value: T | null

What is being shown right now, or null between flashes.


UsePushToTalkOptions = object

Options for usePushToTalk.

optional holdKey?: 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 = object

What usePushToTalk returns.

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: boolean

disabled: boolean

(event) => void

(event) => void

(event) => void

() => void

(event) => void

() => void

cancel: () => void

Close the turn and discard it — nothing is answered. Ignored unless held.

void

press: () => void

Open a turn. Interrupts the agent if it is speaking. Ignored while held.

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: () => void

Close the turn and have the agent answer it. Ignored unless held.

void

talking: boolean

Whether a turn is being held open right now — the button is DOWN.


UserTurnControls = object

Push-to-talk’s three edges on a BrowserSessionsession.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.

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.

void

commit(): void

CLOSE the turn and have the agent answer everything said since start() — the button came up.

void

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.

void


UseSessionControlsResult = object

What useSessionControls returns.

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.

void

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.

void

running: boolean

Whether the started call is live rather than paused — the Pause/Resume hinge.

start: () => void

Dial. What the button before started presses.

void

started: boolean

Whether a call has been started and not yet ended — the Start/End hinge.

toggle: () => void

Pause a running call, or resume a paused one.

void


UseWorkflowProgressResult<T> = object

T = string

latest: T | undefined

The newest chunk, or undefined before the first one lands.

progress: T[]

Every chunk the run has written, oldest first.

streaming: boolean

True while the run is still being read — it may yet say more.

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<R> = object

R = unknown

error: string | undefined

The last read’s failure, cleared by the next successful one.

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 = object

Options for useWorkflowRuns.

optional api?: WorkflowApi

The client to read with. Defaults to one for the page’s own agent.

optional key?: 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.

optional limit?: number

Most runs to return, newest first. The agent clamps its own ceiling.

optional skip?: boolean

Skip the read entirely — for a page that does not know its workflow yet.


UseWorkflowRunsResult<R> = object

What useWorkflowRuns reports.

R = unknown

error: string | undefined

The read’s failure, alongside an empty list — which is why it exists.

loading: boolean

True until the first read settles, and during an explicit refresh.

refresh: () => void

Re-read now. Call it when a run this page started reaches a terminal status.

void

runs: WorkflowRun<R>[]

The runs, newest first. Empty until the first read lands.


UseWorkflowsOptions = object

Options for useWorkflows.

optional api?: WorkflowApi

The client to read the listing with. Defaults to one for the page’s own agent.

optional skip?: 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 = object

What useWorkflows reports.

error: string | undefined

The lookup’s failure. Set alongside an EMPTY list, which is why it exists.

loading: boolean

True until the listing lands, so a form can hold its fields back.

workflows: WorkflowSummary[]

The agent’s declared workflows, each with the JSON Schema of its input.


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 = object

Options for useWorkflowSubmit.

optional api?: WorkflowApi

The client to start runs with. Defaults to one for the page’s own agent.

optional intervalMs?: number

How often the fallback poll re-reads a live run.

optional key?: 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.

optional parallel?: 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.

optional recover?: 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.

optional wait?: 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 = 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.

optional onSessionId?: (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.

string

void

platformUrl: string

Base URL of the AAI platform server.

optional resumeSessionId?: 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.

optional WebSocket?: 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 = WebSocket

Minimal WebSocket constructor type accepted by VoiceSessionOptions.

new WebSocketConstructor(url, protocols?): WebSocket

Minimal WebSocket constructor type accepted by VoiceSessionOptions.

string | URL

string | string[]

WebSocket

readonly OPEN: number


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.

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.

string

WorkflowApiCallOptions

Promise<boolean>

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.

string

WorkflowApiCallOptions

Promise<Blob>

find(workflow, key, options?): Promise<WorkflowRunSnapshot[]>

Runs of workflow started with key, newest first.

string

string

WorkflowRunListOptions

Promise<WorkflowRunSnapshot[]>

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.

string

WorkflowApiCallOptions

AsyncIterable<WorkflowRunSnapshot>

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.

string

WorkflowFollowOutputOptions

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.

string

WorkflowGetOptions

Promise<WorkflowRunSnapshot | undefined>

list(options?): Promise<WorkflowSummary[]>

Declared workflows: name, description, and the input schema to render.

WorkflowApiCallOptions

Promise<WorkflowSummary[]>

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.

string

WorkflowRunListOptions

Promise<WorkflowRunSnapshot[]>

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.

string

unknown

WorkflowStartOptions

Promise<string>

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.

string

unknown

WorkflowStartAndWaitOptions

Promise<WorkflowRunSnapshot>

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.

string

WorkflowStreamOutputOptions

Promise<Response>

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.

UploadBody

UploadOptions

Promise<UploadRef>

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.

string

WorkflowApiCallOptions

Promise<UploadInfo>

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.

string

UploadBody

UploadOptions

Promise<UploadRef>

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.

string

WakeUpOptions & WorkflowApiCallOptions

Promise<number>

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.

string

AbortSignal

Promise<Response>


WorkflowApiOptions = object

optional baseUrl?: 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.

optional token?: 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 = "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<D> = D extends object ? 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.

D

agent.ts
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<D> = D extends object ? Awaited<unknown extends O ? 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.

D

agent.ts
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); // typed

It reads the declared SCHEMA first, and that is what breaks a cycle

Section titled “It reads the declared SCHEMA first, and that is what breaks a cycle”

The DECLARATION is the better source of this type, and the worse one used to be the only one. Deriving R from the body means typeof theDef needs the body’s signature — while a body annotated WorkflowInputOf<typeof theDef> needs typeof theDef, which is TS7022 reported against agent.ts. The documented way out is to ANNOTATE the declaration, and an annotation whose R comes from a schema (WorkflowDef<typeof digestInput, z.infer<typeof digestOutput>>) states the output type once, in the schema, rather than naming it a second time by hand.

That annotated shape is also what the second reading gets WRONG, which is the other half of this rewrite. D extends WorkflowDef<ToolInputSchema, infer R> is an assignability test over the whole def, and run’s input is a function PARAMETER — so a def carrying an input schema is not assignable to one taking the open Record<string, unknown>, and the conditional silently fell to never. It is the same contravariance AnyWorkflowDef was written for, reached by the other route, and it is why the test below matches run as (input: never, ctx: never) => infer Rnever 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 = object

Props for WorkflowPendingNote.

optional className?: 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.

optional scope?: "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.

optional subject?: string

What the run produces, as the noun the sentences name: "draft", "summary", "transcript". Default "run".

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.

readonly pending: boolean

readonly run: WorkflowRun | undefined

readonly startedHere: boolean


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.

R = unknown


WorkflowRunErrorProps = object

Props for WorkflowRunError.

optional className?: 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<O> = object

Props of WorkflowRunPanel.

O

The run’s output type; run.output narrows to it in the completed slot.

optional api?: WorkflowApi

The workflow API client, when the page holds its own.

optional children?: 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".

optional className?: string

Additional CSS class names for the wrapping <section>, appended to its own.

optional live?: ReactNode

Rendered beneath the narration while the run is NOT terminal — a live transcript, a partial result. Nothing once it settles.

optional onClear?: () => void

Renders a Clear button in the header that calls this. Absent, no button.

void

run: WorkflowRun<O>

The run to show. Nothing here handles undefined — a page renders the panel once it has one.

optional statusLabels?: 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 = "pending" | "running" | "completed" | "failed" | "cancelled"

Lifecycle of one workflow run.

  • pending — created, not yet picked up by the queue.
  • running — executing, or suspended at a sleep/hook waiting to resume.
  • completed / failed / cancelled — terminal.

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 through run. It still resolves rather than rejecting on a failed upload — the failure is reported through error, the way a form expects.
  • run is 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.

R = unknown

I = unknown


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.

R = unknown

I = unknown

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.

Promise<boolean>

error: string | undefined

The submit’s own failure (a rejected input), or the watch’s.

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.

void

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.

void

resumeUpload: () => void

Continue a paused upload, sending only the windows the store does not have.

void

run: WorkflowRun<R> | undefined

The run, once started, followed to completion.

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.

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: (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.

I

Promise<void>

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.

FormValues

Promise<void>

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.

Promise<number>


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.

optional description?: string

The workflow’s own description, when it declared one.

optional inputSchema?: 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 }).

optional outputSchema?: 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.

optional uploads?: readonly string[]

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.

const AGENT_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:

  • disconnected is “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.
  • connecting and thinking carry an ellipsis, listening and speaking do not. The first two are waits with nothing for the caller to do; the other two describe someone actually talking. Same distinction WORKFLOW_STATUS_LABELS draws with its one “Working…”.

const Controls: MemoExoticComponent<FunctionComponent<ControlsProps>>

Session control buttons: Stop / Resume and New Conversation.

Reads session state from useSession. Must be rendered inside a SessionProvider.

import { Controls } from "@alexkroman1/aai-ui";
function Footer() {
return <Controls className="justify-end" />;
}

props

Container props.


const Markdown: 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.

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" />;
}

const MessageList: 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.

import { MessageList } from "@alexkroman1/aai-ui";
function Conversation() {
return <MessageList className="flex-1" />;
}

props

Container props.


const WORKFLOW_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.