index
The AAI voice-agent SDK — the AUTHORING surface, and only that.
What you import from here
Section titled “What you import from here”| Declaring | Use |
|---|---|
| the agent | agent — one object; AgentDef documents every field and default, AgentParams which combinations are legal |
| a tool | tool — but a tool is a FILE: tools/<name>.ts default-exporting one IS the tool <name>, and agent({ tools }) is a compile error |
| session state | sessionSlot — a typed named slot; slot.tool() reads it, slot.updateTool() writes it, slot.projected shows it to the browser |
| conversation order | dialog — a tool declared when simply does not run outside those states |
| work that outlives the call | workflow — journaled, resumable; workflowApp for an agent whose front door is a form |
| a second tool loop | subagent, reached with ctx.delegate |
| who is speaking | personas — a roster the session hands the caller between, with handoff |
| the default pipeline, spelled out | assemblyAIPipeline; assemblyAIS2s opts into speech-to-speech instead |
import { agent, sessionSlot } from "@alexkroman1/aai";
export const cart = sessionSlot("cart", () => ({ items: [] as string[] }), { view: (c) => ({ count: c.items.length }),});
export default agent({ name: "Storefront", systemPrompt: "You help callers order from the catalog.", voice: "michael", syncState: cart.projected,});With no provider fields that runs the all-AssemblyAI STT → LLM → TTS
pipeline on one ASSEMBLYAI_API_KEY. Set any subset of stt, llm, tts
to swap a stage; the rest keep the default.
Three primitives here run a defined process, and they are not interchangeable. A dialog gates a CONVERSATION — what the agent may say or do next, across turns. A procedure runs ONE UNIT OF WORK inside a single tool call. A workflow runs DURABLY, outliving the session.
Everything else is on a subpath, chosen by WHO READS IT
Section titled “Everything else is on a subpath, chosen by WHO READS IT”| Subpath | Reach for it when |
|---|---|
@alexkroman1/aai/testing, /testing/vitest |
testing your own tools — createToolContext, deployedAgent, runTool |
@alexkroman1/aai/stt, /llm, /tts, /s2s |
picking a provider for a pipeline stage |
@alexkroman1/aai/step, /step-errors |
writing a step inside a workflow |
@alexkroman1/aai/workflow-api |
calling a deployed agent from a page, a script or a cron job |
@alexkroman1/aai/tools |
calling fetchJson/webSearch/visitWebpage from your own tool code |
@alexkroman1/aai/utils |
small helpers written inside a tool body |
@alexkroman1/aai/ffmpeg |
running ffmpeg from a step |
@alexkroman1/aai-runtime |
self-hosting the Node runtime |
@alexkroman1/aai/protocol, /manifest, /internal |
framework internals; not covered by semver |
A workflows/*.ts body is the one file that reads from two of these: the
declaration and its …Of<typeof def> readings are here, the step vocabulary
is /step.
Functions
Section titled “Functions”addDays()
Section titled “addDays()”addDays(
iso,days):string
iso plus days, as another YYYY-MM-DD. Negative days goes backwards.
Computed in UTC, so it adds calendar days and no machine’s zone can move the
answer. Month and year boundaries are the Date.UTC normalization’s, so
addDays("2026-02-28", 1) is March 1st in a common year and February 29th in
a leap one without either case being written here.
Parameters
Section titled “Parameters”string
number
Returns
Section titled “Returns”string
Throws
Section titled “Throws”RangeError if iso is not a date isIsoDate accepts. Arithmetic
on a value that is not a date has no right answer, and a silently wrong one
becomes a booking — declare the argument with isoDate() and this cannot
happen.
Example
Section titled “Example”import { addDays } from "@alexkroman1/aai";
addDays("2026-06-08", 3); // "2026-06-11"addDays("2026-01-01", -1); // "2025-12-31"agent()
Section titled “agent()”agent(
def):AgentDef
Define an agent: its system prompt, its providers, and its configuration.
Applies sensible defaults for omitted fields. Export as the default
export of your agent.ts file.
Tools are not declared here — a tool is a FILE. tools/echo.ts that
default-exports tool({ … }) is the tool echo, registered by existing, and
agent({ tools }) is a compile error naming the file to create
(InlineToolsMisuse).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”Examples
Section titled “Examples”import { agent } from "@alexkroman1/aai";
export default agent({ name: "Echo Agent", greeting: "Say something and I'll say it back.",});Session state is not declared here either — a sessionSlot owns its
own default and its own storage, so there is no state factory to remember.
syncState takes that slot’s projection.
Default pipeline with a voice and a different LLM
import { agent } from "@alexkroman1/aai";
export default agent({ name: "My Agent", voice: "michael", llm: "claude-sonnet-4-6",});Remarks
Section titled “Remarks”Session mode: with no provider fields the agent runs the default
all-AssemblyAI cascaded pipeline. Set any subset of stt, llm, tts
to swap individual stages (unset stages keep the AssemblyAI default), and
voice to pick the default pipeline’s TTS voice — or set s2s (e.g.
assemblyAIS2s()) to opt into the speech-to-speech path instead. See
AgentDef for every field.
assemblyAIPipeline()
Section titled “assemblyAIPipeline()”assemblyAIPipeline(
options?):object
All three pipeline stages on AssemblyAI, ready to spread into agent().
Every stage bills to ASSEMBLYAI_API_KEY — the one key a published agent is
guaranteed to have — so this configuration runs the moment it is deployed.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”object
llm:
LlmProvider
stt:
SttProvider
tts:
TtsProvider
assemblyAIS2s()
Section titled “assemblyAIS2s()”assemblyAIS2s(
options?):S2sProvider
Select AssemblyAI’s speech-to-speech (Voice Agent API) session mode. STT, the LLM loop, and TTS all run service-side over one socket.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”Example
Section titled “Example”import { agent, assemblyAIS2s } from "@alexkroman1/aai";
export default agent({ name: "Support", systemPrompt: "You are a support agent. Be brief.", s2s: assemblyAIS2s({ voice: "jane", languages: ["en"] }),});Setting s2s replaces the whole stt/llm/tts pipeline, and the
top-level voice convenience is a compile error alongside it — an S2S
voice rides on the descriptor, because the service synthesizes.
clockTime()
Section titled “clockTime()”clockTime(
what?):ZodString
A time-of-day argument: 24-hour HH:MM, zero-padded.
The description states the padding with an example, because that is the half
a model gets wrong — it produces "4:45" for “quarter to five in the
morning” unless told, and an unpadded time sorts wrong against a padded one
stored earlier.
Parameters
Section titled “Parameters”string
The argument, named as the model and the caller should hear it
("the pickup time").
Returns
Section titled “Returns”ZodString
Example
Section titled “Example”import { clockTime, tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Schedule a wake-up call.", inputSchema: z.object({ time: clockTime("the wake-up time") }), execute: (args) => ({ at: args.time }),});createKeyedLock()
Section titled “createKeyedLock()”createKeyedLock():
KeyedLock
Create a KeyedLock.
Prefer withLock at call sites — it releases in every outcome, which
a bare lock() leaves to the caller’s finally.
Returns
Section titled “Returns”createMetricsCollector()
Section titled “createMetricsCollector()”createMetricsCollector(
options?):MetricsCollector
Create a collector — see this module’s doc for an example.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”createSeededRandom()
Section titled “createSeededRandom()”createSeededRandom(
seed):RandomSource
A RandomSource that produces the same sequence every run, from a seed.
The source createToolContext defaults to, and the reason a spec that FORGOT
to stub randomness is still deterministic rather than flaky. It is also what
a seed script or a demo wants: a catalog shuffled the same way on every boot
is reviewable, where one shuffled by Math.random makes every diff of its
output noise.
A constant function is not a substitute, which is the trap this exists to
remove. () => 0.5 looks like the simplest deterministic source and is a
degenerate one: every draw is identical, so shuffled returns a fixed
non-random permutation and mintCode re-draws the same code until it
gives up. Sequences that VARY reproducibly are what tests and seeds both
want.
mulberry32 — a 32-bit generator chosen for being short enough to read and having no state beyond one integer. Not cryptographic, and its period is far below what a simulation would need; it is here so that “deterministic” and “varied” can both be true of a spec.
Parameters
Section titled “Parameters”number
Returns
Section titled “Returns”Example
Section titled “Example”import { createSeededRandom, shuffled } from "@alexkroman1/aai";
const random = createSeededRandom(42);shuffled(["a", "b", "c"], random); // the same order on every rundaysBetween()
Section titled “daysBetween()”daysBetween(
from,to):number
Whole calendar days from from to to — a stay’s night count.
Signed: a to before from is negative. Same day is 0, which is what
makes it a NIGHT count rather than a day count, and is the reading a hotel,
a car rental and a subscription all want.
Parameters
Section titled “Parameters”string
string
Returns
Section titled “Returns”number
Throws
Section titled “Throws”RangeError if either argument is not a date isIsoDate accepts.
Example
Section titled “Example”import { daysBetween } from "@alexkroman1/aai";
daysBetween("2026-06-08", "2026-06-11"); // 3daysBetween("2026-06-08", "2026-06-08"); // 0daysBetween("2026-06-11", "2026-06-08"); // -3dialog()
Section titled “dialog()”Call Signature
Section titled “Call Signature”dialog<
M>(key,machine,options?):Dialog<M>
Declare a dialog statechart for an agent’s conversation.
The machine is an ordinary XState machine, so everything XState knows how to
do with one applies — @xstate/graph can enumerate its paths to generate
dialog test cases, and the machine is serializable for a visualizer.
Type Parameters
Section titled “Type Parameters”M extends AnyStateMachine
Parameters
Section titled “Parameters”string
The store key to occupy, like a sessionSlot’s. Two flows must not share one, and a dialog must not share one with a slot.
machine
Section titled “machine”M
The machine. Give a state a meta.instruction and it becomes
DialogPosition.instruction while that state is active — which is what
a refusal quotes and what every dialog tool’s result carries.
options?
Section titled “options?”Returns
Section titled “Returns”Dialog<M>
Examples
Section titled “Examples”// shared.ts — the one place the dialog is declared.import { dialog } from "@alexkroman1/aai";import { setup } from "xstate";
const machine = setup({ types: {} as { events: { type: "VERIFIED" } | { type: "QUOTED" } },}).createMachine({ id: "claim", initial: "verifying", states: { verifying: { meta: { instruction: "Get the caller's policy number and verify it." }, on: { VERIFIED: "quoting" }, }, quoting: { meta: { instruction: "Read the excess disclosure, then quote." }, on: { QUOTED: "done" }, }, done: { type: "final" }, },});
export const claim = dialog("claim", machine);// tools/quote_claim.ts — cannot run before the caller is verified.// (`no-check`: the point of the example is the OTHER file's declaration.)import { claim } from "../shared.ts";import { z } from "zod";
export default claim.tool({ description: "Quote the claim once the policy is verified", inputSchema: z.object({ excess: z.number() }), when: "quoting", send: { type: "QUOTED" }, execute: ({ excess }) => ({ premium: excess * 2 }),});// The same dialog as a plain state map — no `setup()`, no events union to// restate, no `meta` wrapper. `dialog.send` is typed from the `on` keys.import { dialog } from "@alexkroman1/aai";
export const claim = dialog("claim", { initial: "verifying", states: { verifying: { instruction: "Get the caller's policy number and verify it.", on: { VERIFIED: "quoting" }, }, quoting: { instruction: "Read the excess disclosure, then quote.", on: { QUOTED: "done" }, }, done: { final: true }, },});Remarks
Section titled “Remarks”Three primitives here run a defined process; pick by SCOPE. A dialog gates a CONVERSATION — what the agent may say or do next, across turns, persisted in a session slot. A procedure runs ONE UNIT OF WORK inside a single tool call, never stored. A workflow runs DURABLY, outliving the session.
Call Signature
Section titled “Call Signature”dialog<
S>(key,spec,options?):Dialog<AnyStateMachine,DialogEvent<S>>
Declare a dialog from a plain state map — see DialogSpec.
The overload exists rather than replacing the machine form because the two
answer different questions. A spec covers what every dialog in the templates
actually used and nothing else, on purpose: a persisted snapshot must survive
structuredClone, so guards, context and actions were never available here
anyway, and what an author was paying for full XState was a setup({ types: {} as { events: … } }) block restating the event names already written in the
on maps. A dialog that needs more than the spec can say passes a machine,
and that path is unchanged.
It builds the same machine, so the STORED SNAPSHOT is byte-identical to the
hand-written equivalent’s and a durable: true dialog resumes across the
switch — see machineFromSpec.
Type Parameters
Section titled “Type Parameters”S extends DialogSpec
Parameters
Section titled “Parameters”string
S
options?
Section titled “options?”Returns
Section titled “Returns”Dialog<AnyStateMachine, DialogEvent<S>>
errorDetail()
Section titled “errorDetail()”errorDetail(
err):string
Extract a detailed error string (message + stack) for diagnostic logging.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”string
errorMessage()
Section titled “errorMessage()”errorMessage(
err):string
Extract an error message from an unknown thrown value.
It never answers with an empty string. That is the contract, and it is
worth stating as one: SessionError.message is rendered directly by a
browser client, so "" paints a banner that says an error occurred and
refuses to say what — strictly worse than a generic sentence, because an
absent message reads as absence rather than as a problem.
The shape that produced one is not exotic, it is the FIRST failure a new
project hits. The AI SDK builds an APICallError whose message is
response.statusText whenever the provider’s error body does not match the
schema it expected (createJsonErrorResponseHandler), and a reason phrase is
optional in HTTP/1.1 and does not exist at all in HTTP/2 — so a rejected API
key arrived as {"code":"llm","message":"","fatal":false} with the status,
the URL, and the provider’s own explanation all sitting unread on the error
object.
So a value that says nothing on its own is read one level down, in this
order: the HTTP fields an APICallError-shaped failure carries (the status,
the host that answered, the sentence in the response body), then cause,
then an AggregateError’s members. Detection is STRUCTURAL for the same
reason the schema-issue reading below it is — this module is published,
zod-free, and may not import ai to ask APICallError.isInstance — and it
costs nothing: a numeric statusCode beside a responseBody is the shape,
whoever built it.
An error that DOES state something keeps its own words — an HTTP failure has
the status appended to them, since Unauthorized alone answers neither “which
provider” nor “refused or fell over”, and everything else is returned
verbatim. One message is replaced outright, and it has precedent:
fetch failed (and the browser’s failed to fetch) is
Node’s own placeholder, with the reason — ECONNREFUSED, a DNS failure, a
certificate rejection — one level down in cause. The AI SDK makes exactly
this substitution for its own calls (handleFetchError, which rewrites the
pair as “Cannot connect to API: …”); this extends the same reading to every
direct fetch in the SDK.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”string
failable()
Section titled “failable()”The T | ToolFailure union’s control flow, beside the guard and the
constructor it belongs with: a tool body writes all three. Its own statement
because tool-failure-flow.ts imports sdk/utils.ts, so re-exporting it
from there would close a cycle.
Call Signature
Section titled “Call Signature”failable<
A,R>(fn): (…args) =>Promise<ToolFailure|R>
Wrap a function whose body uses orFail, so a failure it hits becomes the function’s return value.
Works on a sync body and an async one, and answers in kind: a sync body gives
R | ToolFailure, an async one Promise<R | ToolFailure>. A body that
already returns a ToolFailure on some path is unaffected — the union simply
absorbs it.
Type Parameters
Section titled “Type Parameters”A extends readonly unknown[]
R
Parameters
Section titled “Parameters”(…args) => Promise<R>
Returns
Section titled “Returns”(…args) => Promise<ToolFailure | R>
Example
Section titled “Example”Two lookups in front of the work
import { failable, orFail, type ToolFailure } from "@alexkroman1/aai";
type Board = { incidents: Record<string, Incident> };type Incident = { id: string; timeline: string[]; resolved: boolean };
declare function findIncident(board: Board, id: string): Incident | ToolFailure;declare function assertNotResolved(incident: Incident): ToolFailure | null;
const addNote = failable((board: Board, id: string, note: string) => { const incident = orFail(findIncident(board, id)); orFail(assertNotResolved(incident)); incident.timeline.push(note); return { added: note, entries: incident.timeline.length };});Call Signature
Section titled “Call Signature”failable<
A,R>(fn): (…args) =>ToolFailure|R
Wrap a function whose body uses orFail, so a failure it hits becomes the function’s return value.
Works on a sync body and an async one, and answers in kind: a sync body gives
R | ToolFailure, an async one Promise<R | ToolFailure>. A body that
already returns a ToolFailure on some path is unaffected — the union simply
absorbs it.
Type Parameters
Section titled “Type Parameters”A extends readonly unknown[]
R
Parameters
Section titled “Parameters”(…args) => R
Returns
Section titled “Returns”(…args) => ToolFailure | R
Example
Section titled “Example”Two lookups in front of the work
import { failable, orFail, type ToolFailure } from "@alexkroman1/aai";
type Board = { incidents: Record<string, Incident> };type Incident = { id: string; timeline: string[]; resolved: boolean };
declare function findIncident(board: Board, id: string): Incident | ToolFailure;declare function assertNotResolved(incident: Incident): ToolFailure | null;
const addNote = failable((board: Board, id: string, note: string) => { const incident = orFail(findIncident(board, id)); orFail(assertNotResolved(incident)); incident.timeline.push(note); return { added: note, entries: incident.timeline.length };});isClockTime()
Section titled “isClockTime()”isClockTime(
value):boolean
24-hour HH:MM, zero-padded — "09:05" yes, "9:05" no.
The padding requirement is deliberate rather than strict for its own sake:
"9:05" and "09:05" sort differently as strings, and a desk that stores
whichever the model produced cannot compare two of its own appointments.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
Example
Section titled “Example”import { isClockTime } from "@alexkroman1/aai";
isClockTime("19:30"); // trueisClockTime("04:45"); // trueisClockTime("4:45"); // false — not zero-paddedisClockTime("24:00"); // false — midnight is 00:00isIsoDate()
Section titled “isIsoDate()”isIsoDate(
value):boolean
YYYY-MM-DD, and a real calendar date — 2026-02-30 is refused.
Years are taken as written, so 0000-01-01 is a date. Nothing here decides
whether a date is in a range an agent should accept; a stay in 1823 is the
desk’s question, not this one’s.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
Example
Section titled “Example”import { isIsoDate } from "@alexkroman1/aai";
isIsoDate("2026-06-08"); // trueisIsoDate("2026-02-30"); // false — February has no 30thisIsoDate("6/8/2026"); // falseisoDate()
Section titled “isoDate()”isoDate(
what?):ZodString
A calendar date argument: YYYY-MM-DD, and a real date.
refine(isIsoDate) rather than zod’s own z.iso.date(), so that the
predicate an agent’s own code calls and the rule its schema enforces are one
definition and cannot disagree — z.iso.date() accepts 2026-02-30, which
isIsoDate refuses.
Parameters
Section titled “Parameters”string
The argument, named as the model and the caller should hear it
("the arrival date"). Reaches the model in the description and the caller
in the rejection.
Returns
Section titled “Returns”ZodString
Example
Section titled “Example”import { isoDate, tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Book a spa appointment.", inputSchema: z.object({ date: isoDate("the appointment date"), guest: z.string().min(1), }), execute: (args) => ({ booked: args.date }),});isRecord()
Section titled “isRecord()”isRecord(
value):value is Record<string, unknown>
Whether a value is a non-null, non-array object, narrowed to
Record<string, unknown> so its fields can be read without a second cast.
The narrowing is the point. typeof value === "object" && value !== null is
three tokens anyone can write, which is exactly why it was written twelve
times here — and it narrows to object, on which every field read is an
error, so each site paid for it again with a cast
((value as { kind?: unknown }).kind). A cast is not a check: it says
nothing about the value and stops reporting when the shape moves.
Arrays are excluded because every caller is reading a NAMED field — .type,
.error, .kind, .then — none of which an array has. For “any non-null
object, arrays included”, write the two comparisons inline; that case has one
site in this repo and does not want a name.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”value is Record<string, unknown>
Example
Section titled “Example”import { isRecord, safeJsonParse } from "@alexkroman1/aai/utils";
function readStatus(body: string): string | undefined { const parsed = safeJsonParse(body); if (!isRecord(parsed)) return undefined; return typeof parsed.status === "string" ? parsed.status : undefined;}isToolFailure()
Section titled “isToolFailure()”isToolFailure(
value):value is ToolFailure
Whether a value is a ToolFailure.
The guard exists because failures PROPAGATE: a helper resolving an order
returns Order | ToolFailure, and its caller forwards the failure
unchanged rather than re-wording it. if ("error" in value) works only
once the value is known to be an object, which is the check this bundles.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”value is ToolFailure
Example
Section titled “Example”import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
type Order = { id: string; total: number };
function findOrder(id: string): Order | ToolFailure { return { error: `Order ${id} not found.` };}
function orderTotal(id: string): number | ToolFailure { const order = findOrder(id); if (isToolFailure(order)) return order; return order.total;}mcpToolName()
Section titled “mcpToolName()”mcpToolName(
serverKey,remoteName):string
The name the MODEL calls, for one remote tool on one server.
Deterministic, and every input maps to a legal name: the remote half is
lowercased and every character a provider would reject becomes _, because
an MCP server’s names are its own (getWeather, search-docs) and refusing
them would make whole servers unusable for a spelling.
Truncation at MCP_TOOL_NAME_MAX is the one lossy step, and it is why
the caller must still dedupe: two long remote names can land on one truncated
name. registerTools resolves that the same way it resolves every other
collision — first wins in a sorted order, the loser is dropped and logged —
rather than silently overwriting.
Parameters
Section titled “Parameters”serverKey
Section titled “serverKey”string
remoteName
Section titled “remoteName”string
Returns
Section titled “Returns”string
mintCode()
Section titled “mintCode()”mintCode(
prefix,options?):string
A PREFIX-XXXX reference, on an alphabet a caller can read back.
0/O, 1/I and L are all absent, and that is the entire design. Every
code a voice agent issues gets read down a phone and read back, and those are
the characters that come back wrong — a caller says “oh” for a zero, an STT
writes 1 for a spoken “el”. Removing them from the alphabet is the fix that
needs no correction logic anywhere downstream, and it is why this belongs
beside spokenAlphanumeric, which is what parses the read-back.
Parameters
Section titled “Parameters”prefix
Section titled “prefix”string
options?
Section titled “options?”Returns
Section titled “Returns”string
Throws
Section titled “Throws”Error if taken is dense enough that no free code is drawn in a
bounded number of attempts. Unbounded retry is the version that turns a full
code space into a hung call rather than an error someone can act on.
Example
Section titled “Example”import { mintCode } from "@alexkroman1/aai";
mintCode("HTL"); // e.g. "HTL-7K2M"mintCode("RES", { taken: new Set(["RES-7K2M"]) });omitUndefined()
Section titled “omitUndefined()”omitUndefined<
T>(obj): { [K in string | number | symbol]?: unknown extends T[K] ? NonNullable<unknown> | null : Exclude<T[K], undefined> }
Drop the undefined-valued entries of obj, typing every surviving key as
optional-and-defined — exactly what exactOptionalPropertyTypes wants on
the receiving end.
Spread the result into the literal it belongs to; the keys are the object’s
own, so renaming one ({ leadMs: audioLeadMs }) works the same as passing
shorthand.
“Removed” means undefined and nothing else, so a null survives — a null
value is a value; only undefined is an absence here. The unknown extends
branch in the return type is written inline rather than named, so the one
new symbol on the published surface is this function; what it says is that
Exclude<unknown, undefined> is still unknown, which a field declared
body?: unknown (the CLI’s API client has one) then cannot hand to anything
with a narrower parameter. NonNullable<unknown> | null is what “unknown,
but not undefined” means, and it is what the !== undefined narrowing this
replaces already produced. The check catches any too, which lands in the
same place.
Type Parameters
Section titled “Type Parameters”T extends object
Parameters
Section titled “Parameters”T
Returns
Section titled “Returns”{ [K in string | number | symbol]?: unknown extends T[K] ? NonNullable<unknown> | null : Exclude<T[K], undefined> }
Example
Section titled “Example”import { omitUndefined } from "@alexkroman1/aai/utils";
declare const name: string | undefined;declare const greeting: string | undefined;
const config: { slug: string; name?: string; greeting?: string } = { slug: "demo", ...omitUndefined({ name, greeting }),};orFail()
Section titled “orFail()”orFail<
T>(value):T
The value, or abandon the surrounding failable with the failure.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”ToolFailure | T
Returns
Section titled “Returns”T
Throws
Section titled “Throws”A private sentinel, caught by the enclosing failable. Calling it outside one is a programming error and behaves like one — the throw escapes and the tool executor reports it — rather than being silently swallowed.
Example
Section titled “Example”import { failable, orFail, type ToolFailure } from "@alexkroman1/aai";
type Order = { id: string; total: number };declare function findOrder(id: string): Order | ToolFailure;
const orderTotal = failable((id: string) => orFail(findOrder(id)).total);// orderTotal("A1") is number | ToolFailurepersona()
Section titled “persona()”persona<
N>(def):PersonaDef<N>
Define a persona. An identity function, like tool and subagent: it exists for the type, for the name to grep for, and so a persona is declared at module scope where both the roster and a tool that hands off to it can import it.
The name is inferred as a LITERAL (const N), so a roster built from these
knows its own names and a handoff to one that is not on it fails to compile.
Type Parameters
Section titled “Type Parameters”N extends string
Parameters
Section titled “Parameters”PersonaDef<N>
Returns
Section titled “Returns”PersonaDef<N>
personas()
Section titled “personas()”personas<
N>(list):Personas<N>
Declare the roster.
Checked HERE, at module scope, rather than when agent() runs — every
refusal below reaches an author at the declaration, and each is a failure
with no symptom otherwise: two personas with one name route to whichever the
lookup finds; a tool two personas both declare is gated by whichever wrapper
landed last.
Type Parameters
Section titled “Type Parameters”N extends string
Parameters
Section titled “Parameters”readonly PersonaDef<N>[]
Returns
Section titled “Returns”Personas<N>
pickOne()
Section titled “pickOne()”pickOne<
T>(items,random?):T|undefined
One item, uniformly.
undefined for an empty list rather than a throw, so the empty case is
narrowed by the type at the call site — which is where a caller knows
whether “nothing to pick” is a failure or a legal answer. Under
noUncheckedIndexedAccess the hand-written items[Math.floor(...)] this
replaces was T | undefined anyway and was routinely asserted away with
as T, which is the same reachable undefined with the check removed.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”readonly T[]
random?
Section titled “random?”Returns
Section titled “Returns”T | undefined
Example
Section titled “Example”import { pickOne } from "@alexkroman1/aai";
pickOne(["north", "south"], () => 0); // "north"pickOne([]); // undefinedprocedure()
Section titled “procedure()”procedure<
M>(machine):Procedure<M>
Wrap a machine so a tool body can run it without touching an actor.
Type Parameters
Section titled “Type Parameters”M extends AnyStateMachine
Parameters
Section titled “Parameters”machine
Section titled “machine”M
An ordinary XState machine. Give it an output — that is
what Procedure.run resolves with, and a machine with none resolves
undefined.
Returns
Section titled “Returns”Procedure<M>
Example
Section titled “Example”import { procedure, tool } from "@alexkroman1/aai";import { setup } from "xstate";import { z } from "zod";
const machine = setup({ types: {} as { input: { topic: string }; output: { verdict: string } },}).createMachine({ id: "triage", initial: "deciding", context: ({ input }) => ({ topic: input.topic }), states: { deciding: { type: "final" } }, output: ({ context }) => ({ verdict: `looked at ${context.topic}` }),});
const triage = procedure(machine);
export default tool({ description: "Triage a topic", inputSchema: z.object({ topic: z.string() }), // `ctx.signal` is what makes a barge-in stop the procedure mid-run. execute: async ({ topic }, ctx) => await triage.run({ topic }, { signal: ctx.signal }),});Remarks
Section titled “Remarks”Three primitives here run a defined process; pick by SCOPE. A dialog gates a CONVERSATION — what the agent may say or do next, across turns, persisted in a session slot. A procedure runs ONE UNIT OF WORK inside a single tool call, never stored. A workflow runs DURABLY, outliving the session.
pushCapped()
Section titled “pushCapped()”pushCapped<
T>(list,item,max):T[]
Append to a list, dropping the oldest entries so it never exceeds max.
Mutates list in place and returns it.
For the append-only lists an agent keeps in a sessionSlot — a timeline, an
activity feed, a session log. Every one of them feeds an LLM summary or a
syncState payload, so an uncapped list grows what the model reads and
what crosses the wire for the length of the call, unboundedly. In place
rather than returning a new array because the list is usually a property of
the state object (incident.timeline), and reassigning that is a second
thing to remember.
max below 1 keeps nothing — including the entry just appended — which is
what “a cap of zero” has to mean.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”T[]
T
number
Returns
Section titled “Returns”T[]
Example
Section titled “Example”import { pushCapped } from "@alexkroman1/aai";
const log: string[] = ["a", "b", "c"];pushCapped(log, "d", 3); // ["b", "c", "d"]randomInt()
Section titled “randomInt()”randomInt(
maxExclusive,random?):number
A whole number in [0, maxExclusive).
The floor-and-multiply that every call site would otherwise write, in the one
place its two edges can be got right: a maxExclusive of 0 or less has no
value to return and answers 0 rather than -1 or NaN, and a source that
returns exactly 1 — outside Math.random’s contract, but well inside what
a hand-written stub does — is clamped rather than allowed to index one past
the end.
Parameters
Section titled “Parameters”maxExclusive
Section titled “maxExclusive”number
random?
Section titled “random?”Returns
Section titled “Returns”number
Example
Section titled “Example”import { randomInt } from "@alexkroman1/aai";
randomInt(6); // 0..5randomInt(6, () => 0.5); // 3requireEnv()
Section titled “requireEnv()”requireEnv(
ctx,name):string
Read a variable off ToolContext.env, failing by NAME when it is not set.
The ToolContext twin of requireStepEnv, and there for the same reason: a
missing credential is not transient, so it should say which key and how to
set it rather than surface as a TypeError on the first property access —
which tool-executor.ts serializes and hands to the MODEL, so what a caller
hears is the agent apologising for something no log line explains.
export default tool({ description: "Look up a note", inputSchema: z.object({ id: z.string() }), async execute({ id }, ctx) { const key = requireEnv(ctx, "NOTES_API_KEY"); return await fetch(`https://notes.example.com/${id}`, { headers: { authorization: `Bearer ${key}` }, }).then((r) => r.json()); },});Parameters
Section titled “Parameters”Readonly<Partial<Record<string, string>>>
string
Returns
Section titled “Returns”string
resolveOne()
Section titled “resolveOne()”resolveOne<
T>(candidates,spoken,options):T|ToolFailure
Pick the one candidate an utterance names, or fail saying why.
The order is deliberate and is the part worth reusing:
- No candidates — say so, rather than reporting a failed match against an empty list.
- A code (ResolveOneOptions.code), when one is declared — an id read aloud names exactly one thing, so it wins even over a position in the same sentence. A miss falls through rather than failing.
- A position (“the second one”, “the last one”) — a caller who counts is unambiguous even when nothing else is, and this is the case a scorer alone cannot see.
- The words — ResolveOneOptions.match overlap plus ResolveOneOptions.score, summed, whichever are given. A single best candidate wins; a tie fails, listing the tied ones only.
- Exactly one candidate left — it is what they meant.
- Anything else is ambiguous, and the failure lists the candidates.
Steps 2 and 4 are the two shapes every caller of this used to write by hand
(five shipped templates, four incompatible word splitters between them);
score stays for the scorers a domain really owns.
The caller is expected to have narrowed first — by an id, by a status word, by whatever its domain says an utterance can mean. This resolves what is left.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”candidates
Section titled “candidates”readonly T[]
spoken
Section titled “spoken”string
options
Section titled “options”Returns
Section titled “Returns”T | ToolFailure
Example
Section titled “Example”import { resolveOne } from "@alexkroman1/aai";
type Jacket = { id: string; color: string };const jackets: Jacket[] = [ { id: "1", color: "blue" }, { id: "2", color: "red" },];
const picked = resolveOne(jackets, "the blue one", { label: "jacket", describe: (jacket) => `${jacket.id} (${jacket.color})`, score: (jacket, text) => (text.includes(jacket.color) ? 1 : 0),});// → { id: "1", color: "blue" }responseErrorMessage()
Section titled “responseErrorMessage()”responseErrorMessage(
response,label?):Promise<string>
Read a failed Response’s error sentence — the one every route this SDK
serves answers with.
Each 4xx/5xx an agent produces carries { "error": "<sentence>" }, and
that sentence is the whole diagnostic: an unknown workflow names the ones
that are declared, a rejected input names the schema issues, a 404 from an
agent that declares no workflows names both of its causes. Anything ELSE in
the path — a proxy, a CDN, a platform broker answering while a sandbox boots
— replies with a body that shape does not fit, so the status is reported
instead, with a short preview of whatever did come back.
label names the surface that answered and appears ONLY in that fallback:
when the agent gave its own sentence, prefixing it would put our words in
front of the ones worth reading.
It never throws and never rejects — a body that cannot be read at all degrades to the bare status, because this runs on a path that is already reporting a failure and a second one there has nowhere to go.
It deliberately does NOT reuse isToolFailure, whose object shape is identical today: that guard answers for a TOOL’s result union, and the two contracts are free to move apart.
Parameters
Section titled “Parameters”response
Section titled “response”Response
label?
Section titled “label?”string
Returns
Section titled “Returns”Promise<string>
Example
Section titled “Example”import { responseErrorMessage } from "@alexkroman1/aai/utils";
async function startRun(url: string): Promise<string> { const res = await fetch(url, { method: "POST" }); if (!res.ok) throw new Error(await responseErrorMessage(res, "Workflow API")); return ((await res.json()) as { runId: string }).runId;}safeJsonParse()
Section titled “safeJsonParse()”safeJsonParse(
text):unknown
Parse JSON, returning undefined on malformed input. JSON cannot encode
undefined, so the sentinel is unambiguous.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”unknown
sessionSlot()
Section titled “sessionSlot()”sessionSlot<
K,T,After,V>(key,create,options?):SessionSlot<K,T,V>
Declare a named slot of per-session state.
An agent whose tools live in separate modules has no other way to type its own state: a tool is a FILE, so there is no map to check it against the agent’s state shape, and there is no bag to annotate. A slot moves that narrowing into ONE typed seam every module imports, and the lazy install with it — plus, now, the storage. Nothing else stores session state.
SessionSlot.tool and SessionSlot.updateTool are the other
half: a tool declared through them is handed the value directly, so a tool
module needs neither an annotated context nor a slot.get(ctx) line.
Type Parameters
Section titled “Type Parameters”K extends string
T
After = void
V = DeepReadonly<T>
Parameters
Section titled “Parameters”K
The store key to occupy. Two slots must not share one, and
claimKey enforces it per session: two slots on one key that DISAGREE
about the shape they store are refused the moment the second one is
touched, since each would be reading and writing the other’s value.
create
Section titled “create”() => T
Factory for a fresh value. Called once per session on first
access (and again on reset), so a shared module-level default must be
cloned here — () => structuredClone(DEFAULT) — or every session mutates
the same object.
options?
Section titled “options?”SessionSlotOptions<T, After, V>
See SessionSlotOptions. view is the one worth
knowing about up front: it declares what the BROWSER sees, so
SessionSlot.projected is the one object agent({ syncState }) and
useAgentState both take.
Returns
Section titled “Returns”SessionSlot<K, T, V>
Examples
Section titled “Examples”// shared.ts — the one place the slot is declared, view included.import { sessionSlot } from "@alexkroman1/aai";
export type Cart = { items: string[] };export const cartSlot = sessionSlot("cart", (): Cart => ({ items: [] }), { view: (cart) => ({ count: cart.items.length }),});// tools/add_item.ts — no cast, no annotation, no lazy-init boilerplate.// (`no-check`: the point of the example is the OTHER file, so it cannot be// self-contained.)import { cartSlot } from "../shared.ts";import { z } from "zod";
export default cartSlot.updateTool({ description: "Add an item to the cart", inputSchema: z.object({ item: z.string() }), execute: ({ item }, cart) => { cart.items.push(item); return { count: cart.items.length }; },});shuffled()
Section titled “shuffled()”shuffled<
T>(items,random?):T[]
A NEW array holding the same items in a random order.
A Fisher-Yates walk, which is worth having in one place because the
plausible-looking alternatives are subtly not uniform: sort(() => Math.random() - 0.5)
produces a distribution that depends on the engine’s sort algorithm, and a
loop drawing j from the WHOLE range rather than [0, i] is the classic
biased variant that still looks shuffled.
Copies rather than mutating — the input is readonly, and a shuffle applied
in place to a slot’s frozen value is a TypeError at runtime.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”readonly T[]
random?
Section titled “random?”Returns
Section titled “Returns”T[]
Example
Section titled “Example”import { shuffled } from "@alexkroman1/aai";
shuffled([1, 2, 3], () => 0); // a new array; the input is untouchedspokenAlphanumeric()
Section titled “spokenAlphanumeric()”spokenAlphanumeric(
spoken):string
The letters and digits of a spoken code, upper-cased, with everything else dropped — spokenDigits for an id that carries letters too.
An order number, a policy number, a booking reference: “r s four four one
seven” comes through STT as anything from RS4417 to rs-44 17, and none of
them equals the stored RS4417. Comparing the raw string is the version
that tells a covered member they have no plan. Two templates normalized
this way with two regexes; the case fold is the half a hand-written one
forgets.
ASCII only, on purpose: the ids this exists for are ASCII, and a locale-aware fold would make the same utterance normalize differently on two machines.
Parameters
Section titled “Parameters”spoken
Section titled “spoken”string
Returns
Section titled “Returns”string
Example
Section titled “Example”import { spokenAlphanumeric } from "@alexkroman1/aai";
spokenAlphanumeric("rs 44-17"); // "RS4417"spokenAlphanumeric("#W 586 6402"); // "W5866402"spokenDate()
Section titled “spokenDate()”spokenDate(
iso):string
A YYYY-MM-DD as a receptionist says it — "Monday, June 8".
No year, because a date a caller is agreeing to on the phone is almost always within the year and saying it is four wasted syllables. A desk booking further out writes its own sentence around this one.
The weekday is included on purpose: it is the half a caller actually checks. “The 8th” gets agreed to and then turns out to be a Tuesday.
A value that is not a date isIsoDate accepts is returned UNCHANGED —
degrade rather than throw, matching the formatters in format.ts. Declare
the argument with isoDate() and a caller never reaches that path.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string
Example
Section titled “Example”import { spokenDate } from "@alexkroman1/aai";
spokenDate("2026-06-08"); // "Monday, June 8"spokenDate("not a date"); // "not a date"spokenDigits()
Section titled “spokenDigits()”spokenDigits(
spoken):string
The digits of a spoken number, with everything else dropped.
STT renders a read-aloud id every way a human says one — "8642 1975",
"8642-1975", "864 219 75" — and none of them equals the stored id. All of
them have the same digits in the same order.
Parameters
Section titled “Parameters”spoken
Section titled “spoken”string
Returns
Section titled “Returns”string
Example
Section titled “Example”import { spokenDigits } from "@alexkroman1/aai";
spokenDigits("that's 864-219-75"); // "86421975"spokenMoney()
Section titled “spokenMoney()”spokenMoney(
amount):string
An amount as a voice reads it — "240 dollars and 50 cents".
Takes DOLLARS, the same unit as formatMoney (@alexkroman1/aai/utils),
and rounds the same way
it does. That is not a coincidence to preserve by hand: both derive from one
toFixed(2), so the written total on a page and the spoken total on the call
cannot disagree about a half-cent. A desk that counts in cents divides on the
way in, exactly as it already does for formatMoney.
Singular is respected on both halves ("1 dollar and 1 cent"), because “1
dollars” is the kind of thing a caller hears and a transcript diff does not.
A negative amount leads with the word "minus" — a - renders as silence or
as “dash” depending on the engine, and a refund read as a charge is the worst
available outcome. Non-finite degrades to "0 dollars", matching
formatMoney’s $0.00.
The currency WORD is fixed. Symbols are pronounced inconsistently and a
symbol parameter like formatMoney’s would be read out as a symbol; an
agent billing in another currency writes its own sentence.
Parameters
Section titled “Parameters”amount
Section titled “amount”number
Returns
Section titled “Returns”string
Example
Section titled “Example”import { spokenMoney } from "@alexkroman1/aai";
spokenMoney(240.5); // "240 dollars and 50 cents"spokenMoney(240); // "240 dollars"spokenMoney(1.01); // "1 dollar and 1 cent"spokenMoney(0.75); // "75 cents"spokenMoney(-4.99); // "minus 4 dollars and 99 cents"spokenOrdinal()
Section titled “spokenOrdinal()”spokenOrdinal(
spoken):number|undefined
The position an utterance names, as an index, or undefined if it names none.
-1 means the LAST candidate, following Array.prototype.at — which is also
how “the last one” has to be read, since it is a position from the other end.
Matched on word boundaries, so “firstly” and “the 21st” do not read as
positions — a substring test finds first in one and 1st in the other, and
both would pick a candidate the caller never named.
What a boundary cannot rule out is a position word used as an ordinary noun: “the first aid kit” really does contain the word “first”. That is the reason resolveOne takes a position only AFTER the caller has narrowed by whatever its domain understands — an id, a status word — rather than before.
Parameters
Section titled “Parameters”spoken
Section titled “spoken”string
Returns
Section titled “Returns”number | undefined
Example
Section titled “Example”import { spokenOrdinal } from "@alexkroman1/aai";
spokenOrdinal("cancel the second one"); // 1spokenOrdinal("cancel the last one"); // -1spokenOrdinal("cancel my order"); // undefinedspokenTime()
Section titled “spokenTime()”spokenTime(
hhmm):string
A 24-hour HH:MM as a voice reads it — "7 PM", "6:30 PM".
On the hour, the minutes are dropped: "7 PM" rather than "7:00 PM", which
an engine reads as “seven zero zero PM”. AM/PM are upper-cased because
that is the spelling engines pronounce as letters most reliably; "am" is
read as a word often enough to matter.
Midnight is "12 AM" and noon is "12 PM", the American convention that
matches the 12-hour clock this renders into. A desk whose callers would
rather hear “midnight” says so itself — this is the mechanical half.
A value that is not a time isClockTime accepts is returned unchanged, for the reason spokenDate gives.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string
Example
Section titled “Example”import { spokenTime } from "@alexkroman1/aai";
spokenTime("19:00"); // "7 PM"spokenTime("18:30"); // "6:30 PM"spokenTime("04:45"); // "4:45 AM"spokenTime("00:00"); // "12 AM"subagent()
Section titled “subagent()”Call Signature
Section titled “Call Signature”subagent<
S>(def):TypedSubagentDef<InferSchemaOutput<S>>
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”SubagentDef & object
Returns
Section titled “Returns”TypedSubagentDef<InferSchemaOutput<S>>
Call Signature
Section titled “Call Signature”subagent(
def):SubagentDef
Parameters
Section titled “Parameters”Returns
Section titled “Returns”tool()
Section titled “tool()”tool<
P,R>(def):ToolDef<P,R>
Define a tool with a typed input schema and execute function.
Identity function for type inference — returns the input unchanged.
Follows the Vercel AI SDK tool() pattern (inputSchema names the same
field it does there). The schema is any Standard Schema that converts to
JSON Schema; Zod is the documented default.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
R = unknown
Parameters
Section titled “Parameters”ToolDef<P, R>
Returns
Section titled “Returns”ToolDef<P, R>
Examples
Section titled “Examples”import { tool } from "@alexkroman1/aai";import { z } from "zod";
const greet = tool({ description: "Greet someone by name", inputSchema: z.object({ name: z.string() }), execute: ({ name }) => `Hello, ${name}!`,});Reading and writing session state
import { sessionSlot, tool } from "@alexkroman1/aai";import { z } from "zod";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }));
const add = tool({ description: "Add an item to the cart", inputSchema: z.object({ item: z.string() }), execute: ({ item }, ctx) => cartSlot.update(ctx, (cart) => { cart.items.push(item); return cart.items.length; }),});Remarks
Section titled “Remarks”It takes no state type parameter, and neither does ToolContext. A tool reaches session state through a sessionSlot, which types the value in the module that declares it — so a tool in its own file needs neither an annotated context nor a cast.
toolFailure()
Section titled “toolFailure()”toolFailure(
message):ToolFailure
Build a ToolFailure — the failure a tool execute RETURNS when the
model should see it and recover.
The pair to isToolFailure, and named to say so. The object literal
{ error: message } means exactly the same thing and stays perfectly good
TypeScript; this exists so that a tool reaching for “how do I report a
failure?” finds the constructor next to the guard rather than the framework’s
own internal wire form, which is a pre-serialized string this guard does not
narrow.
Parameters
Section titled “Parameters”message
Section titled “message”string
Returns
Section titled “Returns”Example
Section titled “Example”import { tool, toolFailure } from "@alexkroman1/aai";import { z } from "zod";
const orders = new Map<string, { id: string; total: number }>();
export const orderTotal = tool({ description: "Look up an order's total", inputSchema: z.object({ id: z.string() }), execute: ({ id }) => { const order = orders.get(id); if (!order) return toolFailure(`Order ${id} not found.`); return { total: order.total }; },});workflow()
Section titled “workflow()”Call Signature
Section titled “Call Signature”workflow<
P,O>(def):WorkflowDef<P,InferSchemaOutput<O>>
Declare a durable workflow.
An identity function for type inference, exactly like tool() — the returned
object is the input unchanged. Workflows are named by the key they are declared
under, so this takes no name.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
O extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”Omit<WorkflowDef<P, InferSchemaOutput<O>>, "output"> & object
Returns
Section titled “Returns”WorkflowDef<P, InferSchemaOutput<O>>
Remarks
Section titled “Remarks”Three primitives here run a defined process; pick by SCOPE. A dialog gates a CONVERSATION — what the agent may say or do next, across turns, persisted in a session slot. A procedure runs ONE UNIT OF WORK inside a single tool call, never stored. A workflow runs DURABLY, outliving the session.
It validates nothing at declaration time, and there is nothing left to
validate: a body is an ordinary function and a workflow’s identity is the key
it is declared under, so the workflowId a compiler used to attach — and the
check that used to look for it — are both gone. See WorkflowBody.
Two signatures, and which one applies is decided by output. With an
output schema the result type comes from the SCHEMA and the body is CHECKED
against it — a body returning something else is an error at the declaration,
naming the property that disagrees, rather than quietly redefining what the
workflow promises. With no output nothing changes: the result type is
inferred from the body exactly as before. Both answer the same
WorkflowDef<P, R>, so nothing downstream can tell which was used.
Examples
Section titled “Examples”agent.ts — declare the workflow beside the agent. A tool is a FILE, so
agent() takes no tools. Declaring output beside input is what makes
the run’s result checked where it completes and typed where it is read.
import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";import { digestFlow } from "./workflows/digest.ts";
export const digest = workflow({ description: "Research a topic overnight and store the result", input: z.object({ topic: z.string() }), output: z.object({ topic: z.string(), headline: z.string() }), run: digestFlow,});
export default agent({ name: "Researcher", workflows: { digest },});tools/research.ts — the tool that starts a run.
import { tool } from "@alexkroman1/aai";import { z } from "zod";import { digest } from "../agent.ts";
export default tool({ description: "Kick off overnight research on a topic", inputSchema: z.object({ topic: z.string() }), execute: async ({ topic }, ctx) => { // The workflow itself, not its name: typed input, and a typo is a // compile error. `key` is what lets a later turn find this run. const runId = await ctx.workflows.start(digest, { topic }, { key: ctx.sessionId }); return `Working on it — run ${runId}.`; },});Call Signature
Section titled “Call Signature”workflow<
P,R>(def):WorkflowDef<P,R>
Declare a durable workflow.
An identity function for type inference, exactly like tool() — the returned
object is the input unchanged. Workflows are named by the key they are declared
under, so this takes no name.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
R = unknown
Parameters
Section titled “Parameters”WorkflowDef<P, R>
Returns
Section titled “Returns”WorkflowDef<P, R>
Remarks
Section titled “Remarks”Three primitives here run a defined process; pick by SCOPE. A dialog gates a CONVERSATION — what the agent may say or do next, across turns, persisted in a session slot. A procedure runs ONE UNIT OF WORK inside a single tool call, never stored. A workflow runs DURABLY, outliving the session.
It validates nothing at declaration time, and there is nothing left to
validate: a body is an ordinary function and a workflow’s identity is the key
it is declared under, so the workflowId a compiler used to attach — and the
check that used to look for it — are both gone. See WorkflowBody.
Two signatures, and which one applies is decided by output. With an
output schema the result type comes from the SCHEMA and the body is CHECKED
against it — a body returning something else is an error at the declaration,
naming the property that disagrees, rather than quietly redefining what the
workflow promises. With no output nothing changes: the result type is
inferred from the body exactly as before. Both answer the same
WorkflowDef<P, R>, so nothing downstream can tell which was used.
Examples
Section titled “Examples”agent.ts — declare the workflow beside the agent. A tool is a FILE, so
agent() takes no tools. Declaring output beside input is what makes
the run’s result checked where it completes and typed where it is read.
import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";import { digestFlow } from "./workflows/digest.ts";
export const digest = workflow({ description: "Research a topic overnight and store the result", input: z.object({ topic: z.string() }), output: z.object({ topic: z.string(), headline: z.string() }), run: digestFlow,});
export default agent({ name: "Researcher", workflows: { digest },});tools/research.ts — the tool that starts a run.
import { tool } from "@alexkroman1/aai";import { z } from "zod";import { digest } from "../agent.ts";
export default tool({ description: "Kick off overnight research on a topic", inputSchema: z.object({ topic: z.string() }), execute: async ({ topic }, ctx) => { // The workflow itself, not its name: typed input, and a typo is a // compile error. `key` is what lets a later turn find this run. const runId = await ctx.workflows.start(digest, { topic }, { key: ctx.sessionId }); return `Working on it — run ${runId}.`; },});workflowApp()
Section titled “workflowApp()”workflowApp(
def):AgentDef
Define a WORKFLOW APP — an agent whose front door is a form rather than a
microphone, and whose work happens in workflows.
agent({ …, page: "static" }) with the discriminant already set, so the
mode is the CALL rather than a field to remember, and the fields a workflow
app has no use for are absent from the parameter type instead of being
rejected by it. Returns the same AgentDef agent() does — there is
one definition type, one config, one deploy path, and page is only ever
about the front door.
It mirrors the split @alexkroman1/aai-ui already makes in the browser:
mountPage() mounts a workflow app’s UI and mountClient() mounts a voice
one,
because a flag would leave every session-shaped question (“what does this
mean with no session?”) answered by a conditional. Same reasoning, same
seam, other end of the wire.
Parameters
Section titled “Parameters”Omit<StaticAgentParams, "page">
Returns
Section titled “Returns”Example
Section titled “Example”import { workflow, workflowApp } from "@alexkroman1/aai";import { z } from "zod";
export const digest = workflow({ description: "Summarize a link", input: z.object({ url: z.url() }), run: async ({ url }) => ({ url }),});
export default workflowApp({ name: "Link Digest", workflows: { digest },});Classes
Section titled “Classes”KeyedLockTimeoutError
Section titled “KeyedLockTimeoutError”Thrown when an acquire deadline lapses before the key came free.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new KeyedLockTimeoutError(
key,timeoutMs,options?):KeyedLockTimeoutError
Parameters
Section titled “Parameters”string
timeoutMs
Section titled “timeoutMs”number
options?
Section titled “options?”ErrorOptions
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
Properties
Section titled “Properties”
readonlykey:string
ProcedureNotFinishedError
Section titled “ProcedureNotFinishedError”The error a run that did not finish rejects with.
Its own class because the two ways to not finish — aborted by a caller, or stopped for any other reason — are the same fact to a tool body (there is no output) and different facts to a log.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new ProcedureNotFinishedError(
procedure,aborted):ProcedureNotFinishedError
Parameters
Section titled “Parameters”procedure
Section titled “procedure”string
aborted
Section titled “aborted”boolean
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
Properties
Section titled “Properties”aborted
Section titled “aborted”
readonlyaborted:boolean
Whether the run’s signal is what ended it.
procedure
Section titled “procedure”
readonlyprocedure:string
The machine’s id, so a log names which procedure stopped.
Interfaces
Section titled “Interfaces”AgentDef
Section titled “AgentDef”Fully resolved agent definition.
This is what agent() RETURNS, not what you write. You write
AgentParams — the same fields with the defaulted ones optional, plus the
three conveniences agent() normalizes away (llm as a model-id string,
voice, minTurnSilenceMs/maxTurnSilenceMs). This is the reference for what
a field MEANS; AgentParams is the one for which combinations are legal.
Core fields (name, systemPrompt, greeting, maxSteps, tools)
are resolved to their final values with defaults applied. Optional fields
(sttPrompt, the tuning knobs, the provider descriptors, etc.) remain
optional — undefined means “not configured.”
Five groups of fields live on interfaces this extends, each because the
group shares ONE rule that is derived from the declaration rather than
restated beside it: PipelineVoiceTuning (pipeline transport or
nothing), AgentModelTuning (this runtime assembles the request, so
S2S refuses them), AgentGuardrails (the only declarations that may
stop a turn), AgentObservation (the two that deliberately may
not) and AgentVoicePresets (paid for on every model request).
agent() and the deploy-time config check both derive their field
lists from those interfaces, so a new one cannot skip either gate.
Extends
Section titled “Extends”Properties
Section titled “Properties”builtinTools?
Section titled “builtinTools?”
optionalbuiltinTools?: readonlyBuiltinTool[]
Built-in server-side tools enabled for this agent. Unset enables only
think (DEFAULT_BUILTIN_TOOLS), a silent reasoning scratchpad the model
uses between tool calls; every other built-in is opt-in by name. Setting
the field REPLACES the default — include "think" to keep it, and pass
[] for no built-ins at all. See BuiltinTool for the catalog.
Default Value
Section titled “Default Value”["think"] (DEFAULT_BUILTIN_TOOLS)
deadAirCoverMs?
Section titled “deadAirCoverMs?”
optionaldeadAirCoverMs?:number
Pipeline mode only. How long a turn may send nothing to the caller before
the transport speaks a short filler, so a long tool chain doesn’t sound
like a dropped call. MEASURED silence, so a prompt reply pays nothing; 0
disables. The wording is internal and must stay purely declarative — see
DEAD_AIR_COVER_PHRASES for why.
Default Value
Section titled “Default Value”2400 (DEFAULT_DEAD_AIR_COVER_MS)
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.deadAirCoverMs
description?
Section titled “description?”
optionaldescription?:string
What this agent IS, in one line, for whoever is reading a LIST of them.
Its audience is never the model — a registry page, an A2A card, the
studio’s agent picker, the CLI’s aai list. Write it as the job the agent
does (“Books and reschedules dental appointments”), not as instructions;
the instructions are AgentDef.systemPrompt.
Serializable, unlike most of what an author declares, and that is the whole
point: tools, events and workflows are host-only because a consumer
of a stored config could not act on a function, but a description is
exactly what such a consumer wants and could not get. Every peer SDK puts
one on the agent (Anthropic’s AgentDefinition.description is required);
this SDK had one on SubagentDef, WorkflowDef and
ToolDef and none on the agent itself.
dialogs?
Section titled “dialogs?”
optionaldialogs?: readonlyAnyDialog[]
The dialogs this agent runs — see dialog. Declaring one here is
what wires it to the SESSION: its @-prefixed transitions fire (see
DialogSessionEventName), its states’ timeout deadlines are armed,
and its DialogVoiceConfig is applied per state — none of which a
dialog can reach from inside a tool, because all three happen when no tool
is running. An UNDECLARED dialog is unchanged. Host-only, like tools.
errorPhrase?
Section titled “errorPhrase?”
optionalerrorPhrase?:string
Pipeline mode only. Phrase spoken when the turn’s LLM stream fails, so a
provider outage hands the conversation back instead of going silent — a
failed turn produces no text, so nothing would otherwise reach TTS. Set
"" to disable.
Default Value
Section titled “Default Value”"Sorry, I had a problem just then. Could you say that again?" (DEFAULT_ERROR_PHRASE)
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.errorPhrase
events?
Section titled “events?”
optionalevents?:SessionEventHandlers
Observe the session’s own event stream — an audit log, per-turn metrics, or “write every call to my own database”.
Keyed by event type, with "*" matching every event. Typed handlers run
first, then "*", and both run AFTER the event has been recorded in the
session’s retained stream and sent to the client:
import { agent } from "@alexkroman1/aai";
agent({ name: "Audited", events: { "tool.called": (e, ctx) => { // A hook gets `ctx.env` and `ctx.slots`, never a database — persist // through a client of your own if you need to. void fetch(`${ctx.env.AUDIT_URL}`, { method: "POST", body: JSON.stringify({ id: e.meta.id, tool: e.toolName }), }); }, "*": (e) => console.log(e.meta.at, e.type), },});Three properties are load-bearing, and each is a rule rather than a detail:
- Observe-only. A handler cannot inject model context, change a reply, or cancel anything. That is what keeps the stream a LOG rather than a second control path, and it is why a handler receives no way to reply.
- A throw is NON-FATAL. It is logged against the event and the session continues — a failing audit hook must not end a phone call. An async handler is not awaited either, for the same reason: the caller is mid-turn.
- Delivery is at-least-once, and
meta.idis the key. The id is stable across replays, so a handler storing content keys on it; a handler doing a non-idempotent side effect keys on the work’s own coordinates instead, because retried work re-emits under fresh ids.
Before this there was no way for an agent author to observe their own agent
at all: the framework carried 51 internal on* callback options and not one
of them was reachable from agent.ts.
Inherited from
Section titled “Inherited from”greeting
Section titled “greeting”greeting:
string
Sentence spoken when a session starts. Set "" to start silent.
Default Value
Section titled “Default Value”"Hey there! I'm an AI voice assistant. What can I help you with?" (DEFAULT_GREETING)
idleTimeoutMs?
Section titled “idleTimeoutMs?”
optionalidleTimeoutMs?:number
How long the session may go with no inbound audio before it is closed
(ms). Measures silence, not call length — re-armed on every audio frame.
0 or a non-finite value disables the timer entirely.
Default Value
Section titled “Default Value”300_000 (5 minutes, DEFAULT_IDLE_TIMEOUT_MS)
inputGuardrails?
Section titled “inputGuardrails?”
optionalinputGuardrails?: readonlyAgentGuardrail[]
Check what the CALLER said, before the turn is sent to the model.
Pipeline mode only — see this module’s header. Run in order on each committed user utterance; the first one to return a string wins and the model is never asked. The agent says that string instead, the turn is recorded as having happened (so a caller who keeps asking is not talking to an agent with amnesia), and the refused utterance stays in the conversation exactly as it was said.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Support", inputGuardrails: [ (text) => /\b\d{3}-\d{2}-\d{4}\b/.test(text) ? "Please don't read out your social security number — I don't need it." : true, ],});It runs on what the transcriber HEARD, which is the only thing this runtime has: a caller who says a forbidden thing and is misheard is not caught, and one who is misheard INTO saying it is caught wrongly. Write the check so both failures are survivable.
Inherited from
Section titled “Inherited from”AgentGuardrails.inputGuardrails
interruptionBackoffMs?
Section titled “interruptionBackoffMs?”
optionalinterruptionBackoffMs?:number
Pipeline mode only. How long agent audio stays blocked after a real
interruption, in ms. Vapi’s backoffSeconds.
SEQUENTIAL with startSpeakingFloorMs, never cumulative: the two are one
deadline, max(floor, backoff).
Default Value
Section titled “Default Value”0 (DEFAULT_INTERRUPTION_BACKOFF_MS) — today’s behaviour.
Vapi’s own default is 1.0s; see that constant for why this one is not.
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.interruptionBackoffMs
interruptionMinDurationMs?
Section titled “interruptionMinDurationMs?”
optionalinterruptionMinDurationMs?:number
Pipeline mode only. Minimum sustained speech (ms since the utterance’s
first interim transcript) before an interim-triggered barge-in aborts the
agent’s reply — a duration gate alongside minBargeInWords, mirroring
LiveKit’s min_interruption_duration. Committed turns (STT finals) are
never gated. Set 0 to disable the gate.
Default Value
Section titled “Default Value”500 (DEFAULT_INTERRUPTION_MIN_DURATION_MS)
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.interruptionMinDurationMs
optionalllm?:LlmProvider
Pluggable LLM provider descriptor from @alexkroman1/aai/llm (e.g.
llm({ provider: "anthropic", model })) for pipeline mode. Unset (with no s2s), the
stage defaults to the AssemblyAI LLM Gateway. Note this is pure
serializable data, not a Vercel AI SDK LanguageModel instance — the
host resolves the descriptor into a LanguageModel at session start,
using credentials from the agent’s env.
maxOutputTokens?
Section titled “maxOutputTokens?”
optionalmaxOutputTokens?:number
Cap on generated tokens per step, passed straight through to the provider.
The same field GenerateOptions.maxOutputTokens has for a one-shot call. Per STEP, not per turn: a reply that calls three tools has four generations in it, and the cap bounds each.
On a voice agent it is a bluntness knob rather than a cost one — a model that runs long is a model the caller is waiting through — and a value low enough to truncate mid-sentence will truncate mid-sentence, because the provider stops emitting rather than wrapping up.
Inherited from
Section titled “Inherited from”AgentModelTuning.maxOutputTokens
maxRetries?
Section titled “maxRetries?”
optionalmaxRetries?:number
How many times a FAILED provider call is retried before the step is given up on.
Default Value
Section titled “Default Value”the AI SDK’s own (2 retries, exponential backoff)
Transport-level retries of a request that never produced an answer at all (a 429, a 502, a socket reset) — NOT a re-run of one that did. Agent-only: a subagent’s requests retry on the AI SDK default, and its guardrail sending an answer back is SubagentDef.maxRevisions.
0 is the value to reach for on a live call, and the reason is the clock:
the default backoff can spend several seconds before the turn is declared
failed, and the caller hears every one of them as silence. An agent whose
errorPhrase should arrive promptly sets this to 0 and lets the recovery
line do the work.
Inherited from
Section titled “Inherited from”maxSteps
Section titled “maxSteps”maxSteps:
number
Max TOOL-CALLING steps per reply — bounds runaway tool loops. On reaching
the cap the pipeline spends one more step with toolChoice: "none", so a
capped turn still answers rather than stopping mid-chain in silence.
Default Value
Section titled “Default Value”10 (DEFAULT_MAX_STEPS)
mcpServers?
Section titled “mcpServers?”
optionalmcpServers?:Readonly<Record<string,McpServerConfig>>
MCP servers whose tools the model may call alongside this agent’s own.
Each key names one server and prefixes every tool it contributes, so a
docs server’s search arrives as mcp_docs_search — a third party’s
tool can never stand where one of yours stood. HTTP(S) only.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Support", mcpServers: { docs: { url: "https://mcp.example.com/mcp", tokenEnv: "DOCS_MCP_TOKEN" }, }, requiredEnv: ["DOCS_MCP_TOKEN"],});Declaring servers is not enough on its own: a host connects them with
withMcpTools from @alexkroman1/aai-runtime before building the runtime,
because discovery is a network round trip and createRuntime is
synchronous. A server that is down, slow, or missing its token costs its
own tools and nothing else — never the session.
minBargeInWords?
Section titled “minBargeInWords?”
optionalminBargeInWords?:number
Pipeline mode only. Minimum words in an interim transcript before user speech barges in on (aborts) the agent’s in-flight reply. Set 1 to interrupt on any word.
Default Value
Section titled “Default Value”2 (DEFAULT_MIN_BARGE_IN_WORDS) — so one-word
backchannels (“yeah”, “mm-hmm”) don’t cut the agent off.
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.minBargeInWords
name:
string
Display name shown by the default client UI.
outputGuardrails?
Section titled “outputGuardrails?”
optionaloutputGuardrails?: readonlyAgentGuardrail[]
Check what the AGENT is about to say, before any of it is spoken.
Pipeline mode only — see this module’s header for why S2S and text refuse it. Run in order on the reply’s full text once the model has finished and before a single word reaches the synthesizer; the first one to return a string wins and that sentence is spoken in place of the reply. The blocked text is never synthesized and never enters the conversation history.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Pharmacy Line", outputGuardrails: [ (text) => /\b\d+\s?(mg|ml|mcg)\b/i.test(text) ? "I can't give dosage information over the phone. Please check with your pharmacist." : true, ],});It costs the streaming. A reply that must be judged whole cannot be spoken as it arrives, so declaring one trades time-to-first-word for the check: the caller hears nothing until the model has finished, with the dead-air cover filling the gap exactly as it does during a tool chain. That is the price of a block that is real, and it is why this is a field an agent opts into rather than a hook every agent pays for.
Inherited from
Section titled “Inherited from”AgentGuardrails.outputGuardrails
optionalpage?:"voice"|"static"
What this agent’s front door IS — and so whether it serves voice at all.
Default Value
Section titled “Default Value”"voice"
"static" declares a WORKFLOW APP: an ordinary web page over the workflow
HTTP API (/workflows/*), with no microphone, no WebSocket and no session.
The page is still a client.tsx, still React, still Tailwind — it just
mounts with mountPage() instead of mountClient() and reaches the agent
through
createWorkflowApi() / useWorkflowRun() instead of useSession().
Declaring it is not decoration. createRuntimeServer refuses the voice surfaces
for a static agent, so a page that has no session cannot be handed a socket
that would never answer, and AgentDef.telephony is a compile error
on one — an agent with no stt/llm/tts has nothing to put on a call.
The two are not exclusive at the FEATURE level: a "voice" agent may
declare workflows and start them from a tool, and a "static" one may
declare tools it never reaches. This field is only about the surface.
personas?
Section titled “personas?”
optionalpersonas?:Personas<string>
WHO speaks: a roster handed between mid-call over one history; mints handoff and each persona’s tools into tools. Host-only. See sdk/persona.ts.
preemptiveGeneration?
Section titled “preemptiveGeneration?”
optionalpreemptiveGeneration?:boolean
Pipeline mode only. Start generating the reply from a high-confidence INTERIM transcript, and adopt that already-running stream when the committed final turns out to say the same thing.
Default Value
Section titled “Default Value”false — measured on a tool-calling agent and not worth its
cost there. Set true where the arithmetic plausibly differs: a text-heavy
agent, or a longer head start from later endpointing.
Remarks
Section titled “Remarks”Why it is off. A headStartMs/adoption-rate log over a tau2-bench
retail run: 16 speculations started, 14 adopted at a p50 0.44s head start,
and 5 of those 14 (36%) poisoned after adoption by a tool call — unusable
whole, so the generation is discarded and the request reissued, each having
burned p50 0.69s first. Net +8ms per caller turn against a p50 first word of
~1.0s, for 44% of its LLM requests thrown away.
The head start does not survive contact with time-to-first-token: 0.44s against a p50 of 1.10s, so at adoption the speculation has generated nothing and whether its first part will be text or a tool call cannot be known then. A gate on “has it produced text” was tried and reverted — it rejects essentially every adoption, keeping the wasted request and losing the benefit.
Its reach is bounded independently of that: across 815 replies in two
tau2-bench retail runs, 28-33% of replies called a tool at all (the
distribution recorded on DEFAULT_MAX_STEPS), so at most the
remaining 67-72% can ever be accelerated.
What it structurally cannot do, by construction rather than by flag: a speculation never reaches TTS, never emits a client frame, never writes either history view, and never EXECUTES a tool — its tool set is declaration-only, so the model cannot continue past a tool call, and a speculation that reaches one is discarded whole. Adoption requires the final to match the speculated text after normalization (case/punctuation only); an extension, a truncation or a revision all discard and the turn runs exactly as it does with the flag off. At most 2 speculations per utterance. So the worst case is one extra billed LLM request for that utterance.
Turning it back on by default is owed a tau2-bench run at the same tasks and seed showing no reward regression.
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.preemptiveGeneration
requiredEnv?
Section titled “requiredEnv?”
optionalrequiredEnv?: readonlystring[]
Env var names this agent’s code reads (beyond provider credentials, which
are derived from the stt/llm/tts/s2s descriptors automatically).
Deploys check that every listed name is present in the agent’s stored env,
so a missing key surfaces at deploy time instead of as a runtime failure on
the first tool call.
A tool reads them from ToolContext.env; a step has no
tool context and reads them with stepEnv / requireStepEnv from
@alexkroman1/aai/step, which resolve the same record.
resetToolChoice?
Section titled “resetToolChoice?”
optionalresetToolChoice?:boolean
Put toolChoice back to "auto" after the FIRST step of a reply.
Default Value
Section titled “Default Value”true
Only ever observable alongside a toolChoice that DEMANDS a call
("required", or a named tool). Left on every step, such a policy is
re-applied to each one — so the model is obliged to call a tool again after
it already has, and again after that, until the whole maxSteps budget is
spent and forceFinalAnswer rescues the turn on the reserved step.
The turn still answers (it is bounded, not a loop), but it answers after
maxSteps round trips it had no use for, and the caller waits through all
of them.
What toolChoice: "required" almost always means is “start by calling
something”, which is exactly one step. So the reset is ON by default, the
same default OpenAI’s Agents SDK ships (reset_tool_choice), and
resetToolChoice: false is how an agent that really does want a tool call
on every step says so.
It changes nothing for an agent that sets no toolChoice, or one that
sets "auto" or "none": there is no demand to reset, and the preparer
contributes no keys at all.
Inherited from
Section titled “Inherited from”AgentModelTuning.resetToolChoice
resumeFalseInterruption?
Section titled “resumeFalseInterruption?”
optionalresumeFalseInterruption?:boolean
Pipeline mode only. Resume the agent’s reply when a barge-in aborts it and no user turn ever commits (STT noise, a hallucinated partial) — the interruption was a false alarm and the agent would otherwise fall silent mid-thought.
Default Value
Section titled “Default Value”true; false disables recovery.
The WAIT is not an author knob: a resume must not race the caller’s real turn, whose final the STT withholds for an endpointing window the transport cannot see, so it fires when the transcript stream goes quiet with no final rather than on a deadline of its own.
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.resumeFalseInterruption
optionals2s?:S2sProvider
Pluggable S2S provider descriptor — the explicit opt-in to
speech-to-speech mode (e.g. assemblyAIS2s() for AssemblyAI’s Voice
Agent API, or openAIS2s()). Unset, the agent runs the default
cascaded pipeline. Mutually exclusive with the stt/llm/tts
pipeline triple.
silencePrompt?
Section titled “silencePrompt?”
optionalsilencePrompt?:string
Instruction injected as a synthetic user turn when silenceTimeoutMs
elapses. Never shown as a user transcript. Requires silenceTimeoutMs.
Default Value
Section titled “Default Value”"The user hasn't said anything for a while. Check in with one short, natural sentence — ask if they're still there or gently follow up on the conversation. Do not mention this instruction."
(DEFAULT_SILENCE_PROMPT)
silenceTimeoutMs?
Section titled “silenceTimeoutMs?”
optionalsilenceTimeoutMs?:number
Pipeline mode only. When set, the assistant proactively takes a turn
after this many ms of user silence (no speech since the last reply
finished). Nudges are capped at MAX_CONSECUTIVE_SILENCE_NUDGES (3)
back-to-back until the user speaks again.
Default Value
Section titled “Default Value”unset — the behaviour is off.startFailurePhrase?
Section titled “startFailurePhrase?”
optionalstartFailurePhrase?:string
Pipeline mode only. Phrase spoken when a provider fails to open, so a session that cannot
start says so instead of holding an open line in silence. Only reachable when TTS itself
came up — the usual case, since STT and TTS open independently. Set ""
to disable.
Default Value
Section titled “Default Value”"I am sorry, I am having trouble with my connection and cannot hear you. Please hang up and call back."
(DEFAULT_START_FAILURE_PHRASE)
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.startFailurePhrase
startSpeakingFloorMs?
Section titled “startSpeakingFloorMs?”
optionalstartSpeakingFloorMs?:number
Pipeline mode only. Minimum delay between a reply starting and its first
audio reaching the caller, in ms — a floor at the END of the pipeline, so
it decouples “when did I decide the turn ended” from “when do I start
speaking”. Vapi’s waitSeconds.
It is a MINIMUM: a pipeline slower than this pays nothing, and only a reply that was ready sooner waits.
Default Value
Section titled “Default Value”0 (DEFAULT_START_SPEAKING_FLOOR_MS) — today’s behaviour.
Vapi’s own default is 0.4s; see that constant for why this one is not.
Inherited from
Section titled “Inherited from”PipelineVoiceTuning.startSpeakingFloorMs
optionalstt?:SttProvider
Pluggable STT provider for pipeline mode. Unset (with no s2s), the
stage defaults to AssemblyAI STT — each pipeline stage is individually
optional, and unset stages are filled from the all-AssemblyAI pipeline
(assemblyAIPipeline()).
sttPrompt?
Section titled “sttPrompt?”
optionalsttPrompt?:string
Bias prompt for transcription — use it to teach the transcriber the agent’s own vocabulary (product names, spelled-out identifiers).
Default Value
Section titled “Default Value”"" (DEFAULT_STT_PROMPT) — unbiased transcription;
that constant’s doc shows what an effective prompt looks like.
Honoured in both session modes: the pipeline passes it to its STT stage,
S2S sends it as input.transcription_prompt (trimmed to that field’s
1750-char cap). It was pipeline-only until measurement showed what it costs
to drop — on tau2-bench retail a transcription prompt took the caller’s
spelled first name from 1 of 6 attempts correct to 6 of 6, and the S2S path
was ignoring the field without a warning.
subagents?
Section titled “subagents?”
optionalsubagents?:SubagentRoster
Subagents the MODEL may hand a task to, published as one delegate tool.
The other half of ctx.delegate: a tool body naming a subagent is the
AUTHOR routing in code, a roster is the MODEL routing per turn. Every entry
needs a SubagentDef.description — the only thing the router reads —
and agent() refuses one without it. The one field whose declaration MINTS
A TOOL, so a tools/delegate.ts beside a roster is a collision; host-only,
like tools. Worked example and argument: sdk/subagent-roster.ts.
syncState?
Section titled “syncState?”
optionalsyncState?:StateProjection<unknown> | readonlyStateProjection<unknown>[]
Project per-session state to the browser client, so a custom UI can render it without the agent hand-rolling a sync channel.
One projection per slot the client should see, or an array of them — the
agent_state frame carries the merge. A slot the agent does not project
never leaves the server, which is the point: session state routinely holds
things a browser should not have, so the author decides what leaves, and
whatever a projection returns is exactly what useAgentState receives.
Pushed after every tool call, and only when a projection actually changed:
most turns touch no state, and this shares a socket with 384 kbps of PCM.
Declare the view on the slot and pass SessionSlot.projected.
One object the agent pushes with and the page renders with, so the frame
shown before the first tool call cannot describe a different view from the
ones after it. A slot with more than one audience keeps
SessionSlot.projection, a second view over the same slot;
syncState takes an array.
import { agent, sessionSlot } from "@alexkroman1/aai";type Item = { sku: string; qty: number };// `staffPin` has no view, so it stays server-side.const cartSlot = sessionSlot("cart", () => ({ items: [] as Item[], staffPin: "" }), { view: (s) => ({ items: s.items }),});agent({ name: "Cart", syncState: cartSlot.projected });// Two audiences over the one slot:agent({ name: "Cart", syncState: [cartSlot.projected, cartSlot.projection((s) => ({ count: s.items.length }))],});Remarks
Section titled “Remarks”A projection names its own slot, so the runtime can render a session that
has run no tool yet — which is what let AgentDef.state be deleted rather
than remembered. Without it, agents hand-roll a snapshot returned from every
tool and mirrored into useState; 58% of generated agents built one.
Inherited from
Section titled “Inherited from”systemPrompt
Section titled “systemPrompt”systemPrompt:
AgentSystemPrompt
System prompt driving the LLM — the text, or a function that computes it per request from AgentSessionContext.
A resolver is how a prompt reads the session’s own state: which phase the
dialog is in, whether the caller is authenticated, what is in the cart.
It is called once per model request (so once per STEP of a tool-calling
reply), synchronously, and its answer lands exactly where a string’s does —
appended under the agent-specific-instructions header, after the
framework’s voice sections. See agent-instructions.ts, which owns the
rest, including what an S2S agent gets (per-CONNECTION, not per-turn).
import { agent, sessionSlot } from "@alexkroman1/aai";
const caller = sessionSlot("caller", () => ({ verified: false }));
export default agent({ name: "Bank Line", systemPrompt: (ctx) => caller.get(ctx).verified ? "The caller is verified. You may discuss balances." : "The caller is NOT verified. Verify them before discussing anything.",});Default Value
Section titled “Default Value”DEFAULT_SYSTEM_PROMPT — the framework’s own voice-agent prompt. It is assembled from parts, so it is the one default here whose VALUE cannot usefully be inlined; read the constant.
telephony?
Section titled “telephony?”
optionaltelephony?:TelephonyAccess
Which phone carriers may open a media stream against this agent — and so
whether WS /phone is served at all.
Default Value
Section titled “Default Value”none — the route is not mounted
true admits every carrier the runtime ships a codec for; a list admits
exactly those (telephony: ["twilio"] refuses a Telnyx stream); false
and an absent field are the same refusal. See TelephonyAccess.
Declaring it is what MOUNTS the route. It is the one surface an agent gets that is dialled from OUTSIDE the deployment — a carrier reaches it by a URL a phone number points at, not through the page this server hands a browser — so an agent with no phone number has no use for it, and used to serve both carriers’ framing anyway from the moment it booted.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Support", telephony: ["twilio"] });temperature?
Section titled “temperature?”
optionaltemperature?:number
Sampling temperature.
Omitted by default, so the model’s own default applies; some models (Claude 5 among them) ignore it and warn, so set it only for a temperature-capable one. A booking desk and a game master want different values; so do a researcher subagent and the voice that relays what it found.
Inherited from
Section titled “Inherited from”
optionaltext?:true
Opt into TEXT mode — an agent with no audio path at all, driven over a
message list by createTextAgent (@alexkroman1/aai-runtime) instead of
by a transport over a session socket.
A text agent is the same agent() definition every voice agent is —
systemPrompt, tools, maxSteps, toolChoice, builtinTools,
requiredEnv and a tool’s sessionSlots all mean exactly what they mean
elsewhere, and
tools run through the same executor, so one tool works in both. What it
drops is everything downstream of speech: stt, tts and s2s are
rejected (there is no audio to transcribe or synthesize), as are the
voice-UX tuning knobs and the silence nudge. llm is the one stage it
has, and it defaults to the AssemblyAI LLM Gateway like every other.
Explicit, never derived — the same rule s2s follows. A mode reachable
by omission is one a config lands in when it loses a field, and the
symptom there would be a deployed voice agent that answers nothing.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Docs Assistant", text: true, systemPrompt: "Answer questions about the docs.",});Its tools are files under tools/, exactly as a voice agent’s are.
toolChoice?
Section titled “toolChoice?”
optionaltoolChoice?:ToolChoice
How the LLM selects tools each step.
Default Value
Section titled “Default Value”"auto" (DEFAULT_TOOL_CHOICE) — the model decides.
Honored in pipeline mode and by the OpenAI Realtime transport; the AssemblyAI S2S service runs the tool loop service-side and does not take a tool-choice parameter.
tools:
ToolSet
The tools the agent may invoke, keyed by the name the model calls.
Not authored — RESOLVED. agent() returns this empty and rejects a
tools argument outright (InlineToolsMisuse); the table is filled by
withTools, over a registry built from a tools/ directory. The build is
what enumerates that directory — a deployed agent is handed one ESM string
and has no filesystem to scan — and a spec imports the same lowering
ready-made: import agentDef from "virtual:aai/agent" under vitest, or
deployedAgent(def, { tools, systemPrompt }) from
@alexkroman1/aai/testing under any other runner.
So a tool’s name is its FILE name and nothing else records it.
Remarks
Section titled “Remarks”This record carries no state type, and there is none to carry: a tool reads
and writes session state through sessionSlot, which types the value
in the module that declares the slot. The NoInfer<S> this used to hold
existed to keep a single un-annotated tool from dragging the agent’s whole
state shape back to unknown, which is a problem a slot does not have.
optionaltts?:TtsProvider
Pluggable TTS provider for pipeline mode. Unset (with no s2s), the
stage defaults to AssemblyAI TTS (agent()’s voice shorthand picks
its voice).
turnDetection?
Section titled “turnDetection?”
optionalturnDetection?:TurnDetectionMode
Pipeline mode only. WHO decides that the caller’s turn is over.
"auto"— the transcriber does, on a pause. What every agent has always done."manual"— the CLIENT does, which is push-to-talk. The caller’s audio reaches the transcriber only between auser_turn_startand theuser_turn_commitoruser_turn_clearthat closes it (aai-ui’ssession.userTurn.start/.commit/.clear, or itsusePushToTalkhook). Everything transcribed in that window, across however many pauses, is ONE turn, and nothing is answered until the commit. Outside the window the microphone is replaced with silence server-side, so a caller talking to someone else in the room is never heard.
Under "manual" the caller’s speech never barges in by itself — opening a
turn is what interrupts the agent, so the button IS the barge-in — and
preemptive generation is skipped, since no pause is a turn boundary. A
userTurnLimit still applies and, when it fires, commits the turn exactly
as the client’s commit would have.
Default Value
Section titled “Default Value”"auto"
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Walkie", // Nothing the caller says is answered until they let go of the button. turnDetection: "manual",});Inherited from
Section titled “Inherited from”PipelineVoiceTuning.turnDetection
usageLimits?
Section titled “usageLimits?”
optionalusageLimits?:UsageLimits
Bound what one session may spend — see UsageLimits.
Default Value
Section titled “Default Value”unset — no cap. Usage is still MEASURED either way; whether it
is also reported on the session event stream depends on whether anything
reads it. Declaring a limit turns usage.updated on, and so does an
agent({ events }) handler for usage.updated or "*" — an unobserved
session emits nothing rather than spending a durable event per model step.
Inherited from
Section titled “Inherited from”userTurnLimit?
Section titled “userTurnLimit?”
optionaluserTurnLimit?:UserTurnLimit
Pipeline mode only. Cap ONE user turn’s length — by words heard, by elapsed time, or both — see UserTurnLimit.
Default Value
Section titled “Default Value”unset — no cap on a single user turn’s length.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Triage", // Answer after 60 words or 20 seconds, whichever the caller reaches first. userTurnLimit: { maxWords: 60, maxDurationMs: 20_000 },});Inherited from
Section titled “Inherited from”PipelineVoiceTuning.userTurnLimit
voicePresets?
Section titled “voicePresets?”
optionalvoicePresets?: readonlyVoicePresetName[]
Opt-in prompt presets — named reliability behaviours, composed into the system prompt above your own instructions.
Default Value
Section titled “Default Value”none — an agent that declares nothing here sends exactly the prompt it sent before the field existed.
Each name costs tokens on EVERY model request: echoVerification ~190,
speechNormalization ~920, natoAlphabet ~190. Turn
on what the desk needs and nothing else — see VOICE_PRESETS for the
exact text of each and for what it overrides.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Claims Intake", voicePresets: ["echoVerification", "natoAlphabet"],});Order is ignored (the framework emits them in a fixed order) and a repeat is emitted once, so a list assembled from a config cannot change the prompt’s shape.
Inherited from
Section titled “Inherited from”AgentVoicePresets.voicePresets
workflows?
Section titled “workflows?”
optionalworkflows?:Readonly<Record<string,WorkflowDef>>
Durable workflows this agent may start, keyed by workflow name.
Remarks
Section titled “Remarks”The key is the NAME — nothing else records it, which is what makes a rename
a one-place change and what ctx.workflows.start(def, …) resolves a
definition against by identity.
Host-only, like tools, because a definition holds a function. The platform
therefore never reads this record: a page’s GET /workflows listing is
served by the GUEST from its own live agent definition, the same way
name/greeting are proxied rather than read from the stored config.
AgentGuardrails
Section titled “AgentGuardrails”The two guardrail fields on AgentDef — see this module’s header for what each can actually prevent.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”inputGuardrails?
Section titled “inputGuardrails?”
optionalinputGuardrails?: readonlyAgentGuardrail[]
Check what the CALLER said, before the turn is sent to the model.
Pipeline mode only — see this module’s header. Run in order on each committed user utterance; the first one to return a string wins and the model is never asked. The agent says that string instead, the turn is recorded as having happened (so a caller who keeps asking is not talking to an agent with amnesia), and the refused utterance stays in the conversation exactly as it was said.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Support", inputGuardrails: [ (text) => /\b\d{3}-\d{2}-\d{4}\b/.test(text) ? "Please don't read out your social security number — I don't need it." : true, ],});It runs on what the transcriber HEARD, which is the only thing this runtime has: a caller who says a forbidden thing and is misheard is not caught, and one who is misheard INTO saying it is caught wrongly. Write the check so both failures are survivable.
outputGuardrails?
Section titled “outputGuardrails?”
optionaloutputGuardrails?: readonlyAgentGuardrail[]
Check what the AGENT is about to say, before any of it is spoken.
Pipeline mode only — see this module’s header for why S2S and text refuse it. Run in order on the reply’s full text once the model has finished and before a single word reaches the synthesizer; the first one to return a string wins and that sentence is spoken in place of the reply. The blocked text is never synthesized and never enters the conversation history.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Pharmacy Line", outputGuardrails: [ (text) => /\b\d+\s?(mg|ml|mcg)\b/i.test(text) ? "I can't give dosage information over the phone. Please check with your pharmacist." : true, ],});It costs the streaming. A reply that must be judged whole cannot be spoken as it arrives, so declaring one trades time-to-first-word for the check: the caller hears nothing until the model has finished, with the dead-air cover filling the gap exactly as it does during a tool chain. That is the price of a block that is real, and it is why this is a field an agent opts into rather than a hook every agent pays for.
AgentModelTuning
Section titled “AgentModelTuning”ModelTuning plus the two knobs only the agent’s OWN loop has: a per-step tool-choice policy and a session token budget. Every field here is refused in S2S mode; see this module’s header.
A subagent takes ModelTuning alone — it has no toolChoice to reset,
and it spends on its PARENT’s budget, which is where usageLimits lives.
Extends
Section titled “Extends”Extended by
Section titled “Extended by”Properties
Section titled “Properties”maxOutputTokens?
Section titled “maxOutputTokens?”
optionalmaxOutputTokens?:number
Cap on generated tokens per step, passed straight through to the provider.
The same field GenerateOptions.maxOutputTokens has for a one-shot call. Per STEP, not per turn: a reply that calls three tools has four generations in it, and the cap bounds each.
On a voice agent it is a bluntness knob rather than a cost one — a model that runs long is a model the caller is waiting through — and a value low enough to truncate mid-sentence will truncate mid-sentence, because the provider stops emitting rather than wrapping up.
Inherited from
Section titled “Inherited from”maxRetries?
Section titled “maxRetries?”
optionalmaxRetries?:number
How many times a FAILED provider call is retried before the step is given up on.
Default Value
Section titled “Default Value”the AI SDK’s own (2 retries, exponential backoff)
Transport-level retries of a request that never produced an answer at all (a 429, a 502, a socket reset) — NOT a re-run of one that did. Agent-only: a subagent’s requests retry on the AI SDK default, and its guardrail sending an answer back is SubagentDef.maxRevisions.
0 is the value to reach for on a live call, and the reason is the clock:
the default backoff can spend several seconds before the turn is declared
failed, and the caller hears every one of them as silence. An agent whose
errorPhrase should arrive promptly sets this to 0 and lets the recovery
line do the work.
Inherited from
Section titled “Inherited from”resetToolChoice?
Section titled “resetToolChoice?”
optionalresetToolChoice?:boolean
Put toolChoice back to "auto" after the FIRST step of a reply.
Default Value
Section titled “Default Value”true
Only ever observable alongside a toolChoice that DEMANDS a call
("required", or a named tool). Left on every step, such a policy is
re-applied to each one — so the model is obliged to call a tool again after
it already has, and again after that, until the whole maxSteps budget is
spent and forceFinalAnswer rescues the turn on the reserved step.
The turn still answers (it is bounded, not a loop), but it answers after
maxSteps round trips it had no use for, and the caller waits through all
of them.
What toolChoice: "required" almost always means is “start by calling
something”, which is exactly one step. So the reset is ON by default, the
same default OpenAI’s Agents SDK ships (reset_tool_choice), and
resetToolChoice: false is how an agent that really does want a tool call
on every step says so.
It changes nothing for an agent that sets no toolChoice, or one that
sets "auto" or "none": there is no demand to reset, and the preparer
contributes no keys at all.
temperature?
Section titled “temperature?”
optionaltemperature?:number
Sampling temperature.
Omitted by default, so the model’s own default applies; some models (Claude 5 among them) ignore it and warn, so set it only for a temperature-capable one. A booking desk and a game master want different values; so do a researcher subagent and the voice that relays what it found.
Inherited from
Section titled “Inherited from”usageLimits?
Section titled “usageLimits?”
optionalusageLimits?:UsageLimits
Bound what one session may spend — see UsageLimits.
Default Value
Section titled “Default Value”unset — no cap. Usage is still MEASURED either way; whether it
is also reported on the session event stream depends on whether anything
reads it. Declaring a limit turns usage.updated on, and so does an
agent({ events }) handler for usage.updated or "*" — an unobserved
session emits nothing rather than spending a durable event per model step.
AgentObservation
Section titled “AgentObservation”The observe-only half of an agent declaration — see this module’s header.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”events?
Section titled “events?”
optionalevents?:SessionEventHandlers
Observe the session’s own event stream — an audit log, per-turn metrics, or “write every call to my own database”.
Keyed by event type, with "*" matching every event. Typed handlers run
first, then "*", and both run AFTER the event has been recorded in the
session’s retained stream and sent to the client:
import { agent } from "@alexkroman1/aai";
agent({ name: "Audited", events: { "tool.called": (e, ctx) => { // A hook gets `ctx.env` and `ctx.slots`, never a database — persist // through a client of your own if you need to. void fetch(`${ctx.env.AUDIT_URL}`, { method: "POST", body: JSON.stringify({ id: e.meta.id, tool: e.toolName }), }); }, "*": (e) => console.log(e.meta.at, e.type), },});Three properties are load-bearing, and each is a rule rather than a detail:
- Observe-only. A handler cannot inject model context, change a reply, or cancel anything. That is what keeps the stream a LOG rather than a second control path, and it is why a handler receives no way to reply.
- A throw is NON-FATAL. It is logged against the event and the session continues — a failing audit hook must not end a phone call. An async handler is not awaited either, for the same reason: the caller is mid-turn.
- Delivery is at-least-once, and
meta.idis the key. The id is stable across replays, so a handler storing content keys on it; a handler doing a non-idempotent side effect keys on the work’s own coordinates instead, because retried work re-emits under fresh ids.
Before this there was no way for an agent author to observe their own agent
at all: the framework carried 51 internal on* callback options and not one
of them was reachable from agent.ts.
syncState?
Section titled “syncState?”
optionalsyncState?:StateProjection<unknown> | readonlyStateProjection<unknown>[]
Project per-session state to the browser client, so a custom UI can render it without the agent hand-rolling a sync channel.
One projection per slot the client should see, or an array of them — the
agent_state frame carries the merge. A slot the agent does not project
never leaves the server, which is the point: session state routinely holds
things a browser should not have, so the author decides what leaves, and
whatever a projection returns is exactly what useAgentState receives.
Pushed after every tool call, and only when a projection actually changed:
most turns touch no state, and this shares a socket with 384 kbps of PCM.
Declare the view on the slot and pass SessionSlot.projected.
One object the agent pushes with and the page renders with, so the frame
shown before the first tool call cannot describe a different view from the
ones after it. A slot with more than one audience keeps
SessionSlot.projection, a second view over the same slot;
syncState takes an array.
import { agent, sessionSlot } from "@alexkroman1/aai";type Item = { sku: string; qty: number };// `staffPin` has no view, so it stays server-side.const cartSlot = sessionSlot("cart", () => ({ items: [] as Item[], staffPin: "" }), { view: (s) => ({ items: s.items }),});agent({ name: "Cart", syncState: cartSlot.projected });// Two audiences over the one slot:agent({ name: "Cart", syncState: [cartSlot.projected, cartSlot.projection((s) => ({ count: s.items.length }))],});Remarks
Section titled “Remarks”A projection names its own slot, so the runtime can render a session that
has run no tool yet — which is what let AgentDef.state be deleted rather
than remembered. Without it, agents hand-roll a snapshot returned from every
tool and mirrored into useState; 58% of generated agents built one.
AgentSessionContext
Section titled “AgentSessionContext”Sealed
The session a per-session author function is running for.
Properties
Section titled “Properties”env:
Readonly<Partial<Record<string,string>>>
Environment variables available to this agent (from .env under
aai dev, aai secret in production) — the same view a tool reads as
ctx.env.
sessionId
Section titled “sessionId”sessionId:
string
The session this call belongs to — the id a stream read is keyed by.
slots:
SlotStore
This session’s slot storage — reach for sessionSlot, not this, exactly as in a tool. It is on the context because a slot declared in one module has no other way to find the session.
Reading it is the point: a prompt that cannot see the session’s state is a constant with extra steps, and a guardrail that cannot count strikes can only judge one sentence at a time. Writing works too and lands like any other slot write — but a resolver runs on every request, so a resolver that writes is writing several times a turn.
AgentVoicePresets
Section titled “AgentVoicePresets”The opt-in prompt presets, extended by AgentDef.
Its own interface for the reason PipelineVoiceTuning and the three other
field groups have one — types.ts sits at the source-length cap, and a group
of fields sharing one rule reads better stated once. The rule here is the
cost: every name in the list is paid for on every model request, and
VOICE_PRESETS’ table is the price list.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”voicePresets?
Section titled “voicePresets?”
optionalvoicePresets?: readonlyVoicePresetName[]
Opt-in prompt presets — named reliability behaviours, composed into the system prompt above your own instructions.
Default Value
Section titled “Default Value”none — an agent that declares nothing here sends exactly the prompt it sent before the field existed.
Each name costs tokens on EVERY model request: echoVerification ~190,
speechNormalization ~920, natoAlphabet ~190. Turn
on what the desk needs and nothing else — see VOICE_PRESETS for the
exact text of each and for what it overrides.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Claims Intake", voicePresets: ["echoVerification", "natoAlphabet"],});Order is ignored (the framework emits them in a fixed order) and a repeat is emitted once, so a list assembled from a config cannot change the prompt’s shape.
AssemblyAIPipelineOptions
Section titled “AssemblyAIPipelineOptions”Properties
Section titled “Properties”maxTurnSilenceMs?
Section titled “maxTurnSilenceMs?”
optionalmaxTurnSilenceMs?:number
See AssemblyAIPipelineOptions.minTurnSilenceMs.
minTurnSilenceMs?
Section titled “minTurnSilenceMs?”
optionalminTurnSilenceMs?:number
End-of-turn window for the STT stage, in ms — the same two settings
agent({ minTurnSilenceMs, maxTurnSilenceMs }) reaches without the
preset, here for a config that already spreads it (an EU region, say).
maxTurnSilenceMs is the PAUSE-TOLERANCE knob: it bounds only utterances
that never read as complete, so raising it is paid for by hesitant speech
alone. minTurnSilenceMs is the end-of-turn CHECK and taxes every
finished utterance. Read DEFAULT_MAX_TURN_SILENCE_MS and
DEFAULT_MIN_TURN_SILENCE_MS before moving either — both are measured.
region?
Section titled “region?”
optionalregion?:"us"|"eu"
EU data residency. Applies to STT and the LLM gateway; TTS has a single
endpoint. Note the EU gateway serves only Claude and most Gemini models,
so an EU agent must also override llm with a model the EU endpoint
carries (e.g. llm: "claude-sonnet-4-6" after the spread). An override
that replaces a whole stage descriptor must re-declare region itself —
stt: assemblyAIStt({ model, region: "eu" }) — since it replaces the
preset’s descriptor including its region.
voice?
Section titled “voice?”
optionalvoice?:AssemblyAITtsVoice
TTS voice id, e.g. "jane", "michael", "alba". Defaults to
"jane" (US-accented English). Each voice speaks exactly one
language — see
ASSEMBLYAI_TTS_VOICES (from @alexkroman1/aai/tts) for the
catalog; a name outside it fails in-band after connect and leaves the
agent silent. (agent({ voice }) is the same setting without the
preset.)
AssemblyAIS2sOptions
Section titled “AssemblyAIS2sOptions”Options for assemblyAIS2s.
The descriptor took NO options until 2026-08-09, which left every
author-controlled knob on the S2S session unreachable while the pipeline had
all of them. That asymmetry had a measured cost: on tau2-bench retail,
pinning language_codes: ["en"] alongside voice focus and a transcription
prompt took the authenticating caller’s spelled first name from 1 of 6
attempts correct to 6 of 6, and word recall from ~0.89 to ~0.93. The other
two of those three are pinned host-side; the language pin is the one that
MUST stay author-controlled (see AssemblyAIS2sOptions.languages), so
without a field here it could not be set at all.
Deliberately absent: turn_detection. Its service default is adaptive and
entity-aware — it waits out a spelled-out value — and setting
min_silence/max_silence disables both for the rest of the session.
Extends
Section titled “Extends”Properties
Section titled “Properties”apiKeyEnv?
Section titled “apiKeyEnv?”
optionalapiKeyEnv?:string
Env var holding this stage’s credential, replacing the provider default. Names a VARIABLE, not a key.
Inherited from
Section titled “Inherited from”ProviderCredentialOptions.apiKeyEnv
keyterms?
Section titled “keyterms?”
optionalkeyterms?: readonlystring[]
Domain terms to bias transcription toward (input.keyterms) — product
names, proper nouns, spelled identifiers the model would otherwise
mis-hear. Complements sttPrompt, which is prose rather than a term list.
languages?
Section titled “languages?”
optionallanguages?: readonlystring[]
Language codes to bias transcription toward (input.language_codes).
Leave UNSET to detect per turn — that is a real setting, not an absent
one, and a host-side ["en"] default would silently disable multilingual
transcription for every agent (the mirror-image bug of the one this field
fixes). Pin one code for a monolingual line; a multi-element list biases
toward a known subset while keeping code-switching.
voice?
Section titled “voice?”
optionalvoice?:string
Voice for the agent’s synthesized speech (output.voice). Unset uses the
service default.
The accepted set is the service’s, and is NOT verified in this repo — the
failure mode is the one ASSEMBLYAI_TTS_VOICES (from
@alexkroman1/aai/tts) exists to prevent, so treat an id from outside that
catalog as unproven: a voice the service rejects comes back in-band after
the socket opens, leaving an agent that connects, reports ready, and never
speaks.
ClientEventMap
Section titled “ClientEventMap”The agent’s OWN custom events — what ctx.send(event, data) pushes to the
browser — keyed by event name, and EMPTY until the agent declares some.
An interface so an agent can augment it, exactly like
SessionEventMap. Once a name is declared, ctx.send type-checks its
payload: a misspelled field or a wrong type is a compile error in the tool
that sends it, rather than a client handler that silently reads undefined.
A name nobody declared still sends unknown, so declaring one event never
obliges the agent to declare the rest.
import { tool } from "@alexkroman1/aai";import { z } from "zod";
declare module "@alexkroman1/aai" { interface ClientEventMap { "order.progress": { done: number; total: number }; }}
export default tool({ description: "Ship the order", inputSchema: z.object({}), execute: (_args, ctx) => { ctx.send("order.progress", { done: 1, total: 3 }); return { ok: true }; },});The payload is typed on the SENDING side only: on the wire it is still a
custom.emitted frame whose data the schema admits as any JSON value.
DelegateOptions
Section titled “DelegateOptions”Per-call options for DelegateFn.
Properties
Section titled “Properties”context?
Section titled “context?”
optionalcontext?:string
Extra context appended after the subagent’s own systemPrompt for this
call — the caller’s name, what has already been ruled out, the format the
answer should take. Absent by default, because a subagent that needs the
conversation to make sense is one whose task was underspecified.
maxSteps?
Section titled “maxSteps?”
optionalmaxSteps?:number
Override the subagent’s step budget for this call.
task:
string
The task, as the subagent’s first user message. Write it as a complete brief: the subagent’s context is ISOLATED, so it has not read the conversation and knows nothing the task does not say.
DelegateResult
Section titled “DelegateResult”Sealed
What one delegated run returns: the accepted attempt, plus what getting there took.
Extends
Section titled “Extends”Extended by
Section titled “Extended by”Properties
Section titled “Properties”accepted
Section titled “accepted”accepted:
boolean
Whether the guardrail ACCEPTED this answer. Always true when the subagent
declares no guardrail.
false means the retry budget ran out and text is the last REJECTED
attempt. It comes back rather than throwing because the caller is a tool on
a live call and needs something to say — but it is a distinct value, not a
silently-returned failure, so a tool that cares can apologize instead of
reading a bad answer out loud.
complaint?
Section titled “complaint?”
optionalcomplaint?:string
The guardrail’s last complaint. Present exactly when accepted is false
— it is the reason, and a caller that reports the failure should quote it.
revisions
Section titled “revisions”revisions:
number
How many times the guardrail sent an answer back before this one.
0 when it passed first time, and 0 for a subagent with no guardrail at
all. Reported for the same reason steps is: it is most of what the run
cost, and a wait that included two rewrites is a wait the caller was owed a
word about.
steps:
number
How many steps this attempt took, including the final answering step.
Inherited from
Section titled “Inherited from”text:
string
The subagent’s final message — see SubagentDef.expectedOutput.
Inherited from
Section titled “Inherited from”toolCalls
Section titled “toolCalls”toolCalls: readonly
SubagentToolCall[]
Every tool call this attempt made, in order.
Inherited from
Section titled “Inherited from”Dialog
Section titled “Dialog”Sealed
A dialog statechart bound to a session, created by dialog.
Type Parameters
Section titled “Type Parameters”M extends AnyStateMachine
The XState machine this dialog runs.
E = EventFromLogic<M>
The event union Dialog.send and a gated tool’s
send/sendFrom accept. Defaults to the machine’s own — a dialog declared
from a DialogSpec supplies it directly instead, because the machine
it builds is an implementation detail and its type carries no events.
Methods
Section titled “Methods”matches()
Section titled “matches()”matches(
ctx,state):boolean
Whether the active state matches state, as when spells it.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”boolean
position()
Section titled “position()”position(
ctx):DialogPosition
Where this session’s conversation currently is.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”projection()
Section titled “projection()”projection<
V>(project):StateProjection<V>
A syncState projection of this dialog’s position, so a client can render
the step the caller is on without the agent hand-rolling a sync channel.
The projector is REQUIRED, exactly as SessionSlot.projection’s is,
and for the same reason: an optional one cannot be typed without asserting
that the un-projected DialogPosition is the caller’s V. Project the
identity — dialog.projection((at) => at) — to push the whole position.
Type Parameters
Section titled “Type Parameters”V
Parameters
Section titled “Parameters”project
Section titled “project”(position) => V
Returns
Section titled “Returns”receive()
Section titled “receive()”receive(
ctx,event):DialogPosition
Offer a SESSION event to the dialog: the runtime’s half of DialogSessionEventName.
Sends { type: "@<event.type>" } when the active state (or a state
containing it) declares a transition on it, and does nothing at all
otherwise — the position comes back either way, so a caller that wants to
know whether anything moved compares state. XState already ignores an
unhandled event, so the check is not what makes this safe; what it buys is
that the overwhelming majority of session events, which no dialog is
watching, write nothing. A send stores the snapshot whether or not the
machine moved, so on a durable dialog that would be a store round-trip per
transcript frame.
The runtime calls this for a dialog listed in AgentDef.dialogs. It
takes a SlotHolder, which is what a SessionEventContext already
is — both carry slots and sessionId — so an author can drive a dialog
from an events handler today, with no declaration at all:
import { agent, dialog } from "@alexkroman1/aai";
const claim = dialog("claim", { initial: "verifying", states: { verifying: { on: { "@session.timed-out": "abandoned" } }, abandoned: { final: true }, },});
export default agent({ name: "Support", events: { "session.timed-out": (event, ctx) => void claim.receive(ctx, event) },});Parameters
Section titled “Parameters”Returns
Section titled “Returns”reset()
Section titled “reset()”reset(
ctx):DialogPosition
Discard this session’s progress and start the dialog over.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”send()
Section titled “send()”send(
ctx,event):DialogPosition
Advance the dialog, and store the result.
An event the active state does not handle is IGNORED — XState’s own
behaviour, kept rather than turned into a throw, because the alternative is
an agent that crashes a live call over a transition that merely was not
available. The returned position is what actually happened; compare its
state to know whether anything moved.
Parameters
Section titled “Parameters”E
Returns
Section titled “Returns”timeout()
Section titled “timeout()”timeout(
ctx):DialogTimeout|undefined
The deadline declared where the conversation currently is, if any — read from the DEEPEST active state, exactly as DialogPosition.instruction is.
A READ, not a timer: nothing here is armed, and calling this has no effect
on the dialog. The runtime asks once per turn and arms its own deadline;
anything that fires the returned event back through Dialog.send
gets the transition the state declared.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”DialogTimeout | undefined
tool()
Section titled “tool()”tool<
P,R>(def):ToolDef<P,Promise<ToolFailure|DialogToolResult<R>>>
Declare a tool gated on this dialog’s state. See DialogToolDef.
The return type is the WRAPPED one the body actually answers with, not a
bare ToolDef: InferToolOutput<typeof myTool> is then
DialogToolResult<R> | ToolFailure, so a custom client renders the same
shape the tool sends instead of unknown. Narrowing a return type is
covariant, so a gated tool is still assignable wherever the agent’s
registry wants a ToolDef<ToolInputSchema>.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
R = unknown
Parameters
Section titled “Parameters”DialogToolDef<P, R, E>
Returns
Section titled “Returns”ToolDef<P, Promise<ToolFailure | DialogToolResult<R>>>
voiceConfig()
Section titled “voiceConfig()”voiceConfig(
ctx):DialogVoiceConfig|undefined
The voice settings declared where the conversation currently is, if any — deepest active state wins, and a parent contributes nothing to a config a child declares. See DialogVoiceConfig.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”DialogVoiceConfig | undefined
Properties
Section titled “Properties”
readonlykey:string
The store key this dialog’s snapshot occupies. Two flows must not share one.
machine
Section titled “machine”
readonlymachine:M
The machine itself, for a caller that wants to inspect or visualize it.
DialogGate
Section titled “DialogGate”What makes a tool part of a dialog: where it may run, and what it advances.
The half of DialogToolDef that is the dialog’s own — the rest is
ToolDef’s, unchanged. Declared on its own so the gate’s three fields
have one home and DialogToolDef is BUILT from the two halves rather than
restating either.
Extended by
Section titled “Extended by”Type Parameters
Section titled “Type Parameters”R
What the tool’s execute returns, as sendFrom reads it.
E
The machine’s event union.
Properties
Section titled “Properties”
optionalsend?:E
The event to send once execute has succeeded — how the conversation moves
on. Omit both this and sendFrom for a tool that reads without advancing.
Nothing is sent when execute returns a ToolFailure. A tool
that failed did not do the thing, so a dialog that advanced anyway would
leave the conversation a step ahead of reality — the single most expensive
bug this primitive can have, since every later gate is then wrong too.
sendFrom?
Section titled “sendFrom?”
optionalsendFrom?: (result) =>E|undefined
The event to send, decided by the RESULT — for a tool whose outcome picks
the transition. Return undefined to stay put.
Separate from send rather than a union with it because a union of an
event and a function of one cannot be narrowed by typeof: an event type is
generic here, so TypeScript cannot rule out that it is itself callable, and
the check would need a cast to compile. Two fields are also the clearer
authoring surface — the static case stays a literal. Declaring both is an
error.
NoInfer is what makes the parameter mean anything. R is inferred
from execute, and a bare (result: R) => … here puts R in a SECOND
inference position — so which one wins is decided by the object literal’s
source order. A sendFrom written ABOVE execute inferred R = unknown
from its own parameter, and then compiled: the narrowing an author wrote it
for silently stopped meaning anything, with no error anywhere and no way to
tell the two orderings apart by reading either one. NoInfer<R> takes this
position out of the running, so execute decides R in both orderings and
a typo’d property is a TS2551 in both.
Exclude<…, ToolFailure> is the other half, and it was already true at
run time: the failure check returns before sendFrom is reached, so a
failure is never handed to it. Saying so in the type is what lets a body
declared Order | ToolFailure be narrowed here without the author
re-checking a case that cannot arrive.
Parameters
Section titled “Parameters”result
Section titled “result”Exclude<NoInfer<R>, ToolFailure>
Returns
Section titled “Returns”E | undefined
when:
string| readonlystring[]
The state(s) this tool may run in, as DialogPosition.state spells them. Anywhere else the body does not run and the call is refused.
Every name is checked against the machine’s own states when the tool is DECLARED, so a typo is a throw at startup rather than a tool that is silently unreachable for the life of the agent.
The gate holds for the SEND as well as the body. A step’s tool calls run
concurrently, so a sibling can move the dialog while this body is awaiting.
If it has left every when state by the time execute settles, the body’s
result is still returned, but send/sendFrom is NOT applied, and the
position in the result says where the sibling left the conversation.
Otherwise the event would fire whatever transition the new state declares
for it, moving the dialog out of a state this tool was never allowed in.
DialogOptions
Section titled “DialogOptions”Options for dialog.
Properties
Section titled “Properties”durable?
Section titled “durable?”
optionaldurable?:boolean
Whether this dialog’s position is stored durably. Defaults to true — see
SessionSlotOptions.durable. A persisted snapshot is plain JSON by
construction, so there is nothing here that cannot be stored.
DialogPosition
Section titled “DialogPosition”Sealed
Where a dialog currently is.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”
readonlydone:boolean
Whether the machine has reached a final state.
instruction?
Section titled “instruction?”
readonlyoptionalinstruction?:string
The active state’s meta.instruction, when it declares one — what the
agent is supposed to be doing here, in the words the state itself carries.
Read from the DEEPEST active state node, so a nested state’s instruction wins over its parent’s rather than being merged with it.
persona?
Section titled “persona?”
readonlyoptionalpersona?:string
The persona the active state pins, when it declares one — see
DialogStateSpec.persona. Same depth rule as instruction.
readonlystate:string
The active state as a dotted path — "verifying", or "quote.pending" for
a nested one. Parallel regions are joined with ",".
DialogSpec
Section titled “DialogSpec”A dialog’s shape as a plain state map — the argument to the dialog overload that takes no XState machine. See DialogStateSpec.
Properties
Section titled “Properties”initial
Section titled “initial”initial:
string
Which state a fresh dialog starts in.
states
Section titled “states”states:
Record<string,DialogStateSpec>
The states, keyed by the name when and DialogPosition.state use.
DialogStateSpec
Section titled “DialogStateSpec”One state of a DialogSpec — the plain-object form of a dialog’s shape.
It began as the six things every dialog in the templates actually used, and
they were not a subset chosen for convenience: a dialog’s snapshot is
PERSISTED, so it must survive structuredClone, which rules out guards,
actions, context and invoked actors by construction. What was left was an
XState setup({ types: {} as { events: … } }) block whose event union
restated every name already written in the on maps, and a
meta: { instruction } wrapper around every line of guidance.
The six became eleven when a dialog had to be able to describe a CALL rather
than a form: a deadline (timeout) and the five per-phase voice knobs
(voice, bargeIn, toolChoice, temperature). Every one of
them is plain JSON and rides in the same meta the instruction does, so the
constraint above is untouched and a durable: true dialog written before any
of this resumes byte-identically — a state declaring none of them compiles to
a node with no meta at all.
What is deliberately NOT here is after. XState’s delayed transitions are
timers owned by a running actor, and a dialog’s actor is created, sent to,
persisted and stopped inside one synchronous window, so a dialog can never
fire one. Declaring it throws at declaration and the message names timeout,
which is the deadline a runtime can actually arm.
The reason to type it is a SILENT failure, not the line count. The
instruction is read back out of meta untyped (_dialog-snapshot.ts), and
XState types meta as Record<string, any> unless a machine declares
types: {} as { meta: … } — which no template did. So instructions
(plural), or the field one nesting level off, compiled, deployed, and
produced refusals carrying no recovery text at all: exactly the failure the
when gate exists to prevent, arriving through the field that is supposed to
explain it. A declared instruction?: string makes that a typo the compiler
catches.
A dialog that needs anything beyond these six passes a machine instead — the
dialog overload taking one is not going away, and procedure() is
where full XState lives.
Properties
Section titled “Properties”bargeIn?
Section titled “bargeIn?”
optionalbargeIn?:DialogBargeIn
How interruptible the agent is here. A disclosure state may need to FINISH; a menu state wants to be maximally interruptible. See DialogBargeIn.
final?
Section titled “final?”
optionalfinal?:true
Whether reaching this state ENDS the dialog — XState’s type: "final".
initial?
Section titled “initial?”
optionalinitial?:string
For a state with states: which child it starts in.
instruction?
Section titled “instruction?”
optionalinstruction?:string
What the agent is supposed to be doing here, in this state’s own words. Becomes DialogPosition.instruction while the state is active, which is what a refusal quotes and what every gated tool’s result carries.
optionalon?:Record<string,string>
The transitions out of this state: event name to target state, exactly as
an XState on map spells it. Every key here joins the event union
Dialog.send and a gated tool’s send/sendFrom accept, so an
event a spec never declares is a compile error rather than an event
silently ignored at run time.
A key starting with @ is a SESSION event instead — see
DialogSessionEventName. Those are validated against the wire
vocabulary at declaration and are deliberately kept OUT of the union above:
an author does not send @speech.started by hand, the runtime does.
persona?
Section titled “persona?”
optionalpersona?:string
The persona that speaks while the conversation is in this state, by name —
one of the agent’s personas. It PINS: Personas.handoff to anyone else
is refused until the dialog moves on, and the pin is a property of the
position rather than a write, so a resumed session is pinned the same way.
Checked against the roster by agent().
states?
Section titled “states?”
optionalstates?:Record<string,DialogStateSpec>
Nested states, addressed as parent.child by when and by matches.
temperature?
Section titled “temperature?”
optionaltemperature?:number
The model’s sampling temperature while this state is active.
timeout?
Section titled “timeout?”
optionaltimeout?:DialogTimeoutSpec
How long the dialog may stay in this state, and what to send when it has been that long. See DialogTimeoutSpec.
The declarative half of a deadline: nothing here starts a timer, because a
dialog holds no live actor to run one. The runtime reads it through
Dialog.timeout for the state the conversation is actually in and
arms it around the turn — which is why send has to name an event this
state (or one containing it) already handles, checked at declaration.
toolChoice?
Section titled “toolChoice?”
optionaltoolChoice?:ToolChoice
The model’s tool-choice policy while this state is active.
voice?
Section titled “voice?”
optionalvoice?:string
The TTS voice for this phase of the call — a different voice for the disclosure than for the chat, say. See DialogVoiceConfig.
DialogTimeout
Section titled “DialogTimeout”A deadline as Dialog.timeout reports it: how long, and the event to send.
The event is built for the caller rather than left as a name, so a runtime
arming this deadline hands the result straight back to Dialog.send
and never has to know how timeout.send is spelled.
Properties
Section titled “Properties”afterMs
Section titled “afterMs”
readonlyafterMs:number
DialogTimeoutSpec.afterMs, from the state in force.
readonlyevent:object
The event to send when the deadline passes.
readonlytype:string
DialogTimeoutSpec
Section titled “DialogTimeoutSpec”A per-state deadline: how long the dialog may stay here, and what to send when it has been that long. See DialogStateSpec.timeout.
Properties
Section titled “Properties”afterMs
Section titled “afterMs”afterMs:
number
How long the dialog may remain in this state, in milliseconds.
send:
string
The event to send when it has been. Must name an event this state’s own
on map declares — or one declared by a state containing it, since being
in a state is being in all of them — and that is checked when the dialog is
DECLARED: a deadline sending an event nothing handles fires into silence
and leaves the conversation exactly where it was.
DialogToolDef
Section titled “DialogToolDef”The authoring shape of a gated tool — ToolDef plus the DialogGate that makes it part of a dialog.
Built FROM ToolDef, not copied from it. Every field but execute is
ToolDef’s own — description, inputSchema, onError, messages, and
whatever ToolDef grows next — and execute is restated only because a gated
body may return a ToolFailure beside R (see below). The copy this
replaced restated three fields, and the one it missed was messages: a gated
tool could not declare tool-call speech at all, although dialog.tool
spreads the def and the runtime would have spoken it.
onError’s answer goes to the model AS THE RESULT, so it carries no
DialogToolResult envelope and the dialog does not move. The handler
runs after the gated call has already unwound, which is past the point where
send/sendFrom could have fired — the same answer a RETURNED
ToolFailure gets, for the same reason: a tool that failed did not do
the thing. What differs is the SHAPE: where a success carries state, done
and result, the model reads the handler’s failure or string, so a handler
whose message names where the conversation is has to say so itself. A
REFUSAL — the model calling this tool from a state when does not name — is
not a throw and never reaches it.
A messages.failed line fires on a refusal, because a refusal is a
ToolFailure result like any other. A tool whose refusal should be
phrased by the model (it carries the state’s own instruction) declares
failed with role: "system", or none at all.
Extends
Section titled “Extends”Omit<ToolDef<P,R>,"execute">.DialogGate<R,E>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
The tool’s input schema.
R
What execute returns.
E
The machine’s event union.
Methods
Section titled “Methods”execute()
Section titled “execute()”execute(
args,ctx):ToolFailure|R|Promise<ToolFailure|R>
The tool body. Runs only in one of when’s states.
May be async: the result is AWAITED before the failure check and the
transition, so sendFrom and result both see the settled value. Unlike
SessionSlot.updateTool there is no synchronous requirement here —
this opens no mutation window around the body, only inside send.
ToolFailure is in the return type rather than in R, which is what
lets sendFrom be typed over the SUCCESS value alone. A body that can fail
is the ordinary case — it is how a tool reports something the model should
recover from — and folding the failure into R made every sendFrom
narrow a value it is never handed: the failure check returns before it runs.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ToolFailure | R | Promise<ToolFailure | R>
Properties
Section titled “Properties”description
Section titled “description”description:
string
Human-readable description shown to the LLM.
Inherited from
Section titled “Inherited from”Omit.description
inputSchema?
Section titled “inputSchema?”
optionalinputSchema?:P
Schema for the tool’s input, shown to the LLM and used to validate each
call’s arguments before execute runs. Named after the Vercel AI SDK’s
tool({ inputSchema }).
Inherited from
Section titled “Inherited from”Omit.inputSchema
messages?
Section titled “messages?”
optionalmessages?:ToolMessagesInput
What the agent SAYS while this tool runs, and what it says when it lands.
Four kinds — start, delayed, complete, failed — documented on
ToolMessagesInput. Two of them change the shape of the turn rather
than just filling it:
delayedis a LADDER when the timings differ and VARIANTS when they match. Two entries atafterMs: 3000are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.- A
complete/failedentry withrole: "assistant"is spoken verbatim and the model is NOT CALLED. For a deterministic outcome that removes a whole LLM round-trip from the turn.role: "system"is the other arm: the content rides back as a hint and the model writes the sentence.
start and delayed are filler — they are heard, and they are never
recorded into ctx.messages, the model’s view or the committed transcript,
and never count as the agent having spoken (so a caller talking over one
does not interrupt the reply being generated behind it). complete and
failed with role: "assistant" are the opposite on every count: that IS
the agent’s answer.
Example
Section titled “Example”A hold line, a two-rung ladder, and an error the model phrases
import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Look up an order", inputSchema: z.object({ orderId: z.string() }), messages: { start: ["Let me pull that up.", "One second while I check."], delayed: [ { afterMs: 3000, content: "Still looking." }, { afterMs: 9000, content: "Sorry, the order system is slow today." }, ], failed: [{ role: "system", content: "Order lookup failed. Apologize and offer a callback." }], }, execute: async ({ orderId }) => ({ orderId, status: "shipped" }),});Inherited from
Section titled “Inherited from”Omit.messages
onError?
Section titled “onError?”
optionalonError?:ToolErrorHandler
What to do when execute throws — and, by omission, the SDK’s default.
Without it, every exception becomes an ordinary tool result. The
runtime catches whatever execute threw and hands errorMessage(err) back
to the model as that call’s result, which is the same channel a deliberate
toolFailure uses — so a stale credential, a TypeError in the
author’s own code and “no such order” are one thing as far as the model can
tell, and it will keep calling a permanently broken tool until the reply’s
maxSteps budget runs out. That default is unchanged and stays the default:
for the failures a model really can recover from it is the right answer, and
every tool written before this field existed depends on it.
With it, the author classifies. Return a ToolFailure or a string
and that is what the model gets — the same outcome as the default, with a
sentence the author chose. Throw — throw err re-raises the original — and
the failure is FATAL to the call: the runtime logs it, reports it as a
session error (code: "tool"), and the tool call REJECTS instead of
answering, so nothing hands the model something to retry against.
It sees only a THROW. A ToolFailure that execute RETURNED never reaches
it: that is already the author saying “expected, let the model recover”, and
routing it through here would make the two channels one again.
Example
Section titled “Example”Fatal on a missing credential, recoverable on a bad lookup
import { tool, toolFailure } from "@alexkroman1/aai";import { z } from "zod";
class MissingKeyError extends Error {}
export default tool({ description: "Look up an order", inputSchema: z.object({ id: z.string() }), execute: async ({ id }, ctx) => { if (!ctx.env.ORDERS_API_KEY) throw new MissingKeyError("ORDERS_API_KEY is unset"); const res = await fetch(`https://api.example.com/orders/${id}`, { headers: { authorization: `Bearer ${ctx.env.ORDERS_API_KEY}` }, }); if (res.status === 404) return toolFailure(`No order ${id}.`); return await res.json(); }, // A credential the deploy is missing cannot be fixed by asking the model // to try again; a flaky upstream can. onError: (err) => { if (err instanceof MissingKeyError) throw err; return toolFailure("The orders service is unavailable right now."); },});Inherited from
Section titled “Inherited from”Omit.onError
optionalsend?:E
The event to send once execute has succeeded — how the conversation moves
on. Omit both this and sendFrom for a tool that reads without advancing.
Nothing is sent when execute returns a ToolFailure. A tool
that failed did not do the thing, so a dialog that advanced anyway would
leave the conversation a step ahead of reality — the single most expensive
bug this primitive can have, since every later gate is then wrong too.
Inherited from
Section titled “Inherited from”sendFrom?
Section titled “sendFrom?”
optionalsendFrom?: (result) =>E|undefined
The event to send, decided by the RESULT — for a tool whose outcome picks
the transition. Return undefined to stay put.
Separate from send rather than a union with it because a union of an
event and a function of one cannot be narrowed by typeof: an event type is
generic here, so TypeScript cannot rule out that it is itself callable, and
the check would need a cast to compile. Two fields are also the clearer
authoring surface — the static case stays a literal. Declaring both is an
error.
NoInfer is what makes the parameter mean anything. R is inferred
from execute, and a bare (result: R) => … here puts R in a SECOND
inference position — so which one wins is decided by the object literal’s
source order. A sendFrom written ABOVE execute inferred R = unknown
from its own parameter, and then compiled: the narrowing an author wrote it
for silently stopped meaning anything, with no error anywhere and no way to
tell the two orderings apart by reading either one. NoInfer<R> takes this
position out of the running, so execute decides R in both orderings and
a typo’d property is a TS2551 in both.
Exclude<…, ToolFailure> is the other half, and it was already true at
run time: the failure check returns before sendFrom is reached, so a
failure is never handed to it. Saying so in the type is what lets a body
declared Order | ToolFailure be narrowed here without the author
re-checking a case that cannot arrive.
Parameters
Section titled “Parameters”result
Section titled “result”Exclude<NoInfer<R>, ToolFailure>
Returns
Section titled “Returns”E | undefined
Inherited from
Section titled “Inherited from”when:
string| readonlystring[]
The state(s) this tool may run in, as DialogPosition.state spells them. Anywhere else the body does not run and the call is refused.
Every name is checked against the machine’s own states when the tool is DECLARED, so a typo is a throw at startup rather than a tool that is silently unreachable for the life of the agent.
The gate holds for the SEND as well as the body. A step’s tool calls run
concurrently, so a sibling can move the dialog while this body is awaiting.
If it has left every when state by the time execute settles, the body’s
result is still returned, but send/sendFrom is NOT applied, and the
position in the result says where the sibling left the conversation.
Otherwise the event would fire whatever transition the new state declares
for it, moving the dialog out of a state this tool was never allowed in.
Inherited from
Section titled “Inherited from”DialogToolResult
Section titled “DialogToolResult”Sealed
What a Dialog.tool answers on success.
Extends
Section titled “Extends”Type Parameters
Section titled “Type Parameters”R
The author’s own execute return type, under result.
Properties
Section titled “Properties”
readonlydone:boolean
Whether the machine has reached a final state.
Inherited from
Section titled “Inherited from”instruction?
Section titled “instruction?”
readonlyoptionalinstruction?:string
The active state’s meta.instruction, when it declares one — what the
agent is supposed to be doing here, in the words the state itself carries.
Read from the DEEPEST active state node, so a nested state’s instruction wins over its parent’s rather than being merged with it.
Inherited from
Section titled “Inherited from”persona?
Section titled “persona?”
readonlyoptionalpersona?:string
The persona the active state pins, when it declares one — see
DialogStateSpec.persona. Same depth rule as instruction.
Inherited from
Section titled “Inherited from”result
Section titled “result”
readonlyresult:R
Whatever the tool’s own execute returned.
readonlystate:string
The active state as a dotted path — "verifying", or "quote.pending" for
a nested one. Parallel regions are joined with ",".
Inherited from
Section titled “Inherited from”DialogVoiceConfig
Section titled “DialogVoiceConfig”The per-state voice settings a dialog declares — what Dialog.voiceConfig answers with, from the deepest active state that declares any of them.
Every field is plain JSON, which is a requirement rather than a coincidence:
these ride in the state node’s meta, and a dialog’s snapshot is persisted
through structuredClone for a durable session.
Properties
Section titled “Properties”bargeIn?
Section titled “bargeIn?”
readonlyoptionalbargeIn?:DialogBargeIn
How interruptible the agent is here. See DialogBargeIn.
temperature?
Section titled “temperature?”
readonlyoptionaltemperature?:number
The model’s sampling temperature while this state is active.
toolChoice?
Section titled “toolChoice?”
readonlyoptionaltoolChoice?:ToolChoice
The model’s tool-choice policy while this state is active.
voice?
Section titled “voice?”
readonlyoptionalvoice?:string
The TTS voice for this phase of the call.
HandoffOptions
Section titled “HandoffOptions”Per-call options for Personas.handoff.
Properties
Section titled “Properties”
optionalnote?:string
What the next persona should know that the transcript does not say — “the caller is verified”, “wants a refund on invoice 4471”. Rendered into the new persona’s prompt section until the next handoff, so it survives the turn that made it rather than living only in one tool result.
HandoffResult
Section titled “HandoffResult”Sealed
What a handoff returns — the shape a tool hands back as its result so the model learns, in the same turn, who is speaking now.
Properties
Section titled “Properties”
readonlyfrom:string
The persona that was speaking.
handoff
Section titled “handoff”
readonlyhandoff:true
Always true: a discriminant a client or a spec can switch on.
instruction
Section titled “instruction”
readonlyinstruction:string
What the MODEL should do next, phrased for it: the tool result is the last thing it reads before it speaks, and the persona section of its prompt has already changed by the time it does.
readonlyoptionalnote?:string
The note that travelled with it, when one did.
readonlyto:string
The persona speaking now.
MetricsCollector
Section titled “MetricsCollector”Sealed
A running summary of metrics.collected frames — see
createMetricsCollector.
Methods
Section titled “Methods”collect()
Section titled “collect()”collect(
sample):void
Fold in one reply’s metrics.
Parameters
Section titled “Parameters”sample
Section titled “sample”Returns
Section titled “Returns”void
reset()
Section titled “reset()”reset():
void
Forget everything collected.
Returns
Section titled “Returns”void
summary()
Section titled “summary()”summary():
MetricsSummary
The summary so far. A fresh object each call; safe to keep.
Returns
Section titled “Returns”MetricsCollectorOptions
Section titled “MetricsCollectorOptions”Options for createMetricsCollector.
Properties
Section titled “Properties”maxSamples?
Section titled “maxSamples?”
optionalmaxSamples?:number
How many recent samples per measurement the percentiles are computed over. Default 1000.
MetricsSummary
Section titled “MetricsSummary”Sealed
Everything collected so far. A stat is absent until its first sample, never a row of zeroes.
Properties
Section titled “Properties”interrupted
Section titled “interrupted”interrupted:
number
Of those, how many were cut short.
latencyMs?
Section titled “latencyMs?”
optionallatencyMs?:MetricStat
Committed caller turn → first reply audio.
llmDurationMs?
Section titled “llmDurationMs?”
optionalllmDurationMs?:MetricStat
Request → the stream settling.
llmInputTokens
Section titled “llmInputTokens”llmInputTokens:
number
Totals — sums, not distributions.
llmOutputTokens
Section titled “llmOutputTokens”llmOutputTokens:
number
llmSteps
Section titled “llmSteps”llmSteps:
number
llmTtftMs?
Section titled “llmTtftMs?”
optionalllmTtftMs?:MetricStat
Request → the model’s first content part.
replies
Section titled “replies”replies:
number
Replies collected.
sttEndpointingMs?
Section titled “sttEndpointingMs?”
optionalsttEndpointingMs?:MetricStat
Last partial with words → committed final.
ttsCharacters
Section titled “ttsCharacters”ttsCharacters:
number
ttsTtfbMs?
Section titled “ttsTtfbMs?”
optionalttsTtfbMs?:MetricStat
First text into TTS → its first audio.
MetricStat
Section titled “MetricStat”One measurement’s distribution. Durations are milliseconds.
Properties
Section titled “Properties”count:
number
Samples collected.
max:
number
mean:
number
min:
number
p50:
number
Median of the most recent window — see the module doc.
p95:
number
95th percentile of the most recent window.
MintCodeOptions
Section titled “MintCodeOptions”Options for mintCode.
Properties
Section titled “Properties”length?
Section titled “length?”
optionallength?:number
The suffix length. Four characters over a 31-symbol alphabet is about 923,000 codes — enough that a desk with a few thousand live references collides rarely and re-draws cheaply.
random?
Section titled “random?”
optionalrandom?: () =>number
The randomness source, [0, 1). Defaults to Math.random; pass
ctx.random from a tool body to make the code a journaled, replayable
value instead of a fresh one on every run.
Returns
Section titled “Returns”number
taken?
Section titled “taken?”
optionaltaken?:ReadonlySet<string>
Codes already issued. A generated code that collides is discarded and another drawn, so the caller does not have to loop.
ModelTuning
Section titled “ModelTuning”The per-REQUEST knobs every model loop this runtime runs takes — the agent’s own conversational loop and a SubagentDef’s delegated one alike.
One declaration, extended by both, rather than a list each restates.
SubagentDef used to carry its own temperature and maxOutputTokens beside
AgentModelTuning’s, with one-line docs of their own, and a maxRetries that
meant something else entirely (the guardrail’s revision budget, now
SubagentDef.maxRevisions). A knob added here reaches both loops, and
the name means one thing wherever it is written.
Every field is passed straight through to the provider request, so each is
refused in S2S mode on the AGENT — there the provider runs the loop; see this
module’s header. A subagent always runs on this runtime, whatever the parent’s
mode, so it may set temperature and maxOutputTokens — but not
maxRetries, which SubagentDef omits so the old guardrail-budget
spelling fails to compile.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”maxOutputTokens?
Section titled “maxOutputTokens?”
optionalmaxOutputTokens?:number
Cap on generated tokens per step, passed straight through to the provider.
The same field GenerateOptions.maxOutputTokens has for a one-shot call. Per STEP, not per turn: a reply that calls three tools has four generations in it, and the cap bounds each.
On a voice agent it is a bluntness knob rather than a cost one — a model that runs long is a model the caller is waiting through — and a value low enough to truncate mid-sentence will truncate mid-sentence, because the provider stops emitting rather than wrapping up.
maxRetries?
Section titled “maxRetries?”
optionalmaxRetries?:number
How many times a FAILED provider call is retried before the step is given up on.
Default Value
Section titled “Default Value”the AI SDK’s own (2 retries, exponential backoff)
Transport-level retries of a request that never produced an answer at all (a 429, a 502, a socket reset) — NOT a re-run of one that did. Agent-only: a subagent’s requests retry on the AI SDK default, and its guardrail sending an answer back is SubagentDef.maxRevisions.
0 is the value to reach for on a live call, and the reason is the clock:
the default backoff can spend several seconds before the turn is declared
failed, and the caller hears every one of them as silence. An agent whose
errorPhrase should arrive promptly sets this to 0 and lets the recovery
line do the work.
temperature?
Section titled “temperature?”
optionaltemperature?:number
Sampling temperature.
Omitted by default, so the model’s own default applies; some models (Claude 5 among them) ignore it and warn, so set it only for a temperature-capable one. A booking desk and a game master want different values; so do a researcher subagent and the voice that relays what it found.
PersonaDef
Section titled “PersonaDef”One persona: a speaker the session can hand the caller to.
Every field but tools, toolChoice and temperature is required, and each
requirement is a failure with no symptom otherwise: a persona with no
description routes badly and reads as the model being unreliable; one with
no systemPrompt speaks as the agent and nobody can tell it took over.
Type Parameters
Section titled “Type Parameters”N extends string = string
The persona’s name, as a literal when persona
infers it — what lets Personas.handoff refuse a misspelled target
at compile time. Defaults to string, which is every persona written
before the parameter existed.
Properties
Section titled “Properties”description
Section titled “description”description:
string
What this persona is FOR, in one line, written for whoever is choosing
between personas: the handoff tool’s description is these lines and
nothing else, so write it as the job (“Invoices, payments and refunds”),
not the mechanism.
name:
N
What this persona is called — the value of the handoff tool’s persona
argument, the name a log line carries, and what position(ctx).name is.
systemPrompt
Section titled “systemPrompt”systemPrompt:
string
The instructions in force while this persona is speaking, appended to the
agent’s own prompt under a heading naming the persona. The agent’s
systemPrompt stays — this is the section that CHANGES on a handoff.
temperature?
Section titled “temperature?”
optionaltemperature?:number
The model’s sampling temperature while this persona is speaking.
toolChoice?
Section titled “toolChoice?”
optionaltoolChoice?:ToolChoice
The model’s tool-choice policy while this persona is speaking.
tools?
Section titled “tools?”
optionaltools?:Readonly<Record<string,ToolDef<ToolInputSchema,unknown>>>
The tools only this persona may call, by the name the model calls them by.
A MAP, like subagent({ tools }), because these are declared on the
persona rather than discovered from tools/: the agent’s tools/ files are
every persona’s, and this is the strictly narrower set one persona owns.
Each name must be unique across the roster and must not collide with a
tools/ file — one tool has one owner, or the gate cannot say whose it is.
PersonaPosition
Section titled “PersonaPosition”Sealed
Where a session is, persona-wise — the position Personas.position
answers, the shape DialogPosition has for a dialog.
Type Parameters
Section titled “Type Parameters”N extends string = string
The roster’s persona names — see Personas.
Properties
Section titled “Properties”
readonlyoptionalfrom?:string
Who handed off to it, when a handoff has happened this session.
readonlyoptionalnote?:string
The HandoffOptions.note that came with that handoff.
persona
Section titled “persona”
readonlypersona:PersonaDef<N>
The persona speaking now.
pinnedBy?
Section titled “pinnedBy?”
readonlyoptionalpinnedBy?:object
The dialog PINNING this persona, when a dialog state declares one. While a
pin is in force handoff to anyone else is refused — the state said who
speaks here, and the dialog moving is what releases it.
dialog
Section titled “dialog”
readonlydialog:string
readonlystate:string
Personas
Section titled “Personas”Sealed
The roster the agent declares and every tool reaches for — what personas returns.
A HANDLE with methods that take the session, like Dialog, rather than
a bare array like agent({ subagents }): a handoff has to know the whole
roster to name who it came FROM and to refuse a target that is not on it, and
a bare array gives a tool body neither.
Type Parameters
Section titled “Type Parameters”N extends string = string
The roster’s persona names, inferred by personas from
the literal names persona gives each entry, so
desk.handoff(ctx, "biling") is a compile error rather than a throw on a
live call. Defaults to string, so a Personas annotation written before
the parameter existed still accepts any roster.
Methods
Section titled “Methods”active()
Section titled “active()”active(
ctx):PersonaDef<N>
The persona speaking now: position(ctx).persona.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”PersonaDef<N>
handoff()
Section titled “handoff()”handoff(
ctx,to,options?):HandoffResult
Make to the speaker from the next model step on.
Synchronous and cheap: one slot write. The prompt section, the pipeline’s
activeTools and the gate all READ the slot at the next step, so the same
turn continues as the new persona — the model is told so through the
returned HandoffResult.instruction, which the calling tool should
return (or fold into) as its result.
Throws when to is not on the roster, or when a dialog state currently
PINS another persona (see PersonaPosition.pinnedBy). Both are
authoring mistakes a tool body should not have to defend against; the
minted handoff tool turns them into a ToolFailure for the model.
Parameters
Section titled “Parameters”N | PersonaDef<N>
options?
Section titled “options?”Returns
Section titled “Returns”position()
Section titled “position()”position(
ctx):PersonaPosition<N>
Who is speaking, and how they came to be — see PersonaPosition.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”Properties
Section titled “Properties”
readonlylist: readonlyPersonaDef<N>[]
The roster, in declaration order. The first entry is the ENTRY persona.
PipelineVoiceTuning
Section titled “PipelineVoiceTuning”Pipeline-mode voice-UX tuning, extended by AgentDef.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”deadAirCoverMs?
Section titled “deadAirCoverMs?”
optionaldeadAirCoverMs?:number
Pipeline mode only. How long a turn may send nothing to the caller before
the transport speaks a short filler, so a long tool chain doesn’t sound
like a dropped call. MEASURED silence, so a prompt reply pays nothing; 0
disables. The wording is internal and must stay purely declarative — see
DEAD_AIR_COVER_PHRASES for why.
Default Value
Section titled “Default Value”2400 (DEFAULT_DEAD_AIR_COVER_MS)
errorPhrase?
Section titled “errorPhrase?”
optionalerrorPhrase?:string
Pipeline mode only. Phrase spoken when the turn’s LLM stream fails, so a
provider outage hands the conversation back instead of going silent — a
failed turn produces no text, so nothing would otherwise reach TTS. Set
"" to disable.
Default Value
Section titled “Default Value”"Sorry, I had a problem just then. Could you say that again?" (DEFAULT_ERROR_PHRASE)
interruptionBackoffMs?
Section titled “interruptionBackoffMs?”
optionalinterruptionBackoffMs?:number
Pipeline mode only. How long agent audio stays blocked after a real
interruption, in ms. Vapi’s backoffSeconds.
SEQUENTIAL with startSpeakingFloorMs, never cumulative: the two are one
deadline, max(floor, backoff).
Default Value
Section titled “Default Value”0 (DEFAULT_INTERRUPTION_BACKOFF_MS) — today’s behaviour.
Vapi’s own default is 1.0s; see that constant for why this one is not.
interruptionMinDurationMs?
Section titled “interruptionMinDurationMs?”
optionalinterruptionMinDurationMs?:number
Pipeline mode only. Minimum sustained speech (ms since the utterance’s
first interim transcript) before an interim-triggered barge-in aborts the
agent’s reply — a duration gate alongside minBargeInWords, mirroring
LiveKit’s min_interruption_duration. Committed turns (STT finals) are
never gated. Set 0 to disable the gate.
Default Value
Section titled “Default Value”500 (DEFAULT_INTERRUPTION_MIN_DURATION_MS)
minBargeInWords?
Section titled “minBargeInWords?”
optionalminBargeInWords?:number
Pipeline mode only. Minimum words in an interim transcript before user speech barges in on (aborts) the agent’s in-flight reply. Set 1 to interrupt on any word.
Default Value
Section titled “Default Value”2 (DEFAULT_MIN_BARGE_IN_WORDS) — so one-word
backchannels (“yeah”, “mm-hmm”) don’t cut the agent off.
preemptiveGeneration?
Section titled “preemptiveGeneration?”
optionalpreemptiveGeneration?:boolean
Pipeline mode only. Start generating the reply from a high-confidence INTERIM transcript, and adopt that already-running stream when the committed final turns out to say the same thing.
Default Value
Section titled “Default Value”false — measured on a tool-calling agent and not worth its
cost there. Set true where the arithmetic plausibly differs: a text-heavy
agent, or a longer head start from later endpointing.
Remarks
Section titled “Remarks”Why it is off. A headStartMs/adoption-rate log over a tau2-bench
retail run: 16 speculations started, 14 adopted at a p50 0.44s head start,
and 5 of those 14 (36%) poisoned after adoption by a tool call — unusable
whole, so the generation is discarded and the request reissued, each having
burned p50 0.69s first. Net +8ms per caller turn against a p50 first word of
~1.0s, for 44% of its LLM requests thrown away.
The head start does not survive contact with time-to-first-token: 0.44s against a p50 of 1.10s, so at adoption the speculation has generated nothing and whether its first part will be text or a tool call cannot be known then. A gate on “has it produced text” was tried and reverted — it rejects essentially every adoption, keeping the wasted request and losing the benefit.
Its reach is bounded independently of that: across 815 replies in two
tau2-bench retail runs, 28-33% of replies called a tool at all (the
distribution recorded on DEFAULT_MAX_STEPS), so at most the
remaining 67-72% can ever be accelerated.
What it structurally cannot do, by construction rather than by flag: a speculation never reaches TTS, never emits a client frame, never writes either history view, and never EXECUTES a tool — its tool set is declaration-only, so the model cannot continue past a tool call, and a speculation that reaches one is discarded whole. Adoption requires the final to match the speculated text after normalization (case/punctuation only); an extension, a truncation or a revision all discard and the turn runs exactly as it does with the flag off. At most 2 speculations per utterance. So the worst case is one extra billed LLM request for that utterance.
Turning it back on by default is owed a tau2-bench run at the same tasks and seed showing no reward regression.
resumeFalseInterruption?
Section titled “resumeFalseInterruption?”
optionalresumeFalseInterruption?:boolean
Pipeline mode only. Resume the agent’s reply when a barge-in aborts it and no user turn ever commits (STT noise, a hallucinated partial) — the interruption was a false alarm and the agent would otherwise fall silent mid-thought.
Default Value
Section titled “Default Value”true; false disables recovery.
The WAIT is not an author knob: a resume must not race the caller’s real turn, whose final the STT withholds for an endpointing window the transport cannot see, so it fires when the transcript stream goes quiet with no final rather than on a deadline of its own.
startFailurePhrase?
Section titled “startFailurePhrase?”
optionalstartFailurePhrase?:string
Pipeline mode only. Phrase spoken when a provider fails to open, so a session that cannot
start says so instead of holding an open line in silence. Only reachable when TTS itself
came up — the usual case, since STT and TTS open independently. Set ""
to disable.
Default Value
Section titled “Default Value”"I am sorry, I am having trouble with my connection and cannot hear you. Please hang up and call back."
(DEFAULT_START_FAILURE_PHRASE)
startSpeakingFloorMs?
Section titled “startSpeakingFloorMs?”
optionalstartSpeakingFloorMs?:number
Pipeline mode only. Minimum delay between a reply starting and its first
audio reaching the caller, in ms — a floor at the END of the pipeline, so
it decouples “when did I decide the turn ended” from “when do I start
speaking”. Vapi’s waitSeconds.
It is a MINIMUM: a pipeline slower than this pays nothing, and only a reply that was ready sooner waits.
Default Value
Section titled “Default Value”0 (DEFAULT_START_SPEAKING_FLOOR_MS) — today’s behaviour.
Vapi’s own default is 0.4s; see that constant for why this one is not.
turnDetection?
Section titled “turnDetection?”
optionalturnDetection?:TurnDetectionMode
Pipeline mode only. WHO decides that the caller’s turn is over.
"auto"— the transcriber does, on a pause. What every agent has always done."manual"— the CLIENT does, which is push-to-talk. The caller’s audio reaches the transcriber only between auser_turn_startand theuser_turn_commitoruser_turn_clearthat closes it (aai-ui’ssession.userTurn.start/.commit/.clear, or itsusePushToTalkhook). Everything transcribed in that window, across however many pauses, is ONE turn, and nothing is answered until the commit. Outside the window the microphone is replaced with silence server-side, so a caller talking to someone else in the room is never heard.
Under "manual" the caller’s speech never barges in by itself — opening a
turn is what interrupts the agent, so the button IS the barge-in — and
preemptive generation is skipped, since no pause is a turn boundary. A
userTurnLimit still applies and, when it fires, commits the turn exactly
as the client’s commit would have.
Default Value
Section titled “Default Value”"auto"
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Walkie", // Nothing the caller says is answered until they let go of the button. turnDetection: "manual",});userTurnLimit?
Section titled “userTurnLimit?”
optionaluserTurnLimit?:UserTurnLimit
Pipeline mode only. Cap ONE user turn’s length — by words heard, by elapsed time, or both — see UserTurnLimit.
Default Value
Section titled “Default Value”unset — no cap on a single user turn’s length.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Triage", // Answer after 60 words or 20 seconds, whichever the caller reaches first. userTurnLimit: { maxWords: 60, maxDurationMs: 20_000 },});Procedure
Section titled “Procedure”Sealed
A machine that can be run as a unit of work, created by procedure.
Type Parameters
Section titled “Type Parameters”M extends AnyStateMachine
The XState machine.
Methods
Section titled “Methods”run(
input,options?):Promise<OutputFrom<M>>
Run to completion and resolve with the machine’s output.
Rejects when the machine ENDS badly rather than when it decides badly: an
invoked actor whose promise rejects with no onError stops the machine and
rejects here, and so does an aborted or otherwise unfinished run. A machine
that reached a final state resolves — so every way of failing at the work
should be a final state whose output says so, which is what keeps a procedure’s
failures inspectable instead of thrown.
Parameters
Section titled “Parameters”InputFrom<M>
options?
Section titled “options?”Returns
Section titled “Returns”Promise<OutputFrom<M>>
Properties
Section titled “Properties”machine
Section titled “machine”
readonlymachine:M
The machine itself, for a caller that wants to inspect or visualize it.
ProcedureRunOptions
Section titled “ProcedureRunOptions”Options for one Procedure.run.
Properties
Section titled “Properties”signal?
Section titled “signal?”
optionalsignal?:AbortSignal
Abort the run — pass ctx.signal and a barge-in stops the procedure.
This is the reason a long procedure should be run through here rather than by
hand. A CRAG loop is five to nine model calls; a caller who interrupts on
the second is charged for the remaining seven unless something stops it, and
ctx.signal is already aborted on barge-in, reset and session stop. Aborting
stops the actor, which cancels nothing already in flight but issues nothing
further, and run then throws rather than returning a half-built output.
ProviderCredentialOptions
Section titled “ProviderCredentialOptions”The credential override every provider descriptor accepts.
Names an env VARIABLE holding this stage’s key, replacing the provider
default (DEEPGRAM_API_KEY, ASSEMBLYAI_API_KEY, …). It names a variable
and never a key, so the descriptor stays secret-free and safe to serialize
across the CLI → server → guest boundary. The variable must be present in
the agent’s env (.env, or aai secret put), like any other credential.
Remarks
Section titled “Remarks”Every provider options interface extends this, because the host has always
honoured the field on every provider. descriptorEnvVar() in
@alexkroman1/aai-runtime reads apiKeyEnv off any descriptor’s options
through an untyped cast, so all thirteen factories accepted it at runtime
while only the four AssemblyAI options types could spell it — a shape that
cost the aai:s2s contract an epoch, where the field was added to one stage
and left off the rest.
The argument for keeping it AssemblyAI-only was that AssemblyAI keys are environment-scoped, so a mixed staging/production pipeline needs two live at once, and no other vendor has that problem. True, and not the whole test: a type that cannot spell what the runtime accepts is wrong regardless of who needs it, and per-stage key separation is equally the answer for two accounts with one vendor, for per-tenant keys, and for a rotation that runs both keys briefly.
Extended by
Section titled “Extended by”AssemblyAIS2sOptionsAssemblyAISttOptionsDeepgramSttOptionsElevenLabsSttOptionsSonioxSttOptionsAssemblyAITtsOptionsCartesiaTtsOptionsRimeTtsOptionsLlmOptionsOpenAIS2sOptions
Properties
Section titled “Properties”apiKeyEnv?
Section titled “apiKeyEnv?”
optionalapiKeyEnv?:string
Env var holding this stage’s credential, replacing the provider default. Names a VARIABLE, not a key.
ProviderDescriptor
Section titled “ProviderDescriptor”Base shape for a provider descriptor. A kind tag + opaque options
payload lets the host registry pick the right resolver and pass the
caller’s options through verbatim.
Type Parameters
Section titled “Type Parameters”Kind extends string
Options
Section titled “Options”Options
Properties
Section titled “Properties”
readonlykind:Kind
options
Section titled “options”
readonlyoptions:Options
ResolveOneOptions
Section titled “ResolveOneOptions”Options for resolveOne.
Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”
optionalcode?: (candidate) =>string
The candidate’s CODE, if it has one — an order number, a policy number, a
booking reference. Compared through spokenAlphanumeric, so
#W5866402 is found in “that’s order W 586-6402” however STT spaced,
punctuated or cased it.
Tried FIRST, before a position and before the words: a caller who reads an
id out has named exactly one thing, even in an utterance that also says
“the first one”. The candidate’s code must be at least
MIN_CODE_CHARS (4) characters after normalization — below that,
containment in a whole utterance is noise rather than a match.
A code that matches NOTHING is not a refusal here, it falls through to
the rest of the ladder. Whether an id-shaped utterance is a closed question
(“that order is not on this account”) is the caller’s knowledge, not this
function’s — retail-orders-agent keeps its own branch for exactly that
sentence.
Parameters
Section titled “Parameters”candidate
Section titled “candidate”T
Returns
Section titled “Returns”string
describe
Section titled “describe”describe: (
candidate) =>string
One candidate as the model should hear it read back — this is what a failure lists, so it has to be enough to choose between them out loud.
Parameters
Section titled “Parameters”candidate
Section titled “candidate”T
Returns
Section titled “Returns”string
label?
Section titled “label?”
optionallabel?:string
What the candidates are called, for the failure sentences. Defaults to
"option". Singular: the plural is formed with s.
match?
Section titled “match?”
optionalmatch?: (candidate) =>string
The candidate’s own text, for the WORD-OVERLAP scorer this module ships — how a caller names a thing when they are not reading an id: by the words in it. Return the fields worth matching on and nothing else (“a body match on ‘meeting’ would tie half the inbox”).
Every candidate word of at least MIN_MATCH_WORD (3) characters that the
utterance also says scores one, so “Priya Raman” beats “Priya” alone and
“room” ties Room (3 nights) with Room service — a tie being a REFUSAL
that asks, which is the outcome a desk wants.
It exists because four shipped templates had each written this scorer with
four different splitting rules (/\s+/ vs [^a-z0-9]+ vs a {3,} match;
a two-character floor vs three; one filler list vs none), so the same
utterance resolved differently in each. Matching is on whole WORDS both
ways rather than text.includes(word), which is the rule three of those
four intended and one of them got: a substring test lets a candidate word
match inside an unrelated one.
What it does NOT do is stemming, so a plural in the utterance does not
match a singular field (“books” ≠ “book”). A domain where that matters
wants score — see entertainment-picks-agent, which scores two named
fields against a listener’s plural.
Combines with ResolveOneOptions.score by SUM when both are given, so a domain scorer can break a tie the words leave.
Parameters
Section titled “Parameters”candidate
Section titled “candidate”T
Returns
Section titled “Returns”string
score?
Section titled “score?”
optionalscore?: (candidate,text) =>number
How well a candidate matches the utterance — higher wins, 0 means no
match at all. Optional: with no scorer, an utterance that names no position
resolves only when there is exactly one candidate.
For the DOMAIN scorers a built-in cannot express — a status word, two named fields weighted apart, a distance over prices. Reach for ResolveOneOptions.match first: plain word overlap is what most callers wrote this by hand to get.
text is the utterance lower-cased, since every scorer wants that.
Parameters
Section titled “Parameters”candidate
Section titled “candidate”T
string
Returns
Section titled “Returns”number
SessionEventMap
Section titled “SessionEventMap”Every session event, keyed by its type — derived from SessionEventSchema.
Read one member with SessionEvent<"tool.called">, never with
Extract<SessionEvent, { type: … }>: the lookup fails to compile on a
misspelled name, where the Extract silently resolves to never.
Extends
Section titled “Extends”EventMapOf<z.infer<typeofSessionEventSchema>>
Properties
Section titled “Properties”agent-transcript.committed
Section titled “agent-transcript.committed”agent-transcript.committed:
object
{
at:number;id:string; }
recovery?
Section titled “recovery?”
optionalrecovery?:"session-failed"|"turn-failed"
text:
string
type:
"agent-transcript.committed"
Inherited from
Section titled “Inherited from”EventMapOf.agent-transcript.committed
agent-transcript.updated
Section titled “agent-transcript.updated”agent-transcript.updated:
object
{
at:number;id:string; }
text:
string
type:
"agent-transcript.updated"
Inherited from
Section titled “Inherited from”EventMapOf.agent-transcript.updated
audio.completed
Section titled “audio.completed”audio.completed:
object
{
at:number;id:string; }
type:
"audio.completed"
Inherited from
Section titled “Inherited from”EventMapOf.audio.completed
custom.emitted
Section titled “custom.emitted”custom.emitted:
object
data:
unknown
event:
string
{
at:number;id:string; }
type:
"custom.emitted"
Inherited from
Section titled “Inherited from”EventMapOf.custom.emitted
error.reported
Section titled “error.reported”error.reported:
object
code:
"stt"|"llm"|"tts"|"audio"|"connection"|"internal"|"protocol"|"tool"
fatal:
boolean
message
Section titled “message”message:
string
{
at:number;id:string; }
type:
"error.reported"
Inherited from
Section titled “Inherited from”EventMapOf.error.reported
guardrail.blocked
Section titled “guardrail.blocked”guardrail.blocked:
object
direction
Section titled “direction”direction:
"output"|"input"
{
at:number;id:string; }
replacement
Section titled “replacement”replacement:
string
type:
"guardrail.blocked"
Inherited from
Section titled “Inherited from”EventMapOf.guardrail.blocked
history.restored
Section titled “history.restored”history.restored:
object
messages
Section titled “messages”messages:
object[]
{
at:number;id:string; }
toolCalls
Section titled “toolCalls”toolCalls:
object[]
type:
"history.restored"
Inherited from
Section titled “Inherited from”EventMapOf.history.restored
metrics.collected
Section titled “metrics.collected”metrics.collected:
object
interrupted
Section titled “interrupted”interrupted:
boolean
latencyMs?
Section titled “latencyMs?”
optionallatencyMs?:number
{
durationMs:number;inputTokens?:number;outputTokens?:number;steps:number;ttftMs?:number; }
{
at:number;id:string; }
{
endpointingMs?:number; }
{
characters:number;ttfbMs?:number; }
type:
"metrics.collected"
Inherited from
Section titled “Inherited from”EventMapOf.metrics.collected
reply.cancelled
Section titled “reply.cancelled”reply.cancelled:
object
{
at:number;id:string; }
type:
"reply.cancelled"
Inherited from
Section titled “Inherited from”EventMapOf.reply.cancelled
reply.completed
Section titled “reply.completed”reply.completed:
object
{
at:number;id:string; }
type:
"reply.completed"
Inherited from
Section titled “Inherited from”EventMapOf.reply.completed
session.configured
Section titled “session.configured”session.configured:
object
audioFormat
Section titled “audioFormat”audioFormat:
string
{
at:number;id:string; }
sampleRate
Section titled “sampleRate”sampleRate:
number
sessionId?
Section titled “sessionId?”
optionalsessionId?:string
ttsSampleRate
Section titled “ttsSampleRate”ttsSampleRate:
number
type:
"session.configured"
Inherited from
Section titled “Inherited from”EventMapOf.session.configured
session.reset
Section titled “session.reset”session.reset:
object
{
at:number;id:string; }
type:
"session.reset"
Inherited from
Section titled “Inherited from”EventMapOf.session.reset
session.timed-out
Section titled “session.timed-out”session.timed-out:
object
{
at:number;id:string; }
type:
"session.timed-out"
Inherited from
Section titled “Inherited from”EventMapOf.session.timed-out
speech.started
Section titled “speech.started”speech.started:
object
{
at:number;id:string; }
type:
"speech.started"
Inherited from
Section titled “Inherited from”EventMapOf.speech.started
speech.stopped
Section titled “speech.stopped”speech.stopped:
object
{
at:number;id:string; }
type:
"speech.stopped"
Inherited from
Section titled “Inherited from”EventMapOf.speech.stopped
state.updated
Section titled “state.updated”state.updated:
object
{
at:number;id:string; }
state:
unknown
type:
"state.updated"
Inherited from
Section titled “Inherited from”EventMapOf.state.updated
tool.called
Section titled “tool.called”tool.called:
object
args:
z.ZodRecord<z.ZodString,z.ZodUnknown>
{
at:number;id:string; }
toolCallId
Section titled “toolCallId”toolCallId:
string
toolName
Section titled “toolName”toolName:
string
type:
"tool.called"
Inherited from
Section titled “Inherited from”EventMapOf.tool.called
tool.completed
Section titled “tool.completed”tool.completed:
object
{
at:number;id:string; }
result
Section titled “result”result:
string
toolCallId
Section titled “toolCallId”toolCallId:
string
type:
"tool.completed"
Inherited from
Section titled “Inherited from”EventMapOf.tool.completed
usage.updated
Section titled “usage.updated”usage.updated:
object
inputTokens
Section titled “inputTokens”inputTokens:
number
{
at:number;id:string; }
outputTokens
Section titled “outputTokens”outputTokens:
number
steps:
number
totalTokens
Section titled “totalTokens”totalTokens:
number
type:
"usage.updated"
Inherited from
Section titled “Inherited from”EventMapOf.usage.updated
user-transcript.committed
Section titled “user-transcript.committed”user-transcript.committed:
object
{
at:number;id:string; }
text:
string
type:
"user-transcript.committed"
Inherited from
Section titled “Inherited from”EventMapOf.user-transcript.committed
user-transcript.updated
Section titled “user-transcript.updated”user-transcript.updated:
object
eotConfidence?
Section titled “eotConfidence?”
optionaleotConfidence?:number
{
at:number;id:string; }
text:
string
type:
"user-transcript.updated"
Inherited from
Section titled “Inherited from”EventMapOf.user-transcript.updated
user-turn.exceeded
Section titled “user-turn.exceeded”user-turn.exceeded:
object
durationMs
Section titled “durationMs”durationMs:
number
limit:
"words"|"duration"
{
at:number;id:string; }
type:
"user-turn.exceeded"
words:
number
Inherited from
Section titled “Inherited from”EventMapOf.user-turn.exceeded
SessionSlot
Section titled “SessionSlot”Sealed
A named slot of per-session state, created by sessionSlot.
Type Parameters
Section titled “Type Parameters”K extends string
The key this slot occupies in the session’s state.
T
The value’s shape.
V = DeepReadonly<T>
What SessionSlot.projected projects to — the return of SessionSlotOptions.view, or the whole value when no view was declared.
Methods
Section titled “Methods”create()
Section titled “create()”create():
T
A fresh default value, as get would install one.
Returns
Section titled “Returns”T
get(
ctx):DeepReadonly<T>
This session’s value, installing the default on first access.
Readonly all the way down, and frozen to match. Mutating what this
returns is a compile error at every depth — cart.items.push(x) as much as
cart.total = 0 — and a TypeError for a caller with no types, because a
mutation applied here is applied to a value nothing is going to store.
Every write goes through SessionSlot.update. See
DeepReadonly for why the type is deep rather than shallow.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”DeepReadonly<T>
projection()
Section titled “projection()”projection<
P>(project):StateProjection<P>
A syncState projection over this slot: read the value (defaulting when
the session has not touched it), then project.
Reach for SessionSlot.projected first — one view, declared with
the slot, passed by both ends. This is the multi-view case: syncState
takes an array, so an agent that shows one slot to two audiences composes a
second projection here.
The result is CALLABLE as well as declarable, which is what lets a client
derive its own empty state from the same function the server pushes —
slot.projection(view)() is the pre-first-tool-call frame. Declaring it is
agent({ syncState: slot.projection(view) }), and an agent with more than
one slot passes an array; the frame carries the merge.
project receives a REAL value, so a projection needs no optional chaining
for the moment before the first tool call.
Type Parameters
Section titled “Type Parameters”P
Parameters
Section titled “Parameters”project
Section titled “project”(value) => P
Returns
Section titled “Returns”Example
Section titled “Example”import { agent, sessionSlot } from "@alexkroman1/aai";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }));
export default agent({ name: "Shop", syncState: cartSlot.projection((cart) => ({ count: cart.items.length })),});reset()
Section titled “reset()”reset(
ctx):DeepReadonly<T>
Discard this session’s value and install a fresh default, and return it.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”DeepReadonly<T>
set(
ctx,value):DeepReadonly<T>
Replace this session’s value wholesale (a load, an import, a restore), and
return it as get would.
The caller’s object is COPIED, not adopted. A durable slot freezes what
it stores, and this method’s own examples — a load, an import, a restore —
are exactly the cases where the caller still holds a reference to what it
passed: freezing in place turned an unrelated later line
(imported.items.push(...)) into a TypeError from a stack that names
nothing about this slot. SessionSlot.update was already safe because
its draft is a copy; this is the same rule applied to the other writer.
Parameters
Section titled “Parameters”T
Returns
Section titled “Returns”DeepReadonly<T>
snapshot()
Section titled “snapshot()”snapshot(
ctx):T
A MUTABLE deep copy of this session’s value — structuredClone of what
SessionSlot.get returns, typed as the slot’s own T rather than
its readonly view, installing the default on first access like get.
For the caller that wants to hold a value BESIDE the slot: a spec recording
the state before a tool runs so it can restore it with
SessionSlot.set, or a tool building a modified candidate it may
never store. Mutating the copy changes nothing stored — every write still
goes through update or set. It replaces
structuredClone(slot.get(ctx)) as Parameters<typeof slot.set>[1], whose
cast was the only way back from DeepReadonly<T> to T.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”T
tool()
Section titled “tool()”tool<
P,R>(def):ToolDef<P,R>
Define a READ-ONLY tool over this slot: execute is handed the frozen
value, so the body needs neither a context annotation nor an opening
slot.get(ctx).
A body that mutates wants SessionSlot.updateTool. This one’s value
is DeepReadonly<T>, so choosing wrong is a compile error — at any
depth — rather than a write that goes nowhere or throws.
R is threaded out, as tool’s is: R used to be bound here and
thrown away at the interface, so InferToolOutput answered unknown for
exactly the tools an agent most often writes. Narrowing a return type is
covariant, so the tool stays assignable to ToolDef<ToolInputSchema>.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
R = unknown
Parameters
Section titled “Parameters”SlotToolDef<P, DeepReadonly<T>, R>
Returns
Section titled “Returns”ToolDef<P, R>
Example
Section titled “Example”import { sessionSlot } from "@alexkroman1/aai";import { z } from "zod";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }));
export default cartSlot.tool({ description: "How many items are in the cart", inputSchema: z.object({}), execute: (_args, cart) => ({ count: cart.items.length }),});update()
Section titled “update()”update<
R>(ctx,mutate):RejectThenableResult<R>
Mutate this session’s value, and store the result.
mutate is handed a mutable DRAFT — a private copy of the current value —
and whatever it leaves behind becomes the stored value when it returns.
Resolves to whatever mutate returned, so a tool body can compute its
result and its mutation in one pass.
It is SYNCHRONOUS, and that is the invariant, not an implementation detail. There is no await between the read and the write, so a read-modify-write cannot interleave with another JS turn — which matters because the LLM loop runs a step’s tool calls CONCURRENTLY. Await in FRONT of the mutation instead:
import { sessionSlot, tool } from "@alexkroman1/aai";import { z } from "zod";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[], quote: 0 }));
export default tool({ description: "Price the cart", inputSchema: z.object({}), execute: async (_args, ctx) => { const quote = await ctx.generate({ prompt: "price it" }); // await first return cartSlot.update(ctx, (cart) => { // then mutate cart.quote = Number(quote.text); return { quote: cart.quote }; }); },});A mutator that throws stores NOTHING: the draft is discarded and the
mutator’s error propagates. The after hook does not run either — see
SessionSlotOptions.after.
For serialized work that is not a slot mutation — an external resource, a
key that isn’t the session id, or a mutation that must fail rather than
queue — reach for createKeyedLock/withLock. They are public for exactly
that, and this method no longer takes a lock at all: a synchronous window
has nothing to serialize.
Type Parameters
Section titled “Type Parameters”R
Parameters
Section titled “Parameters”mutate
Section titled “mutate”(draft) => R
Returns
Section titled “Returns”RejectThenableResult<R>
updateTool()
Section titled “updateTool()”updateTool<
P,R>(def):ToolDef<P,R>
Define a MUTATING tool over this slot: the body runs inside SessionSlot.update, so it is handed a draft and whatever it leaves behind is stored.
The body must therefore be SYNCHRONOUS. A tool that has to await does the
awaiting in an ordinary tool() and calls update afterwards; see
update’s example.
That is enforced at RUN TIME rather than in the type, and the reason is
worth knowing before “fixing” it: a conditional return type
(R extends Promise<unknown> ? never : R) cannot be satisfied by a generic
WRAPPER around this method, and a per-agent wrapper is the main way it gets
used (retail-orders-agent’s retailTool). The runtime check has the better message
anyway, and it is the half a user’s project actually runs — neither bundler
type-checks user code.
It fires at DECLARATION for the common case. An async body is an
AsyncFunction, visible the moment the module loads — under aai dev, in
the build, in the agent’s own spec. A sync function that RETURNS a promise
is the other half, and only the call can catch it.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
R = unknown
Parameters
Section titled “Parameters”SlotToolDef<P, T, R> & RejectThenable<R>
Returns
Section titled “Returns”ToolDef<P, R>
Example
Section titled “Example”import { sessionSlot } from "@alexkroman1/aai";import { z } from "zod";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }));
export default cartSlot.updateTool({ description: "Add an item to the cart", inputSchema: z.object({ item: z.string() }), execute: ({ item }, cart) => { cart.items.push(item); return { count: cart.items.length }; },});Properties
Section titled “Properties”durable
Section titled “durable”
readonlydurable:boolean
Whether this slot’s value is stored durably. true unless the slot
declared otherwise — see SessionSlotOptions.durable.
readonlykey:K
The store key this slot occupies. Two slots must not share one.
projected
Section titled “projected”
readonlyprojected:StateProjection<V>
This slot’s declared view as a syncState projection — built ONCE, here,
so both ends can pass the same object.
agent({ syncState: cartSlot.projected }) on the server and
useAgentState(cartSlot.projected) in the browser are then the SAME
projection by construction, and the frame rendered before the first push
cannot describe a different view than the frames pushed after it. Composing
slot.projection(view) at each end is what could: the two expressions have
to name the same view and nothing checks that they do.
Being built at declaration also makes it identity-stable, which
useAgentState memoizes its empty frame on — so this spelling cannot
produce the fresh-object-per-render an inline slot.projection(view) does.
With no SessionSlotOptions.view, this projects the whole value. Declare one to narrow it.
Example
Section titled “Example”import { agent, sessionSlot } from "@alexkroman1/aai";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }), { view: (cart) => ({ count: cart.items.length }),});
export default agent({ name: "Shop", syncState: cartSlot.projected });SessionSlotOptions
Section titled “SessionSlotOptions”Options for sessionSlot.
Type Parameters
Section titled “Type Parameters”T
After = void
V = DeepReadonly<T>
What SessionSlotOptions.view projects to, inferred from the view itself. Defaults to the whole value, which is what SessionSlot.projected projects when no view is declared.
Properties
Section titled “Properties”after?
Section titled “after?”
optionalafter?: (draft) =>After&RejectThenable<After>
Invariant restoration, run on the draft at the end of every successful SessionSlot.update — pruning growth, recalculating a derived field.
It exists so those rules live with the slot rather than being re-listed at every mutating call site, which is how one gets forgotten. Because it runs inside the mutation window, it sees the complete value about to be stored and may mutate it in place.
It does NOT run when mutate throws. A mutator that failed part-way
may have left the draft in a shape the hook itself cannot handle, and an
error thrown from the hook would replace the one that actually explains the
failure. Nothing is stored in that case either.
It runs INSIDE the mutation window, so it is synchronous too — an
async hook is a compile error naming the rule — see RejectThenable,
which is off the docs for the reason the AgentParams misuse types are:
you meet it in what tsc prints, never by name.
The After parameter exists only to carry that check: it is inferred from
the hook and defaults to void, so a caller never writes it.
optionalcaps?:SlotCaps<T>
Growth caps on the slot’s top-level arrays, enforced by the SLOT on every
store — update, set, reset, and the first get that installs the
default — dropping the OLDEST entries past each cap.
For the append-only lists an agent keeps: a call log, an activity feed, a
finding board. Every one feeds a prompt or a syncState frame, so
uncapped it grows what the model reads and what crosses the wire for the
length of the call. Declared here rather than at each push, because a
wrapper caps only the paths that call it: a slot with three capped arrays
and a fourth pushed to directly is the shape this replaces.
It runs AFTER SessionSlotOptions.after, and that ordering is a
decision rather than an accident. A hook may itself append (restoring a
sentinel, recording what it recalculated), so a cap applied before it
could be exceeded by the hook’s own write; applied after, the cap is the
last word and the stored value never exceeds it. The price is that the
hook sees the UNTRIMMED draft: a derived field that reads the array’s
TAIL (lastLine: log.at(-1)) is unaffected, one that reads its length
counts the entries about to fall off. A mutator’s own result is in the
same position, as it already is with after.
Top-level arrays only — a key is accepted only when the value under it
is an array (see SlotCaps). A nested list (one timeline per
incident) has no single key to declare and stays on pushCapped, which is
the same bound applied by hand.
A cap that is not a non-negative integer is refused at DECLARATION, naming
the slot and the key. Zero keeps nothing, as pushCapped(…, 0) does.
import { sessionSlot } from "@alexkroman1/aai";
type Desk = { log: string[]; findings: string[]; open: string | null };export const deskSlot = sessionSlot( "desk", (): Desk => ({ log: [], findings: [], open: null }), { caps: { log: 40, findings: 12 } },);durable?
Section titled “durable?”
optionaldurable?:boolean
Whether this slot’s value is STORED. Defaults to true.
false declares a VIRTUAL slot: a per-session box whose contents are
neither checked, frozen, nor committed, and which does not survive the
process. That is the right shape for a value whose lifetime is one call and
which could not be stored anyway — a provider handle, an open socket, a
cached client.
It is a property of the slot’s DECLARATION rather than a per-value opt-out,
which is what makes it a decision the author makes once instead of a check
somebody has to remember to skip. Note get on a virtual slot returns the
live value: there is nothing to protect it from, since nothing is going to
store a copy of it.
optionalview?: (value) =>V
What this slot shows the BROWSER — declared here so it is written once and read from both ends as SessionSlot.projected.
agent({ syncState: cartSlot.projected }) and
useAgentState(cartSlot.projected) are then the same object, so the frame
the server pushes and the frame the page renders before the first push
cannot disagree. That drift is what this field exists to remove:
SessionSlot.projection is a METHOD, so the projection is a value
somebody has to name, export and import at both ends — and every shipped
example that got it right did so by exporting
export const cartProjection = cartSlot.projection(cartView) from a
shared.ts, eight of them also hand-writing the StateProjection<V>
annotation that follows from the view.
It also makes the memoization caveat on useAgentState evaporate for this
path: projected is built ONCE, at declaration, so it is identity-stable
for the life of the module and a projection spelled inline in a render body
is not something this spelling can express.
Absent, the WHOLE value is projected. Declare a view to narrow it — to what the page renders, rather than to whatever the slot happens to hold.
A slot with more than one audience keeps
SessionSlot.projection: syncState takes an array, so a second view
is a second projection over the same slot.
import { agent, sessionSlot } from "@alexkroman1/aai";
type Cart = { items: string[]; nextId: number };export const cartSlot = sessionSlot("cart", (): Cart => ({ items: [], nextId: 1 }), { view: (cart) => ({ count: cart.items.length }),});
export default agent({ name: "Shop", syncState: cartSlot.projected });Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”V
SlotToolDef
Section titled “SlotToolDef”The authoring shape of a slot-backed tool: ToolDef with the slot’s
value handed to execute directly.
Built FROM ToolDef, not copied from it. Every field but execute is
ToolDef’s own — description, inputSchema, onError, messages, and
whatever ToolDef grows next — so a slot-backed tool can declare anything a
plain one can. The field-by-field copy this replaced restated three of them,
and the one it missed was messages: a slot tool could not declare tool-call
speech at all, although the builder spreads the def and the runtime would
have spoken it.
onError behaves exactly as it does on any tool, and the slot is not
involved: an updateTool mutator that threw stored nothing, by that method’s
own contract, so a handler is classifying a call that changed no state.
value comes SECOND because it is what a slot-backed tool body actually
uses; most take (args, cart) and never mention ctx at all, which is the
point. Putting it there rather than third cannot be got wrong silently — a
body converted from tool() that still names its second parameter ctx is a
type error the first time it reads ctx.env, since V is not a
ToolContext.
Extends
Section titled “Extends”Omit<ToolDef<P,R>,"execute">
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
V
What execute is handed: a deep-frozen
DeepReadonly<T> from SessionSlot.tool, a mutable draft
from SessionSlot.updateTool.
R
Methods
Section titled “Methods”execute()
Section titled “execute()”execute(
args,value,ctx):R
The tool body, handed this session’s slot value alongside the usual args.
Parameters
Section titled “Parameters”V
Returns
Section titled “Returns”R
Properties
Section titled “Properties”description
Section titled “description”description:
string
Human-readable description shown to the LLM.
Inherited from
Section titled “Inherited from”Omit.description
inputSchema?
Section titled “inputSchema?”
optionalinputSchema?:P
Schema for the tool’s input, shown to the LLM and used to validate each
call’s arguments before execute runs. Named after the Vercel AI SDK’s
tool({ inputSchema }).
Inherited from
Section titled “Inherited from”Omit.inputSchema
messages?
Section titled “messages?”
optionalmessages?:ToolMessagesInput
What the agent SAYS while this tool runs, and what it says when it lands.
Four kinds — start, delayed, complete, failed — documented on
ToolMessagesInput. Two of them change the shape of the turn rather
than just filling it:
delayedis a LADDER when the timings differ and VARIANTS when they match. Two entries atafterMs: 3000are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.- A
complete/failedentry withrole: "assistant"is spoken verbatim and the model is NOT CALLED. For a deterministic outcome that removes a whole LLM round-trip from the turn.role: "system"is the other arm: the content rides back as a hint and the model writes the sentence.
start and delayed are filler — they are heard, and they are never
recorded into ctx.messages, the model’s view or the committed transcript,
and never count as the agent having spoken (so a caller talking over one
does not interrupt the reply being generated behind it). complete and
failed with role: "assistant" are the opposite on every count: that IS
the agent’s answer.
Example
Section titled “Example”A hold line, a two-rung ladder, and an error the model phrases
import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Look up an order", inputSchema: z.object({ orderId: z.string() }), messages: { start: ["Let me pull that up.", "One second while I check."], delayed: [ { afterMs: 3000, content: "Still looking." }, { afterMs: 9000, content: "Sorry, the order system is slow today." }, ], failed: [{ role: "system", content: "Order lookup failed. Apologize and offer a callback." }], }, execute: async ({ orderId }) => ({ orderId, status: "shipped" }),});Inherited from
Section titled “Inherited from”Omit.messages
onError?
Section titled “onError?”
optionalonError?:ToolErrorHandler
What to do when execute throws — and, by omission, the SDK’s default.
Without it, every exception becomes an ordinary tool result. The
runtime catches whatever execute threw and hands errorMessage(err) back
to the model as that call’s result, which is the same channel a deliberate
toolFailure uses — so a stale credential, a TypeError in the
author’s own code and “no such order” are one thing as far as the model can
tell, and it will keep calling a permanently broken tool until the reply’s
maxSteps budget runs out. That default is unchanged and stays the default:
for the failures a model really can recover from it is the right answer, and
every tool written before this field existed depends on it.
With it, the author classifies. Return a ToolFailure or a string
and that is what the model gets — the same outcome as the default, with a
sentence the author chose. Throw — throw err re-raises the original — and
the failure is FATAL to the call: the runtime logs it, reports it as a
session error (code: "tool"), and the tool call REJECTS instead of
answering, so nothing hands the model something to retry against.
It sees only a THROW. A ToolFailure that execute RETURNED never reaches
it: that is already the author saying “expected, let the model recover”, and
routing it through here would make the two channels one again.
Example
Section titled “Example”Fatal on a missing credential, recoverable on a bad lookup
import { tool, toolFailure } from "@alexkroman1/aai";import { z } from "zod";
class MissingKeyError extends Error {}
export default tool({ description: "Look up an order", inputSchema: z.object({ id: z.string() }), execute: async ({ id }, ctx) => { if (!ctx.env.ORDERS_API_KEY) throw new MissingKeyError("ORDERS_API_KEY is unset"); const res = await fetch(`https://api.example.com/orders/${id}`, { headers: { authorization: `Bearer ${ctx.env.ORDERS_API_KEY}` }, }); if (res.status === 404) return toolFailure(`No order ${id}.`); return await res.json(); }, // A credential the deploy is missing cannot be fixed by asking the model // to try again; a flaky upstream can. onError: (err) => { if (err instanceof MissingKeyError) throw err; return toolFailure("The orders service is unavailable right now."); },});Inherited from
Section titled “Inherited from”Omit.onError
StandardSchemaIssue
Section titled “StandardSchemaIssue”One validation issue in a failed Standard Schema result.
Properties
Section titled “Properties”errors?
Section titled “errors?”
readonlyoptionalerrors?:unknown
A union’s per-branch issues, one entry per branch — an off-spec VENDOR
EXTENSION, which is why it is typed unknown rather than described.
Standard Schema declares a flat { message, path }, so a validator with
alternatives has nowhere to put the reason each one was rejected. Zod
therefore passes an errors array through the ~standard interface
anyway, and its parent issue’s own message is the placeholder
"Invalid input". formatSchemaIssues reads this when it is
shaped like branches and ignores it otherwise; nothing in this SDK
requires a vendor to supply it, and no caller should produce it.
issues?
Section titled “issues?”
readonlyoptionalissues?:unknown
The issues a vendor nested inside this one as its CAUSE — a second
off-spec extension, typed unknown for the same reason as errors.
Zod 4 wraps a record’s failed KEY this way: the outer issue is
invalid_key carrying the generic "Invalid key in record", and the key
schema’s own issues — including any custom error its author wrote — sit
here. Without reading it, a message written FOR an author is replaced by
one that does not say what is wrong: mcpServers.my-docs reported
Invalid key in record while agent-config.ts had spelled out the key
grammar and the reason for it.
Unlike errors these are not alternatives, so formatSchemaIssues
APPENDS rather than replaces — see renderIssue.
message
Section titled “message”
readonlymessage:string
readonlyoptionalpath?: readonly (PropertyKey| {key:PropertyKey; })[]
StandardSchemaV1
Section titled “StandardSchemaV1”The Standard Schema V1 interface, inlined as the spec recommends (it is a types-only contract). A Zod, ArkType, or Valibot schema all satisfy it.
Type Parameters
Section titled “Type Parameters”Input = unknown
The type the schema accepts for validation.
Output
Section titled “Output”Output = Input
The type validation produces.
Properties
Section titled “Properties”~standard
Section titled “~standard”
readonly~standard:object
The Standard Schema properties object.
types?
Section titled “types?”{
input:Input;output:Output; }
Inferred types, when the vendor exposes them.
validate
Section titled “validate”(
value) =>StandardSchemaResult<Output> |Promise<StandardSchemaResult<Output>>
Validate value, returning the typed value or issues.
vendor
Section titled “vendor”
readonlyvendor:string
The vendor name, e.g. "zod", "arktype", "valibot".
version
Section titled “version”
readonlyversion:1
The version of the standard implemented (always 1).
StateProjection()
Section titled “StateProjection()”One slot’s contribution to the agent_state frame — what
SessionSlot.projected and SessionSlot.projection are, and what
agent({ syncState }) takes.
It is a FUNCTION carrying the two facts the runtime needs, rather than a
plain record, and the callable half is load-bearing at both ends. The server
calls it with whatever the store holds; a client.tsx calls it with nothing
to derive the frame it renders before the first tool call, from the same
function — so a field added to the projection reaches the first render
instead of being missing until something changes.
Type Parameters
Section titled “Type Parameters”V = unknown
StateProjection(
value?):V
Project a stored value, or the slot’s default when there is none.
Parameters
Section titled “Parameters”value?
Section titled “value?”unknown
Returns
Section titled “Returns”V
Properties
Section titled “Properties”create
Section titled “create”
readonlycreate: () =>unknown
The slot’s default, for a session that has not touched it.
Returns
Section titled “Returns”unknown
readonlykey:string
The slot key whose value this projects.
SubagentAnswer
Section titled “SubagentAnswer”ONE attempt at an answer — what a SubagentGuardrail judges.
text is the answer; steps and toolCalls are what the attempt COST,
which is the half a voice agent needs in order to say something true about
the wait (“I checked four sources”). They are a report, not a transcript: the
tool RESULTS stay inside the subagent’s context, which is the entire reason
to have delegated.
Split from DelegateResult so a guardrail cannot read the fields that
only make sense once the run is OVER — revisions counts the guardrail’s own
verdicts, and asking it to judge an answer against its own past judgements is
not a check, it is a loop.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”steps:
number
How many steps this attempt took, including the final answering step.
text:
string
The subagent’s final message — see SubagentDef.expectedOutput.
toolCalls
Section titled “toolCalls”toolCalls: readonly
SubagentToolCall[]
Every tool call this attempt made, in order.
SubagentDef
Section titled “SubagentDef”A subagent definition — what subagent returns and DelegateFn runs.
Every field except name and systemPrompt is optional, and the defaults
are the parent agent’s: the same LLM descriptor, no tools, and
the framework default (DEFAULT_MAX_STEPS) steps.
It takes ModelTuning WITHOUT maxRetries, deliberately. That name
was this def’s guardrail budget before the knobs were unified, and on
ModelTuning it means provider retries; accepting it here would have kept
subagent({ guardrail, maxRetries: 3 }) compiling while silently changing
what the 3 bounds. Refused instead, it is a compile error whose message
names SubagentDef.maxRevisions. A subagent’s provider requests
retry on the AI SDK’s default.
Extends
Section titled “Extends”Omit<ModelTuning,"maxRetries">
Extended by
Section titled “Extended by”Properties
Section titled “Properties”builtinTools?
Section titled “builtinTools?”
optionalbuiltinTools?: readonlyBuiltinTool[]
Builtins this subagent may call, resolved exactly as agent({ builtinTools }) resolves them. Independent of the parent’s: a parent that
enables none can still delegate to a subagent that searches the web.
description?
Section titled “description?”
optionaldescription?:string
What this subagent is FOR, in one line, written for whoever is choosing between subagents rather than for the subagent itself.
Ignored by call-site delegation — ctx.delegate(researcher, …) names the
subagent in code, so the choice is already made and there is nothing to
describe it to. It is REQUIRED of a subagent listed in
agent({ subagents }), and that is the whole reason it exists: a roster is
routed by the model, which reads this and nothing else. agent() refuses a
roster entry without one rather than shipping an agent that picks a
subagent off a list of bare names.
Write it as the job, not the mechanism: “Researches a topic on the open web and reports what it found” — not “calls web_search”.
expectedOutput?
Section titled “expectedOutput?”
optionalexpectedOutput?:string
What a GOOD final message looks like — the shape of the answer, declared apart from the instructions for producing it.
The runtime appends it to the instructions as its own labelled section, so it lands in the same place every time rather than wherever an author happened to put it in prose. It is also what a SubagentDef.guardrail is quoted against when it sends an answer back, so the two halves of “what this run owes” stay one sentence rather than two that can disagree.
Split out of systemPrompt for the reason CrewAI splits expected_output
off description: the failure it prevents is structural, not a matter of
prompting skill. A subagent whose brief says only what to DO ends its run
when it is done, which for a delegated run is precisely the wrong moment to
stop talking.
import { subagent } from "@alexkroman1/aai";
const researcher = subagent({ name: "researcher", systemPrompt: "Research the task with the tools you have.", expectedOutput: "A self-contained paragraph of what you found, naming the sources you " + "trusted. Three sentences is plenty; do not write a report.",});guardrail?
Section titled “guardrail?”
optionalguardrail?:SubagentGuardrail
Check the subagent’s answer, and send it back with a complaint when it is not good enough.
Return true to accept. Return a STRING to reject: the string is the
complaint, and the runtime re-runs the subagent with its own rejected
answer and that complaint appended to the conversation it already has — so
the retry keeps every tool result the first attempt paid for and is told
exactly what to fix. Bounded by SubagentDef.maxRevisions.
A schema is not this. ctx.generate({ schema }) constrains the SHAPE
of an answer and cannot say that a citation is missing, that the sources
were all one publisher, or that the answer contradicts what the caller
already said. That judgement is a function, and until now the only place to
put it was after the delegation returned — where the one thing it could not
do was ask for a better answer.
Runs on every attempt including the last. Throwing from it fails the
delegation, so a guardrail that cannot decide should return true.
import { subagent } from "@alexkroman1/aai";
const researcher = subagent({ name: "researcher", systemPrompt: "Research the task with the tools you have.", expectedOutput: "A paragraph naming the sources you trusted.", guardrail: ({ text, toolCalls }) => toolCalls.length === 0 ? "You answered without looking anything up. Search first, then answer." : text.length > 1200 ? "Too long for someone listening on a phone — three sentences." : true,});
optionalllm?:LlmSpec
LLM for this subagent: a descriptor from @alexkroman1/aai/llm, or a
model-id string — the same shorthand as agent({ llm }) and
GenerateOptions.llm. Defaults to the parent agent’s own LLM.
Naming a cheaper model here is the usual reason to set it: a subagent doing lookups is spending most of its tokens on tool results, not on reasoning.
maxOutputTokens?
Section titled “maxOutputTokens?”
optionalmaxOutputTokens?:number
Cap on generated tokens per step, passed straight through to the provider.
The same field GenerateOptions.maxOutputTokens has for a one-shot call. Per STEP, not per turn: a reply that calls three tools has four generations in it, and the cap bounds each.
On a voice agent it is a bluntness knob rather than a cost one — a model that runs long is a model the caller is waiting through — and a value low enough to truncate mid-sentence will truncate mid-sentence, because the provider stops emitting rather than wrapping up.
Inherited from
Section titled “Inherited from”maxRetries?
Section titled “maxRetries?”
optionalmaxRetries?:"a subagent's guardrail budget is `maxRevisions` (was `maxRetries`); a subagent takes no provider-retry setting"
Not a field. Typed as the message that names the rename, so
subagent({ maxRetries: 3 }) fails to compile with the fix in the error
rather than with a bare excess-property one — the idiom agent({ tools })
uses. See SubagentDef.maxRevisions.
maxRevisions?
Section titled “maxRevisions?”
optionalmaxRevisions?:number
How many times a SubagentDef.guardrail may send an answer back.
Default Value
Section titled “Default Value”1 (DEFAULT_GUARDRAIL_MAX_REVISIONS)
Was maxRetries. Renamed because ModelTuning.maxRetries retries
a provider REQUEST that failed (a 429, a socket reset), where this re-runs a
delegation that SUCCEEDED and was judged not good enough. A subagent does
not accept maxRetries at all, so code written against the old name fails
to compile rather than quietly meaning something else.
One, not CrewAI’s three, because a revision is another FULL run of the subagent and the caller is on a live phone call — the third attempt at a summary arrives well after the moment anyone was waiting for it. Raise it for a subagent delegated from a workflow step, where nobody is listening.
Exhausting the budget is not an error: the last attempt comes back with
DelegateResult.accepted false and the guardrail’s
DelegateResult.complaint, because a voice agent holding a rejected
answer still has to say something, and it should be the caller’s tool —
not the runtime — that decides what.
maxSteps?
Section titled “maxSteps?”
optionalmaxSteps?:number
Tool-calling steps this subagent may take before it must answer. Defaults
to the framework’s DEFAULT_MAX_STEPS.
The budget is the mechanism: a subagent told to “keep looking until sure” is a subagent whose cost nobody can quote. Past the cap it is asked for its answer with tools withheld, so a capped run still returns prose rather than stopping mid-chain.
name:
string
What this subagent is called. It reaches the model only as the id on the subagent’s own requests; its reader is a log line and a failure message (“subagent "researcher" ran out of steps”), which is why it is required and why an anonymous subagent is not expressible.
schema?
Section titled “schema?”
optionalschema?:StandardSchemaV1<unknown,unknown>
The SHAPE the final message must have — any Standard Schema, zod being the documented default. The runtime parses the answer as JSON and checks it, and a reply that does not match is sent BACK the way a SubagentDef.guardrail rejection is, with the schema’s own issues as the complaint. Declare it through subagent to get the parsed value typed on TypedDelegateResult.object.
This is not the guardrail, and the two are complementary. A schema settles the SHAPE — that a verdict is one of three words rather than a sentence that implies one — where a guardrail is the judgement a shape cannot express (a missing citation, sources that are all one publisher). A subagent may declare both; the shape is checked first, because a guardrail asked to judge a malformed answer is being asked the wrong question.
Reach for it when the CALLER has to branch on the answer.
topic-briefing-agent’s fact-checker had a three-value verdict crossing three
layers as an English sentence prefix — restated in expectedOutput,
re-checked by a guardrail doing startsWith, and re-asked up to the retry
budget — because a model that wrote "Confirmed - " was wrong in a way
only prose could describe. A schema makes that a parse.
import { subagent } from "@alexkroman1/aai";import { z } from "zod";
const factChecker = subagent({ name: "fact-checker", systemPrompt: "Check ONE claim against what you can find.", schema: z.object({ verdict: z.enum(["confirmed", "contradicted", "unclear"]), detail: z.string(), }),});systemPrompt
Section titled “systemPrompt”systemPrompt:
string
The subagent’s system prompt.
Tell it to summarize — or, better, declare SubagentDef.expectedOutput
and let the runtime say it. The parent gets DelegateResult.text,
which is the subagent’s FINAL message, so a subagent that ends its run by
saying “Done.” has thrown away everything it learned and no amount of step
budget recovers it. This is the single most common way a subagent
disappoints, and it was a sentence every author had to remember to write
here; expectedOutput is the field that remembers it for them.
temperature?
Section titled “temperature?”
optionaltemperature?:number
Sampling temperature.
Omitted by default, so the model’s own default applies; some models (Claude 5 among them) ignore it and warn, so set it only for a temperature-capable one. A booking desk and a game master want different values; so do a researcher subagent and the voice that relays what it found.
Inherited from
Section titled “Inherited from”tools?
Section titled “tools?”
optionaltools?:Readonly<Record<string,ToolDef<ToolInputSchema,unknown>>>
The tools this subagent may call, by the name the model calls them by.
A MAP rather than the filesystem registration agent() uses, and the
difference is deliberate: tools/ declares what the CALLER can reach, and
this declares the strictly narrower set one delegated task can reach. A
subagent with no entry here and no builtinTools is a pure reasoning
pass — legal, and occasionally what you want.
SubagentToolCall
Section titled “SubagentToolCall”One tool call a subagent made, as reported back to the caller.
Properties
Section titled “Properties”input:
unknown
The arguments it was called with.
name:
string
The tool’s name, as the subagent’s model called it.
TypedDelegateResult
Section titled “TypedDelegateResult”Run a subagent to completion — the signature of ctx.delegate.
Rejects when the run cannot be started (no LLM configured or named, an unknown builtin) and when the parent turn is cancelled. A subagent whose own TOOL fails does not reject: the failure goes back to the subagent as a tool result, exactly as it would in the parent loop, and the subagent gets to recover from it.
A SubagentDef.guardrail that never accepts does not reject either —
the run comes back with DelegateResult.accepted false. The two
rejections above are both “this delegation could not happen”; a rejected
answer is a delegation that happened and produced something, and a caller on
a live call can use the difference.
Extends
Section titled “Extends”Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”accepted
Section titled “accepted”accepted:
boolean
Whether the guardrail ACCEPTED this answer. Always true when the subagent
declares no guardrail.
false means the retry budget ran out and text is the last REJECTED
attempt. It comes back rather than throwing because the caller is a tool on
a live call and needs something to say — but it is a distinct value, not a
silently-returned failure, so a tool that cares can apologize instead of
reading a bad answer out loud.
Inherited from
Section titled “Inherited from”complaint?
Section titled “complaint?”
optionalcomplaint?:string
The guardrail’s last complaint. Present exactly when accepted is false
— it is the reason, and a caller that reports the failure should quote it.
Inherited from
Section titled “Inherited from”object
Section titled “object”object:
T
The final message, PARSED against SubagentDef.schema.
Present exactly when the subagent declares one, which is why it lives on this type rather than on DelegateResult: a caller that declared no shape should not be handed a field it has no way to read.
text is still the raw answer beside it — the JSON the model wrote — so a
caller that wants to quote what came back can, and one that wants to branch
on it reads this.
revisions
Section titled “revisions”revisions:
number
How many times the guardrail sent an answer back before this one.
0 when it passed first time, and 0 for a subagent with no guardrail at
all. Reported for the same reason steps is: it is most of what the run
cost, and a wait that included two rewrites is a wait the caller was owed a
word about.
Inherited from
Section titled “Inherited from”steps:
number
How many steps this attempt took, including the final answering step.
Inherited from
Section titled “Inherited from”text:
string
The subagent’s final message — see SubagentDef.expectedOutput.
Inherited from
Section titled “Inherited from”toolCalls
Section titled “toolCalls”toolCalls: readonly
SubagentToolCall[]
Every tool call this attempt made, in order.
Inherited from
Section titled “Inherited from”TypedSubagentDef
Section titled “TypedSubagentDef”Define a subagent.
An identity function, like tool — it exists for the type, for the
name to grep for, and so a subagent is declared at module scope rather than
rebuilt inside execute on every call.
Extends
Section titled “Extends”Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”builtinTools?
Section titled “builtinTools?”
optionalbuiltinTools?: readonlyBuiltinTool[]
Builtins this subagent may call, resolved exactly as agent({ builtinTools }) resolves them. Independent of the parent’s: a parent that
enables none can still delegate to a subagent that searches the web.
Inherited from
Section titled “Inherited from”description?
Section titled “description?”
optionaldescription?:string
What this subagent is FOR, in one line, written for whoever is choosing between subagents rather than for the subagent itself.
Ignored by call-site delegation — ctx.delegate(researcher, …) names the
subagent in code, so the choice is already made and there is nothing to
describe it to. It is REQUIRED of a subagent listed in
agent({ subagents }), and that is the whole reason it exists: a roster is
routed by the model, which reads this and nothing else. agent() refuses a
roster entry without one rather than shipping an agent that picks a
subagent off a list of bare names.
Write it as the job, not the mechanism: “Researches a topic on the open web and reports what it found” — not “calls web_search”.
Inherited from
Section titled “Inherited from”expectedOutput?
Section titled “expectedOutput?”
optionalexpectedOutput?:string
What a GOOD final message looks like — the shape of the answer, declared apart from the instructions for producing it.
The runtime appends it to the instructions as its own labelled section, so it lands in the same place every time rather than wherever an author happened to put it in prose. It is also what a SubagentDef.guardrail is quoted against when it sends an answer back, so the two halves of “what this run owes” stay one sentence rather than two that can disagree.
Split out of systemPrompt for the reason CrewAI splits expected_output
off description: the failure it prevents is structural, not a matter of
prompting skill. A subagent whose brief says only what to DO ends its run
when it is done, which for a delegated run is precisely the wrong moment to
stop talking.
import { subagent } from "@alexkroman1/aai";
const researcher = subagent({ name: "researcher", systemPrompt: "Research the task with the tools you have.", expectedOutput: "A self-contained paragraph of what you found, naming the sources you " + "trusted. Three sentences is plenty; do not write a report.",});Inherited from
Section titled “Inherited from”guardrail?
Section titled “guardrail?”
optionalguardrail?:SubagentGuardrail
Check the subagent’s answer, and send it back with a complaint when it is not good enough.
Return true to accept. Return a STRING to reject: the string is the
complaint, and the runtime re-runs the subagent with its own rejected
answer and that complaint appended to the conversation it already has — so
the retry keeps every tool result the first attempt paid for and is told
exactly what to fix. Bounded by SubagentDef.maxRevisions.
A schema is not this. ctx.generate({ schema }) constrains the SHAPE
of an answer and cannot say that a citation is missing, that the sources
were all one publisher, or that the answer contradicts what the caller
already said. That judgement is a function, and until now the only place to
put it was after the delegation returned — where the one thing it could not
do was ask for a better answer.
Runs on every attempt including the last. Throwing from it fails the
delegation, so a guardrail that cannot decide should return true.
import { subagent } from "@alexkroman1/aai";
const researcher = subagent({ name: "researcher", systemPrompt: "Research the task with the tools you have.", expectedOutput: "A paragraph naming the sources you trusted.", guardrail: ({ text, toolCalls }) => toolCalls.length === 0 ? "You answered without looking anything up. Search first, then answer." : text.length > 1200 ? "Too long for someone listening on a phone — three sentences." : true,});Inherited from
Section titled “Inherited from”
optionalllm?:LlmSpec
LLM for this subagent: a descriptor from @alexkroman1/aai/llm, or a
model-id string — the same shorthand as agent({ llm }) and
GenerateOptions.llm. Defaults to the parent agent’s own LLM.
Naming a cheaper model here is the usual reason to set it: a subagent doing lookups is spending most of its tokens on tool results, not on reasoning.
Inherited from
Section titled “Inherited from”maxOutputTokens?
Section titled “maxOutputTokens?”
optionalmaxOutputTokens?:number
Cap on generated tokens per step, passed straight through to the provider.
The same field GenerateOptions.maxOutputTokens has for a one-shot call. Per STEP, not per turn: a reply that calls three tools has four generations in it, and the cap bounds each.
On a voice agent it is a bluntness knob rather than a cost one — a model that runs long is a model the caller is waiting through — and a value low enough to truncate mid-sentence will truncate mid-sentence, because the provider stops emitting rather than wrapping up.
Inherited from
Section titled “Inherited from”maxRetries?
Section titled “maxRetries?”
optionalmaxRetries?:"a subagent's guardrail budget is `maxRevisions` (was `maxRetries`); a subagent takes no provider-retry setting"
Not a field. Typed as the message that names the rename, so
subagent({ maxRetries: 3 }) fails to compile with the fix in the error
rather than with a bare excess-property one — the idiom agent({ tools })
uses. See SubagentDef.maxRevisions.
Inherited from
Section titled “Inherited from”maxRevisions?
Section titled “maxRevisions?”
optionalmaxRevisions?:number
How many times a SubagentDef.guardrail may send an answer back.
Default Value
Section titled “Default Value”1 (DEFAULT_GUARDRAIL_MAX_REVISIONS)
Was maxRetries. Renamed because ModelTuning.maxRetries retries
a provider REQUEST that failed (a 429, a socket reset), where this re-runs a
delegation that SUCCEEDED and was judged not good enough. A subagent does
not accept maxRetries at all, so code written against the old name fails
to compile rather than quietly meaning something else.
One, not CrewAI’s three, because a revision is another FULL run of the subagent and the caller is on a live phone call — the third attempt at a summary arrives well after the moment anyone was waiting for it. Raise it for a subagent delegated from a workflow step, where nobody is listening.
Exhausting the budget is not an error: the last attempt comes back with
DelegateResult.accepted false and the guardrail’s
DelegateResult.complaint, because a voice agent holding a rejected
answer still has to say something, and it should be the caller’s tool —
not the runtime — that decides what.
Inherited from
Section titled “Inherited from”maxSteps?
Section titled “maxSteps?”
optionalmaxSteps?:number
Tool-calling steps this subagent may take before it must answer. Defaults
to the framework’s DEFAULT_MAX_STEPS.
The budget is the mechanism: a subagent told to “keep looking until sure” is a subagent whose cost nobody can quote. Past the cap it is asked for its answer with tools withheld, so a capped run still returns prose rather than stopping mid-chain.
Inherited from
Section titled “Inherited from”name:
string
What this subagent is called. It reaches the model only as the id on the subagent’s own requests; its reader is a log line and a failure message (“subagent "researcher" ran out of steps”), which is why it is required and why an anonymous subagent is not expressible.
Inherited from
Section titled “Inherited from”schema
Section titled “schema”schema:
StandardSchemaV1<unknown,T>
The SHAPE the final message must have — any Standard Schema, zod being the documented default. The runtime parses the answer as JSON and checks it, and a reply that does not match is sent BACK the way a SubagentDef.guardrail rejection is, with the schema’s own issues as the complaint. Declare it through subagent to get the parsed value typed on TypedDelegateResult.object.
This is not the guardrail, and the two are complementary. A schema settles the SHAPE — that a verdict is one of three words rather than a sentence that implies one — where a guardrail is the judgement a shape cannot express (a missing citation, sources that are all one publisher). A subagent may declare both; the shape is checked first, because a guardrail asked to judge a malformed answer is being asked the wrong question.
Reach for it when the CALLER has to branch on the answer.
topic-briefing-agent’s fact-checker had a three-value verdict crossing three
layers as an English sentence prefix — restated in expectedOutput,
re-checked by a guardrail doing startsWith, and re-asked up to the retry
budget — because a model that wrote "Confirmed - " was wrong in a way
only prose could describe. A schema makes that a parse.
import { subagent } from "@alexkroman1/aai";import { z } from "zod";
const factChecker = subagent({ name: "fact-checker", systemPrompt: "Check ONE claim against what you can find.", schema: z.object({ verdict: z.enum(["confirmed", "contradicted", "unclear"]), detail: z.string(), }),});Overrides
Section titled “Overrides”systemPrompt
Section titled “systemPrompt”systemPrompt:
string
The subagent’s system prompt.
Tell it to summarize — or, better, declare SubagentDef.expectedOutput
and let the runtime say it. The parent gets DelegateResult.text,
which is the subagent’s FINAL message, so a subagent that ends its run by
saying “Done.” has thrown away everything it learned and no amount of step
budget recovers it. This is the single most common way a subagent
disappoints, and it was a sentence every author had to remember to write
here; expectedOutput is the field that remembers it for them.
Inherited from
Section titled “Inherited from”temperature?
Section titled “temperature?”
optionaltemperature?:number
Sampling temperature.
Omitted by default, so the model’s own default applies; some models (Claude 5 among them) ignore it and warn, so set it only for a temperature-capable one. A booking desk and a game master want different values; so do a researcher subagent and the voice that relays what it found.
Inherited from
Section titled “Inherited from”tools?
Section titled “tools?”
optionaltools?:Readonly<Record<string,ToolDef<ToolInputSchema,unknown>>>
The tools this subagent may call, by the name the model calls them by.
A MAP rather than the filesystem registration agent() uses, and the
difference is deliberate: tools/ declares what the CALLER can reach, and
this declares the strictly narrower set one delegated task can reach. A
subagent with no entry here and no builtinTools is a pure reasoning
pass — legal, and occasionally what you want.
Inherited from
Section titled “Inherited from”UsageLimits
Section titled “UsageLimits”The token budget a session may spend before the runtime stops it.
Host-side accounting only. The numbers come from what the provider
reports on each completed step (the AI SDK’s usage), summed across the
session, so they exist in pipeline and text mode and NOT in S2S — there the
provider runs the loop and this runtime sees no token counts at all, which is
why an S2S agent is refused this field instead of being handed zeroes.
What counts against it
Section titled “What counts against it”Every model request the runtime makes for this session:
- the conversational loop — every step of every turn;
ctx.generatefrom a tool body;ctx.delegate/subagent()— every step of the delegated run, including a guardrail’s revisions.
That list is the whole budget, and it is stated because the first release of
this field counted only the first line: a delegating agent spends most of
what it spends inside ctx.delegate, so a cap set on one bounded the cheap
half of the session while reading as though it bounded the session.
What does NOT count against it
Section titled “What does NOT count against it”- A durable WORKFLOW step.
ctx.workflows.start(…)returns a run that outlives this session — it is resumed by the engine’s own timers, possibly in another process, and a REPLAYED step reads its journaled result without calling a model at all — so a step’s tokens are not part of what a SESSION spent and are not bounded here. An agent whose real cost is in its workflows needs a run-scoped budget, which this is not. - A tool executing behind the platform relay, whose own model calls are metered by the runtime running it rather than by this one. Built-in tools, which run host-side, do count.
- S2S, which reports nothing and is refused this field outright.
There is no cost limit here on purpose. A price is a per-model, per-region, per-contract number this package does not carry and could only guess at, and a budget that silently guesses wrong is worse than one stated in the unit the provider actually reports.
Properties
Section titled “Properties”totalTokens?
Section titled “totalTokens?”
optionaltotalTokens?:number
Stop the session once this many total tokens (input + output, every step of every turn) have been spent.
Checked BEFORE each model request rather than mid-stream, and before EVERY one of the three above — so the request that crosses the line finishes and the NEXT one is refused, wherever it was about to be made. A cap enforced mid-sentence would cut the agent off in the middle of speech, and the caller would hear a dropped call rather than a limit; the overrun is therefore one request rather than one turn, which matters for a tool that fans out to several subagents at once.
Reaching it ends the session at its next turn with a fatal error.reported
frame — a browser client releases the microphone and hangs up. In between,
a ctx.generate or ctx.delegate that asks for more is refused with the
same sentence, which the calling tool may catch and answer around. An agent
that wants a softer landing watches usage.updated through
agent({ events }) and says something before the cap arrives.
UserTurnLimit
Section titled “UserTurnLimit”A cap on ONE user turn — see PipelineVoiceTuning.userTurnLimit.
End-of-turn detection is the STT provider’s, and it is driven by SILENCE: a
caller who never pauses never ends a turn, so a monologue holds the floor
for as long as it runs and the agent cannot answer, redirect or hand off
until it stops. This is the bound on that. When the open utterance crosses
either cap the runtime asks the transcriber to END THE TURN NOW, exactly as
a pause would have — the words heard so far commit as the caller’s turn, the
agent replies to them, and whatever the caller says next opens the next turn
— and a user-turn.exceeded event records that it happened.
Both members are optional; set one or both. A limit that names neither is refused at config time rather than accepted as a cap on nothing.
Two things it is NOT: it is not a barge-in gate (minBargeInWords and
interruptionMinDurationMs decide whether the caller’s speech interrupts a
reply; this decides when the caller’s own turn is long enough), and it does
not discard anything the caller says — speech after the cut lands in the
turn that follows.
The cut is made by the STT provider, so it needs one that can force an end of
turn mid-stream: the default assemblyAIStt() can. On a provider that
cannot, the event is still reported and the runtime logs once that the cap
is inert — the same treatment a provider that cannot move its endpointing
window gets.
Properties
Section titled “Properties”maxDurationMs?
Section titled “maxDurationMs?”
optionalmaxDurationMs?:number
End the caller’s turn once it has run this long, in ms — measured from the first word the transcriber reported for it. A positive integer.
maxWords?
Section titled “maxWords?”
optionalmaxWords?:number
End the caller’s turn once this many words have been heard in it. A
positive integer; counted on the transcriber’s interim transcript, so it
is what the transcriber HEARD, exactly as minBargeInWords is.
Type Aliases
Section titled “Type Aliases”AgentGuardrail
Section titled “AgentGuardrail”AgentGuardrail = (
text,ctx) =>GuardrailVerdict|Promise<GuardrailVerdict>
Judge one piece of text — see AgentDef.inputGuardrails and AgentDef.outputGuardrails.
May be async: an input guardrail runs before the model request is assembled and an output guardrail runs before anything is synthesized, so both have a moment to await a classifier. Both are on the critical path of a live call — whatever they spend, the caller waits.
A guardrail that THROWS fails open: the throw is reported on the session’s
error stream and the text is allowed through. A check that cannot decide has
not decided, and taking a call down because a moderation endpoint timed out
is the wrong trade for every agent that is not a moderation product. An agent
that wants the other trade returns a verdict from its own catch.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”GuardrailVerdict | Promise<GuardrailVerdict>
AgentInstructions
Section titled “AgentInstructions”AgentInstructions = (
ctx) =>string
Compute the agent’s instructions for the request about to be assembled.
Synchronous: the request is being built, and there is no point at which a promise could be awaited without putting a round trip in front of every turn. Work that needs awaiting belongs in a tool, whose result the next request carries.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”string
AgentParams
Section titled “AgentParams”AgentParams =
PipelineAgentParams|S2sAgentParams|TextAgentParams|StaticAgentParamsCore
The author-facing parameter shape of agent: every AgentDef field, with the defaulted ones optional.
Derived from AgentDef rather than re-declared, so a field added there is
automatically declarable here — the inline re-declaration this replaces let
fields (send, state) ship as runtime-working but excess-property errors
for authors, because neither bundler typechecks user code. Field docs live
on AgentDef and carry through the mapped types.
Three author-facing conveniences widen the derived shape (all normalized
away by agent(), so AgentDef stays canonical). It said FOUR, and the
fourth — system as an alias of systemPrompt — has never existed:
normalizeAgentConveniences implements only the three below, so
agent({ system }) is refused by name at the stray-field check. That
refusal is the better error, and it is why the alias is not being added to
make this paragraph true.
llmalso accepts a model-id string:"creator/model"routes through the Vercel AI Gateway (AI_GATEWAY_API_KEY), a bare id through the AssemblyAI LLM Gateway (ASSEMBLYAI_API_KEY).voice— the TTS voice for the default AssemblyAI pipeline, desugared totts: assemblyAITts({ voice }). Only valid when no explicitttsdescriptor is set (the voice rides on the descriptor there) and never in S2S mode (the S2S descriptor owns its voice).minTurnSilenceMs/maxTurnSilenceMs— the end-of-turn window for the default AssemblyAI STT stage, desugared tostt: assemblyAIStt({ … }). Same rule asvoice: only valid when no explicitsttdescriptor is set.maxTurnSilenceMsis the pause-tolerance knob, and it is here because it is the highest-value tuning an agent has and used to be the highest-friction to express — one number cost a whole stage descriptor, which then silently dropped whatever else the default fill would have supplied.
Pipeline stages are individually optional: declare any subset of
stt/llm/tts and the unset stages run on the default all-AssemblyAI
pipeline. The shape is a union over the three session modes — pipeline,
S2S (S2sAgentParams) and text (TextAgentParams) — so a
field belonging to another mode fails the build with a message naming the
rule (PipelineOnlyMisuse) rather than failing at the first
aai dev/aai deploy. Configs that never went through agent() are
still caught when toAgentConfig runs in the bundle entry.
The fourth arm (StaticAgentParams) is the WORKFLOW APP, and it is
keyed on the front door rather than on a session mode: page: "static" has
no session at all, so every field the other three arms exist to arbitrate
between is inert there. workflowApp is the same arm with the
discriminant already set.
AgentSystemPrompt
Section titled “AgentSystemPrompt”AgentSystemPrompt =
string|AgentInstructions
What agent({ systemPrompt }) accepts: the text, or a function that answers
it per request.
A plain string is byte-identical to what shipped before resolvers existed —
it is not called, not wrapped, and reaches buildSystemPrompt as it always
did.
With a system-prompt.md
Section titled “With a system-prompt.md”Almost every real agent keeps its prose in a file beside agent.ts, and a
resolver composes against that file by IMPORTING it — the same
?raw import the composed-string case takes, closed over by the function:
import { agent } from "@alexkroman1/aai";import prompt from "./system-prompt.md?raw";import { gameSlot, statusBlock } from "./shared.ts";
export default agent({ name: "Cavern Adventure", systemPrompt: (ctx) => `${prompt}\n\n${statusBlock(gameSlot.get(ctx))}`,});The generated bundle entry still discovers the file and hands it to
withSystemPrompt (sdk/system-prompt-file.ts), which leaves a resolver
exactly as written — an author who declared a function has taken over
composing the prompt. That module’s header owns the argument, including why
the file is not passed to the resolver as a second argument.
AnyDialog
Section titled “AnyDialog”AnyDialog =
Dialog<AnyStateMachine,unknown>
Any dialog, whatever its machine and event union — what AgentDef.dialogs holds.
The erasure is on E and it is what makes the array possible at all: two
dialogs in one agent have different event unions by construction (the names
come from their own on maps), so readonly Dialog<AnyStateMachine>[] would
be a list nothing but a machine-form dialog with the default parameter could
join. unknown rather than any because every member that takes an E is
declared with METHOD syntax, whose parameters are compared bivariantly — so a
Dialog<M, { type: "VERIFIED" }> is assignable here without spending an
escape hatch on it, and the runtime, which only ever calls the members that
take no event (receive, timeout, voiceConfig, position), never has an
any to hand something.
AssemblyAIGatewayModel
Section titled “AssemblyAIGatewayModel”AssemblyAIGatewayModel =
"claude-haiku-4-5-20251001"|"claude-opus-4-5-20251101"|"claude-opus-4-6"|"claude-opus-4-7"|"claude-opus-4-8"|"claude-opus-5"|"claude-sonnet-4-5-20250929"|"claude-sonnet-4-6"|"claude-sonnet-5"|"gemini-2.5-flash"|"gemini-2.5-flash-lite"|"gemini-2.5-pro"|"gemini-3.1-flash-lite"|"gemini-3.5-flash"|"gemini-3.5-flash-lite"|"gemini-3.6-flash"|"gemini-3.7-flash"|"gemini-3.8-flash"|"gemma-4-31b"|"gpt-4.1"|"gpt-5"|"gpt-5-mini"|"gpt-5-nano"|"gpt-5.1"|"gpt-5.2"|"gpt-5.5"|"gpt-5.6-luna"|"gpt-5.6-sol"|"gpt-5.6-terra"|"gpt-6-astra"|"gpt-oss-120b"|"gpt-oss-20b"|"qwen3-32B"|"qwen3-next-80b-a3b"|"qwen3.5-4b-32k-fast"|string&object
A model id on AssemblyAI’s LLM Gateway — one the gateway advertised when this catalog was generated, or any other string.
The literal half is GENERATED from what the gateway advertises, so it is a snapshot of a service that ships models faster than this package releases: a model added upstream after this release is still a legal id, and a regeneration that drops one breaks no author’s build. Autocomplete, not a guard.
AssemblyAITtsVoice
Section titled “AssemblyAITtsVoice”AssemblyAITtsVoice =
"alba"|"anna"|"charles"|"eve"|"george"|"jane"|"jean"|"mary"|"michael"|"paul"|"vera"|"giovanni"|"lola"|"juergen"|"rafael"|"estelle"|string&object
A voice id from ASSEMBLYAI_TTS_VOICES.
The (string & {}) arm is deliberate: the catalog is the service’s, not
ours, so a voice added after this release must still compile, and so must
a deprecated one an existing agent already names. It keeps the current
names visible at the call site without turning a stale SDK into a build
failure.
So this type is AUTOCOMPLETE, not a guard, and there is no runtime assert
to pair with it the way assertAssemblyAITtsLanguage pairs with
AssemblyAITtsLanguage. The two are not the same job: the language
map is a TRANSLATION this SDK owns (an ISO code the service has never heard
of, rendered as a name it accepts), so a code outside it cannot be sent at
all and rejecting it is a fact about this package. The voice catalog is the
SERVICE’s, and a snapshot of it goes stale between releases — an assert
would refuse a voice AssemblyAI shipped last week, which is the same
silent-mute failure from the other side. Read the catalog; do not expect the
compiler to check you did.
BuiltinTool
Section titled “BuiltinTool”BuiltinTool =
"web_search"|"visit_webpage"|"get_page_design"|"fetch_json"|"run_code"|"think"|"remember"|"recall"|"calculate"|string&object
Identifier for a built-in server-side tool.
Built-in tools run on the host process (not inside the sandboxed worker) and provide capabilities like web search, code execution, and API access.
"web_search"— Search the web for current information, facts, or news."visit_webpage"— Fetch a URL and return its content as clean text."get_page_design"— Fetch a URL’s raw HTML and CSS (markup, style blocks, linked stylesheets) to study or mimic a site’s visual design."fetch_json"— Call a REST API endpoint and return the JSON response."run_code"— Execute JavaScript in a sandbox for calculations and data processing."think"— Private no-op scratchpad for policy checks and planning (never spoken)."remember"— Save a confirmed fact (ID, code, date) to private session notes."recall"— Read back facts saved withremember."calculate"— Safely evaluate an arithmetic expression (no code execution).
When builtinTools is not set, only think is enabled
(DEFAULT_BUILTIN_TOOLS); every other built-in is something an agent asks
for by name. Setting the field replaces the default rather than extending
it — include "think" to keep it, and pass [] for no built-ins at all.
OPEN, like VoicePresetName: the names above are written inline as the
autocomplete half, and any other string compiles, so an agent naming a
builtin a later release adds still builds against this one. The runtime
resolves only the names it ships and skips the rest, so an unknown name is a
tool that silently never appears — which is why aai build / aai dev warn
about it (agentConfigWarnings) rather than the type refusing it. Inline
rather than an exported closed Known… half, so a builtin added here is a
compatible change to this type.
ClientEventSender
Section titled “ClientEventSender”ClientEventSender = <
K>(event,data) =>void
What ctx.send is: push one custom event to the connected browser client,
typed by ClientEventMap.
A name declared in the map must be sent with its declared payload; any other
name takes unknown. ONE conditional signature rather than a typed overload
in front of a (string, unknown) fallback, deliberately: with overloads, a
DECLARED name sent with the wrong payload fails the first signature and
silently resolves against the fallback, so the declaration would type
nothing. Any (event: string, data: unknown) => void is one of these, which
is how the runtime and the test doubles implement it.
Type Parameters
Section titled “Type Parameters”K extends keyof ClientEventMap | string & object
Parameters
Section titled “Parameters”K
K extends keyof ClientEventMap ? ClientEventMap[K] : unknown
Returns
Section titled “Returns”void
DeepReadonly
Section titled “DeepReadonly”DeepReadonly<
T> =Textends (…args) =>unknown?T:Textends readonly infer E[] ? readonlyDeepReadonly<E>[] :Textendsobject?{ readonly [K in keyof T]: DeepReadonly<T[K]> }:T
Readonly<T>, all the way down.
The type a slot’s reading half hands out, and the runtime it describes.
freezeStorable (sdk/session-state.ts) walks a durable value on every
write and calls Object.freeze on every array and every nested object, so
the value a reader holds is deep-frozen and every mutation of it is a
TypeError in strict mode. Readonly<T> described only the top level, which
left the runtime STRICTER THAN THE TYPE — game.inventory.push(item) and
game.flags[key] = true both compiled, and both threw on the first call.
Two shipped templates did exactly that, in tools nothing in the repo ran.
The cost is real and was the reason for the shallow type: a deep readonly
DOES propagate, because TypeScript ignores readonly modifiers on properties
in assignability but NOT on arrays — readonly string[] is not assignable to
string[]. So a domain helper an agent’s own modules declare
(orderTotal(cart: Cart)) has to take DeepReadonly<Cart> (or its own
readonly shape) to keep accepting a slot read. That is a compile error where
the alternative is a TypeError at the first call in production, and it
points at the helper that would have mutated.
Functions pass through untouched: a virtual slot (durable: false) is the
only one that can hold one, and nothing there is frozen.
Type Parameters
Section titled “Type Parameters”T
DefaultToolResult
Section titled “DefaultToolResult”DefaultToolResult =
any
Default type of a tool result observed on the client (useToolResult) —
any, so untyped reads compile. Pass the shape —
useToolResult<Quote>("get_quote", …) — for real checking.
Remarks
Section titled “Remarks”any because a tool result is the author’s own return value
round-tripped through JSON — the client already knows its shape, and the
framework cannot. The strict default (unknown) made reading one field a
compile error in a client that runs correctly, which blocked publishing
once aai build type-checked.
DelegateFn
Section titled “DelegateFn”DelegateFn = {<
T>(subagent,options):Promise<TypedDelegateResult<T>>; (subagent,options):Promise<DelegateResult>; }
Run a subagent to completion — the signature of ctx.delegate.
OVERLOADED, the way GenerateFn is and for the same reason: a subagent
that declares a SubagentDef.schema answers with the parsed value
typed on TypedDelegateResult.object, and one that does not should not
be handed the field at all. Declaring the def through subagent is
what picks the overload — a SubagentRoster entry stays a plain
SubagentDef, so a model-chosen delegation is untyped, which is
correct: nothing at that call site knows which subagent the model picked.
Call Signature
Section titled “Call Signature”<
T>(subagent,options):Promise<TypedDelegateResult<T>>
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”subagent
Section titled “subagent”options
Section titled “options”Returns
Section titled “Returns”Promise<TypedDelegateResult<T>>
Call Signature
Section titled “Call Signature”(
subagent,options):Promise<DelegateResult>
Parameters
Section titled “Parameters”subagent
Section titled “subagent”options
Section titled “options”Returns
Section titled “Returns”Promise<DelegateResult>
DialogBargeIn
Section titled “DialogBargeIn”DialogBargeIn =
"default"|"off"| {minDurationMs?:number;minWords?:number; }
How interruptible the agent is while a dialog state is active.
"default" leaves the agent’s own minBargeInWords /
interruptionMinDurationMs in place; "off" means the agent finishes what it
is saying, which is what a disclosure or a legally-required read needs; the
object form tightens or loosens the same two gates for this phase only — a
menu wants { minWords: 1 } so a caller can cut in on the first word.
Union Members
Section titled “Union Members”"default"
"off"
Type Literal
Section titled “Type Literal”{ minDurationMs?: number; minWords?: number; }
minDurationMs?
Section titled “minDurationMs?”
optionalminDurationMs?:number
Sustained speech before an interim-triggered barge-in counts, in ms.
minWords?
Section titled “minWords?”
optionalminWords?:number
Words in an interim transcript before a barge-in counts.
DialogEvent
Section titled “DialogEvent”DialogEvent<
S> =Exclude<DialogEventNames<S["states"]>,`@${string}`> extends infer N ?Nextendsstring?object:never:never
The event union a DialogSpec declares — synthesized from its on
keys at every depth.
This is what a spec-declared dialog gets INSTEAD of the setup({ types: {} as { events: … } }) block it replaces: the names are already written in the
on maps, so restating them is a second source of truth that can disagree
with the first. dialog.send, send and sendFrom are typed against it, so
a misspelled event is a compile error at the call site rather than an event
XState quietly ignores.
The @ names are SUBTRACTED, which is the one thing this union does that
the on maps do not say by themselves. A session-event transition is driven
by the runtime — nobody writes dialog.send(ctx, { type: "@speech.started" })
— so leaving those names in would put a dozen events an author must never
send by hand into the autocomplete for the one they must. See
DialogSessionEventName; Dialog.receive is how they arrive.
Type Parameters
Section titled “Type Parameters”S extends DialogSpec
DialogEventNames
Section titled “DialogEventNames”DialogEventNames<
M> =MextendsRecord<string,unknown> ?M[keyofM] extends infer C ?Cextendsunknown?Cextendsobject?Extract<keyofO,string> :never|Cextendsobject?DialogEventNames<N> :never:never:never:never
Every event name the on maps of a states map declare, at every depth.
Exported, and owned by the dialog capability, because DialogEvent is
written in terms of it and a type a signature reaches but nobody can import is
a shape an author has to satisfy without being able to name.
Distributed over the map’s VALUES first, because keyof a UNION of on maps
is the INTERSECTION of their keys — i.e. never for any dialog with more than
one state, which is a spec whose events all type-check as nothing at all.
The recursion is bounded by DialogStateSpec declaring states as
OPTIONAL: { states?: … } does not match { states: infer N }, so walking
the bare constraint — which is what dialog<const S extends DialogSpec> makes
the compiler do while checking the overload — stops at the first level instead
of chasing a self-referential type forever. Making that property required
would reintroduce a TS2589 on a declaration nobody has written yet.
Type Parameters
Section titled “Type Parameters”M
DialogSessionEventName
Section titled “DialogSessionEventName”DialogSessionEventName =
`@${SessionEventType}`
A session event as a dialog names it: the wire type under a leading @.
"@session.timed-out", "@speech.started", "@user-transcript.committed" —
every SessionEventType is one of these, and nothing else is. The
prefix is a NAMESPACE rather than decoration: an author’s own event names are
unconstrained, so a dialog that declared on: { "reply.completed": … } for
its own purposes would otherwise start firing on every reply the agent made.
Declaring one is what lets a dialog move on something the model did not do — the caller went quiet, barged in, hung up, or said something that called no tool. The runtime sends them through Dialog.receive, which is wired up by listing the dialog in AgentDef.dialogs.
EventMapOf
Section titled “EventMapOf”EventMapOf<
U> ={ [E in U as E["type"]]: E }
Key a union of { type } members by their type.
What SessionEventMap is derived through, and exported so a host with a vocabulary of its own can build the same shape for it.
Type Parameters
Section titled “Type Parameters”U extends object
GenerateFn
Section titled “GenerateFn”GenerateFn = {<
S>(options):Promise<GenerateObjectResult<InferSchemaOutput<S>>>; (options):Promise<GenerateResult>; }
One-shot LLM generation — the signature of ctx.generate. A call with a
Standard Schema schema returns a result whose object is typed by that
schema and non-optional; a plain-JSON-Schema or schemaless call returns
GenerateResult, whose object is unknown and must be narrowed.
Call Signature
Section titled “Call Signature”<
S>(options):Promise<GenerateObjectResult<InferSchemaOutput<S>>>
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”options
Section titled “options”GenerateOptions & object
Returns
Section titled “Returns”Promise<GenerateObjectResult<InferSchemaOutput<S>>>
Call Signature
Section titled “Call Signature”(
options):Promise<GenerateResult>
Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”Promise<GenerateResult>
GenerateObjectResult
Section titled “GenerateObjectResult”GenerateObjectResult<
T> =object
Result of a generation call that passed a Standard Schema — object is
REQUIRED, matching what the host guarantees.
Split from GenerateResult rather than expressed as
GenerateResult<T> with an optional object: the optionality survived the
typed overload, so the one spelling the overload exists to reward —
const { object } = await ctx.generate({ prompt, schema }) — needed a !
or an if before any field could be read, even though host/generate.ts
returns { text, object } unconditionally on that path.
Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”object
Section titled “object”object:
T
The schema-validated object. Always present on this overload.
text:
string
The generated text — the JSON-stringified object.
GenerateOptions
Section titled “GenerateOptions”GenerateOptions =
object
Options for one LLM generation call.
No signal: the call is already bound to ctx.signal by the runtime — see
the module doc for why a field here would be a second, competing one.
Properties
Section titled “Properties”
optionalllm?:LlmSpec
LLM provider for this call: a descriptor from @alexkroman1/aai/llm,
or a model-id string ("creator/model" routes through the Vercel AI
Gateway; a bare id through the AssemblyAI LLM Gateway — same shorthand
as agent({ llm })). Defaults to the agent’s own pipeline llm.
Credentials resolve from the agent’s env — an S2S agent can use
generate by naming a provider whose API key it holds as a secret.
maxOutputTokens?
Section titled “maxOutputTokens?”
optionalmaxOutputTokens?:number
Cap on generated tokens passed through to the provider.
prompt
Section titled “prompt”prompt:
string
The user prompt for this call.
schema?
Section titled “schema?”
optionalschema?:StandardSchemaV1|Record<string,unknown>
Schema for structured output. When set, the model is constrained to the
schema and the result’s object carries the parsed value. Accepts a
Zod schema (or any Standard Schema convertible to JSON Schema) — the
typed result follows from it — or a plain JSON Schema object, in which
case object is unknown.
system?
Section titled “system?”
optionalsystem?:string
Optional system prompt.
temperature?
Section titled “temperature?”
optionaltemperature?:number
Sampling temperature passed through to the provider.
GenerateResult
Section titled “GenerateResult”GenerateResult =
object
Sealed
Result of one LLM generation call without a Standard Schema — text only.
object is declared as optional-and-unknown rather than omitted because
this is also what a PLAIN JSON Schema call returns: the host does produce an
object there, but nothing types it, so a caller must narrow before reading.
Properties
Section titled “Properties”object?
Section titled “object?”
optionalobject?:unknown
The parsed object when a plain JSON Schema was passed; absent otherwise.
text:
string
The generated text. For schema calls, the JSON-stringified object.
GuardrailVerdict
Section titled “GuardrailVerdict”GuardrailVerdict =
true|string
A guardrail’s verdict: true to accept, or the complaint to send back.
A bare string rather than { ok: false, reason } because every rejection
must carry a reason — the retry is only worth running if the subagent is told
what was wrong, and a shape that lets the reason be omitted invites exactly
the rejection that teaches nothing.
InferSchemaOutput
Section titled “InferSchemaOutput”InferSchemaOutput<
S> =SextendsStandardSchemaV1<unknown, infer O> ?O:never
The output (validated) type of a Standard Schema.
Type Parameters
Section titled “Type Parameters”S
InferToolInput
Section titled “InferToolInput”InferToolInput<
T> =Parameters<T["execute"]>[0]
The validated input type a tool’s execute receives — inferred from the
tool’s inputSchema. The Vercel AI SDK’s InferToolInput pattern, so a
client (or another tool) can share the exact argument shape without
re-declaring it.
import { type InferToolInput, tool } from "@alexkroman1/aai";import { z } from "zod";
const add = tool({ description: "Add an item", inputSchema: z.object({ item: z.string() }), execute: ({ item }) => item,});type AddInput = InferToolInput<typeof add>; // { item: string }Type Parameters
Section titled “Type Parameters”T extends ToolDef<ToolInputSchema>
InferToolOutput
Section titled “InferToolOutput”InferToolOutput<
T> =Awaited<ReturnType<T["execute"]>>
The result type a tool’s execute returns (awaited, so a sync and an async
body infer alike). Pair with useToolResult<InferToolOutput<typeof myTool>>(...)
in a custom client so the rendered shape has a single source of truth.
Type Parameters
Section titled “Type Parameters”T extends ToolDef<ToolInputSchema>
KeyedLock
Section titled “KeyedLock”KeyedLock = (
key,options?) =>Promise<() =>void> &object
The utilities written INSIDE a tool body — all fifteen of them, which is
@alexkroman1/aai/utils minus the five whose reader is not a tool body:
decodeHtmlEntities and the four narration formatters (formatBytes,
formatDuration, countWords, plural), which a step and a client.tsx
both reach for and which are therefore reachable ONLY on that subpath.
The rule is that the two lists agree for everything else, because the
split they used to describe was not one anybody could apply: safeJsonParse was here and
isRecord — the guard you call on what it returns — was not, so a tool body
needing both wrote two import lines for one line of helpers, and templates
routed around it by taking the root’s own names off /utils instead. That
subpath’s membership is a BUILD property (zero-zod, so the CLI can import it
on every invocation), which is a fact about its graph rather than a statement
about who reads it; nothing on it fails this barrel’s own membership test.
The narrower subpath stays, because it is what the CLI and the platform
import — and because a tool body reaching for one helper should not have to
name the root. Neither the slug contract nor the framework’s wire helpers are
involved either way: those left sdk/utils.ts for @alexkroman1/aai/internal.
Type Declaration
Section titled “Type Declaration”
readonlysize:number
Number of keys currently held or queued. Exposed for tests and metrics.
KeyedLockOptions
Section titled “KeyedLockOptions”KeyedLockOptions =
object
The utilities written INSIDE a tool body — all fifteen of them, which is
@alexkroman1/aai/utils minus the five whose reader is not a tool body:
decodeHtmlEntities and the four narration formatters (formatBytes,
formatDuration, countWords, plural), which a step and a client.tsx
both reach for and which are therefore reachable ONLY on that subpath.
The rule is that the two lists agree for everything else, because the
split they used to describe was not one anybody could apply: safeJsonParse was here and
isRecord — the guard you call on what it returns — was not, so a tool body
needing both wrote two import lines for one line of helpers, and templates
routed around it by taking the root’s own names off /utils instead. That
subpath’s membership is a BUILD property (zero-zod, so the CLI can import it
on every invocation), which is a fact about its graph rather than a statement
about who reads it; nothing on it fails this barrel’s own membership test.
The narrower subpath stays, because it is what the CLI and the platform
import — and because a tool body reaching for one helper should not have to
name the root. Neither the slug contract nor the framework’s wire helpers are
involved either way: those left sdk/utils.ts for @alexkroman1/aai/internal.
Properties
Section titled “Properties”timeoutMs?
Section titled “timeoutMs?”
optionaltimeoutMs?:number
Give up waiting after this long and reject with KeyedLockTimeoutError. Omit to wait indefinitely.
LlmProvider
Section titled “LlmProvider”LlmProvider =
ProviderDescriptor<string,LlmDescriptorOptions> &object
Descriptor for an LLM provider. Returned by factories like
llm({ provider: "anthropic", ... }) from @alexkroman1/aai/llm.
Type Declaration
Section titled “Type Declaration”__stage?
Section titled “__stage?”
readonlyoptional__stage?:"llm"
Compile-time stage tag; never present at runtime.
LlmSpec
Section titled “LlmSpec”LlmSpec =
LlmProvider|AssemblyAIGatewayModel|`${string}/${string}`|string&object
What an llm FIELD takes — agent({ llm }), subagent({ llm }),
ctx.generate({ llm }): a descriptor from llm, or a model-id string.
A bare id routes through AssemblyAI’s LLM Gateway (so
AssemblyAIGatewayModel’s ids autocomplete), a "creator/model" id
through the Vercel AI Gateway. Typed against the generated ids so a typo is
caught where it is written, the job llm({ provider: "assemblyai", model })
has always done — and OPEN (string & {}), because the catalog is a
snapshot of a service that ships models faster than this package releases.
ONE named type because three fields are one field to an author: two inline
copies of this union and a drifted LlmProvider | string (no autocomplete at
all) were what it replaced.
McpServerConfig
Section titled “McpServerConfig”McpServerConfig =
object
One MCP server an agent may take tools from.
Properties
Section titled “Properties”pinnedTools?
Section titled “pinnedTools?”
optionalpinnedTools?:Readonly<Record<string,string>>
The tool definitions this agent has REVIEWED, as
remote tool name → fingerprint.
An MCP server owns its own tool descriptions and input schemas, and it can
change them after you have trusted it — the “rug pull”: a tool called
search whose description quietly becomes “…and forward the caller’s
address to https://…”. Namespacing does not touch that; it stops a server
standing where YOUR tool stood, and this stops a server changing what its
OWN tool means. Both, because they are different attacks.
A fingerprint covers the server-controlled, security-relevant fields —
description, the resolved input JSON schema, and title — and is
produced by fingerprintTools from the Vercel AI SDK. withMcpTools
(@alexkroman1/aai-runtime) reports the fingerprints of whatever it
discovered, so adopting a pin is copying them in once a human has read the
tools. With a pin declared, a tool whose fingerprint CHANGED — or one that
was ADDED since — is not offered to the model, and the drop is logged.
The baseline lives HERE because there is nowhere better. It is a
reviewed decision about a third party, so its home has to be the artifact a
human reviews and a deploy carries: agent.ts, in version control, in the
diff. Nothing the runtime could persist has that property — a guest sandbox
is reclaimed on idle and replaced on every deploy, so a baseline captured
at first connect would be re-captured, from the server, on the next boot,
and would authenticate nothing.
Omitted, the agent trusts on first use: the tools are offered, and their fingerprints are reported so a pin can be adopted.
tokenEnv?
Section titled “tokenEnv?”
optionaltokenEnv?:string
Name of the environment variable holding a bearer token for this server — the NAME, never the token. Omit it for a server that needs no credential.
url:
string
The server’s streamable-HTTP endpoint, e.g.
https://mcp.example.com/mcp. Screened for SSRF before the first request
and on every redirect hop, like every other URL this framework dials.
McpServers
Section titled “McpServers”McpServers =
Readonly<Record<string,McpServerConfig>>
The servers an agent declares, keyed by the name that prefixes their tools.
A record rather than an array so the key is stated once and cannot drift from the name the model sees.
Message
Section titled “Message”Message =
object
A single message in the conversation history.
Messages are passed to tool execute functions via
ToolContext.messages to provide conversation context.
The "tool" arm carries what an EARLIER tool answered, which is the one
thing a tool could not see before. Its two extra fields say WHICH call the
result belongs to — a bare string cannot, and a tool reading a sibling’s
output has to know whether it is reading the one it cares about. They are
optional because content is the only field every arm has, and every reader
that predates them (m.role === "user" filters, { role, content }
projections, the history.restored wire frame, which carries user and
assistant turns only) keeps working untouched.
Read a tool arm by ROLE, never by the presence of a field: a "tool" message
replayed out of a session’s own event log by a resume names the tool it
answers, and one from a transport that never recorded the call may not.
Properties
Section titled “Properties”content
Section titled “content”content:
string
The text content of the message.
For a "tool" message this is the result the tool returned, already
serialized and capped the same way the client’s own tool.completed frame
caps it — so what a tool reads live is what it reads again after a resume,
which rebuilds this from that frame.
role:
"user"|"assistant"|"tool"
The role of the message sender.
toolCallId?
Section titled “toolCallId?”
optionaltoolCallId?:string
role: "tool" only — the id of the call content answers.
Pairs with ToolCallInfo.id on the client and with tool.called /
tool.completed on the event stream, so a tool can tell two calls of the
same tool in one turn apart.
toolName?
Section titled “toolName?”
optionaltoolName?:string
role: "tool" only — the name of the tool whose result content is.
The name the MODEL calls it by (the registry key), so a tool matching on it
uses the same string it would put in ctx.messages’ own tool schemas.
MetricsCollectedEvent
Section titled “MetricsCollectedEvent”MetricsCollectedEvent =
SessionEvent<"metrics.collected">
One metrics.collected frame, envelope included — what an
events: { "metrics.collected" } hook receives.
Declared here, off the session event union, rather than beside its schema:
the schema lives on the non-authoring /protocol surface, and this is the
name an author annotates a handler with.
MetricsSample
Section titled “MetricsSample”MetricsSample =
Omit<MetricsCollectedEvent,"type"|"meta">
What MetricsCollector.collect reads off one frame — the event without its envelope, so a hook’s event and a transport’s body both fit.
PipelineAgentParams
Section titled “PipelineAgentParams”PipelineAgentParams =
SharedAgentParams&Partial<Pick<AgentDef,Exclude<PipelineOnlyField,SilenceNudgeField>>> &SilenceNudgeParams&object& {maxTurnSilenceMs?:EndpointingOnDescriptorMisuse<"maxTurnSilenceMs">;minTurnSilenceMs?:EndpointingOnDescriptorMisuse<"minTurnSilenceMs">;stt:SttProvider; } | {maxTurnSilenceMs?:number;minTurnSilenceMs?:number;stt?:undefined; } & {tts:TtsProvider;voice?:"`voice` picks the default pipeline's TTS voice — an explicit `tts` descriptor owns its own voice (e.g. `assemblyAITts({ voice })`); set it there or remove `tts`"; } | {tts?:undefined;voice?:AssemblyAITtsVoice; }
Pipeline-mode params: any subset of the provider triple (unset stages run
on the default all-AssemblyAI pipeline), never s2s. The voice
shorthand picks the default pipeline’s TTS voice; an explicit tts
descriptor owns its voice, so combining the two is a compile error naming
the rule.
Type Declaration
Section titled “Type Declaration”
optionalllm?:LlmSpec
See AgentDef.llm; a string is gateway model-id shorthand —
AssemblyAIGatewayModel for a bare id on the AssemblyAI LLM
Gateway, "creator/model" for the Vercel AI Gateway. Unset → the default
AssemblyAI LLM Gateway model.
Typed against the generated union so a typo is caught where it is
written, which is the same job llm({ provider: "assemblyai", model }) has done all
along — from-string.ts desugars this field straight into that factory,
so one field had two types and only the longer spelling checked anything.
A bare string here made llm: "claude-sonnet-4-6" a name with no
autocomplete and a typo a gateway 400 at the first live session.
The string & {} arm of LlmSpec keeps it a WIDENING: the catalog
is a snapshot of a service that ships models faster than this package
releases, so every id that compiled before still compiles — see
AssemblyAITtsVoice, which is autocomplete over its catalog for
exactly the same reason and with the same non-guarantee.
optionalpage?:"voice"|StaticFrontDoorMisuse
See AgentDef.page. A pipeline agent’s front door is a mic.
optionals2s?:undefined
optionaltext?:undefined
Remarks
Section titled “Remarks”The long string-literal types on the fields below are COMPILE-ERROR MESSAGES,
not values this arm accepts. Setting one of those fields makes tsc print the
sentence in place of a bare excess-property error, so the diagnostic names the
rule and what to do about it. Never pass one as a string.
RandomSource
Section titled “RandomSource”RandomSource = () =>
number
A source of uniform floats in [0, 1) — Math.random’s contract, and the
one a caller substitutes.
Returns
Section titled “Returns”number
S2sAgentParams
Section titled “S2sAgentParams”S2sAgentParams =
SharedAgentParams&object&{ [K in PipelineOnlyField]?: PipelineOnlyMisuse<K> }
S2S-mode params: an s2s descriptor, no pipeline providers, and the
pipeline-only tuning knobs typed as PipelineOnlyMisuse so setting
one fails with a message instead of silently doing nothing.
Type Declaration
Section titled “Type Declaration”
optionalllm?:"`llm` cannot be combined with `s2s` — S2S runs the LLM loop service-side"
maxTurnSilenceMs?
Section titled “maxTurnSilenceMs?”
optionalmaxTurnSilenceMs?:"`maxTurnSilenceMs` tunes a pipeline STT stage — S2S runs STT service-side; remove it or remove `s2s`"
minTurnSilenceMs?
Section titled “minTurnSilenceMs?”
optionalminTurnSilenceMs?:"`minTurnSilenceMs` tunes a pipeline STT stage — S2S runs STT service-side; remove it or remove `s2s`"
optionalpage?:"voice"|StaticFrontDoorMisuse
See AgentDef.page. An S2S agent’s front door is a mic.
s2s:
S2sProvider
See AgentDef.s2s — the explicit opt-in to speech-to-speech mode.
optionalstt?:"`stt` cannot be combined with `s2s` — S2S runs STT service-side"
optionaltext?:"`text` cannot be combined with `s2s` — an agent is text-only or speech-to-speech, not both"
optionaltts?:"`tts` cannot be combined with `s2s` — S2S runs TTS service-side"
voice?
Section titled “voice?”
optionalvoice?:"`voice` is pipeline-mode only — an S2S agent's voice rides on the `s2s` descriptor"
Remarks
Section titled “Remarks”The long string-literal types on the fields below are COMPILE-ERROR MESSAGES,
not values this arm accepts. Setting one of those fields makes tsc print the
sentence in place of a bare excess-property error, so the diagnostic names the
rule and what to do about it. Never pass one as a string.
S2sProvider
Section titled “S2sProvider”S2sProvider =
ProviderDescriptor<string,Record<string,unknown>> &object
Descriptor for an S2S provider. Returned by assemblyAIS2s(...) (root
export) or openAIS2s(...) from @alexkroman1/aai/s2s.
Type Declaration
Section titled “Type Declaration”__stage?
Section titled “__stage?”
readonlyoptional__stage?:"s2s"
Compile-time stage tag; never present at runtime.
SessionEvent
Section titled “SessionEvent”SessionEvent<
K> =SessionEventMap[K]
One server→client session event, envelope included: a fact the session reports, in the shape it takes on the wire and in the retained stream.
Bare, it is the whole union — what a "*" handler receives and what a client
parses. With a name (or a union of names) it is just those members.
Host code EMITS a SessionEventBody and the session’s emitter stamps
the envelope — see protocol-events.ts.
Type Parameters
Section titled “Type Parameters”K extends SessionEventType = SessionEventType
SessionEventBody
Section titled “SessionEventBody”SessionEventBody<
K> ={ [T in K]: Omit<SessionEventMap[T], "meta"> }[K]
A session event as its EMITTER writes it — everything but the meta
envelope, which the session stamps exactly once. Distributes over K, so
each member keeps its own type.
Type Parameters
Section titled “Type Parameters”K extends SessionEventType = SessionEventType
SessionEventContext
Section titled “SessionEventContext”SessionEventContext =
object
Sealed
What a session event handler is handed alongside the event.
Deliberately much smaller than ToolContext, and the omissions are still the
design: there is no send, no generate, no delegate and no messages. A
handler MAY NOT SPEAK. Giving it a way to would make the event stream a second
control path into the turn — which is the thing that keeps a log honest, since
anything a reader can change it can no longer describe.
slots is here, and it does not cross that line. The rule the omissions
enforce is that a handler cannot change the TURN — what the agent says, which
tool runs, whether a reply is cancelled. Maintaining the session’s own state is
a different act, and one the alternative made worse: an author who wanted a
fact recorded per turn had no choice but to declare a TOOL for it and instruct
the model to call it, which is a model-cooperation problem standing in for a
bookkeeping one — see text-adventure-agent, whose game_state_history tool
existed to hand the framework back a transcript it already had. A hook writes
the fact directly, on every turn, whether or not the model cooperates.
What a write here still cannot do is be READ by the turn it happened in: the model sees a slot’s value through a tool result, and this runs beside that path rather than in front of it.
db used to be here, because the first thing an audit hook wants is somewhere
to write and the agent already had one. It is gone with ctx.db: the platform
provides no database, so a hook that wants to persist brings its own client and
credential — the same change tool code saw, and for the same reason.
Properties
Section titled “Properties”env:
Readonly<Partial<Record<string,string>>>
Environment variables available to this agent (from .env under aai dev,
aai secret in production).
sessionId
Section titled “sessionId”sessionId:
string
The session this event belongs to — the id a stream read is keyed by.
slots:
SlotStore
This session’s slot storage — reach for sessionSlot, not this, exactly as in a tool. It is on the context because a slot declared in one module has no other way to find the session.
A handler’s writes are committed after it returns, so a hook that mutates
should do so SYNCHRONOUSLY. An await before slot.update still stores the
value, but it lands after the commit for this event and is not persisted
until the next one (or the next tool call) commits — which for a durable
slot means a crash in between loses it.
SessionEventHandler
Section titled “SessionEventHandler”SessionEventHandler<
E> = (event,ctx) =>unknown
One handler: an event of the type it was declared under, plus the context.
Parameterized by the EVENT, as it always was; name one member with the map
rather than an Extract — SessionEventHandler<SessionEvent<"tool.called">>.
The return type is unknown, and that is deliberate rather than lazy.
void | Promise<void> reads better and does not compile for the most obvious
handler anyone writes: TypeScript’s rule that a value-returning function is
assignable where void is expected applies to void ALONE, not to a union
containing it — so (e) => seen.push(e) (returning number) and
(e) => void persist(…) are errors, on an observe-only API where the return
value is by definition ignored. unknown accepts every shape, and the emitter
checks for a promise at run time to decide whether to attach a rejection
handler.
Type Parameters
Section titled “Type Parameters”E extends SessionEvent = SessionEvent
Parameters
Section titled “Parameters”E
Returns
Section titled “Returns”unknown
SessionEventHandlers
Section titled “SessionEventHandlers”SessionEventHandlers =
{ [K in SessionEventType]?: SessionEventHandler<SessionEvent<K>> }&object
The events map an agent declares — keyed by event type, plus "*".
The mapped half is what makes a handler’s parameter TYPED: declaring
"tool.called" hands the handler an event that has toolName and args,
with no narrowing at the call site. "*" receives the whole union, which is
the right shape for the handlers that motivate it (a log line, a metrics
counter) and the reason it cannot be typed more narrowly.
Type Declaration
Section titled “Type Declaration”*?
optional*?:SessionEventHandler
Runs for every event, AFTER the typed handler for that event.
SessionEventType
Section titled “SessionEventType”SessionEventType =
Extract<keyofSessionEventMap,string>
Every event name a handler map, a dialog’s @ keys or a spec may name.
Name it to write a list of event names down in your own code:
import type { SessionEventType } from "@alexkroman1/aai";
const AUDITED: readonly SessionEventType[] = ["tool.called", "error.reported"];SessionSourcedEventType
Section titled “SessionSourcedEventType”SessionSourcedEventType = typeof
SESSION_SOURCED_EVENT_TYPES[number]
One of SESSION_SOURCED_EVENT_TYPES.
SharedAgentParams
Section titled “SharedAgentParams”SharedAgentParams =
Omit<AgentDef,DefaultedAgentField|PipelineOnlyField|ProviderField|FrontDoorField> &Partial<Pick<AgentDef,Exclude<DefaultedAgentField,InlineToolsField>>> &object
Fields shared by both session modes: everything on AgentDef minus the providers and the pipeline-only tuning knobs, plus the authoring conveniences.
Type Declaration
Section titled “Type Declaration”tools?
Section titled “tools?”
optionaltools?:InlineToolsMisuse
Not a field. See InlineToolsMisuse — a tool is declared by its
FILE, so this is typed as the message that names the one to create.
SleepOptions
Section titled “SleepOptions”SleepOptions =
object
Per-sleep options.
Properties
Section titled “Properties”correlationId?
Section titled “correlationId?”
optionalcorrelationId?:string
A name for this wait, so it can be ended early by name.
Not required, and the default is deliberately the broad one: a wake naming
no ids ends every outstanding wait on the run. An id is what lets a run with
two concurrent waits — a review window and a retry backoff — have one of them
cut short without the other.
SlotCaps
Section titled “SlotCaps”SlotCaps<
T> =Textendsobject?{ readonly [K in keyof T as NonNullable<T[K]> extends readonly unknown[] ? K : never]?: number }:never
Growth caps for the ARRAYS at the top level of a slot’s value — the type of SessionSlotOptions.caps.
A key is accepted only when the value under it is an array (or an array
behind null/undefined), so declaring a cap on a counter or a nested
object is a compile error naming the key rather than a bound that silently
applies to nothing. Each cap is the most entries that array keeps.
Type Parameters
Section titled “Type Parameters”T
SlotHolder
Section titled “SlotHolder”SlotHolder =
object
Anything that can reach one session’s slots.
Every SessionSlot and Dialog method takes this rather than a full ToolContext, and the widening is the whole reason a session event handler can maintain state: these two fields are ALL any of them ever read, so requiring the other eight was a statement that slots are a tool-only capability — which stopped being true when SessionEventContext grew one.
Both a ToolContext and a SessionEventContext satisfy it
structurally, so no existing call site changed.
Properties
Section titled “Properties”sessionId
Section titled “sessionId”
readonlysessionId:string
Which session. Not reachable from SlotStore, which is already scoped
to one — a slot needs the id to key its open-draft guard, the check that
refuses a set/reset/update issued from inside another update’s
mutator.
readonlyslots:SlotStore
This session’s slot storage.
SlotStore
Section titled “SlotStore”SlotStore =
object
One session’s slot storage, as a tool’s context carries it.
Two methods and no index signature, which is the point: it replaced
ctx.state, a field typed any whose entire justification was that the bag
it held was dynamic. A slot’s value is typed by its own sessionSlot<T>,
which is stronger than the annotation authors used to be told to write, and
there is no longer a bag to cast.
Reach for sessionSlot rather than this. It is on the context because a slot lives in a module that has no other way to find the session, not because a tool body should call it.
Methods
Section titled “Methods”read()
Section titled “read()”read(
key):unknown
This session’s value for key, or undefined when the slot has never
been written (a fresh session, or one whose stored value was discarded).
The returned object is FROZEN — see freezeStorable in this module.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”unknown
write()
Section titled “write()”write(
key,value,durable):void
Store this session’s value for key.
durable is the slot’s own declaration. A durable value is checked and
frozen here and committed to the backend at the end of the tool call; a
virtual one is neither, because the things a virtual slot exists to hold
(a provider handle, an open socket) can be neither serialized nor frozen.
Parameters
Section titled “Parameters”string
unknown
durable
Section titled “durable”boolean
Returns
Section titled “Returns”void
StandardSchemaResult
Section titled “StandardSchemaResult”StandardSchemaResult<
Output> = {issues?:undefined;value:Output; } | {issues: readonlyStandardSchemaIssue[]; }
A successful or failed Standard Schema validation.
Type Parameters
Section titled “Type Parameters”Output
Section titled “Output”Output
StaticAgentParams
Section titled “StaticAgentParams”StaticAgentParams =
Omit<StaticAgentParamsCore,WorkflowAppOnlyField> &{ [K in WorkflowAppOnlyField]?: WorkflowAppMisuse<K> }
Workflow-app params: page: "static", the workflows that ARE the product,
and nothing from the session half of the agent shape.
Not a session mode like the other three arms — a front door. What it drops is everything downstream of having a session at all.
What it keeps is the surface a page and a deploy actually read: name and
greeting (both served by GET /client-config, so a page can render its
shell from the agent — mountPage() does not fetch it the way mountClient()
does, so
a page that wants them calls fetchClientConfig() itself), workflows, and
requiredEnv (a step reads keys with stepEnv from
@alexkroman1/aai/step, and a deploy still checks they are present).
workflows is REQUIRED here, unlike on AgentDef: a workflow app whose
whole API is /workflows/* and which declares none serves a form with nothing
behind it, and the page’s api.start(name, …) would 400 on every submit.
Remarks
Section titled “Remarks”The long string-literal types on the fields below are COMPILE-ERROR MESSAGES,
not values this arm accepts. Setting one of those fields makes tsc print the
sentence in place of a bare excess-property error, so the diagnostic names the
rule and what to do about it. Never pass one as a string.
StepOptions
Section titled “StepOptions”StepOptions<
S> =object
Per-step overrides. Everything here has a default that is right for most steps; passing nothing is the common case.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1 = StandardSchemaV1
The schema StepOptions.schema carries, when one is
given. Defaulted, so StepOptions is still spellable without an argument —
every caller that predates the schema still means what it meant.
Properties
Section titled “Properties”maxAttempts?
Section titled “maxAttempts?”
optionalmaxAttempts?:number
How many times to run this step before the run fails, counting the first attempt.
Only a RetryableError (or an unclassified throw) consumes an attempt — a
FatalError fails the run on the spot, which is the point of the
distinction. See @alexkroman1/aai/step-errors.
Defaults to DEFAULT_STEP_MAX_ATTEMPTS. It is a per-step number rather than a global because the right answer is a property of what the step DOES: a model call worth retrying three times and a payment capture worth retrying never are both ordinary.
schema?
Section titled “schema?”
optionalschema?:S
The shape this step’s output must have — any Standard Schema, zod being the documented default. Its OUTPUT type is what the step resolves to.
Checked on BOTH sides of the journal, and the two catch different bugs. On the WRITE, before the entry is appended, so a body that produced the wrong shape — or a value the journal’s codec cannot carry — fails at the step that produced it rather than on a replay days later; that failure is the step’s own, so it spends an attempt and a retry may well fix it. On the READ, when a later walk is answered from the journal, which is what catches a REDEPLOY mid-flight: the run resumes against a bundle whose step returns a different shape, and without this the body is handed the old one under the new type. That failure is NOT the step’s — the step succeeded, days ago — so it fails the run the way a divergence does and journals nothing.
Durable session state has been checked structurally in both backends for a
long time (packages/aai/CLAUDE.md, “A slot OWNS its session state”: Map
→ {}, Date → string, NaN → null — the values that corrupt do not
throw, so JSON.stringify is not the check). A step’s output is exactly as
durable and had no check at all.
A schema that COERCES is supported and often the better answer: what is journaled is what the schema passed, never the raw value, so the next walk reads the same thing this one was handed.
StepSchemaOptions
Section titled “StepSchemaOptions”StepSchemaOptions<
S> =StepOptions<S> &object
StepOptions with the schema PRESENT — what selects the validating
overload of ctx.step, whose result is the schema’s output rather than
whatever the body happened to return.
Type Declaration
Section titled “Type Declaration”schema
Section titled “schema”schema:
S
The shape — see StepOptions.schema.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1 = StandardSchemaV1
SttProvider
Section titled “SttProvider”SttProvider =
ProviderDescriptor<string,Record<string,unknown>> &object
Descriptor for an STT provider. Returned by factories like
assemblyAIStt(...) from @alexkroman1/aai/stt.
Type Declaration
Section titled “Type Declaration”__stage?
Section titled “__stage?”
readonlyoptional__stage?:"stt"
Compile-time stage tag; never present at runtime.
SubagentGuardrail
Section titled “SubagentGuardrail”SubagentGuardrail = (
answer) =>GuardrailVerdict|Promise<GuardrailVerdict>
Judge one attempt — see SubagentDef.guardrail.
Parameters
Section titled “Parameters”answer
Section titled “answer”Returns
Section titled “Returns”GuardrailVerdict | Promise<GuardrailVerdict>
SubagentRoster
Section titled “SubagentRoster”SubagentRoster = readonly
SubagentDef[]
The subagents an agent publishes for the MODEL to choose between —
agent({ subagents }).
Every entry needs a SubagentDef.description: it is the only thing the
router reads, and agent() refuses a roster without one rather than shipping
an agent that picks off a list of bare names.
TelephonyAccess
Section titled “TelephonyAccess”TelephonyAccess =
boolean| readonlyTelephonyCarrier[]
What an agent declares about WS /phone.
true— every carrier this build ships a codec for (Twilio, Telnyx).- a list — exactly those (
["twilio"]serves Twilio and refuses Telnyx). false,[], or an absent field — the route is not served at all.
false and an empty list are the same refusal rather than two spellings of a
mode: this is an allow-list, and an allow-list that admits nothing is not a
surprise. What it is NOT is a claim about credentials — the carrier’s own
webhook signature is checked where the webhook lands, on the platform, and a
carrier does not sign the WebSocket upgrade this gates.
TelephonyCarrier
Section titled “TelephonyCarrier”TelephonyCarrier =
"twilio"|"telnyx"|string&object
A phone carrier that can open a media stream against an agent — "twilio"
or "telnyx", the two this release ships a codec for, or any other string.
OPEN, like VoicePresetName: the known carriers are written inline as the
autocomplete half, so a declaration naming a carrier a later release adds
still compiles and deploys against this one. The runtime serves only the
carriers it ships a codec for and DROPS the rest (enabledCarriers), so an
unknown name mounts nothing — which is why aai build / aai dev warn about
it (agentConfigWarnings) rather than the type refusing it. Inline rather
than an exported closed Known… half, so a carrier added here is a
compatible change to this type; code that must be TOTAL over the shipped
carriers keys off TELEPHONY_CARRIERS (@alexkroman1/aai/internal) instead.
TextAgentParams
Section titled “TextAgentParams”TextAgentParams =
Omit<SharedAgentParams,"sttPrompt"|"telephony"> &object&{ [K in PipelineOnlyField]?: PipelineOnlyMisuse<K, "text"> }
Text-mode params: text: true, optionally an llm, and nothing else from
the audio half of the agent shape.
Every speech field is typed as a message rather than left absent, on the
same reasoning as S2sAgentParams: a bare excess-property error
names the field and not the rule, and the rule here (“a text agent has no
audio path”) is exactly what an author moving a voice agent to text needs
told. sttPrompt is included even though it is otherwise mode-agnostic —
it biases a transcriber, and there is none.
The pipeline-only voice knobs are derived from PipelineOnlyField,
so a knob added to PipelineVoiceTuning is rejected here for free.
Type Declaration
Section titled “Type Declaration”
optionalllm?:LlmSpec
See AgentDef.llm; a string is gateway model-id shorthand. Unset → the default AssemblyAI LLM Gateway model. The one provider stage a text agent has.
Typed exactly as the pipeline arm’s llm — read the argument there. The
two are one field to an author, and typing them differently is how the
shorthand would come to autocomplete on a voice agent and not on a text
one.
maxTurnSilenceMs?
Section titled “maxTurnSilenceMs?”
optionalmaxTurnSilenceMs?:"`maxTurnSilenceMs` tunes an STT stage — a text agent has none; remove it or remove `text`"
minTurnSilenceMs?
Section titled “minTurnSilenceMs?”
optionalminTurnSilenceMs?:"`minTurnSilenceMs` tunes an STT stage — a text agent has none; remove it or remove `text`"
optionalpage?:"voice"|StaticFrontDoorMisuse
See AgentDef.page. A text agent has no browser front door of its
own — it is driven by createTextAgent, not by a page.
optionals2s?:"`s2s` cannot be combined with `text` — an agent is text-only or speech-to-speech, not both"
optionalstt?:"`stt` cannot be combined with `text` — a text agent has no audio to transcribe"
sttPrompt?
Section titled “sttPrompt?”
optionalsttPrompt?:"`sttPrompt` biases a transcriber — a text agent has none; remove it or remove `text`"
telephony?
Section titled “telephony?”
optionaltelephony?:"`telephony` admits a phone call, which is audio — a text agent has no audio path; remove it or remove `text`"
text:
true
See AgentDef.text — the explicit opt-in to text mode.
optionaltts?:"`tts` cannot be combined with `text` — a text agent has no audio to synthesize"
voice?
Section titled “voice?”
optionalvoice?:"`voice` is pipeline-mode only — a text agent never speaks"
Remarks
Section titled “Remarks”The long string-literal types on the fields below are COMPILE-ERROR MESSAGES,
not values this arm accepts. Setting one of those fields makes tsc print the
sentence in place of a bare excess-property error, so the diagnostic names the
rule and what to do about it. Never pass one as a string.
ToolChoice
Section titled “ToolChoice”ToolChoice =
"auto"|"required"|"none"| {toolName:string;type:"tool"; }
How the LLM should select tools. Mirrors the Vercel AI SDK’s toolChoice.
It is resolved PER REQUEST, and one value can arrive from four different scopes, which is why none of the arms below can be described as a property of “the session”. Every LLM request carries whichever of these is set, each one overriding the ones above it:
- The agent —
agent({ toolChoice })is the standing default for every request the agent makes, and what an unset field falls back to. A DEMANDING value is put back to"auto"after the reply’s first step unlessresetToolChoice: falsesays otherwise — see the"required"arm below. - The turn — in text mode a caller may override it for one turn
(
stream({ toolChoice })). A voice session has no such caller. - The dialog state — a
dialog()state may carrytoolChoice, read deepest-active-state-first, so a state that must not act overrides the two above for exactly as long as the conversation is in it, one step at a time. - The step — the runtime forces
"none"on the reply’s LAST step (forceFinalAnswer), so a reply that ran out of tool-calling budget still ends in an answer instead of silence. That override wins over all three, including an agent-level"required", which would otherwise demand a tool call on the one step where tools are switched off.
So the same value means “for every reply”, “for this turn”, “while in this state” or “on this one step” depending on where it was written. The arms:
"auto"— the model decides whether to call a tool on this request (the default, and what an unset field resolves to)."required"— the model must call at least one tool on this request. By default it lasts ONE step, not the whole reply. Each step is its own request, so a demand left standing re-obliges the model to call a tool after it already has, and again after that, until the reply has spent its wholemaxStepsbudget and the forced final step rescues it — bounded, but the caller waits through every round trip it had no use for. What"required"almost always means is “start by calling something”, which is exactly one step, soagent({ resetToolChoice })—trueunless you set it, the same default as OpenAI’s Agents SDK ships asreset_tool_choice— puts the choice back to"auto"from the second step on.resetToolChoice: falseis how an agent that really does want a tool call on every step says so, and it is the only way to get that behaviour. The reset applies to the demand resolved from scope 1 or 2; a dialog state’stoolChoice(scope 3) is re-read on every step and holds for as long as the conversation is in that state, and scope 4 still wins over both."none"— the model may not call a tool on this request. It is not a session-wide switch, and cannot be one: a later request in the same session is resolved again from whatever scope applies to it.{ type: "tool", toolName }— the model must call the named tool on this request.
ToolCompletionMessage
Section titled “ToolCompletionMessage”ToolCompletionMessage =
object
The role switch, and the reason this feature is worth having.
"assistant"— the content IS the reply. It is spoken verbatim and the model is not called at all: the step loop stops at this tool result, so a deterministic outcome costs zero further LLM round-trips. Exclusive-or, as Vapi states it — there is no arm where both happen."system"— the content is a HINT. It rides back with the tool’s result as guidance and the model writes the sentence, which is what an outcome the agent has to reason about (or apologize for) needs.
Defaults to "assistant", because a message worth writing out in full is
usually one worth saying.
Properties
Section titled “Properties”content
Section titled “content”content:
string
Spoken verbatim under role: "assistant"; told to the model under "system".
optionalrole?:"assistant"|"system"
Defaults to "assistant".
optionalwhen?:ToolMessageCondition[]
Conditions on the call’s arguments — see ToolMessageCondition.
ToolConditionOperator
Section titled “ToolConditionOperator”ToolConditionOperator =
"eq"|"neq"|"gt"|"gte"|"lt"|"lte"
Comparison a ToolMessageCondition applies. Vapi’s six, unchanged.
Spelled out as a union rather than derived from the tuple below, which is
the direction that reads right on a published type: TypeDoc refuses to
document a typeof CONST[number] whose constant is not itself published,
and publishing a tuple nobody names from an agent.ts would fail the root
barrel’s own membership test. The tuple satisfies the union, so an
operator added to one and not the other fails to compile.
ToolContext
Section titled “ToolContext”ToolContext =
object
Sealed
Context passed to tool execute functions.
Eleven fields, grouped by what a tool reaches for:
- Its own configuration —
env, the agent’s secrets and settings. - The session —
slots(its state; reach for sessionSlot rather than the store itself),messages(the conversation so far) andsessionId. - Its own deadline —
signalanddeadlineAt, the two a tool doing slow work has to honour: the signal aborts on barge-in, reset, stop or timeout, and the deadline says WHEN that will happen, so a tool that can answer partially still can. - A model —
generatefor one prompt,delegatefor a whole subagent loop. - Work that outlives the call —
workflows, which starts and inspects durable runs. - The connected page —
send, one custom event to the browser client. - Randomness —
random, the seam a spec can pin instead ofMath.random.
There is no ctx.db and no ctx.state: the platform hands tool code no
database, and a session’s state lives in sessionSlots.
Remarks
Section titled “Remarks”It takes no type parameter. It used to take the agent’s state shape, because
ctx.state was a bag whose type a tool could only learn from an annotated
context — so every module in a multi-file agent either restated the
annotation or cast. sessionSlot is the whole of that job now: a
slot’s value is typed by the slot, in the one module that declares it.
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
const lookupNote = tool({ description: "Look up a note", inputSchema: z.object({ id: z.string() }), execute: async ({ id }, ctx) => { // `ctx.env` for a credential, and whatever client the author brought — // there is no `ctx.db`, because the platform hands tool code no database. const res = await fetch(`${ctx.env.NOTES_API}/notes/${id}`); return { id, note: res.ok ? await res.json() : null }; },});Properties
Section titled “Properties”deadlineAt
Section titled “deadlineAt”deadlineAt:
number
When THIS call’s deadline expires, as epoch milliseconds — the instant the runtime will abort ToolContext.signal and hand the model a timeout.
Read it to budget under the deadline rather than to be cut off by it: a
tool that can answer partially (a search that has some results, a graph that
has walked some of its nodes) should leave itself room to return something
useful, because what the model gets otherwise is
Tool "x" timed out after 30000ms and nothing else.
import { tool } from "@alexkroman1/aai";import { z } from "zod";
export const search = tool({ description: "Search the archive.", inputSchema: z.object({ query: z.string() }), async execute({ query }, ctx) { // Room left to write an answer, rather than being cut off without one. const budget = Math.max(0, ctx.deadlineAt - Date.now() - 2_000); const stop = AbortSignal.any([ctx.signal, AbortSignal.timeout(budget)]); const res = await fetch(`https://archive.example/?q=${query}`, { signal: stop }); return { hits: res.ok ? await res.json() : [] }; },});Remarks
Section titled “Remarks”The ELEVENTH field on this type, and the one that raised
guard-invariants rule 24 from nine occurrences to ten. The rule asks
that a field earn its place by being per-CALL and unreachable any other
way, and this is both.
It is per-call because the deadline is not a constant: executeToolCall
resolves options.timeoutMs ?? TOOL_EXECUTION_TIMEOUT_MS, and a caller
that passes its own timeoutMs — createTextAgent does — gives its tools
a different one. And it was unreachable because the default lives on
@alexkroman1/aai/internal, a subpath an agent may not import, while the
per-call override was visible nowhere at all. What an author wrote instead
was the number, by hand: technical-support-agent carried
const LOOKUP_BUDGET_MS = 28_000 under a comment saying where the real
constant lived and that this copy would have to be moved with it.
An absolute INSTANT rather than a duration, because a duration is only
true at the moment it is read — a tool that awaited twice and subtracted
the same timeoutMs twice would budget against a deadline that had
already moved. Subtracting Date.now() at each use is the correct
reading and is what the example does.
delegate
Section titled “delegate”delegate:
DelegateFn
Hand a bounded task to a SUBAGENT — a second tool loop with its own instructions, model, tools and context window — and get back what it concluded, not how it got there (DelegateFn).
The sibling of ToolContext.generate, and the line between them is
how many model turns the answer takes: generate is one prompt, delegate
is a loop whose intermediate tool results the caller has no reason to
carry. Executes on the host wherever the runtime runs, like generate.
A subagent’s own tools cannot delegate further — their ctx.delegate
rejects naming the reason. One level is a bill a caller can quote; a
subagent that may delegate can delegate to itself, and nothing at this
seam can see the recursion.
Remarks
Section titled “Remarks”The TENTH field on this type, and the one that raised guard-invariants
rule 24 from nine. Recorded here because that is where a baselined
occurrence’s reason belongs: a field on this type is a capability the
runtime must supply on EVERY tool call, on every host, in every test
double — so it is a promise, not a convenience, and the rule exists to make
adding one an argued decision rather than a diff nobody reads.
The argument for this one is that it passes the test the rule sets: it is
per-CALL and it cannot be reached any other way. A tool body cannot build a
subagent runner itself — resolving the model, the builtins, the step budget
and the nesting refusal are all the host’s, exactly as they are for
generate. Anything reachable from a value the author already holds is not
this, and belongs in that value’s own module.
env:
Readonly<Partial<Record<string,string>>>
Environment variables available to this agent’s tools (from .env under
aai dev, aai secret in production). Custom keys a tool depends on
should be declared in AgentDef.requiredEnv so a missing value
fails at deploy time.
Partial, so every read is string | undefined. A variable that was
never set is undefined at runtime whatever the type says, and the type
used to say string: ctx.env.NEVER_DECLARED type-checked, built green,
and threw a TypeError on the first live call — which tool-executor.ts
then hands to the MODEL, so the caller hears the agent improvise an
apology. noUncheckedIndexedAccess says the same thing, but it is the
AUTHOR’s tsconfig and cannot be relied on from here.
Reach for requireEnv rather than a ?? at each site — it throws
a sentence naming the variable and pointing at requiredEnv.
generate
Section titled “generate”generate:
GenerateFn
One-shot LLM generation, executed on the host.
Defaults to the agent’s pipeline llm; pass llm in the options to use
another provider (its API key must be in the agent’s env). Throws when
no LLM is configured or named. Pass a Zod schema for typed structured
output (GenerateFn).
messages
Section titled “messages”messages: readonly
Message[]
Read-only snapshot of conversation messages so far.
random
Section titled “random”random:
RandomSource
A uniform float in [0, 1) — the SEAM a tool reaches for instead of
Math.random.
In production it IS Math.random, so this buys nothing at run time. What
it buys is a tool whose randomness a spec can state:
createToolContext({ random: () => 0.5 }) makes a dice roll, a shuffle, an
ETA jitter or a minted reference code an exact assertion rather than a
range check. Ten call sites across seven templates called the global
directly and none of them could be pinned; the one template that could had
hand-threaded a random parameter through its own helpers to get here.
Pass it on rather than re-deriving: randomInt, pickOne, shuffled and mintCode all take a RandomSource as their last argument.
Not journaled, and not a replay seam. A tool call happens once; a
WORKFLOW body replays, and WorkflowContext.random() is the different
mechanism that makes a run re-derive the same number. Not
cryptographic either — anything an attacker gains by guessing wants
crypto.getRandomValues.
Example
Section titled “Example”import { pickOne, tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Suggest somewhere to eat.", inputSchema: z.object({}), execute: (_args, ctx) => ({ pick: pickOne(["Luigi's", "The Anchor"], ctx.random) }),});send:
ClientEventSender
Push a custom event to the connected browser client. Fire-and-forget:
events whose name exceeds MAX_CLIENT_EVENT_NAME_LENGTH or whose
serialized payload exceeds MAX_CLIENT_EVENT_PAYLOAD_BYTES are
dropped (with a warning log), not thrown.
Typed by ClientEventMap. An event name the agent declared there
(by module augmentation) must be sent with that payload — a wrong shape is
a compile error here rather than a client handler reading undefined. Any
other name still takes unknown, so the map is opt-in per event.
See ClientEventSender for the signature, and for why it is one conditional signature rather than a typed overload.
sessionId
Section titled “sessionId”sessionId:
string
Unique identifier for the current session. Useful for correlating logs across concurrent sessions.
signal
Section titled “signal”signal:
AbortSignal
Cooperative cancellation signal. Aborts when the turn that issued this
tool call is cancelled (barge-in, reset, or session stop), and also when
the call itself settles exceptionally — above all on timeout. Long-running
tools should pass it to fetch etc. so their work stops promptly.
Remarks
Section titled “Remarks”Always present. It was optional until it was checked: the executor builds
a per-call AbortController on every path and there has never been a
context without one, so the ? only bought authors a ?. on every
ctx.signal.aborted and a ! wherever a non-optional AbortSignal was
wanted. A context that genuinely cannot cancel supplies a signal that
never aborts rather than omitting the field.
slots:
SlotStore
This session’s slot storage. Reach for sessionSlot, not this — it is on the context because a slot declared in one module has no other way to find the session, not because a tool body should call it.
It replaced ctx.state, a field typed any whose whole justification was
that the bag it held was dynamic. There is no bag: a slot owns its value,
types it, and is the only thing that writes it.
workflows
Section titled “workflows”workflows:
WorkflowClient
Start and inspect durable workflow runs — the way a tool hands off work that must outlive the call.
A voice tool cannot do slow work inline: the caller is on the line. So it
starts a run and answers in the same turn (“I’ve kicked that off, I’ll text
you”), and the run continues on the queue after the session ends. Pass
{ key: ctx.sessionId } so a later turn — or a later CALL — can find it
again; see StartOptions.key (@alexkroman1/aai/workflow-api).
Every method rejects when the app declares no workflows or has no workflow backend configured, naming which.
ToolDef
Section titled “ToolDef”ToolDef<
P,R> =object
Definition of a custom tool that the agent can invoke.
Tools are the primary way to extend agent capabilities. Each tool has a
description (shown to the LLM), an optional input schema, and an
execute function that runs inside the sandboxed worker.
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
const weatherTool = tool({ description: "Get current weather for a city", inputSchema: z.object({ city: z.string().describe("City name"), }), execute: async ({ city }) => { const res = await fetch(`https://wttr.in/${city}?format=j1`); return await res.json(); },});Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
The tool’s input schema: any Standard Schema that can convert to JSON Schema — a Zod object schema (the documented default) or e.g. an ArkType type. Defaults to a permissive record schema so tools without inputs don’t need an explicit type argument.
R = unknown
What execute returns, inferred at the tool call and
read by InferToolOutput. Defaults to unknown, so ToolDef<typeof schema> still means “any result”.
Methods
Section titled “Methods”execute()
Section titled “execute()”execute(
args,ctx):R
Function that executes the tool and returns a result, JSON-serialized for the LLM and the client.
The model gets it WHOLE; only the client’s copy is capped.
MAX_TOOL_RESULT_CHARS (4000) bounds the tool.completed frame — a longer
result is trimmed there and ends with a [truncated] marker — and bounds
nothing on the provider side, where the full string is appended to the
conversation and re-sent on every later turn of the call. This doc used to
say the cap applied to both, which made an unshaped await res.json() look
free: it is the whole response, in the prompt, for the rest of the turn.
Return the fields the model needs. A result over the cap is warned about
once per tool (see warnOversizedResult in aai-runtime’s
tool-executor.ts).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”R
Properties
Section titled “Properties”description
Section titled “description”description:
string
Human-readable description shown to the LLM.
inputSchema?
Section titled “inputSchema?”
optionalinputSchema?:P
Schema for the tool’s input, shown to the LLM and used to validate each
call’s arguments before execute runs. Named after the Vercel AI SDK’s
tool({ inputSchema }).
messages?
Section titled “messages?”
optionalmessages?:ToolMessagesInput
What the agent SAYS while this tool runs, and what it says when it lands.
Four kinds — start, delayed, complete, failed — documented on
ToolMessagesInput. Two of them change the shape of the turn rather
than just filling it:
delayedis a LADDER when the timings differ and VARIANTS when they match. Two entries atafterMs: 3000are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.- A
complete/failedentry withrole: "assistant"is spoken verbatim and the model is NOT CALLED. For a deterministic outcome that removes a whole LLM round-trip from the turn.role: "system"is the other arm: the content rides back as a hint and the model writes the sentence.
start and delayed are filler — they are heard, and they are never
recorded into ctx.messages, the model’s view or the committed transcript,
and never count as the agent having spoken (so a caller talking over one
does not interrupt the reply being generated behind it). complete and
failed with role: "assistant" are the opposite on every count: that IS
the agent’s answer.
Example
Section titled “Example”A hold line, a two-rung ladder, and an error the model phrases
import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Look up an order", inputSchema: z.object({ orderId: z.string() }), messages: { start: ["Let me pull that up.", "One second while I check."], delayed: [ { afterMs: 3000, content: "Still looking." }, { afterMs: 9000, content: "Sorry, the order system is slow today." }, ], failed: [{ role: "system", content: "Order lookup failed. Apologize and offer a callback." }], }, execute: async ({ orderId }) => ({ orderId, status: "shipped" }),});onError?
Section titled “onError?”
optionalonError?:ToolErrorHandler
What to do when execute throws — and, by omission, the SDK’s default.
Without it, every exception becomes an ordinary tool result. The
runtime catches whatever execute threw and hands errorMessage(err) back
to the model as that call’s result, which is the same channel a deliberate
toolFailure uses — so a stale credential, a TypeError in the
author’s own code and “no such order” are one thing as far as the model can
tell, and it will keep calling a permanently broken tool until the reply’s
maxSteps budget runs out. That default is unchanged and stays the default:
for the failures a model really can recover from it is the right answer, and
every tool written before this field existed depends on it.
With it, the author classifies. Return a ToolFailure or a string
and that is what the model gets — the same outcome as the default, with a
sentence the author chose. Throw — throw err re-raises the original — and
the failure is FATAL to the call: the runtime logs it, reports it as a
session error (code: "tool"), and the tool call REJECTS instead of
answering, so nothing hands the model something to retry against.
It sees only a THROW. A ToolFailure that execute RETURNED never reaches
it: that is already the author saying “expected, let the model recover”, and
routing it through here would make the two channels one again.
Example
Section titled “Example”Fatal on a missing credential, recoverable on a bad lookup
import { tool, toolFailure } from "@alexkroman1/aai";import { z } from "zod";
class MissingKeyError extends Error {}
export default tool({ description: "Look up an order", inputSchema: z.object({ id: z.string() }), execute: async ({ id }, ctx) => { if (!ctx.env.ORDERS_API_KEY) throw new MissingKeyError("ORDERS_API_KEY is unset"); const res = await fetch(`https://api.example.com/orders/${id}`, { headers: { authorization: `Bearer ${ctx.env.ORDERS_API_KEY}` }, }); if (res.status === 404) return toolFailure(`No order ${id}.`); return await res.json(); }, // A credential the deploy is missing cannot be fixed by asking the model // to try again; a flaky upstream can. onError: (err) => { if (err instanceof MissingKeyError) throw err; return toolFailure("The orders service is unavailable right now."); },});ToolDelayedMessage
Section titled “ToolDelayedMessage”ToolDelayedMessage =
object
Spoken when the tool has been running for ToolDelayedMessage.afterMs.
Same timing means VARIANTS; different timings mean STAGED updates. Two entries at 3000 are two phrasings of one rung and one of them is drawn; an entry at 3000 and another at 8000 are a ladder — “still checking”, then “almost there”. That is Vapi’s rule verbatim, and it is the whole reason the timing is on the message rather than on the list.
Properties
Section titled “Properties”afterMs
Section titled “afterMs”afterMs:
number
Milliseconds from the start of the tool call. Rungs fire at their own offset, not one after another, so a ladder of 3000/8000 speaks at 3s and 8s — never at 3s and 11s.
content
Section titled “content”content:
string
What is spoken.
optionalwhen?:ToolMessageCondition[]
Conditions on the call’s arguments — see ToolMessageCondition.
ToolErrorHandler
Section titled “ToolErrorHandler”ToolErrorHandler = (
err,ctx) =>ToolFailure|string
What a tool does with an exception its execute threw — the shape of
ToolDef.onError.
Returning decides what the MODEL sees; throwing decides that it sees
nothing. A returned ToolFailure or string is handed to the model
as that call’s result, exactly as if execute had returned it — so the model
can apologise, ask again, or try another route. Throwing (including
re-throwing err unchanged) declares the failure UNRECOVERABLE: the runtime
reports it and the tool call ends in a rejection rather than a result, so the
model is never invited to retry a tool that cannot work.
It is called with the same ToolContext execute was given, so a
handler can read ctx.env to tell a missing credential from a bad one, or
ctx.signal.aborted to tell a real fault from a cancelled turn.
Synchronous, deliberately. It runs after the call’s deadline has already
passed on the timeout path, so there is no budget left to await anything in;
the runtime refuses a thenable return and treats it as fatal, the same rule
slot.updateTool applies to a mutator body. Do the awaiting inside
execute, where the deadline still applies.
Parameters
Section titled “Parameters”unknown
Returns
Section titled “Returns”ToolFailure | string
ToolFailure
Section titled “ToolFailure”ToolFailure =
object
A tool result that reports a recoverable failure to the LLM.
Return one from execute (instead of throwing) when the failure is
something the model should see and act on — “no order matches that
description, ask which one” — rather than an internal fault. The runtime
serializes it like any other result, so it reaches the model as
{"error":"…"} and reaches a test as an inspectable object.
A tool that returns failures declares them in its own result union
(Order | ToolFailure), which is what makes isToolFailure a
narrowing guard at every call site that forwards one.
Properties
Section titled “Properties”error:
string
ToolInputSchema
Section titled “ToolInputSchema”ToolInputSchema =
StandardSchemaV1<unknown,Record<string,unknown>>
A schema accepted for tool inputs and ctx.generate structured output:
any Standard Schema that can also convert to JSON Schema (Zod natively,
or a vendor toJsonSchema() method). Zod object schemas are the
documented default.
ToolMessageCondition
Section titled “ToolMessageCondition”ToolMessageCondition =
object
One test a tool call’s ARGUMENTS must pass for the message carrying it to be eligible.
This is what makes a per-argument-value line possible — a different sentence when looking up an order than when issuing a refund — without splitting one tool into two. Conditions on a message are ANDed; a message with none always matches.
arg names a top-level argument by the key the model sends. Values compare
as JSON scalars: eq/neq are Object.is-style equality, and the four
ordering operators apply only when BOTH sides are numbers (a condition that
asks to order a string against a number does not match rather than throwing —
the model chooses these values, so a comparison it makes nonsense of must not
be able to fail a call).
Properties
Section titled “Properties”arg:
string
The argument’s key, as the model sends it.
optionalop?:ToolConditionOperator
Defaults to "eq".
value:
string|number|boolean|null
The value to compare against.
ToolMessages
Section titled “ToolMessages”ToolMessages =
object
A tool’s messages in NORMALIZED form — what a ToolSchema carries and
what the runtime reads. Authors write ToolMessagesInput, which
agentToolsToSchemas normalizes into this.
Properties
Section titled “Properties”complete?
Section titled “complete?”
optionalcomplete?:ToolCompletionMessage[]
What a settled call says — see ToolCompletionMessage.
delayed?
Section titled “delayed?”
optionaldelayed?:ToolDelayedMessage[]
The delay ladder — see ToolDelayedMessage.
failed?
Section titled “failed?”
optionalfailed?:ToolCompletionMessage[]
What a FAILED call says — a tool that returned a ToolFailure, or one
whose throw the runtime serialized into one. The same role switch:
"system" is what lets the model produce an error-aware reply instead of a
canned one, which is almost always the better answer for a failure.
start?
Section titled “start?”
optionalstart?:ToolStartMessage[]
Spoken as the call begins — see ToolStartMessage.
ToolMessagesInput
Section titled “ToolMessagesInput”ToolMessagesInput =
object
What an author writes for tool({ messages }) — every kind also accepts the
shorthands, because the common declaration is one string.
import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Look up an order", inputSchema: z.object({ orderId: z.string() }), messages: { start: true, // the default filler pool, one drawn per call delayed: [ { afterMs: 3000, content: "Still pulling that up." }, { afterMs: 3000, content: "Bear with me one second." }, { afterMs: 8000, content: "Sorry — this one is taking a while." }, ], failed: [{ role: "system", content: "The order service is down. Offer a callback." }], }, execute: async ({ orderId }) => ({ orderId, status: "shipped" }),});Properties
Section titled “Properties”complete?
Section titled “complete?”
optionalcomplete?:string| readonly (string|ToolCompletionMessage)[]
A bare string is { role: "assistant", content }.
delayed?
Section titled “delayed?”
optionaldelayed?: readonlyToolDelayedMessage[]
No shorthand: a rung without its afterMs is not a rung.
failed?
Section titled “failed?”
optionalfailed?:string| readonly (string|ToolCompletionMessage)[]
A bare string is { role: "assistant", content }.
start?
Section titled “start?”
optionalstart?:boolean|string| readonly (string|ToolStartMessage)[]
true draws one of the five default hold lines per invocation.
ToolSet
Section titled “ToolSet”ToolSet =
Readonly<Record<string,ToolDef>>
A map of tools by the name the model calls them by — the shape every field
that DECLARES a set of tools takes: AgentDef.tools (what tools/
lowers to), PersonaDef.tools and SubagentDef.tools.
Named once so the three cannot drift, and so a helper that builds or filters
a set of tools has one type to be written against rather than restating
Readonly<Record<string, ToolDef>>.
ToolStartMessage
Section titled “ToolStartMessage”ToolStartMessage =
object
Spoken as the tool call BEGINS.
Several entries are VARIANTS: one is drawn at random per invocation. Never fires for a call the model made and then abandoned, and never while the caller is talking — see the runner.
Properties
Section titled “Properties”blocking?
Section titled “blocking?”
optionalblocking?:boolean
Hold the tool call until this has been spoken. Defaults to false.
The honest default, because the alternative charges every call the length of a sentence for a tool that may answer in 80ms. Reach for it when the tool has a side effect the caller should hear about BEFORE it happens (“Okay, I’m cancelling that order now.”) — the hold is bounded at eight seconds whatever the line, so a slow speech path costs one slow call and never a wedged turn.
content
Section titled “content”content:
string
What is spoken.
optionalwhen?:ToolMessageCondition[]
Conditions on the call’s arguments — see ToolMessageCondition.
TtsProvider
Section titled “TtsProvider”TtsProvider =
ProviderDescriptor<string,Record<string,unknown>> &object
Descriptor for a TTS provider. Returned by factories like
cartesiaTts(...) from @alexkroman1/aai/tts.
Type Declaration
Section titled “Type Declaration”__stage?
Section titled “__stage?”
readonlyoptional__stage?:"tts"
Compile-time stage tag; never present at runtime.
TurnDetectionMode
Section titled “TurnDetectionMode”TurnDetectionMode =
"auto"|"manual"|string&object
A turn-detection mode — "auto" or "manual", the two this release
implements (see PipelineVoiceTuning.turnDetection), or any other
string.
OPEN so a mode a later release adds compiles against this one. The runtime
treats every value but "manual" as "auto", and aai build / aai dev
warn about a value it does not know, rather than the type refusing it.
The known modes are written INLINE rather than as an exported closed
KnownTurnDetectionMode half: inline they are only the autocomplete of an
open type, so a mode added here is a compatible change, where a published
closed union that grows is not assignable back to the one it grew from.
VoicePresetName
Section titled “VoicePresetName”VoicePresetName =
"echoVerification"|"speechNormalization"|"natoAlphabet"|string&object
A preset name — one of the opt-in prompt presets THIS release ships (see VOICE_PRESETS for what each one says and what it costs), or any other string.
OPEN so an agent naming a preset a later release adds compiles against this
one. An unknown name emits no text (the prompt is assembled from the known
names only), and aai build / aai dev warn about it rather than the type
refusing it.
The known names are written INLINE rather than as an exported
KnownVoicePresetName half. A closed union on the published surface is a
type an author’s code can pin, so a preset added to it changed a published
type in a way no probe can call compatible (a union that grows is not
assignable back to the one it grew from). Inline, the literals are only the
autocomplete of an open type — "a" | "b" | (string & {}) and
"a" | "b" | "c" | (string & {}) are the same set — so a regenerated list is
a compatible change. Spelled as literals rather than derived from
VOICE_PRESET_NAMES so the API report and the docs render the strings.
WaitForOptions
Section titled “WaitForOptions”WaitForOptions<
S> =object
Per-wait options, for a wait that carries a DEADLINE.
A wait that carries only a schema takes WaitForSchemaOptions instead —
two types rather than one optional timeoutMs, because the deadline is what
decides whether the call can resolve undefined, and a single bag with both
halves optional would put | undefined on the result of a wait that has no
way to end unanswered.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1 = StandardSchemaV1
Properties
Section titled “Properties”schema?
Section titled “schema?”
optionalschema?:S
The shape the payload must have — any Standard Schema, zod being the documented default. Its OUTPUT type is what the wait resolves to, in place of the type parameter.
A payload is UNTRUSTED: it arrives over public HTTP, through
ctx.workflows.signal or a webhook delivery to
ctx.workflows.publicWebhookUrl(token), and nothing between the sender and
the body inspects it. The type parameter says what you EXPECT; this is the
only thing that checks. stepGenerateJson on @alexkroman1/aai/step makes
the same trade against a model’s reply, and its module doc carries the
general argument under “Why a schema rather than a type parameter”.
A payload that fails is a FATAL failure of the run rather than a retry or an
undefined: the payload is journaled, so every later delivery reads the
same bytes and refuses identically — there is nothing a redelivery could
change.
Validation runs AFTER the window has been decided, and does not un-decide
it. Whether this wait was answered or timed out is settled by a
compare-and-set on the hook before the body continues (closeHook) — that
ordering is what stops a signal landing a moment later from making the next
replay answer a window this one timed out — so by the time a payload is
checked, the delivery has already happened and been recorded. A rejected
payload therefore leaves the hook exactly as it found it: DELIVERED, not
reopened. Reopening would be worse in both directions — it would invite a
second signal to overwrite the first, and it would make the run’s history
disagree with the request the sender was answered on. Nobody sent the wrong
shape twice by accident, and the run failing loudly is the outcome that gets
it fixed.
timeoutMs elapsing unanswered is NOT a validation failure: there is no
payload, the wait resolves undefined, and the schema is never consulted.
A schema that coerces or strips unknown keys is supported and is usually what a webhook wants; the validated value is what the body receives.
import type { WorkflowContext } from "@alexkroman1/aai";import { z } from "zod";
// Derived from the run's own input, so the tool handing the URL out and the// body waiting on it agree — see `WorkflowContext.waitFor`.declare function approvalToken(id: string): string;
export async function reviewFlow(input: { id: string }, ctx: WorkflowContext) { const approval = await ctx.waitFor(approvalToken(input.id), { schema: z.object({ approved: z.boolean() }), timeoutMs: 24 * 60 * 60 * 1000, }); if (approval === undefined) return { published: false, reason: "expired" }; return { published: approval.approved };}timeoutMs
Section titled “timeoutMs”timeoutMs:
number
How long to wait before giving up, in milliseconds.
Resolves undefined when it elapses unanswered — not a throw, because a
window closing is an ordinary outcome a body branches on rather than a
failure. A signal that arrives after it is answered false, so a caller
cannot be told their answer was taken when it was not.
WaitForSchemaOptions
Section titled “WaitForSchemaOptions”WaitForSchemaOptions<
S> =object
A wait that carries a schema and NO deadline — ctx.waitFor(token, { schema }).
Its own type rather than an optional timeoutMs on WaitForOptions,
for the reason stated there: an unbounded wait has no unanswered branch, so
its result must not carry | undefined.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1 = StandardSchemaV1
Properties
Section titled “Properties”schema
Section titled “schema”schema:
S
The shape the payload must have — see WaitForOptions.schema.
WorkflowClient
Section titled “WorkflowClient”WorkflowClient =
object
Start and inspect workflow runs. Reaches tool code as ctx.workflows.
Prefer passing the workflow itself over its name. Every method here is
overloaded on WorkflowDef | string, and the def overload is the one that
types the input against the workflow’s own schema, types output against its
return, and turns a misspelled workflow into a compile error instead of a
promise rejection the model reads as a tool failure. The string overload stays
for a name that genuinely is data — read from config, a database, a request.
The def is resolved to its declared name by IDENTITY against
agent({ workflows }), so that record stays the single source of the name,
and to its workflowId through its own run function.
Methods
Section titled “Methods”cancel()
Section titled “cancel()”cancel(
runId):Promise<boolean>
Stop a run. Resolves true when this call is what ended it, false when it was already terminal (or no such run exists).
A cancelled run is terminal: it is never resumed, and its event log is kept so what it did before stopping stays readable.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Promise<boolean>
find()
Section titled “find()”Call Signature
Section titled “Call Signature”find<
P,R>(workflow,key,options?):Promise<WorkflowRunSnapshot<R>[]>
Runs of workflow started with this correlation key, newest first.
The read half of StartOptions.key — see there for why a voice agent needs it. Resolves an empty array when nothing matches.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot<R>[]>
Call Signature
Section titled “Call Signature”find(
workflow,key,options?):Promise<WorkflowRunSnapshot[]>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot[]>
Call Signature
Section titled “Call Signature”get<
R>(runId,workflow):Promise<WorkflowRunSnapshot<R> |undefined>
Look up a run by id. Resolves undefined when no such run exists.
Pass the workflow as the second argument to type output on a completed
run; with the id alone there is nothing to infer it from, so it is
unknown. The argument is used ONLY for that — the run’s own record says
which workflow it belongs to.
Type Parameters
Section titled “Type Parameters”R
Parameters
Section titled “Parameters”string
workflow
Section titled “workflow”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot<R> | undefined>
Call Signature
Section titled “Call Signature”get(
runId):Promise<WorkflowRunSnapshot|undefined>
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Promise<WorkflowRunSnapshot | undefined>
lastLine()
Section titled “lastLine()”lastLine(
runId,options?):Promise<unknown>
The NEWEST chunk a run has written, or undefined when it has written
nothing.
Reach for this instead of composing streamTail and
stream — the composition is the one a tool gets wrong, and getting
it wrong HANGS. A progress channel is never closed (no step knows it is
the last one), so stream on a run with nothing in it yields nothing and
waits forever rather than ending: a voice agent’s tool call stops mid-turn
with no error, no timeout of its own, and nothing in a log to read. The
bound that prevents it is streamTail() < 0, which has to come FIRST and
is not an optimization. Two templates carried the same six-line comment
saying exactly that, above the same eight lines, which is what a missing
front door looks like.
This method cannot hang: it asks for the tail before it opens anything, and it opens a stream only once the tail says there is a chunk to read. It reads ONE chunk and cancels, so nothing is left draining behind it.
The chunk is unknown — whatever the body passed to getWritable(), which
this SDK does not constrain. A tool narrating progress wants
String(line); a body writing structured records should narrow with a
guard.
streamTail and stream stay public and are still the right pair for reading a WHOLE log — a page rendering every line, a reader resuming from where it got to. This is only the “read me the newest thing” case, which is the one with a trap in it.
options.namespace selects the stream, as everywhere else. A non-negative
options.startIndex acts as a FLOOR: nothing is resolved until the run has
written that far, which is what a reader that has already seen up to an
index wants. A negative one asks for the newest chunk, which is what this
returns anyway.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<unknown>
listing()
Section titled “listing()”listing():
WorkflowSummary[]
The workflows this agent declares, name + description + input schema.
Synchronous, and on the CLIENT rather than only on the engine, because tool
code is a legitimate reader: the workflow_status builtin has to ask about
every declared workflow when the model named none, and nothing else in
ToolContext could tell it what those are. Empty when no backend is
available, which is the same answer as “this app declares none”.
Returns
Section titled “Returns”publicWebhookUrl()
Section titled “publicWebhookUrl()”publicWebhookUrl(
token):string
The PUBLIC URL a third party delivers a webhook to, for a hook holding
token — this agent’s configured public base URL plus the DevKit’s webhook
route.
Not hook.url, and that is the whole reason it exists: the DevKit
composes its own from getWorkflowMetadata().url, which is
http://localhost:<port> off the running process — the inside of a container
that has self-exited by the time the callback comes. Treat hook.url as
guest-local and use this for anything leaving the system.
Synchronous, and it THROWS when no public URL is configured, naming the
option. The token is the CALLER’s, exactly as signal takes it. See
“A callback URL comes from publicWebhookUrl” in packages/aai/CLAUDE.md.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string
recent()
Section titled “recent()”Call Signature
Section titled “Call Signature”recent<
P,R>(workflow,options?):Promise<WorkflowRunSnapshot<R>[]>
Runs of workflow, newest first, whatever key they carry.
The OPERATOR’s read where find is the agent’s. A console — the
studio’s Settings pane, a curl — asking “what has this workflow been doing”
holds no correlation key, and most runs carry none at all: a page keeps its
own runId, so only a voice agent’s runs are keyed.
Deliberately its own method rather than find with an optional key, because
a keyless lookup is not a lookup that matched every key. Sharing one method
would let a caller meaning “this session’s runs” read every session’s the
moment its key went undefined — a scoping bug with no symptom.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot<R>[]>
Call Signature
Section titled “Call Signature”recent(
workflow,options?):Promise<WorkflowRunSnapshot[]>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot[]>
signal()
Section titled “signal()”signal(
token,payload?):Promise<boolean>
Deliver a payload to a run parked on createHook({ token }), resuming it.
Resolves true when a hook was listening on token, false when none was.
This is the half of the mechanism a voice agent needs and could not
reach. A run that has to WAIT for a person — an approval, a choice, a
“yes, go ahead” — parks on a hook, and until now the only way to feed one
was the public webhook URL createWebhook() mints, which is for a third
party with a callback to make. The caller on the phone is neither: they are
right here, mid-turn, and the thing that should resume the run is a tool.
wakeUp is not this. It ends a pending sleep(), which is a run
waiting for TIME; a hook is a run waiting for an ANSWER, and the answer is
the payload. A body that raced a hook against a sleep — the shape a
decision-with-a-deadline takes — needs both, and they mean different things.
The token is the contract, and it has to be derivable on both sides. A
hook’s token is chosen by the BODY and typed in by the tool, so it must be
something each can compute from what it already has:
`retention:${input.requestedBy}` in the body against
`retention:${ctx.sessionId}` in the tool. Put that expression in one
exported helper both import, rather than writing the template literal twice.
Two properties come with it. A token is claimed by ONE live hook, so two
runs that would derive the same token collide — the body detects that with
hook.getConflict(), and the ordinary fix is the one a voice agent wants
anyway: at most one live run per caller. And a token is a capability: it
addresses a run, so derive it from something session-scoped rather than from
anything a caller could name.
false is an answer. Nobody listening is the normal case, not a
failure — the run has moved past its hook, or finished, or was never
started. Same shape as
cancel resolving false and wakeUp resolving 0, and a voice
tool should say so out loud (“that one had already gone ahead”) rather than
treat it as an error.
Parameters
Section titled “Parameters”string
payload?
Section titled “payload?”unknown
Returns
Section titled “Returns”Promise<boolean>
start()
Section titled “start()”Call Signature
Section titled “Call Signature”start<
P,R>(workflow,input,options?):Promise<string>
Create a run and return its id without waiting for it to finish — the point of the whole mechanism. A tool that calls this answers the caller in the same turn (“started, I’ll text you”) while the run continues past the end of the session.
Rejects when the workflow is not declared on this agent, when the input fails its schema, or when no workflow backend is configured.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
Required for the definition form, even for a workflow that declares no
schema — pass {} there. Optional would mean a schema-CARRYING workflow
could be started with no input by omission, which is the mistake this
overload exists to catch; {} is a small cost for that.
options?
Section titled “options?”Returns
Section titled “Returns”Promise<string>
Call Signature
Section titled “Call Signature”start(
workflow,input?,options?):Promise<string>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
input?
Section titled “input?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<string>
stream()
Section titled “stream()”stream(
runId,options?):Promise<ReadableStream<unknown>>
Read what a run has WRITTEN while running, as a stream.
The gap this fills: a snapshot carries a status and, once terminal, an
output — so a run that takes ten minutes is running for ten minutes and
then done, with nothing in between. A workflow that wants to report progress
writes to getWritable() (imported from workflow, like sleep), and this
is the read side.
Chunks are RETAINED with the run, not live-only, so this is equally a replay:
a page that reloads mid-run reads the whole stream from the start by default,
and startIndex is for a reader that knows where it got to.
The stream is lazy — a run that does not exist surfaces when it is read, not here — so a caller wanting a clean “no such run” answer should get it first, which is what the HTTP route does.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<ReadableStream<unknown>>
streamTail()
Section titled “streamTail()”streamTail(
runId,options?):Promise<number>
How far the run’s stream currently goes: the index of the last chunk
written, or -1 for a stream nothing has written to.
This is what makes reading a progress stream terminate. A workflow stream reports its end only once it has been CLOSED, and a progress channel written by one step after another is never closed — no step knows it is the last one. So stream on a finished run yields every chunk and then waits forever. A reader bounds itself by this instead, which is also what a reconnecting reader needs in order to ask for what it has not seen.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<number>
wakeUp()
Section titled “wakeUp()”wakeUp(
runId,options?):Promise<number>
Interrupt a run’s pending sleep() calls, resuming it early. Resolves how
many sleeps were interrupted — 0 when the run was not sleeping, had
already finished, or does not exist.
This is the counterpart of a sleep() long enough to be worth shortening,
which is most of the ones worth writing: a review delay, a retry backoff, a
“follow up tomorrow”. Without it the only handle on a sleeping run is
cancel, so “send it now” and “throw it away” were the same button.
Pass correlationIds to target specific sleeps; omitted, every pending one
in the run is interrupted.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<number>
WorkflowContext
Section titled “WorkflowContext”WorkflowContext =
object
The handle a workflow body receives as its second argument.
export async function researchFlow( input: { topic: string }, ctx: WorkflowContext,) { const brief = await ctx.step("writeBrief", () => writeBrief(input.topic)); const notes = await ctx.step("investigate", () => investigate(brief)); return { topic: input.topic, notes };}Deliberately NOT the same object as a tool’s ToolContext. A tool’s execute
runs once inside a live session, so it holds live things — send, signal,
generate. A workflow body is REPLAYED from the top on every resume. Sharing
one type would put those in reach of a body that re-runs them on each resume,
the bug the DevKit migration removed and which this must not reintroduce.
Methods
Section titled “Methods”now():
Promise<number>
The wall clock, read ONCE and journaled — the same instant on every replay.
The body is replayed from the top, so a plain Date.now() here answers
differently on every walk and every duration derived from it is a different
duration. This reads the clock the first time it is reached, journals the
number, and hands the identical number back forever after: it is the moment
the run really reached this line, however many times the line is walked.
import type { WorkflowContext } from "@alexkroman1/aai";
declare function transcribe(recording: string): Promise<string>;
export async function timedFlow(input: { recording: string }, ctx: WorkflowContext) { const startedAt = await ctx.now(); const transcript = await ctx.step("transcribe", () => transcribe(input.recording)); const finishedAt = await ctx.now(); return { transcript, elapsedMs: finishedAt - startedAt };}Not legal inside a WorkflowContext.step — the engine refuses one and
the message names the fix. A step’s internals are not replayed, so a plain
Date.now() inside one is already durable and is what to write there.
Returns
Section titled “Returns”Promise<number>
Epoch milliseconds, as Date.now() answers them.
random()
Section titled “random()”random():
Promise<number>
A random float in [0, 1), journaled — the same float on every replay.
ONE draw per call, keyed by its own occurrence, so a loop is correct without
anything further: random!0, random!1, … each carry their own journaled
value. That is deliberately not a seeded SEQUENCE — a seed would make every
draw’s value depend on how many draws came before it, so a body that reaches
a different NUMBER of them before a loop silently re-draws the whole tail,
and it would need a PRNG whose exact algorithm became part of the durable
contract.
The cost is one journal row per call, which is the same trade ctx.step makes
and the reason a BULK draw belongs in a step:
ctx.step("jitter", () => Array.from({ length: 1000 }, Math.random)).
Not legal inside a WorkflowContext.step, for WorkflowContext.now’s reason.
Returns
Section titled “Returns”Promise<number>
sleep()
Section titled “sleep()”sleep<
Label>(label,until,options?):Promise<void>
Wait, durably — for a duration in milliseconds, or until an absolute Date.
This is not setTimeout, and the difference is the whole point. The run
SUSPENDS: the body stops, the process is free, and the engine re-delivers the
run when the time comes — which is what makes “check back tomorrow” a thing a
workflow can express at all.
label is the wait’s IDENTITY, exactly as a step’s name is
Section titled “label is the wait’s IDENTITY, exactly as a step’s name is”It is journaled as sleep!<label>#<occurrence>, so a label is what makes
a wait survive a body that reaches a different NUMBER of waits than the walk
that journaled them — a wait behind a condition, a wait added or removed
while a run is in flight. Waits used to be keyed by POSITION alone, and then
every wait after the one that moved read its predecessor’s record: measured,
a week-long ctx.sleep was skipped in full and the run reported
completed, with the clock unmoved. aai-runtime/workflow-replay-divergence.ts
carries that reproduction.
So the same rules apply as to WorkflowContext.step’s name, and the
Literal constraint says so at the call site: make it a string literal,
give two call sites two labels, and let a loop reuse one — the occurrence
count is what separates the iterations.
How long it really survives is a property of the JOURNAL, which the
DEPLOYMENT picks and the runtime’s boot line names. On the platform and
against a Postgres it is durable — a wait outlives the body, the worker and
the process, so a multi-day schedule is a thing to write. With neither the
journal is in memory, which is aai dev’s default and where a restart loses
every outstanding wait.
A sleep is journaled the first time it is reached, so its wake time is decided ONCE. That matters because the body is replayed: computing the deadline from the clock on every replay would push it further out each time and a run could sleep forever.
Call it from the BODY, never from inside a WorkflowContext.step — a step body that waits fails the run, and the message names the fix.
await ctx.step("draft", () => draft(input.topic));await ctx.sleep("review-window", 6 * 60 * 60 * 1000, { correlationId: "review" });await ctx.step("publish", () => publish(input.topic));Type Parameters
Section titled “Type Parameters”Label extends string
Parameters
Section titled “Parameters”Label & Literal<Label>
This wait’s identity in the journal. A string LITERAL, for
the reason above; it is also what aai workflow prints for a suspended
run, so “review-window” reads where sleep!0 did not.
number | Date
Milliseconds to wait, or the Date to wait until. A value
already in the past returns immediately rather than erroring — a deadline
that has passed HAS been reached, and a run resuming after a long outage
meets that case legitimately.
options?
Section titled “options?”correlationId names this wait so
ctx.workflows.wakeUp(runId, { correlationIds: [id] }) can end it early,
which is how a “send it now” tool cuts a scheduled wait short. A wakeUp
naming no ids wakes every outstanding SLEEP on the run — and deliberately
not a waitFor’s deadline, so cutting a schedule short cannot also close
an approval window.
Deliberately NOT defaulted from label, which is a different question:
label decides which JOURNAL ROW this wait is, and correlationId
decides which waits one wakeUp ends. A schedule polled in a loop wants
one label and one correlation id across every iteration; two independent
waits want two labels and may well want one shared id.
Returns
Section titled “Returns”Promise<void>
step()
Section titled “step()”Call Signature
Section titled “Call Signature”step<
S,Name>(name,fn,options):Promise<InferSchemaOutput<S>>
Run fn once and journal what it returns; on every later replay, return
the journaled value without running it again.
fn may not wait. WorkflowContext.sleep and
WorkflowContext.waitFor reached inside a step fail the run, because a
suspend unwinds out of the step without journaling it — so the body would
re-run from the top on every delivery, and every later wait in the run would
read the wrong record. Put the wait in the body, between two steps. For a
plain in-step delay that is not durable, use an ordinary timer.
name identifies the step in the journal and in aai workflow output, so
make it a string LITERAL. A computed one has to produce the same string on
every replay or the walk reads a key that was never written — and a name
built from the run’s own data is unreadable in that run’s history besides.
A loop needs no name of its own per round: the occurrence count is what
separates the iterations.
The Literal constraint is what makes “a string LITERAL” a compile error
rather than a sentence in this paragraph. It is deliberately not exported —
an author meets it as the message tsc prints, never by name — so its doc,
carrying the two shapes it cannot reach and which layer catches each, is in
sdk/_workflow-ctx-literal.ts beside the declaration. A harness that means
to pass an unbounded name narrows ctx.step through one typed alias rather
than casting at each site.
options.schema checks the output on both sides of the journal and makes
the schema’s output what this resolves to — see StepOptions.schema
for what each side catches, and why a read-side failure is not the step’s.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Name extends string
Parameters
Section titled “Parameters”Name & Literal<Name>
() => unknown
options
Section titled “options”Returns
Section titled “Returns”Promise<InferSchemaOutput<S>>
Call Signature
Section titled “Call Signature”step<
T,Name>(name,fn,options?):Promise<T>
Type Parameters
Section titled “Type Parameters”T
Name extends string
Parameters
Section titled “Parameters”Name & Literal<Name>
() => T | Promise<T>
options?
Section titled “options?”Returns
Section titled “Returns”Promise<T>
uuid()
Section titled “uuid()”uuid():
Promise<string>
A fresh UUID, journaled — the same string on every replay.
What an idempotency key for a downstream API wants: minted once, and still the same value after a crash, so the retry the far side sees is recognisably the same request rather than a second one.
import type { WorkflowContext } from "@alexkroman1/aai";
declare function charge(amount: number, idempotencyKey: string): Promise<void>;
export async function chargeFlow(input: { amount: number }, ctx: WorkflowContext) { const idempotencyKey = await ctx.uuid(); await ctx.step("charge", () => charge(input.amount, idempotencyKey));}Not a hook TOKEN. WorkflowContext.waitFor’s token must be DERIVED from the run’s own input, because whoever signals is usually a tool and a tool cannot see the body’s local variables — a journaled uuid is stable across replays and still unnameable from outside the body.
Not legal inside a WorkflowContext.step, for WorkflowContext.now’s reason.
Returns
Section titled “Returns”Promise<string>
waitFor()
Section titled “waitFor()”Call Signature
Section titled “Call Signature”waitFor<
S>(token,options):Promise<InferSchemaOutput<S> |undefined>
Wait for somebody OUTSIDE the run to answer, and resolve what they sent.
Suspends like WorkflowContext.sleep and with no deadline at all: the run
waits until ctx.workflows.signal(token, payload) is called. That is how a
run parks on a human approval, a review that may take a week, or anything
else somebody else decides.
The WEBHOOK route reaches this. ctx.workflows.publicWebhookUrl(token)
mints a URL that createRuntimeServer serves, and a delivery to it resolves the
wait: the route calls WorkflowClient.signal, which writes the payload
against this hook’s own journal row and re-walks the body. So a
payment-callback flow is a supported shape.
It was NOT, until recently, and the note here said so — the URL was served
by the DevKit’s own hook table, which knew nothing about this wait and
answered HookNotFound. Both hops are covered now: the route→signal hop
by server-workflow-app.test.ts, and signal→resume by
workflow/in-process.test.ts.
// The token is the AUTHOR's, derived so the body and the tool that hands it// out agree — see below.const approval = await ctx.waitFor<{ approved: boolean }>(approvalToken(input.id));if (!approval.approved) return { published: false };The token must be DERIVED, not random. Whoever hands the URL out is
usually a tool, and a tool cannot see the body’s local variables — so a
random token leaves the run waiting on something nobody can name. Export one
function that computes the token from the run’s own input and import it in
both places. This replaced the DevKit’s createHook(), whose token was
generated body-side for exactly this reason a problem.
A payload is UNTRUSTED. It arrives over public HTTP, so validate it with
options.schema — the type parameter is a claim, not a check, and until
that option existed this paragraph was advice with no mechanism under it. A
schema SUPERSEDES the parameter, and a payload failing one fails the RUN
fatally with the window left as the delivery found it;
WaitForOptions.schema carries why none of the three can be otherwise.
A deadline is an OPTION, and still the one to reach for
Section titled “A deadline is an OPTION, and still the one to reach for”“Wait for an answer, but not forever” is the common case — Temporal’s
timeoutOrUserAction, and what a retention gate or an approval window is.
Write it as waitFor(token, { timeoutMs }), which resolves undefined when
the window closes unanswered.
Promise.race([ctx.waitFor(t), ctx.sleep(ms)]) does now COMPOSE, and
this paragraph used to say it could not. A wait no longer unwinds the stack:
it hands back a promise that never settles, so the body walks on and reaches
every wait a race or an all puts in front of it, and the run suspends
ONCE afterwards carrying the earliest deadline among them. Whichever wait
ends first is the one the race resolves on, on the delivery that ends it.
The parameter is still the better API for a DEADLINE, and for two reasons
the composition does not give you. timeoutMs is journaled with the hook,
so one decision fixes the window; a raced ctx.sleep is a second wait whose
own deadline is fixed at ITS first reach, so the two agree only by accident.
And the timeout arm CLOSES the hook — a compare-and-set — before the body
continues, which is what stops a signal landing a moment later from making
the next replay answer a window this one timed out. A race has no such
moment. So reach for a race when the two waits are genuinely independent (a
review window beside a retry backoff), not to put a deadline on one wait.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”string
Who is being waited for, and also this wait’s IDENTITY in
the journal — it is keyed hook!<token>#<occurrence>, which is what makes
a wait survive a body that reaches a different number of them (see the
module doc). Two concurrent waits in one body must use different tokens,
or a single signal resolves whichever the journal registered first and the
other waits forever.
options
Section titled “options”WaitForOptions<S> & WaitForSchemaOptions<S>
timeoutMs closes the window. Measured from the first time
the wait is REACHED and journaled there, so a replay does not extend it.
schema checks what the signaller actually sent, and decides the type.
Returns
Section titled “Returns”Promise<InferSchemaOutput<S> | undefined>
Call Signature
Section titled “Call Signature”waitFor<
S>(token,options):Promise<InferSchemaOutput<S>>
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”string
options
Section titled “options”Returns
Section titled “Returns”Promise<InferSchemaOutput<S>>
Call Signature
Section titled “Call Signature”waitFor<
T>(token):Promise<T>
Type Parameters
Section titled “Type Parameters”T = unknown
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Promise<T>
Call Signature
Section titled “Call Signature”waitFor<
T>(token,options):Promise<T|undefined>
Type Parameters
Section titled “Type Parameters”T = unknown
Parameters
Section titled “Parameters”string
options
Section titled “options”Returns
Section titled “Returns”Promise<T | undefined>
Properties
Section titled “Properties”
readonlyrunId:string
This run’s id — the same value ctx.workflows.start() resolved to.
workflow
Section titled “workflow”
readonlyworkflow:string
Key the workflow is declared under in agent({ workflows }).
WorkflowDef
Section titled “WorkflowDef”WorkflowDef<
P,R> =object
Definition of one durable workflow: its schema, its description, and the function that is its body.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
Input schema (any Standard Schema, Zod by convention),
validated at start(). The input is serialized into the run record, so it
must be JSON-serializable.
R = unknown
What the body resolves with — inferred from the declared
WorkflowDef.output schema when there is one, and from the function
otherwise. It reaches a caller as WorkflowRunSnapshot’s output, so
passing the workflow to start/get/find is what makes a completed
run’s result typed instead of unknown.
Properties
Section titled “Properties”description?
Section titled “description?”
optionaldescription?:string
What this workflow does. Not shown to an LLM — workflows are started by code, not chosen by a model.
input?
Section titled “input?”
optionalinput?:P
Schema for the run input, validated at start() so a bad payload fails at the call site.
output?
Section titled “output?”
optionaloutput?:StandardSchemaV1<unknown,R>
Schema for what a COMPLETED run answers with — input from the other end.
Optional, and a workflow that declares none behaves exactly as it always did. What declaring one buys is three things a body’s inferred return type cannot:
- The value is checked where the run completes, once, against this
schema; a body that returns something the declaration denies fails the
run rather than reporting
completedwith an output its own workflow says is impossible. A run’s output crosses a durable journal, a typed-JSON codec and an HTTP hop before a page reads it, anduseWorkflowRun<R>’srun.outputis otherwise an unchecked CLAIM about everything that happened in between. WorkflowOutputOfreads THIS, so a page’s type comes from the declaration rather than from inferring the body — which is what lets an annotatedagent.tsresolve it without the body’s signature. See that type for the circularity that removes.- A page can render results the way it renders the form, because the listing serves it as JSON Schema (WorkflowSummary.outputSchema).
Any Standard Schema, Zod by convention — the same acceptance as input,
and not a TypeScript type for the same reason: a type is erased, and this
has to be checked at run time and converted for a browser.
What is stored is the schema’s PARSED value, exactly as start() stores
the parsed input. So an unknown key a zod object strips is not in what the
caller reads back, and the type a caller holds is a promise the run kept
rather than a claim about it.
run:
WorkflowBody<InferSchemaOutput<P>,R>
The workflow body.
Takes the validated input and a WorkflowContext. The input is ONE object rather than a positional list on purpose — it is schema-validated, and a schema describes one value.
uploads?
Section titled “uploads?”
optionaluploads?: readonlystring[]
Input properties that carry an UPLOAD ID rather than a value of their own.
A run’s input is journaled and replayed on every resume, so a file’s bytes
may not travel in it — the bytes go to POST /workflows/uploads and the
input carries the id it answered with, which a step reads windows of through
stepReadUpload. Naming the property here is what makes that automatic at both
ends: <WorkflowFields> renders a file picker for it instead of a text box,
and useWorkflowSubmit uploads the chosen file and substitutes its id.
Declared on the workflow rather than in the schema because the schema may be
any Standard Schema, and a marker inside one would only work for the library
that happened to carry it. The property itself stays an ordinary
z.string() — an upload id is what the run really receives.
WorkflowInputOf
Section titled “WorkflowInputOf”WorkflowInputOf<
D> =Dextendsobject?I:never
A workflow’s INPUT type — what its declared schema parses to, which is exactly what the body’s parameter should be.
The reason it exists is that nothing checks a hand-written parameter.
WorkflowBody takes its input as a function PARAMETER, so it is
contravariant: a body declaring a WIDER shape than the schema produces is
assignable, and a body declaring the same shape with a field’s optionality or
a default’s type subtly different is assignable too. Both compile. A
z.number().default(5) against a body that writes input.limit ?? 3 is the
sharp version — the schema guarantees limit is present, the ?? is dead,
and the two numbers disagree with nothing to report it.
It reads the parameter WorkflowDef.run declares, which IS the schema’s
output (InferSchemaOutput<P>), by matching run’s shape — see
WorkflowOutputOf for why a reading matches a shape.
Two details a restated shape gets wrong by hand, both of which this gets
right for free. A zod .optional() infers a property that may be PRESENT AND
undefined, which under exactOptionalPropertyTypes is ?: T | undefined
and not ?: T — two templates carry the same four-line comment explaining
that, which is a comment z.infer makes unnecessary. And a .default() makes
the OUTPUT property required while the input stays optional, so a body reading
it needs no fallback at all.
Like WorkflowOutputOf, it needs no build step: import type is
erased, so a body in workflows/ naming WorkflowInputOf<typeof theDef>
through a type-only import of ../agent.ts drags no runtime cycle behind it.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”export const digest = workflow({ input: z.object({ topic: z.string(), limit: z.number().default(5) }), run: digestFlow,});
// workflows/digest.ts — `import type` is erased, so there is no cycle.import type { WorkflowInputOf } from "@alexkroman1/aai";import type { digest } from "../agent.ts";
export async function digestFlow(input: WorkflowInputOf<typeof digest>, ctx: WorkflowContext) { // `limit` is `number`, not `number | undefined` — the default already ran. return await research(input.topic, input.limit);}Published from @alexkroman1/aai as well as @alexkroman1/aai/workflow-api.
The root is the one an author wants: this annotation lives in a
workflows/*.ts body, next to the workflow() that declared it.
WorkflowRunOf
Section titled “WorkflowRunOf”WorkflowRunOf<
D> =WorkflowRunSnapshot<WorkflowOutputOf<D>>
A run of D, with its output already typed — WorkflowRunSnapshot and
WorkflowOutputOf composed.
The composition is what a tool reporting on a run actually holds, and writing
it out costs a three-name import (WorkflowRunSnapshot, WorkflowOutputOf,
and the def) at every such tool. Two templates compose it by hand today, in
files whose whole job is to answer “how is that run going”.
The result is still the DISCRIMINATED union, so isTerminal(run) and
run.status === "completed" narrow exactly as they do on the uncomposed type
— this names the shape, it does not flatten it.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”import type { WorkflowRunOf } from "@alexkroman1/aai";import { isTerminal } from "@alexkroman1/aai/workflow-api";import type { research } from "../agent.ts";
function describe(run: WorkflowRunOf<typeof research>): string { if (!isTerminal(run)) return "still working on it"; return run.status === "completed" ? run.output.summary : "that one did not finish";}Published from @alexkroman1/aai as well as @alexkroman1/aai/workflow-api,
because the caller this composition was written for is a *_status TOOL.
Note isTerminal is on /workflow-api only — it is a value, so it is not
erased, and a tool importing it is importing the client half on purpose.
Variables
Section titled “Variables”ASSEMBLYAI_TTS_VOICES
Section titled “ASSEMBLYAI_TTS_VOICES”
constASSEMBLYAI_TTS_VOICES:Readonly<Record<"alba"|"anna"|"charles"|"eve"|"george"|"jane"|"jean"|"mary"|"michael"|"paul"|"vera"|"giovanni"|"lola"|"juergen"|"rafael"|"estelle",AssemblyAITtsVoiceInfo>>
The voice catalog — voice id → the language it speaks and its accent.
The accent is descriptive metadata for choosing a voice, not a settable
option: AssemblyAITtsOptions has no accent field.
A constant rather than a sentence in a doc comment, because a wrong voice
id is a silent failure: it is a free-form string the service rejects
in-band after the socket opens, so the agent connects, reports ready, and
never speaks — the same shape as the unmapped-language bug below, and
nothing upstream of a live session catches it.
It is a constant for a second reason, learned the hard way. The list this
replaced lived in a doc comment and was simply wrong — it carried ten names
(azelma, cosette, fantine, javert, marius, peter_yearsley …)
that are in no published catalog, while omitting most of the real ones. A
list nobody can check drifts into fiction, and here the fiction is
indistinguishable, at authoring time, from a working agent.
Source: https://assemblyai.com/docs/voice-agents/voice-agent-api/voices
Anything that shows an author their choices — the scaffold guide, a picker — should read this rather than restate it. A partial list is what sends someone guessing, which is the failure being prevented. To look a voice up by a value typed AssemblyAITtsVoice, use ttsVoiceInfo.
The keys are spelled out in the annotation rather than named as a closed
AssemblyAITtsVoiceId union: a closed union an author can import is one a
catalog refresh breaks, and the open AssemblyAITtsVoice carries the
same literals for autocomplete. tts-voice-ids.test.ts holds the two lists
equal.
DEFAULT_GUARDRAIL_MAX_REVISIONS
Section titled “DEFAULT_GUARDRAIL_MAX_REVISIONS”
constDEFAULT_GUARDRAIL_MAX_REVISIONS:number
How many times a SubagentDef.guardrail may send an answer back when the subagent names no SubagentDef.maxRevisions of its own.
Declared here rather than in constants.ts for the reason
DEFAULT_STEP_MAX_ATTEMPTS is declared beside ctx.step: a budget whose
only reader is one field is documented by sitting next to it.
DEFAULT_STEP_MAX_ATTEMPTS
Section titled “DEFAULT_STEP_MAX_ATTEMPTS”
constDEFAULT_STEP_MAX_ATTEMPTS:number
Attempts a step gets when StepOptions.maxAttempts says nothing.
Three, which is what the DevKit’s queue hardcoded — kept deliberately so the migration changes no retry behaviour it does not have to. Note attempts ARE burned by failed boots, so a step can reach its ceiling without ever having run its body; that was true before this change and is unchanged by it.
DEFAULT_SYSTEM_PROMPT
Section titled “DEFAULT_SYSTEM_PROMPT”
constDEFAULT_SYSTEM_PROMPT:string
Default system prompt used when systemPrompt is not provided.
A general-purpose base for any kind of voice agent — assistant, support, tutor, game, companion. It covers only what every spoken conversation needs (voice delivery, transcript noise, tool fidelity) and leaves the persona and domain rules to the agent’s own instructions, which take precedence over these defaults.
Remarks
Section titled “Remarks”What it contains. Five sections, joined by blank lines, in this order — the last is included only when the session has tools:
- (role framing) — you are a voice agent on a live transcript; later agent instructions decide WHAT you do and do not override the two channel sections below.
## PERSONALITY— warm, calm, competent; fully overridable.## SPEAKING— two sentences per reply, an eight-word first sentence, no markdown, how to say numbers and identifiers, one question per turn.## LISTENING— read through fillers and self-corrections, take a value in one piece before asking for it spelled, normalize spoken identifiers.## TOOLS— never fabricate, act first and ask second, report results rather than intentions, and the mis-hearing retry ladder.
agent({ systemPrompt }) does NOT replace any of it — it is APPENDED.
buildSystemPrompt always emits these sections and then adds your
prompt last, under a header saying it overrides them where they conflict. So
write only your own domain rules:
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Cart", systemPrompt: "Only discuss items in the catalog.",});Do not interpolate this constant into that string. This doc used to show
exactly that (`${DEFAULT_SYSTEM_PROMPT}\n\nOnly discuss…`) on the false
premise that it was replaced, which sent the ~10,000-character voice core
twice — the repetition this module’s whole section split exists to prevent,
paid for in tokens on every turn and in a prompt that contradicts itself
where the two copies land under different precedence headers.
buildSystemPrompt now strips a leading copy rather than emitting it
again, so an agent that followed the old advice is corrected on upgrade; that
is a repair, not an invitation to keep composing.
It is exported to be READ, not composed: printed while tuning an agent, diffed across SDK versions, or asserted on in a test. The full text is assembled from parts and is not reproduced here — a second copy in a comment would drift from the one the agent runs.
DELEGATE_TOOL_NAME
Section titled “DELEGATE_TOOL_NAME”
constDELEGATE_TOOL_NAME:"delegate"="delegate"
The name the model calls a roster by.
One tool with a subagent argument rather than one tool PER subagent, which
is the other obvious lowering. Per-subagent tools put the roster in the tool
LIST, which reads well — and the list is fixed for the whole session
(toolSchemas is computed once and handed to the transport at session
creation, the same constraint sdk/dialog.ts documents), so a roster that
varies by state is unreachable either way, and n tools cost n schemas in every
request where this costs one. The deciding reason is smaller: delegate is
also where a shared instruction about HOW to brief a subagent goes, and n
copies of it is n places for it to drift.
HANDOFF_TOOL_NAME
Section titled “HANDOFF_TOOL_NAME”
constHANDOFF_TOOL_NAME:"handoff"="handoff"
The name the model hands off by.
MCP_SERVER_KEY_RE
Section titled “MCP_SERVER_KEY_RE”
constMCP_SERVER_KEY_RE:RegExp
The grammar for a server KEY — the name an author gives one server, and the first segment of every tool name it contributes.
The same shape a tool file name must have (tool-registry.ts), for the same
reason: it becomes part of what the MODEL calls, and providers reject a tool
name outside [a-zA-Z0-9_-]. Capped at 24 so that a key plus the mcp_
prefix plus a realistic remote name still clears MCP_TOOL_NAME_MAX
without truncation, which is the case where two remote tools can collapse
onto one name.
MCP_TOOL_NAME_MAX
Section titled “MCP_TOOL_NAME_MAX”
constMCP_TOOL_NAME_MAX:number
Longest tool name a provider accepts — OpenAI’s ^[a-zA-Z0-9_-]{1,64}$, the
strictest this SDK routes to, and therefore the one that decides. Same
constant and same reason as tool-registry.ts’s cap; a name over it is
refused when the tool list is sent, by a vendor, in a message that names
neither the server nor the tool.
MCP_TOOL_PREFIX
Section titled “MCP_TOOL_PREFIX”
constMCP_TOOL_PREFIX:"mcp_"="mcp_"
The prefix every MCP-derived tool name carries.
Namespacing is not tidiness here. An MCP server is a third party that
publishes its own tool names, so without a prefix a server could publish
transfer_funds and quietly stand where the agent’s own tool of that name
stood — the model would call it and nothing would say so. With the prefix,
shadowing a native tool takes an author writing a tools/mcp_*.ts file
themselves, and even that loses: the native tool wins and the drop is logged
(registerTools, in @alexkroman1/aai-runtime’s mcp-tools.ts).
SESSION_SOURCED_EVENT_TYPES
Section titled “SESSION_SOURCED_EVENT_TYPES”
constSESSION_SOURCED_EVENT_TYPES: readonly ["session.configured","session.reset","session.timed-out","custom.emitted","state.updated","usage.updated","guardrail.blocked","history.restored"]
The events only the SESSION itself can be the source of — never a transport.
The complement of what aai-runtime’s TransportEventBody accepts, and the
one place that decision is written down, so a new event is REPORTABLE by
default — the session publishes a report it has no case for — and needs
no edit to a list in another package. Each is here for a reason:
session.configured is the handshake, session.reset and
session.timed-out come from the client and the idle watchdog,
custom.emitted is ctx.send, state.updated is a syncState projection,
usage.updated and guardrail.blocked are the session’s own accounting and
refusals, and history.restored is a resume.
SessionEventSchema
Section titled “SessionEventSchema”
constSessionEventSchema:z.ZodDiscriminatedUnion<[z.ZodObject<{audioFormat:z.ZodString;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;sampleRate:z.ZodNumber;sessionId:z.ZodOptional<z.ZodString>;ttsSampleRate:z.ZodNumber;type:z.ZodLiteral<"session.configured">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"audio.completed">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"speech.started">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"speech.stopped">; },z.core.$strip>,z.ZodObject<{eotConfidence:z.ZodOptional<z.ZodNumber>;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;text:z.ZodString;type:z.ZodLiteral<"user-transcript.updated">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;text:z.ZodString;type:z.ZodLiteral<"user-transcript.committed">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;text:z.ZodString;type:z.ZodLiteral<"agent-transcript.updated">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;recovery:z.ZodOptional<z.ZodEnum<{session-failed:"session-failed";turn-failed:"turn-failed"; }>>;text:z.ZodString;type:z.ZodLiteral<"agent-transcript.committed">; },z.core.$strip>,z.ZodObject<{args:z.ZodRecord<z.ZodString,z.ZodUnknown>;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;toolCallId:z.ZodString;toolName:z.ZodString;type:z.ZodLiteral<"tool.called">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;result:z.ZodString;toolCallId:z.ZodString;type:z.ZodLiteral<"tool.completed">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"reply.completed">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"reply.cancelled">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"session.reset">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"session.timed-out">; },z.core.$strip>,z.ZodObject<{code:z.ZodEnum<{audio:"audio";connection:"connection";internal:"internal";llm:"llm";protocol:"protocol";stt:"stt";tool:"tool";tts:"tts"; }>;fatal:z.ZodBoolean;message:z.ZodString;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"error.reported">; },z.core.$strip>,z.ZodObject<{data:z.ZodUnknown;event:z.ZodString;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"custom.emitted">; },z.core.$strip>,z.ZodObject<{meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;state:z.ZodUnknown;type:z.ZodLiteral<"state.updated">; },z.core.$strip>,z.ZodObject<{inputTokens:z.ZodNumber;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;outputTokens:z.ZodNumber;steps:z.ZodNumber;totalTokens:z.ZodNumber;type:z.ZodLiteral<"usage.updated">; },z.core.$strip>,z.ZodObject<{direction:z.ZodEnum<{input:"input";output:"output"; }>;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;replacement:z.ZodString;type:z.ZodLiteral<"guardrail.blocked">; },z.core.$strip>,z.ZodObject<{durationMs:z.ZodNumber;limit:z.ZodEnum<{duration:"duration";words:"words"; }>;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;type:z.ZodLiteral<"user-turn.exceeded">;words:z.ZodNumber; },z.core.$strip>,z.ZodObject<{interrupted:z.ZodBoolean;latencyMs:z.ZodOptional<z.ZodNumber>;llm:z.ZodOptional<z.ZodObject<{durationMs:z.ZodNumber;inputTokens:z.ZodOptional<z.ZodNumber>;outputTokens:z.ZodOptional<z.ZodNumber>;steps:z.ZodNumber;ttftMs:z.ZodOptional<z.ZodNumber>; },z.core.$strip>>;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;stt:z.ZodOptional<z.ZodObject<{endpointingMs:z.ZodOptional<z.ZodNumber>; },z.core.$strip>>;tts:z.ZodOptional<z.ZodObject<{characters:z.ZodNumber;ttfbMs:z.ZodOptional<z.ZodNumber>; },z.core.$strip>>;type:z.ZodLiteral<"metrics.collected">; },z.core.$strip>,z.ZodObject<{messages:z.ZodArray<z.ZodObject<{content:z.ZodString;role:z.ZodEnum<{assistant:"assistant";user:"user"; }>; },z.core.$strip>>;meta:z.ZodObject<{at:z.ZodNumber;id:z.ZodString; },z.core.$strip>;toolCalls:z.ZodArray<z.ZodObject<{afterMessageIndex:z.ZodNumber;args:z.ZodRecord<z.ZodString,z.ZodUnknown>;callId:z.ZodString;name:z.ZodString;result:z.ZodOptional<z.ZodString>;status:z.ZodEnum<{done:"done";pending:"pending"; }>; },z.core.$strip>>;type:z.ZodLiteral<"history.restored">; },z.core.$strip>],"type">
The session event vocabulary — the ONE source of truth. SessionEventMap,
SessionEvent and SessionEventType are all derived from it.
VOICE_PRESETS
Section titled “VOICE_PRESETS”
constVOICE_PRESETS:Readonly<Record<"echoVerification"|"speechNormalization"|"natoAlphabet",string>>
The shipped text of every preset, keyed by the name agent({ voicePresets })
takes.
Exported to be READ — printed while tuning an agent, diffed across SDK
versions, asserted on in a spec, or quoted into an agent’s own
system-prompt.md when it wants the behaviour with one clause changed. It is
the same membership argument DEFAULT_SYSTEM_PROMPT passes, and the same
warning applies in reverse: do NOT interpolate a value here into your
systemPrompt in order to turn the preset on. Name it in voicePresets and
the framework emits it once, above your instructions, under a stated
precedence.
Typed as a record of STRINGS, not as its own text. It used to be
un-annotated and as const, so the declaration’s TYPE was the prompt text —
which made every wording change a change to the published TYPE, and every
such change a contract decision about nothing an author’s code can observe.
A preset’s words are a behaviour: they are reviewed in this file’s diff, held
to a token band by voice-presets.test.ts, and owed a changeset, the same
treatment DEFAULT_SYSTEM_PROMPT gets. The annotation also keeps the record
total — a name in VOICE_PRESET_NAMES with no text here is a compile error.
Example
Section titled “Example”Turn two of them on
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Pharmacy Line", voicePresets: ["echoVerification", "natoAlphabet"],});withLock
Section titled “withLock”
constwithLock: <T>(lock,key,fn,options?) =>Promise<T>
Run fn while holding a keyed lock, releasing it in every outcome.
Type Parameters
Section titled “Type Parameters”T
Parameters
Section titled “Parameters”(key, options?) => Promise<() => void>
string
() => Promise<T>
options?
Section titled “options?”Returns
Section titled “Returns”Promise<T>