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 | speaker, reached with ctx.delegate |
| who is speaking | roster — speaks: true entries 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";
// `sync: true` is what the browser sees; the prompt is system-prompt.md.export const cart = sessionSlot("cart", () => ({ items: [] as string[] }), { view: (c) => ({ count: c.items.length }), sync: true,});
export default agent({ name: "Storefront" });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-runtime/testing, @alexkroman1/aai-runtime/testing/vitest |
any test file — the pure fakes and readers (createToolContext, runTool, runWorkflow) from the first, every install* and the eval suites from the second; they re-export this package’s /testing and /testing/vitest |
@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.
The membership rule
Section titled “The membership rule”A name is on the root when an agent.ts, a tool module or a workflow()
body NAMES it. A name that a narrower subpath OWNS is re-exported here only
when it is one of:
- A type (or catalog) a root signature is spelled in — the stage types
AgentDefnames,LlmSpec,AssemblyAIGatewayModel, the voice catalog, the three…Of<typeof def>readings,ToolFailureand the helpers a tool body calls beside it — so an author never needs a second import to write down whatagent(),tool()orworkflow()asked for. - A MODE preset —
assemblyAIPipelineandassemblyAIS2s, the two configurations that pick a session mode. A factory that swaps ONE stage (assemblyAITts,assemblyAIStt,llm, every vendor’s) stays on its stage subpath;agent({ voice, llm: "<model id>" })covers the AssemblyAI stages without one.
Nothing else crosses: /utils’ formatters are read by a page or a step,
/step by a step, /workflow-api by a caller outside the agent. The
narrower subpath keeps every name it re-exports here and keeps owning its
capability.
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()”Call Signature
Section titled “Call Signature”agent<
W>(def):AgentDefWith<"pipeline",W>
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.
Overloaded over AgentMode, one signature per member of
AgentParams: mode picks the member, and a field that member does
not have is a compile error naming it — silence is a pipeline group,
so it does not exist on S2sAgentParams at all. With no mode the
agent is a pipeline agent. AgentDeclaration documents what every
field means; the returned AgentDef is the resolved shape.
The workflows map is kept as DECLARED on the result (its literal keys
and each def’s own type — AgentDefWith), so a page can name a
workflow through the agent: useWorkflowSubmit<typeof agentDef>("digest")
types the name as the keys the agent declares and the output as that body’s.
A def without workflows declares NO names: the typed form refuses every
name on it, and a workflows/<name>.ts the build discovers is reached by
the untyped string form (the def’s type cannot see the directory).
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).
Type Parameters
Section titled “Type Parameters”W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows
Parameters
Section titled “Parameters”PipelineAgentParams & object
Returns
Section titled “Returns”AgentDefWith<"pipeline", W>
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,
and a slot declared sessionSlot(key, create, { sync: true }) is what the
browser sees: agent() collects those projections into
AgentDef.syncState, which is not a parameter.
Nor is the system prompt — it is the system-prompt.md beside
agent.ts, discovered by the build; text computed beside it is
AgentDeclaration.promptContext.
Default pipeline with another voice and a different LLM
import { agent } from "@alexkroman1/aai";import { assemblyAITts } from "@alexkroman1/aai/tts";
export default agent({ name: "My Agent", tts: assemblyAITts({ voice: "aai-michael" }), llm: "claude-sonnet-4-6",});Call Signature
Section titled “Call Signature”agent<
W>(def):AgentDefWith<"s2s",W>
Define a speech-to-speech agent: mode: "s2s" and its s2s descriptor. See
S2sAgentParams.
Type Parameters
Section titled “Type Parameters”W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows
Parameters
Section titled “Parameters”Omit<AgentDeclaration, "systemPrompt" | "greeting" | "maxSteps" | "tools" | "syncState" | "mode" | "stt" | "llm" | "tts" | "s2s" | "turnTaking" | "interruption" | "silence" | "errorPhrase" | "startFailurePhrase" | "inputGuardrails" | "outputGuardrails" | "resetToolChoice" | "usageLimits" | "temperature" | "maxOutputTokens" | "maxRetries"> & Partial<Pick<AgentDeclaration, "greeting" | "maxSteps">> & object & object & object
Returns
Section titled “Returns”AgentDefWith<"s2s", W>
Example
Section titled “Example”import { agent, assemblyAIS2s } from "@alexkroman1/aai";
export default agent({ name: "Concierge", mode: "s2s", s2s: assemblyAIS2s() });Call Signature
Section titled “Call Signature”agent<
W>(def):AgentDefWith<"text",W>
Define a text agent: mode: "text", driven over a message list by
createTextAgent rather than by a session socket. See TextAgentParams.
Type Parameters
Section titled “Type Parameters”W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows
Parameters
Section titled “Parameters”Omit<SharedAgentParams, "sttPrompt" | "telephony"> & Pick<AgentDeclaration, keyof AgentModelTuning> & object & object
Returns
Section titled “Returns”AgentDefWith<"text", W>
Example
Section titled “Example”import { agent } from "@alexkroman1/aai";
export default agent({ name: "Docs Assistant", mode: "text", promptContext: "The docs cover the v2 API only.",});Call Signature
Section titled “Call Signature”agent<
W>(def):AgentDefWith<"workflow-app",W>
Define a workflow app: mode: "workflow-app". workflowApp is the
same member with the mode already set. See WorkflowAppAgentParams.
Type Parameters
Section titled “Type Parameters”W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows
Parameters
Section titled “Parameters”Omit<WorkflowAppAgentParams, "workflows"> & object
Returns
Section titled “Returns”AgentDefWith<"workflow-app", W>
Call Signature
Section titled “Call Signature”agent(
def):AgentDef
Any member of AgentParams — the signature a value typed as the whole union resolves against (an options bag assembled elsewhere, a wrapper that forwards its argument).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”apiKeyRoute()
Section titled “apiKeyRoute()”apiKeyRoute(
options,handler):RouteHandler
A route handler that runs handler only for a request carrying the key in
env[options.env].
Three answers, none of which run the handler: an unset variable is a
503 { error } naming it — the route is not CONFIGURED, which is the
operator’s problem and not the caller’s, so it is not a 401 that would send
them checking their key; a missing or wrong key is 401 { error }. The
compare is constant-time (_timing-safe-equal.ts), so a guess learns
nothing from how long a refusal took.
Routes are as open as the server (see agent-routes.ts’s security note),
which is what this is for: the app’s own callers hold the key, nobody else
does, and the key never leaves the env — the handler reads what it needs
from the request, not from the credential.
Parameters
Section titled “Parameters”options
Section titled “options”handler
Section titled “handler”Returns
Section titled “Returns”Example
Section titled “Example”import { agent, apiKeyRoute } from "@alexkroman1/aai";
export default agent({ name: "Errand calls", routes: { "POST /calls": apiKeyRoute({ env: "CALLS_API_KEY" }, async (req, ctx) => { const runId = await ctx.workflows.start("call", req.body); return { runId }; }), },});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", mode: "s2s", s2s: assemblyAIS2s({ voice: "jane", languages: ["en"] }),});Setting s2s replaces the whole stt/llm/tts pipeline. An S2S voice
rides on this descriptor, because the service synthesizes.
clientRunsRoutes()
Section titled “clientRunsRoutes()”clientRunsRoutes(
options?):Record<string,RouteHandler>
The pair of routes a page’s “Running” panel reads and cancels through —
GET <path> answering ClientRunsResponse, DELETE <path>/:runId
answering { cancelled } — to spread into agent({ routes }). See this
module’s doc.
Both require ?client= (a 400 without it). The cancel answers a 404 unless
the run’s correlation key is that client, and { cancelled: false } for a
run that had already finished.
Parameters
Section titled “Parameters”options?
Section titled “options?”The path, the window and the three per-run choices; see ClientRunsRoutesOptions.
Returns
Section titled “Returns”Record<string, RouteHandler>
The two handlers, keyed "GET <path>" and "DELETE <path>/:runId".
Example
Section titled “Example”A speaker’s running jobs, without the appEvent runs that told nobody
import { agent, clientRunsRoutes } from "@alexkroman1/aai";
export default agent({ name: "Kitchen speaker", routes: { ...clientRunsRoutes({ include: (r) => !(r.workflow === "appEvent" && r.status === "completed" && (r.output as { told?: unknown } | undefined)?.told !== true), progressFor: (r) => r.workflow === "research", // A call completes whether or not anyone answered: what it said is the result. detail: (r) => { const said = r.status === "completed" && r.workflow === "call" ? (r.output as { said?: unknown } | undefined)?.said : undefined; return typeof said === "string" ? said : undefined; }, }), },});clientTool()
Section titled “clientTool()”clientTool<
P>(def):ToolDef<P>
Define a tool the connected browser executes.
The model sees an ordinary tool. When it calls one, the page’s
useClientTool(name, handler) (from @alexkroman1/aai-ui) runs with the
validated arguments, and whatever the handler returns — JSON-serialized — is
the result the model reads. A handler that throws is a failed call the model
is told about, exactly as a server tool’s throw is. The call fails the same
way when no page answers within timeoutMs, or when the turn is cancelled.
Only a voice/browser session can answer one. A text agent, a subagent, or a
spec calling execute directly gets a failure naming the tool.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
Parameters
Section titled “Parameters”Returns
Section titled “Returns”ToolDef<P>
Example
Section titled “Example”`tools/get_location.ts`, answered by the page
import { clientTool } from "@alexkroman1/aai";import { z } from "zod";
export default clientTool({ description: "Get the caller's current location from their browser", inputSchema: z.object({}), timeoutMs: 20_000,});import { useClientTool } from "@alexkroman1/aai-ui";
function Location() { useClientTool("get_location", () => new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition( (p) => resolve({ lat: p.coords.latitude, lon: p.coords.longitude }), (err) => reject(new Error(err.message)), ), ), ); return null;}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 }),});codeMatches()
Section titled “codeMatches()”codeMatches(
said,hash):Promise<boolean>
Whether what a caller SAID is the code whose hashCode is hash.
said is normalized with spokenDigits — everything but digits dropped — so
STT’s "4819 02", "4819-02" and "it's 4 8 1 9 0 2" all match "481902".
The hashes are compared in constant time, and hex case is ignored. A
read-back with no digits at all never matches.
Parameters
Section titled “Parameters”string
string
Returns
Section titled “Returns”Promise<boolean>
Example
Section titled “Example”import { codeMatches } from "@alexkroman1/aai";
declare const stored: { codeHash: string; attempts: number };
export async function verify(said: string): Promise<boolean> { if (stored.attempts >= 5) return false; // the cap is the app's stored.attempts++; return await codeMatches(said, stored.codeHash);}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"); // -3describeRun()
Section titled “describeRun()”describeRun(
def,run):string
One spoken line about run: the workflow’s own describeRun when it
declared one, and a plain sentence per status otherwise.
The one place the fallback is spelled, so the workflow_status builtin and a
route handler answering “how is it going” say the same thing for the same
run. def is optional because the run may belong to a workflow this agent
no longer declares, and a run that outlived its declaration still has a
status to report.
import { describeRun, workflow, type WorkflowRunOf } from "@alexkroman1/aai";
// In an agent this is `import { digest } from "../agent.ts"`.const digest = workflow({ run: async () => ({ headline: "…" }) });
declare const run: WorkflowRunOf<typeof digest>;const line = describeRun(digest, run);Parameters
Section titled “Parameters”AnyWorkflowDef<unknown> | undefined
Returns
Section titled “Returns”string
dialog()
Section titled “dialog()”Call Signature
Section titled “Call Signature”dialog<
E>(key,machine,options?):Dialog<E>
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”E extends object
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”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<E>
Examples
Section titled “Examples”// 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);
// file: tools/quote_claim.ts// Cannot run before the caller is verified.import { z } from "zod";import { claim } from "../shared.ts";
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<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.
Write the spec inline, or hold it in a const with satisfies DialogSpec — never as const. The const S parameter keeps every on
name a literal on its own, and both forms keep the spec contextually typed,
so an onEnter: (ctx) => … sees ctx as a SlotHolder. An as const
assertion drops that contextual typing (the literal is typed before the
parameter sees it), which is TS7006 on every onEnter parameter and
nothing gained.
Type Parameters
Section titled “Type Parameters”S extends DialogSpec
Parameters
Section titled “Parameters”string
S
options?
Section titled “options?”Returns
Section titled “Returns”Dialog<DialogEvent<S>>
endSession()
Section titled “endSession()”endSession(
ctx,options?):boolean
End this session — a phone agent’s end_call.
By default the current reply finishes speaking first, so return what the agent should say last and the caller hears it:
import { endSession, tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Hang up. Call it once the caller has said goodbye.", inputSchema: z.object({}), execute(_args, ctx) { endSession(ctx); return { ended: true, say: "Say a short goodbye." }; },});The end is a normal stop: onSessionEnd fires and the session’s events are
flushed. A phone session’s carrier stream is closed, which with
<Connect><Stream> hangs the call up; a browser or device socket is closed
with a normal (1000) close. A second call is a no-op.
Parameters
Section titled “Parameters”Pick<ToolContext, "sessionId">
options?
Section titled “options?”Returns
Section titled “Returns”boolean
true when the session was live and will end; false when there is
nothing to end (it already ended, or this context belongs to no connected
session).
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 };});hashCode()
Section titled “hashCode()”hashCode(
code):Promise<string>
The SHA-256 of code, as lower-case hex — what to STORE for a one-time code
instead of the code itself.
Hashes exactly the string given. It does not normalize, because the one
string it is handed at mint time is already the canonical code; a READ-BACK
goes through codeMatches, which normalizes before it hashes. Hashing
a read-back here by hand means normalizing it first (spokenDigits).
A six-digit code’s hash is brute-forced in a millisecond by anyone holding it, so the hash keeps the code out of logs and backups, not out of a determined attacker with the table: the attempt cap and the expiry are the defence.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”Promise<string>
instructionOf()
Section titled “instructionOf()”instructionOf(
result):string|undefined
The instruction a tool result carries, or undefined when it carries none.
Structural, so it reads a result that crossed a JSON round trip (the
runtime’s own copy is a string by the time it looks) as well as the object
execute returned. A non-record, or a $instruction that is not a string,
answers undefined.
Parameters
Section titled “Parameters”result
Section titled “result”unknown
Returns
Section titled “Returns”string | undefined
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"]) });mintDigitCode()
Section titled “mintDigitCode()”mintDigitCode(
digits?):string
A digits-long code of decimal digits, drawn from the platform’s
cryptographic randomness with no modulo bias — "048190", leading zeros
kept, because the code is a STRING a caller reads, not a number.
Deliberately takes no random source, unlike mintCode: a code that proves
who is on the line must not be a journaled, replayable value. Mint it inside
a tool body or a ctx.step and store hashCode’s output, never the
code.
Parameters
Section titled “Parameters”digits?
Section titled “digits?”number
How many digits. Default 6. An integer from 1 to 32.
Returns
Section titled “Returns”string
Throws
Section titled “Throws”RangeError if digits is not an integer in that range.
Example
Section titled “Example”import { hashCode, mintDigitCode } from "@alexkroman1/aai";
const code = mintDigitCode(); // e.g. "048190"const stored = await hashCode(code); // keep this, say `code`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()”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”orFail<
A,R>(call): (…args) =>Promise<R>
The value, or abandon the surrounding failable with the failure — or, handed a FUNCTION, that function with its failure classified for the step engine.
Two arms, one meaning — “this, or fail the way the caller is owed”. In a tool, failing is answering a ToolFailure; in a step, it is throwing the verdict the engine retries on.
- A value (
orFail(lookup())): the value, unless it is aToolFailure, which abandons the enclosing failable with that exact failure. - A function (
orFail(stepGenerate)): the same function, except that what it throws — or a non-2xxResponseit resolves to — leaves as aFatalError, or aRetryableErrorcarrying the far side’s ownRetry-After(toStepErroron@alexkroman1/aai/step-errorsis the verdict; anything it cannot classify is rethrown unchanged). This is how aworkflows/step calls a/stepprimitive —orFail(stepFetch),orFail(stepGenerateJson),orFail(stepTranscribeSubmit),orFail(sendToChannel)— and it replaced the eight*OrFailtwins that spelled it one name per call. A failure labelled by its request (GET https://…) is the function arm’s one special case: a call whose first argument is a URL.
A ToolFailure is never a function, so the arms cannot be confused at run
time. A function VALUE passed only to be handed back — a helper answering
(() => X) | ToolFailure — now comes back wrapped; the wrapper calls through
and answers in kind (sync stays sync).
Type Parameters
Section titled “Type Parameters”A extends readonly unknown[]
R
Parameters
Section titled “Parameters”(…args) => Promise<R>
Returns
Section titled “Returns”(…args) => Promise<R>
Throws
Section titled “Throws”A private sentinel (value arm), 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.
Examples
Section titled “Examples”A tool helper
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 | ToolFailureA step
import { stepFetch, stepGenerate } from "@alexkroman1/aai/step";import { orFail } from "@alexkroman1/aai/step-errors";
export async function summarizeFeed(url: string): Promise<string> { // A 404 stops the step; a 429 waits the Retry-After the server named. const feed = await (await orFail(stepFetch)(url)).text(); return await orFail(stepGenerate)(feed, { system: "Summarize in two sentences." });}Call Signature
Section titled “Call Signature”orFail<
T>(value):T
The value arm of orFail: value, unless it is a ToolFailure,
which abandons the enclosing failable with that exact 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.
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); // 3readCallBrief()
Section titled “readCallBrief()”readCallBrief(
call,secret):Promise<CallBrief|undefined>
The brief a call’s parameters carry, or undefined when they were not
signed by this app — a stranger, a stale secret, a truncated stream, or no
secret at all (an unset env is a refusal, never an open door). The secret is
trimmed, as stepPlaceCall trims it before signing, so a value pasted with
a trailing newline verifies the calls it placed; a blank one is absent. An
id carrying a newline — the signed text’s separator — is refused unread,
so a signed brief cannot be replayed with part of its prompt moved into
the id.
The one reader: call is the SessionCall sessionContext and
onSessionEnd receive, or sessionCall(ctx) in a tool.
Parameters
Section titled “Parameters”parameters
Section titled “parameters”Readonly<Record<string, string>>
secret
Section titled “secret”string | undefined
Returns
Section titled “Returns”Promise<CallBrief | undefined>
Example
Section titled “Example”Refuse a stream the app did not dial
import { agent, readCallBrief } from "@alexkroman1/aai";
agent({ name: "Errand calls", telephony: ["twilio"], async sessionContext({ call, env }) { const brief = call && (await readCallBrief(call, env.CALLS_API_KEY)); if (!brief) return { refuse: "not a call this app placed" }; return { instructions: `Your task on this call: ${brief.prompt}` }; },});requireEnv()
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 aai-runtime/src/tools/executor.ts serializes and hands to the MODEL, so what a caller
hears is the agent apologising for something no log line explains.
import { requireEnv, tool } from "@alexkroman1/aai";import { z } from "zod";
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
requireSessionClient()
Section titled “requireSessionClient()”requireSessionClient(
ctx,message?):string|ToolFailure
This session’s client id (sessionClientId), or a ToolFailure
saying why the tool cannot run without one — the guard every tool that keys
work by device opens with.
import { requireSessionClient, tool } from "@alexkroman1/aai";import { isToolFailure } from "@alexkroman1/aai/utils";import { z } from "zod";
export default tool({ description: "Email them the last answer.", inputSchema: z.object({ body: z.string() }), async execute({ body }, ctx) { const clientId = requireSessionClient(ctx, "Email works on a speaker only."); if (isToolFailure(clientId)) return clientId; return { queued: body.length, for: clientId }; },});Parameters
Section titled “Parameters”Pick<ToolContext, "sessionId">
message?
Section titled “message?”string
The sentence the model reads when there is no client id.
Returns
Section titled “Returns”string | ToolFailure
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;}roster()
Section titled “roster()”roster<
N>(list):Roster<N>
Declare the roster — checked HERE, at module scope, because every refusal below stands in for a failure with no symptom (two entries of one name route to whichever the lookup finds; a tool two speakers own is gated by whichever landed last).
Type Parameters
Section titled “Type Parameters”N extends string
Parameters
Section titled “Parameters”readonly SpeakerDef<N>[]
Returns
Section titled “Returns”Roster<N>
route()
Section titled “route()”route<
S,Client>(def):RouteHandler
A route handler with its checks at the door: body validated against a
Standard Schema and ?client= required, each refused with a 400 and the
reason. A RouteError thrown inside answers its own status.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>
Client
Section titled “Client”Client extends boolean = false
Parameters
Section titled “Parameters”RouteDef<S, Client>
Returns
Section titled “Returns”Example
Section titled “Example”import { agent, route } from "@alexkroman1/aai";import { z } from "zod";
export default agent({ name: "Kitchen speaker", routes: { "PUT /profile": route({ body: z.object({ name: z.string().max(80) }), requireClient: true, handler: async (req) => ({ saved: req.body.name, for: req.clientId }), }), },});routeError()
Section titled “routeError()”routeError(
status,message):RouteError
A RouteError to throw from anywhere under a route handler:
throw routeError(400, "name: text up to 80 characters"). The route answers
that status with { error: message } rather than a 500.
Parameters
Section titled “Parameters”status
Section titled “status”number
message
Section titled “message”string
Returns
Section titled “Returns”Example
Section titled “Example”import { type RouteRequest, routeError } from "@alexkroman1/aai";
function appSlug(req: RouteRequest): string { const slug = req.params.app ?? ""; if (!/^[a-z0-9_-]{1,64}$/.test(slug)) throw routeError(400, "not an app name"); return slug;}routeResponse()
Section titled “routeResponse()”routeResponse(
status,body?,options?):RouteResponse
Answer a route with status and body instead of a plain 200:
return routeResponse(404, { error: "No such memory" }),
return routeResponse(201, created, { headers: { Location: "/memories/42" } }).
Throws a RangeError for a status that is not 2xx, 4xx or 5xx: a 1xx or a
redirect is not a JSON answer, and a typo there should fail where it was made.
Parameters
Section titled “Parameters”status
Section titled “status”number
unknown
options?
Section titled “options?”Returns
Section titled “Returns”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
sessionCall()
Section titled “sessionCall()”sessionCall(
ctx):SessionCall|undefined
The phone call this session is — the carrier, its call id and the stream’s
custom parameters — or undefined for a session that did not arrive on
WS /phone (a browser tab, a device).
The same object sessionContext and onSessionEnd receive as call:
import { sessionCall, tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Record how the call went.", inputSchema: z.object({ outcome: z.string() }), async execute({ outcome }, ctx) { const callId = sessionCall(ctx)?.parameters.call; if (!callId) return { error: "This is not one of our calls." }; await fetch(`${ctx.env.CALLS_URL}/calls/${callId}`, { method: "PATCH", body: JSON.stringify({ outcome }), }); return { recorded: true }; },});Parameters
Section titled “Parameters”Pick<ToolContext, "sessionId">
Returns
Section titled “Returns”SessionCall | undefined
sessionClientId()
Section titled “sessionClientId()”sessionClientId(
ctx):string|undefined
The client id this session’s device connected with (?client= on
WS /websocket), or undefined for a client that sent none — a browser tab,
a phone call.
Put it in a workflow’s input to reach the same device after the session ends:
import { sessionClientId, tool, workflow } from "@alexkroman1/aai";import { z } from "zod";
const remind = workflow({ input: z.object({ clientId: z.string(), text: z.string() }), run: async () => ({ delivered: true }),});
export default tool({ description: "Remind them about something later, on this speaker.", inputSchema: z.object({ text: z.string() }), async execute({ text }, ctx) { const clientId = sessionClientId(ctx); if (!clientId) return { error: "This device cannot receive reminders." }; await ctx.workflows.start(remind, { clientId, text }); return { scheduled: true }; },});Parameters
Section titled “Parameters”Pick<ToolContext, "sessionId">
Returns
Section titled “Returns”string | undefined
sessionClientLocation()
Section titled “sessionClientLocation()”sessionClientLocation(
ctx):string|undefined
Where this session’s client is — the location sessionContext answered for
it, else the one its socket reported (?location= on WS /websocket, the
location option of createBrowserSession and mountClient) — or
undefined when neither said.
The same value google_places and open_meteo default to, so a custom tool
that searches “near me” agrees with them:
import { sessionClientLocation, tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Find the nearest open pharmacy.", inputSchema: z.object({}), async execute(_args, ctx) { const near = sessionClientLocation(ctx); if (!near) return { error: "I don't know where this speaker is." }; const url = `${ctx.env.PHARMACY_API}/nearest?near=${encodeURIComponent(near)}`; const res = await fetch(url); return await res.json(); },});Trust model: it is whatever the client or the app CLAIMED. Nothing geolocates the caller. Personal data: do not log it.
Parameters
Section titled “Parameters”Pick<ToolContext, "sessionId">
Returns
Section titled “Returns”string | undefined
sessionClientPhone()
Section titled “sessionClientPhone()”sessionClientPhone(
ctx):string|undefined
The phone number this session’s client reported (?phone= on
WS /websocket, the phone option of createBrowserSession and
mountClient), in E.164 form — "+15035550123" — or undefined when it
reported none or an invalid one.
Trust model: it is whatever the connecting client CLAIMED. Nothing verifies the caller owns the number. A tool that texts it is texting a number the browser supplied — fine for a single-owner home device whose owner typed their own number in, but NOT for a public agent, where anyone who can open a session could point your SMS tool at any number they like.
So do not text it as is: pass it through allowedSmsRecipient (from
@alexkroman1/aai/channels), which uses it only when it equals
SMS_TO_PHONE or one of the comma-separated SMS_ALLOWED_PHONES, and
otherwise falls back to SMS_TO_PHONE. The text_me builtin does exactly
that. Personal data: do not log it.
import { sessionClientPhone, tool } from "@alexkroman1/aai";import { allowedSmsRecipient, sendToChannel, textbeltChannel } from "@alexkroman1/aai/channels";import { z } from "zod";
export default tool({ description: "Text the owner a link.", inputSchema: z.object({ url: z.string().url() }), async execute({ url }, ctx) { const to = allowedSmsRecipient(sessionClientPhone(ctx), ctx.env); const key = ctx.env.TEXTBELT_KEY; if (!to || !key) return { error: "Texting is not set up." }; await sendToChannel(textbeltChannel({ key, to }), { text: url }); return { sent: true }; },});Parameters
Section titled “Parameters”Pick<ToolContext, "sessionId">
Returns
Section titled “Returns”string | undefined
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 | (() => T)
A fresh value: a FACTORY, called once per session on first
access (and again on reset), or the plain VALUE, which the slot
structuredClones on every install so no two sessions share it. A shared
module-level default therefore no longer needs the
() => structuredClone(DEFAULT) wrapper — pass DEFAULT. A plain value
a durable slot could not store — a class instance, a Date, a Map, a
function field — is refused HERE, naming the slot and the field, by the
same check every store runs. A slot whose
value IS a function (a virtual slot holding a callback) must use the
factory form, since a function argument is read as one.
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 AgentDef.syncState holds
and useAgentState takes; sync: true is what hands it to agent().
Returns
Section titled “Returns”SessionSlot<K, T, V>
Example
Section titled “Example”// 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 }),});
// file: tools/add_item.ts// No cast, no annotation, no lazy-init boilerplate: the slot types the draft.import { z } from "zod";import { cartSlot } from "../shared.ts";
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 untouchedsignCallBrief()
Section titled “signCallBrief()”signCallBrief(
brief,secret):Promise<Record<string,string>>
The <Parameter>s for one call: call, sig, and the prompt as p0…pN.
What stepPlaceCall({ brief }) merges into the call’s parameters; exported
for an app that dials some other way and wants the same wire shape.
Parameters
Section titled “Parameters”secret
Section titled “secret”string
Returns
Section titled “Returns”Promise<Record<string, string>>
Throws
Section titled “Throws”When the prompt is over MAX_CALL_BRIEF_PROMPT_CHARS, or the id is empty or carries a newline (the separator of the signed text).
speaker()
Section titled “speaker()”Call Signature
Section titled “Call Signature”speaker<
N,S>(def):TypedSpeakerDef<InferSchemaOutput<S>,N>
Define a speaker. An identity function, like tool: it exists for the type (the name inferred as a LITERAL, the schema’s output typed), for the name to grep for, and so a speaker is declared at module scope where both a roster and a tool that delegates or hands off to it can import it.
Type Parameters
Section titled “Type Parameters”N extends string
S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”SpeakerDef<N> & object
Returns
Section titled “Returns”TypedSpeakerDef<InferSchemaOutput<S>, N>
Call Signature
Section titled “Call Signature”speaker<
N>(def):SpeakerDef<N>
Define a speaker. An identity function, like tool: it exists for the type (the name inferred as a LITERAL, the schema’s output typed), for the name to grep for, and so a speaker is declared at module scope where both a roster and a tool that delegates or hands off to it can import it.
Type Parameters
Section titled “Type Parameters”N extends string
Parameters
Section titled “Parameters”SpeakerDef<N>
Returns
Section titled “Returns”SpeakerDef<N>
spokenAlphanumeric()
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"stripInstruction()
Section titled “stripInstruction()”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.
Call Signature
Section titled “Call Signature”stripInstruction<
T>(result):Omit<T, typeofINSTRUCTION_KEY>
The tool result WITHOUT its instruction — a new object, the caller’s untouched. A result carrying none, or that is no record at all, is returned as it was.
The runtime calls this on the copy a page and a later tool read; a spec calls it to assert on a tool’s data without restating the key.
Type Parameters
Section titled “Type Parameters”T extends Record<string, unknown>
Parameters
Section titled “Parameters”result
Section titled “result”T
Returns
Section titled “Returns”Omit<T, typeof INSTRUCTION_KEY>
Call Signature
Section titled “Call Signature”stripInstruction(
result):unknown
The tool result WITHOUT its instruction — a new object, the caller’s untouched. A result carrying none, or that is no record at all, is returned as it was.
The runtime calls this on the copy a page and a later tool read; a spec calls it to assert on a tool’s data without restating the key.
Parameters
Section titled “Parameters”result
Section titled “result”unknown
Returns
Section titled “Returns”unknown
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 | undefined = 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 }; },});toolRefusal()
Section titled “toolRefusal()”toolRefusal(
reason,message):ToolRefusal
Build a ToolRefusal.
Parameters
Section titled “Parameters”reason
Section titled “reason”message
Section titled “message”string
Returns
Section titled “Returns”verifyStandardWebhook()
Section titled “verifyStandardWebhook()”verifyStandardWebhook(
req,secret,options?):Promise<boolean>
Whether a route request is a genuine Standard Webhooks delivery signed with
secret: its webhook-signature carries a v1 HMAC-SHA256 of
${webhook-id}.${webhook-timestamp}.${rawBody}, and the timestamp is within
toleranceS of now. Any of several space-separated signatures may match (a
secret rotation). Compared in constant time; never throws.
false for a missing header, an empty secret, a request with no rawBody,
or a stale timestamp.
Parameters
Section titled “Parameters”Pick<RouteRequest, "headers" | "rawBody">
secret
Section titled “secret”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<boolean>
Example
Section titled “Example”import { routeResponse, type RouteHandler, verifyStandardWebhook } from "@alexkroman1/aai";
export const onEvent: RouteHandler = async (req, { env }) => { if (!(await verifyStandardWebhook(req, env.WEBHOOK_SECRET ?? ""))) { return routeResponse(401, { error: "bad signature" }); } return { ok: true };};webhookRoute()
Section titled “webhookRoute()”webhookRoute(
options,handler):RouteHandler
A route handler that runs handler only for a delivery
verifyStandardWebhook accepts under the secret in env[secretEnv].
A bad or missing signature answers 401 { error }; an unset secret answers
500 naming the variable, and the handler never runs.
Parameters
Section titled “Parameters”options
Section titled “options”handler
Section titled “handler”Returns
Section titled “Returns”Example
Section titled “Example”import { agent, webhookRoute } from "@alexkroman1/aai";
export default agent({ name: "Kitchen speaker", routes: { "POST /composio/webhook": webhookRoute({ secretEnv: "COMPOSIO_WEBHOOK_SECRET" }, (req) => { return { received: req.headers["webhook-id"] }; }), },});withInstruction()
Section titled “withInstruction()”withInstruction<
T>(value,instruction):WithInstruction<T>
Attach a per-outcome instruction to a tool result.
Returned from execute in place of the bare value. The model reads the
instruction beside the result; the page and every later tool read the
result alone. A failure may carry one too — withInstruction(toolFailure("No such order."), "Ask for the order number again, digit by digit.") is still a
ToolFailure to isToolFailure.
Per OUTCOME, not per tool. A sentence that is the same on every call
belongs in the tool’s description or in messages with role: "system";
this is for the sentence the tool only knows once it has the answer.
Throws on a blank instruction: a reserved key with nothing in it would be stripped on the way out and the author would learn nothing.
Type Parameters
Section titled “Type Parameters”T extends Record<string, unknown>
Parameters
Section titled “Parameters”T
instruction
Section titled “instruction”string
Returns
Section titled “Returns”Example
Section titled “Example”import { tool, withInstruction } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Draft the day's plan", inputSchema: z.object({ goals: z.array(z.string()) }), execute: ({ goals }) => withInstruction( { steps: goals.map((goal, i) => `${i + 1}. ${goal}`) }, "Read the plan back in one breath, then ask whether to start.", ),});A result built from several arms (a union) keeps its event field only when
EVERY arm spells it: the intersection this returns stops TypeScript’s
object-literal normalisation, so an arm that stays put writes
next: undefined rather than omitting the key.
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.
Example
Section titled “Example”agent.ts declares 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;
tools/research.ts is the tool that starts a run.
import type { WorkflowContext } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";
export async function digestFlow(input: { topic: string }, ctx: WorkflowContext) { const headline = await ctx.step("headline", () => stepGenerate(`Headline: ${input.topic}`)); return { topic: input.topic, headline };}
// file: agent.tsimport { 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 },});
// file: tools/research.tsimport { 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.
Example
Section titled “Example”agent.ts declares 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;
tools/research.ts is the tool that starts a run.
import type { WorkflowContext } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";
export async function digestFlow(input: { topic: string }, ctx: WorkflowContext) { const headline = await ctx.step("headline", () => stepGenerate(`Headline: ${input.topic}`)); return { topic: input.topic, headline };}
// file: agent.tsimport { 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 },});
// file: tools/research.tsimport { 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,O>(def):WorkflowDeclaration<P,InferSchemaOutput<O>>
The two-phase form: declare everything but the body, and attach it with
.body(fn). See WorkflowDeclaration for the cycle this avoids and
what it costs. Listed AFTER the single-phase overloads, so a declaration
carrying run is still the plain form.
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" | "run"> & object
Returns
Section titled “Returns”WorkflowDeclaration<P, InferSchemaOutput<O>>
Call Signature
Section titled “Call Signature”workflow<
P>(def):WorkflowDeclaration<P,unknown>
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
Parameters
Section titled “Parameters”Omit<WorkflowDef<P, unknown>, "run">
Returns
Section titled “Returns”WorkflowDeclaration<P, unknown>
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.
Example
Section titled “Example”agent.ts declares 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;
tools/research.ts is the tool that starts a run.
import type { WorkflowContext } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";
export async function digestFlow(input: { topic: string }, ctx: WorkflowContext) { const headline = await ctx.step("headline", () => stepGenerate(`Headline: ${input.topic}`)); return { topic: input.topic, headline };}
// file: agent.tsimport { 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 },});
// file: tools/research.tsimport { 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<
W>(def):AgentDefWith<"workflow-app",W>
Define a WORKFLOW APP — an agent whose front door is a form rather than a
microphone, and whose work happens in workflows.
agent({ mode: "workflow-app", … }) 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. Returns the
same AgentDef agent() does — there is one definition type, one
config, one deploy path.
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.
requiredEnv defaults to WORKFLOW_APP_REQUIRED_ENV — the AssemblyAI
key every shipped workflow app reads; requiredEnv: [] opts out. The
workflows map comes back AS DECLARED (AgentDefWith), which is what
lets a page write useWorkflowSubmit<typeof app>("digest"). It is optional:
an app whose workflows are all workflows/<name>.ts default exports
declares none here (the build discovers them), and its page reaches them by
the untyped string form.
Type Parameters
Section titled “Type Parameters”W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows
Parameters
Section titled “Parameters”Omit<WorkflowAppAgentParams, "workflows" | "mode"> & object
Returns
Section titled “Returns”AgentDefWith<"workflow-app", W>
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.
RouteError
Section titled “RouteError”A route’s refusal, THROWN: the route answers status with
{ error: message }. Build one with routeError.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new RouteError(
status,message):RouteError
Throws a RangeError for a status that is not 4xx or 5xx.
Parameters
Section titled “Parameters”status
Section titled “status”number
message
Section titled “message”string
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
Properties
Section titled “Properties”status
Section titled “status”
readonlystatus:number
The HTTP status the route answers: 4xx or 5xx.
Interfaces
Section titled “Interfaces”AgentClientInbox
Section titled “AgentClientInbox”Input
The device-inbox half of an agent declaration — see this module’s header.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”clientInbox?
Section titled “clientInbox?”
optionalclientInbox?:ClientInboxOptions
Defaults for what a workflow pushes to a device over WS /inbox: today the
sampleRate stepSayOnClient speaks at. Serializable, and read by the
host that serves the inbox, so it holds for every run in the deployment.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Speaker", clientInbox: { sampleRate: 16_000 } });AgentDeclaration
Section titled “AgentDeclaration”Input
What an author may WRITE in agent({ … }) — and THE reference for what every
field means.
You write one member of AgentParams, chosen by AgentMode: these fields with the defaulted ones optional, cut down to the ones that mode has (a pipeline-only knob is simply absent from the S2S member). The members carry no prose of their own; a field’s documentation lives here once.
agent() returns the RESOLVED shape, AgentDef: these fields with the
defaults applied and the conveniences normalized away, plus the one table
no author writes (tools).
The field groups live on interfaces this extends, each sharing ONE rule:
PipelineTuning and PipelinePhrases (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),
AgentVoicePresets (paid for on every model request),
AgentSessionLifecycle (once per session), AgentRoutes and
AgentClientInbox (no session at all), AgentInstructions
(appended under the prompt) and AgentListing (read by a list, never
the model). The agent() union and the runtime refusal for an untyped
caller are both cut from those groups, so no field skips either.
Extends
Section titled “Extends”PipelineTuning.PipelinePhrases.AgentModelTuning.AgentGuardrails.AgentObservation.AgentVoicePresets.AgentSessionLifecycle.AgentRoutes.AgentClientInbox.AgentInstructions.AgentListing
Extended by
Section titled “Extended by”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)
clientInbox?
Section titled “clientInbox?”
optionalclientInbox?:ClientInboxOptions
Defaults for what a workflow pushes to a device over WS /inbox: today the
sampleRate stepSayOnClient speaks at. Serializable, and read by the
host that serves the inbox, so it holds for every run in the deployment.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Speaker", clientInbox: { sampleRate: 16_000 } });Inherited from
Section titled “Inherited from”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
Phrase spoken when the turn’s LLM stream fails — 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”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
interruption?
Section titled “interruption?”
optionalinterruption?:InterruptionTuning|"off"
When the caller may cut the agent off — see InterruptionTuning;
"off" means never.
Inherited from
Section titled “Inherited from”listing?
Section titled “listing?”
optionallisting?: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 system-prompt.md, and a line of context for the
model is promptContext.
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 listing is exactly
what such a consumer wants and could not get.
Inherited from
Section titled “Inherited from”
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 SpeakerDef.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" }, },});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. Each tokenEnv is added
to the deploy’s env check (see AgentDeclaration.requiredEnv).
optionalmode?:AgentMode
Which kind of agent this is — see AgentMode, which says what each mode has.
Default Value
Section titled “Default Value”"pipeline"
agent() always writes it on the definition it returns; absent means
"pipeline" everywhere it is read. It crosses the wire unchanged, so the
browser, the CLI and a deploy all see the same mode the author declared — a
workflow app is "workflow-app" there too, and its session-less front door
is a consequence of that rather than a second flag.
name:
string
Display name shown by the default client UI.
onSessionEnd?
Section titled “onSessionEnd?”
optionalonSessionEnd?: (ctx) =>unknown
Called each time a session stops — hang-up, disconnect or idle timeout —
after its events are written, so a run it starts can read them back with
stepClientTranscript.
Fire-and-forget: its return value is discarded, an async one is not awaited by anything the caller waits on, and a throw is logged. Delivery is at-least-once across a restart, so key the work it starts (see SessionEndContext.workflows).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”unknown
Inherited from
Section titled “Inherited from”AgentSessionLifecycle.onSessionEnd
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
promptContext?
Section titled “promptContext?”
optionalpromptContext?:AgentPromptContext
Context appended under the agent’s instructions — the discovered
system-prompt.md — separated by a blank line. See
AgentPromptContext for the two forms and when each is resolved.
A workflow app refuses it: nothing there reads a prompt.
Inherited from
Section titled “Inherited from”AgentInstructions.promptContext
requiredEnv?
Section titled “requiredEnv?”
optionalrequiredEnv?: readonlystring[]
Env var names this agent’s code reads that nothing else declares. Deploys
(and aai dev) check that every listed name is present in the agent’s env,
so a missing key surfaces at deploy time instead of as a runtime failure on
the first tool call.
Derived names do not belong here: provider credentials (from the
stt/llm/tts/s2s descriptors), each MCP server’s tokenEnv, and the
keys of the keyed BuiltinTools (brave_search, google_places,
text_me) are added to the check automatically. List what your own tools
and steps read.
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
roster?
Section titled “roster?”
optionalroster?:Roster<string>
The SpeakerDefs the MODEL routes to: handoff puts a speaks: true
entry on the line, delegate hands the rest a task. Mints both tools (and
each speaking entry’s gated tools) onto the resolved agent, so a tools/handoff.ts
or tools/delegate.ts beside it is a collision. Host-only. See sdk/roster.ts.
routes?
Section titled “routes?”
optionalroutes?:Record<string,RouteHandler>
JSON endpoints served under /api, keyed "<METHOD> <path>":
"GET /memories", "POST /memories/:id". A :name segment matches one
path segment and is handed to the handler as req.params.name. The method
is one of GET, POST, PUT, PATCH, DELETE; a key that is not that
shape fails the runtime’s start rather than never matching.
An unknown path is a JSON 404; a known path with the wrong method is a 405
naming the methods it has. Bodies are capped (MAX_ROUTE_BODY_BYTES on
aai-runtime, 64 KiB) and must be JSON.
As open as the server itself — see this module’s security note.
Inherited from
Section titled “Inherited from”
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.
sessionContext?
Section titled “sessionContext?”
optionalsessionContext?: (ctx) =>SessionContext|Promise<SessionContext|undefined> |undefined
Context for a session, fetched once when it connects and before its first model call.
import { agent } from "@alexkroman1/aai";
agent({ name: "Kitchen speaker", async sessionContext({ clientId, env, signal }) { if (!clientId) return undefined; const res = await fetch(`${env.MEMORY_URL}/profile/${clientId}`, { signal }); const { summary, summarizedUntil } = await res.json(); return { instructions: summary, historySince: summarizedUntil }; },});Bounded by SESSION_CONTEXT_TIMEOUT_MS (1.5 s): the caller is waiting to
be heard. A throw, a timeout or undefined is logged and the session starts
without it — a memory service that is down must not stop the speaker
answering.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”SessionContext | Promise<SessionContext | undefined> | undefined
Inherited from
Section titled “Inherited from”AgentSessionLifecycle.sessionContext
silence?
Section titled “silence?”
optionalsilence?:SilenceTuning
Dead air and the silence nudge — see SilenceTuning.
Inherited from
Section titled “Inherited from”startFailurePhrase?
Section titled “startFailurePhrase?”
optionalstartFailurePhrase?:string
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. 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”PipelinePhrases.startFailurePhrase
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.
syncState?
Section titled “syncState?”
optionalsyncState?:Readonly<Record<string,StateProjection<unknown>>>
The per-session state projected to the browser client, keyed by each
slot’s name — so the agent_state frame is { [slot]: view } and a custom
UI renders it without the agent hand-rolling a sync channel.
Not authored. agent() fills it from every slot declared
sessionSlot(key, create, { view, sync: true }) in a module agent.ts
imports (tools/ files included — the build re-collects at the lowering),
and refuses the key as a parameter. The slot owns its view, and the page
reads the same object (useAgentState(cartSlot.projected) reads
state.cart), so the frame rendered before the first push cannot describe
a different view from the ones after it. Two slots on one name are
refused by name.
A slot not declared sync: true never leaves the server: session state
routinely holds things a browser should not have, so the author decides
what leaves, and whatever a projection returns is exactly what the page
receives under that key. Pushed after every tool call, and only when a
projection changed (this shares a socket with 384 kbps of PCM).
import { agent, sessionSlot } from "@alexkroman1/aai";type Item = { sku: string; qty: number };// `staffPin` is not in the view, so it stays server-side.const cartSlot = sessionSlot("cart", () => ({ items: [] as Item[], staffPin: "" }), { view: (s) => ({ items: s.items }), sync: true,});const prefsSlot = sessionSlot("prefs", () => ({ units: "metric" }), { sync: true });// Two slots, one frame: { cart: { items }, prefs: { units } }.export default agent({ name: "Cart" });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.
Not authored. The prompt is the system-prompt.md beside agent.ts:
the build discovers the file and withSystemPrompt
(@alexkroman1/aai/manifest) sets it on the lowered definition, which is
also the door a host uses to set a prompt of its own. agent() fills the
framework default and refuses the key as a parameter. Text that has to be
computed — a menu, or a line over the session’s own state (which phase the
dialog is in, whether the caller is verified) — is
AgentInstructions.promptContext, a string or a per-request
resolver appended under the file.
Either half may be a resolver on the lowered def; one 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).
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”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.
optionaltts?:TtsProvider
Pluggable TTS provider for pipeline mode. Unset (with no s2s), the
stage defaults to assemblyAITts(). A voice is this descriptor’s option
(assemblyAITts({ voice: "aai-michael" })); there is no agent-level field.
turnTaking?
Section titled “turnTaking?”
optionalturnTaking?:TurnTakingTuning
When the caller’s turn ends — see TurnTakingTuning.
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.
Inherited from
Section titled “Inherited from”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.
AgentDef
Section titled “AgentDef”Sealed
The RESOLVED agent definition — what agent() returns and what a host runs.
Every AgentDeclaration field, with the core ones (systemPrompt,
greeting, maxSteps, mode) resolved to their final values, the author
conveniences (llm as a model-id string, the end-of-turn window)
normalized to one canonical spelling, the collected syncState, and the
two tables nobody authors. Optional fields remain optional — undefined
means “not configured”. Write an AgentParams member; read this.
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)
Inherited from
Section titled “Inherited from”clientInbox?
Section titled “clientInbox?”
optionalclientInbox?:ClientInboxOptions
Defaults for what a workflow pushes to a device over WS /inbox: today the
sampleRate stepSayOnClient speaks at. Serializable, and read by the
host that serves the inbox, so it holds for every run in the deployment.
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Speaker", clientInbox: { sampleRate: 16_000 } });Inherited from
Section titled “Inherited from”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.
Inherited from
Section titled “Inherited from”errorPhrase?
Section titled “errorPhrase?”
optionalerrorPhrase?:string
Phrase spoken when the turn’s LLM stream fails — 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”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)
Inherited from
Section titled “Inherited from”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)
Inherited from
Section titled “Inherited from”AgentDeclaration.idleTimeoutMs
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”AgentDeclaration.inputGuardrails
interruption?
Section titled “interruption?”
optionalinterruption?:InterruptionTuning|"off"
When the caller may cut the agent off — see InterruptionTuning;
"off" means never.
Inherited from
Section titled “Inherited from”listing?
Section titled “listing?”
optionallisting?: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 system-prompt.md, and a line of context for the
model is promptContext.
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 listing is exactly
what such a consumer wants and could not get.
Inherited from
Section titled “Inherited from”
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.
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”AgentDeclaration.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 SpeakerDef.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)
Inherited from
Section titled “Inherited from”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" }, },});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. Each tokenEnv is added
to the deploy’s env check (see AgentDeclaration.requiredEnv).
Inherited from
Section titled “Inherited from”
optionalmode?:AgentMode
Which kind of agent this is — see AgentMode, which says what each mode has.
Default Value
Section titled “Default Value”"pipeline"
agent() always writes it on the definition it returns; absent means
"pipeline" everywhere it is read. It crosses the wire unchanged, so the
browser, the CLI and a deploy all see the same mode the author declared — a
workflow app is "workflow-app" there too, and its session-less front door
is a consequence of that rather than a second flag.
Inherited from
Section titled “Inherited from”name:
string
Display name shown by the default client UI.
Inherited from
Section titled “Inherited from”onSessionEnd?
Section titled “onSessionEnd?”
optionalonSessionEnd?: (ctx) =>unknown
Called each time a session stops — hang-up, disconnect or idle timeout —
after its events are written, so a run it starts can read them back with
stepClientTranscript.
Fire-and-forget: its return value is discarded, an async one is not awaited by anything the caller waits on, and a throw is logged. Delivery is at-least-once across a restart, so key the work it starts (see SessionEndContext.workflows).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”unknown
Inherited from
Section titled “Inherited from”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”AgentDeclaration.outputGuardrails
promptContext?
Section titled “promptContext?”
optionalpromptContext?:AgentPromptContext
Context appended under the agent’s instructions — the discovered
system-prompt.md — separated by a blank line. See
AgentPromptContext for the two forms and when each is resolved.
A workflow app refuses it: nothing there reads a prompt.
Inherited from
Section titled “Inherited from”AgentDeclaration.promptContext
requiredEnv?
Section titled “requiredEnv?”
optionalrequiredEnv?: readonlystring[]
Env var names this agent’s code reads that nothing else declares. Deploys
(and aai dev) check that every listed name is present in the agent’s env,
so a missing key surfaces at deploy time instead of as a runtime failure on
the first tool call.
Derived names do not belong here: provider credentials (from the
stt/llm/tts/s2s descriptors), each MCP server’s tokenEnv, and the
keys of the keyed BuiltinTools (brave_search, google_places,
text_me) are added to the check automatically. List what your own tools
and steps read.
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.
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.
Inherited from
Section titled “Inherited from”AgentDeclaration.resetToolChoice
roster?
Section titled “roster?”
optionalroster?:Roster<string>
The SpeakerDefs the MODEL routes to: handoff puts a speaks: true
entry on the line, delegate hands the rest a task. Mints both tools (and
each speaking entry’s gated tools) onto the resolved agent, so a tools/handoff.ts
or tools/delegate.ts beside it is a collision. Host-only. See sdk/roster.ts.
Inherited from
Section titled “Inherited from”routes?
Section titled “routes?”
optionalroutes?:Record<string,RouteHandler>
JSON endpoints served under /api, keyed "<METHOD> <path>":
"GET /memories", "POST /memories/:id". A :name segment matches one
path segment and is handed to the handler as req.params.name. The method
is one of GET, POST, PUT, PATCH, DELETE; a key that is not that
shape fails the runtime’s start rather than never matching.
An unknown path is a JSON 404; a known path with the wrong method is a 405
naming the methods it has. Bodies are capped (MAX_ROUTE_BODY_BYTES on
aai-runtime, 64 KiB) and must be JSON.
As open as the server itself — see this module’s security note.
Inherited from
Section titled “Inherited from”
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.
Inherited from
Section titled “Inherited from”sessionContext?
Section titled “sessionContext?”
optionalsessionContext?: (ctx) =>SessionContext|Promise<SessionContext|undefined> |undefined
Context for a session, fetched once when it connects and before its first model call.
import { agent } from "@alexkroman1/aai";
agent({ name: "Kitchen speaker", async sessionContext({ clientId, env, signal }) { if (!clientId) return undefined; const res = await fetch(`${env.MEMORY_URL}/profile/${clientId}`, { signal }); const { summary, summarizedUntil } = await res.json(); return { instructions: summary, historySince: summarizedUntil }; },});Bounded by SESSION_CONTEXT_TIMEOUT_MS (1.5 s): the caller is waiting to
be heard. A throw, a timeout or undefined is logged and the session starts
without it — a memory service that is down must not stop the speaker
answering.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”SessionContext | Promise<SessionContext | undefined> | undefined
Inherited from
Section titled “Inherited from”AgentDeclaration.sessionContext
silence?
Section titled “silence?”
optionalsilence?:SilenceTuning
Dead air and the silence nudge — see SilenceTuning.
Inherited from
Section titled “Inherited from”startFailurePhrase?
Section titled “startFailurePhrase?”
optionalstartFailurePhrase?:string
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. 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”AgentDeclaration.startFailurePhrase
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()).
Inherited from
Section titled “Inherited from”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.
Inherited from
Section titled “Inherited from”syncState?
Section titled “syncState?”
optionalsyncState?:Readonly<Record<string,StateProjection<unknown>>>
The per-session state projected to the browser client, keyed by each
slot’s name — so the agent_state frame is { [slot]: view } and a custom
UI renders it without the agent hand-rolling a sync channel.
Not authored. agent() fills it from every slot declared
sessionSlot(key, create, { view, sync: true }) in a module agent.ts
imports (tools/ files included — the build re-collects at the lowering),
and refuses the key as a parameter. The slot owns its view, and the page
reads the same object (useAgentState(cartSlot.projected) reads
state.cart), so the frame rendered before the first push cannot describe
a different view from the ones after it. Two slots on one name are
refused by name.
A slot not declared sync: true never leaves the server: session state
routinely holds things a browser should not have, so the author decides
what leaves, and whatever a projection returns is exactly what the page
receives under that key. Pushed after every tool call, and only when a
projection changed (this shares a socket with 384 kbps of PCM).
import { agent, sessionSlot } from "@alexkroman1/aai";type Item = { sku: string; qty: number };// `staffPin` is not in the view, so it stays server-side.const cartSlot = sessionSlot("cart", () => ({ items: [] as Item[], staffPin: "" }), { view: (s) => ({ items: s.items }), sync: true,});const prefsSlot = sessionSlot("prefs", () => ({ units: "metric" }), { sync: true });// Two slots, one frame: { cart: { items }, prefs: { units } }.export default agent({ name: "Cart" });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.
Not authored. The prompt is the system-prompt.md beside agent.ts:
the build discovers the file and withSystemPrompt
(@alexkroman1/aai/manifest) sets it on the lowered definition, which is
also the door a host uses to set a prompt of its own. agent() fills the
framework default and refuses the key as a parameter. Text that has to be
computed — a menu, or a line over the session’s own state (which phase the
dialog is in, whether the caller is verified) — is
AgentInstructions.promptContext, a string or a per-request
resolver appended under the file.
Either half may be a resolver on the lowered def; one 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).
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.
Inherited from
Section titled “Inherited from”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"] });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”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.
Inherited from
Section titled “Inherited from”tools:
ToolMap
The tools the agent may invoke, keyed by the name the model calls.
Not authored. 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. It carries
no state type: a tool reaches session state through sessionSlot.
optionaltts?:TtsProvider
Pluggable TTS provider for pipeline mode. Unset (with no s2s), the
stage defaults to assemblyAITts(). A voice is this descriptor’s option
(assemblyAITts({ voice: "aai-michael" })); there is no agent-level field.
Inherited from
Section titled “Inherited from”turnTaking?
Section titled “turnTaking?”
optionalturnTaking?:TurnTakingTuning
When the caller’s turn ends — see TurnTakingTuning.
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.
Inherited from
Section titled “Inherited from”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”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.
Inherited from
Section titled “Inherited from”AgentGuardrails
Section titled “AgentGuardrails”Input
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.
AgentInstructions
Section titled “AgentInstructions”Input
The field group promptContext is declared in — one field, its own
interface for the reason every group has one: AgentDeclaration extends
it, and the rule it carries (appended under the prompt, static half on the
wire, function half resolved per session) is this module’s.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”promptContext?
Section titled “promptContext?”
optionalpromptContext?:AgentPromptContext
Context appended under the agent’s instructions — the discovered
system-prompt.md — separated by a blank line. See
AgentPromptContext for the two forms and when each is resolved.
A workflow app refuses it: nothing there reads a prompt.
AgentListing
Section titled “AgentListing”Input
The listing field group — see this module’s header.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”listing?
Section titled “listing?”
optionallisting?: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 system-prompt.md, and a line of context for the
model is promptContext.
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 listing is exactly
what such a consumer wants and could not get.
AgentModelTuning
Section titled “AgentModelTuning”Input
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 SpeakerDef.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”Input
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?:Readonly<Record<string,StateProjection<unknown>>>
The per-session state projected to the browser client, keyed by each
slot’s name — so the agent_state frame is { [slot]: view } and a custom
UI renders it without the agent hand-rolling a sync channel.
Not authored. agent() fills it from every slot declared
sessionSlot(key, create, { view, sync: true }) in a module agent.ts
imports (tools/ files included — the build re-collects at the lowering),
and refuses the key as a parameter. The slot owns its view, and the page
reads the same object (useAgentState(cartSlot.projected) reads
state.cart), so the frame rendered before the first push cannot describe
a different view from the ones after it. Two slots on one name are
refused by name.
A slot not declared sync: true never leaves the server: session state
routinely holds things a browser should not have, so the author decides
what leaves, and whatever a projection returns is exactly what the page
receives under that key. Pushed after every tool call, and only when a
projection changed (this shares a socket with 384 kbps of PCM).
import { agent, sessionSlot } from "@alexkroman1/aai";type Item = { sku: string; qty: number };// `staffPin` is not in the view, so it stays server-side.const cartSlot = sessionSlot("cart", () => ({ items: [] as Item[], staffPin: "" }), { view: (s) => ({ items: s.items }), sync: true,});const prefsSlot = sessionSlot("prefs", () => ({ units: "metric" }), { sync: true });// Two slots, one frame: { cart: { items }, prefs: { units } }.export default agent({ name: "Cart" });AgentRoutes
Section titled “AgentRoutes”Input
The routes field of an agent declaration — see this module’s header.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”routes?
Section titled “routes?”
optionalroutes?:Record<string,RouteHandler>
JSON endpoints served under /api, keyed "<METHOD> <path>":
"GET /memories", "POST /memories/:id". A :name segment matches one
path segment and is handed to the handler as req.params.name. The method
is one of GET, POST, PUT, PATCH, DELETE; a key that is not that
shape fails the runtime’s start rather than never matching.
An unknown path is a JSON 404; a known path with the wrong method is a 405
naming the methods it has. Bodies are capped (MAX_ROUTE_BODY_BYTES on
aai-runtime, 64 KiB) and must be JSON.
As open as the server itself — see this module’s security note.
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.
turn:
number
How many USER turns this session has taken so far — 0 before the caller
has said anything, 1 while the reply to the first thing they said is
being assembled. For a promptContext that changes with the
conversation’s age (“the caller has been on the line a while; wrap up”)
without a hook counting turns into a slot; the runtime reads it off the
session’s own turn counter, the same count the metrics report.
AgentSessionLifecycle
Section titled “AgentSessionLifecycle”Input
The session-bracketing half of an agent declaration — see this module’s header.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”onSessionEnd?
Section titled “onSessionEnd?”
optionalonSessionEnd?: (ctx) =>unknown
Called each time a session stops — hang-up, disconnect or idle timeout —
after its events are written, so a run it starts can read them back with
stepClientTranscript.
Fire-and-forget: its return value is discarded, an async one is not awaited by anything the caller waits on, and a throw is logged. Delivery is at-least-once across a restart, so key the work it starts (see SessionEndContext.workflows).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”unknown
sessionContext?
Section titled “sessionContext?”
optionalsessionContext?: (ctx) =>SessionContext|Promise<SessionContext|undefined> |undefined
Context for a session, fetched once when it connects and before its first model call.
import { agent } from "@alexkroman1/aai";
agent({ name: "Kitchen speaker", async sessionContext({ clientId, env, signal }) { if (!clientId) return undefined; const res = await fetch(`${env.MEMORY_URL}/profile/${clientId}`, { signal }); const { summary, summarizedUntil } = await res.json(); return { instructions: summary, historySince: summarizedUntil }; },});Bounded by SESSION_CONTEXT_TIMEOUT_MS (1.5 s): the caller is waiting to
be heard. A throw, a timeout or undefined is logged and the session starts
without it — a memory service that is down must not stop the speaker
answering.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”SessionContext | Promise<SessionContext | undefined> | undefined
AgentVoicePresets
Section titled “AgentVoicePresets”Input
The opt-in prompt presets, extended by AgentDef.
Its own interface for the reason PipelineTuning 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.
AnySessionEvent
Section titled “AnySessionEvent”Sealed
What a "*" handler receives: ANY event, by its envelope alone. Open on
purpose — a new event reaches a wildcard handler without a type change, and
a handler that wants a member’s fields declares the per-name handler.
Properties
Section titled “Properties”
readonlymeta:SessionEventMeta
readonlytype:string
ApiKeyRouteOptions
Section titled “ApiKeyRouteOptions”Input
What apiKeyRoute takes.
Properties
Section titled “Properties”env:
string
The agent-env variable holding the key, e.g. "CALLS_API_KEY".
header?
Section titled “header?”
optionalheader?:string
The request header the caller sends the key in, lower-case. Default
"authorization", where a Bearer prefix is optional; a custom header
("x-api-key") is compared as sent. On the deployed platform only
authorization and x-api-key reach the agent — the proxy forwards an
allow-list of request headers — so any other name works under aai dev
and self-hosted alone.
AssemblyAIPipelineOptions
Section titled “AssemblyAIPipelineOptions”Input
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 all three stages: STT, the LLM gateway, and
TTS (host: ASSEMBLYAI_TTS_EU_HOST). 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. "aai-jane", "aai-michael", "aai-alba". Defaults
to "aai-jane". See ASSEMBLYAI_TTS_VOICES (from @alexkroman1/aai/tts)
for the library voices; an unknown id is refused when the session opens. (tts: assemblyAITts({ voice }) is the same setting
without the preset.)
AssemblyAIS2sOptions
Section titled “AssemblyAIS2sOptions”Input
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”Input
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.
ClientRun
Section titled “ClientRun”Sealed
One row of the list clientRunsRoutes answers — plain JSON, what a page renders a run from.
Properties
Section titled “Properties”createdAt
Section titled “createdAt”createdAt:
number
When the run was created, as epoch ms — the list is in this order, oldest first.
detail?
Section titled “detail?”
optionaldetail?:string
One line about it, when there is one: options.detail’s answer; else, for
a failed run, its error as spokenErrorReason says it (short, no URL, no
credential); else, for a running one, its newest progress line when that
is a string.
runId:
string
The run’s id — what the cancel route takes.
status
Section titled “status”status:
ClientRunStatus
Where it has got to; see ClientRunStatus.
title:
string
The run’s label (StartOptions.label), else its workflow’s key.
workflow
Section titled “workflow”workflow:
string
The workflow’s key in agent({ workflows }).
ClientRunsResponse
Section titled “ClientRunsResponse”Sealed
The body the list route answers.
Properties
Section titled “Properties”runs:
ClientRun[]
The client’s runs, oldest first.
ClientRunsRoutesOptions
Section titled “ClientRunsRoutesOptions”Input
Options for clientRunsRoutes.
Properties
Section titled “Properties”detail?
Section titled “detail?”
optionaldetail?: (run) =>string|undefined
The run’s detail, when you have a better one — a completed call’s own
summary of what happened, say. undefined falls back to the defaults
ClientRun.detail lists.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”string | undefined
include?
Section titled “include?”
optionalinclude?: (run) =>boolean
Whether to list this run at all, before the recent-window rule. Default: every run. What drops a run that finished having decided to do nothing — an event judged not worth telling anyone about.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”boolean
limit?
Section titled “limit?”
optionallimit?:number
Most runs read per request, newest first, before filtering. Default 100 (the ceiling).
optionalpath?:string
The routes’ path, as an agent({ routes }) key spells it: the list is
GET <path>, the cancel DELETE <path>/:runId. Default "/tasks".
progressFor?
Section titled “progressFor?”
optionalprogressFor?: (run) =>boolean
Whether to read this RUNNING run’s newest progress line
(ctx.workflows.lastLine) for its detail. Default: every running run.
Each is one stream read per request, so narrow it to the workflows that
narrate. A line that cannot be read is simply not shown.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”boolean
recentMs?
Section titled “recentMs?”
optionalrecentMs?:number
A finished run is listed while it was created less than this long ago (see the module doc for why creation). Pending and running runs are always listed. Default 10 minutes.
DelegateAnswer
Section titled “DelegateAnswer”Sealed
ONE attempt at an answer — what a SpeakerGuardrail 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 SpeakerDef.expectedOutput.
toolCalls
Section titled “toolCalls”toolCalls: readonly
DelegateToolCall[]
Every tool call this attempt made, in order.
DelegateOptions
Section titled “DelegateOptions”Input
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 SpeakerDef.expectedOutput.
Inherited from
Section titled “Inherited from”toolCalls
Section titled “toolCalls”toolCalls: readonly
DelegateToolCall[]
Every tool call this attempt made, in order.
Inherited from
Section titled “Inherited from”DelegateToolCall
Section titled “DelegateToolCall”Sealed
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.
Dialog
Section titled “Dialog”Sealed
A dialog statechart bound to a session, created by dialog.
Type Parameters
Section titled “Type Parameters”E = { type: string; }
The event union Dialog.send and a gated tool’s
send/sendFrom accept: the machine’s own events for the machine form,
or the names a DialogSpec’s on maps spell.
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: 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 AgentDeclaration.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.timedOut": "abandoned" } }, abandoned: { final: true }, },});
export default agent({ name: "Support", events: { "session.timedOut": (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,K>(def,options?):DialogSendFromChecked<K,R,E,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>.
The optional second argument carries sendFrom for a tool whose def
cannot order it below an inline execute — see DialogSendOptions.
A send/sendFrom in both places is refused at declaration.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
R = unknown
K extends string = string
Parameters
Section titled “Parameters”DialogToolDef<P, R, E, K> & NoInfer<DialogSendFromGuard<K, R, E>>
options?
Section titled “options?”DialogSendOptions<R, E, K> & NoInfer<DialogSendFromGuard<K, R, E>>
Returns
Section titled “Returns”DialogSendFromChecked<K, R, E, 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:unknown
The machine itself, for a caller that wants to inspect or visualize it —
an XState machine, typed unknown so XState’s types stay off the contract:
dialog.machine as AnyStateMachine.
DialogGate
Section titled “DialogGate”Input
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 fields have
one home and DialogToolDef is BUILT from the two halves rather than
restating either; a slot-backed tool carries the same gate beside its
dialog (see SlotDialogGate).
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.
K extends string = string
Properties
Section titled “Properties”reset?
Section titled “reset?”
optionalreset?:true
Start the dialog over BEFORE the body runs — for a setup tool (start_order,
new_claim) that begins a fresh pass through the conversation.
when, if declared, is checked against the position before the reset.
The reset itself is applied once every setup stage has passed — after a
slot tool’s require and prepare, right before the body — so a tool
refused by its own precondition leaves the conversation where it was.
The post-body send/sendFrom is sent to the RESET dialog and is not
re-checked against when (it was declared for the dialog this tool just
started over, which when says nothing about). The initial state’s
onEnter does not fire on a reset, the same as on a fresh session.
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.
An event the CURRENT state does not declare (none of its own on keys,
nor an ancestor’s) is a no-op, as it is for Dialog.send: XState drops an
unhandled event, the position is answered unchanged, and nothing is
logged — the same for sendFrom.
sendFrom?
Section titled “sendFrom?”
optionalsendFrom?:DialogSendFrom<R,E,K>
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.
The string form reads the event off the result — sendFrom: "next"
sends { type: result.next }, or stays put when the field is undefined.
Typed as a key whose value is E["type"] | undefined
(DialogSendFromKey), it needs no callback and so no ordering: an
inline (result) => … written ABOVE an inline-arrow execute still sees
unknown (TS18046), and the field name does not. The other way around
that rule is the second argument, DialogSendOptions.
optionalwhen?: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.
Omitted, the tool is legal in EVERY state and never refuses — a tool
that reads or resets the dialog, or one whose only job is the envelope and
the send. It still answers the { ...position, result } envelope and
still honours send/sendFrom.
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.
DialogMachine
Section titled “DialogMachine”Input
The machine behind a dialog, as the SDK names it: an XState machine handed to
dialog(key, machine), or the one a DialogSpec compiles to.
The SDK’s own STRUCTURAL type rather than XState’s AnyStateMachine, so
XState’s types stay out of the published contract — an XState upgrade is not
an SDK break. Any XState machine satisfies it, and E is inferred from the
machine’s own events.
Type Parameters
Section titled “Type Parameters”E extends object = { type: string; }
The events the machine accepts.
Methods
Section titled “Methods”transition()
Section titled “transition()”transition(
snapshot,event,actorScope):unknown
Parameters
Section titled “Parameters”snapshot
Section titled “snapshot”never
E
actorScope
Section titled “actorScope”never
Returns
Section titled “Returns”unknown
Properties
Section titled “Properties”
readonlyid:string
DialogOptions
Section titled “DialogOptions”Input
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 ",".
DialogSendOptions
Section titled “DialogSendOptions”Input
The second argument of Dialog.tool (and of a gated slot tool): the transition, declared AFTER the def rather than inside it.
It exists for one reason — an inline-arrow execute is contextually typed
only once the whole object literal is, so a sendFrom written ABOVE it in
the same literal sees result as unknown (TS18046), and the fix was an
ordering rule nobody can see from the declaration. A second argument is
checked after the first has settled R, so the order inside the def no
longer matters. Declaring send/sendFrom in both places is refused at
declaration.
Type Parameters
Section titled “Type Parameters”R
What the tool’s execute returns.
E
The machine’s event union.
K extends string = string
Properties
Section titled “Properties”sendFrom
Section titled “sendFrom”sendFrom:
DialogSendFrom<R,E,K>
The transition the result decides — see DialogGate.sendFrom.
DialogSpec
Section titled “DialogSpec”Input
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”Input
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, interruption, 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”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.
interruption?
Section titled “interruption?”
optionalinterruption?:InterruptionTuning|"off"
How interruptible the agent is here — the same
PipelineTuning["interruption"] the agent declares, overriding it field by
field while this state is active. A disclosure state may need to FINISH
("off"); a menu state wants { minWords: 1 } so a caller can cut in on
the first word. See InterruptionTuning.
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.
onEnter?
Section titled “onEnter?”
optionalonEnter?: (ctx) =>void
Run when the dialog ENTERS this state — by Dialog.send or Dialog.receive, synchronously, inside the same mutation window the position moves in, after the new position has been written to the draft.
For the side effect a phase change owns: clearing a scratch slot on
checkout, stamping a timestamp on escalated. It is handed the
SlotHolder the move came through, so it can write OTHER slots; a
send/reset of this dialog from inside it throws (the window is open),
and a throw discards the move — nothing is stored, the caller sees the
error.
Synchronous, like every slot mutation: a thenable return is not awaited. XState’s entry semantics: a nested state fires its parent’s hook and then its own; a self-transition does not re-enter. A restored snapshot does not re-fire it, nor does a fresh session installing the initial state, nor Dialog.reset — only a move does.
A function, so it is the one field of a state that does not ride in the
machine’s meta; the spec form only (a machine passes XState’s own entry).
Parameters
Section titled “Parameters”Returns
Section titled “Returns”void
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: Roster.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”Input
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”Input
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”Input
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,K>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined
The tool’s input schema.
R
What execute returns.
E
The machine’s event union.
K extends string = string
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
effect?
Section titled “effect?”
optionaleffect?:ToolEffect
What the tool does to the world. "write" declares something that changes
state the caller cannot take back by asking — a booking, an order, a
cancellation, a message sent, a ticket filed for someone to act on. In a
voice pipeline a declared write runs only on a caller turn that did not
start over the agent’s own audio: an “uh-huh” spoken over “Shall I cancel
it?” is the caller listening, not agreeing, and the model is told to ask
again instead.
"handoff" declares a tool that ends the agent’s part of the
conversation — a transfer to a person, an escalation queue. In a voice
pipeline it runs only when the caller asked for a person in one of their
last two turns, or the model calls it again after a further caller turn;
otherwise the model is told the transfer was NOT made and to keep helping.
A transfer ends the call, so one the caller didn’t ask for loses every
change still left to make.
"read" or no effect runs as an ordinary call. A cart or draft step that
a later tool confirms is not a write; the confirming tool is. An MCP tool’s
effect comes from its server’s readOnlyHint (see mcp/adapt.ts).
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Cancel a pending order.", inputSchema: z.object({ orderId: z.string() }), effect: "write", execute: ({ orderId }) => ({ cancelled: orderId }),});Inherited from
Section titled “Inherited from”Omit.effect
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 }).
| undefined is spelled out so a wrapper can forward its own optional
schema field (inputSchema: spec.inputSchema) under
exactOptionalPropertyTypes; an explicit undefined means the same as an
absent key.
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
reset?
Section titled “reset?”
optionalreset?:true
Start the dialog over BEFORE the body runs — for a setup tool (start_order,
new_claim) that begins a fresh pass through the conversation.
when, if declared, is checked against the position before the reset.
The reset itself is applied once every setup stage has passed — after a
slot tool’s require and prepare, right before the body — so a tool
refused by its own precondition leaves the conversation where it was.
The post-body send/sendFrom is sent to the RESET dialog and is not
re-checked against when (it was declared for the dialog this tool just
started over, which when says nothing about). The initial state’s
onEnter does not fire on a reset, the same as on a fresh session.
Inherited from
Section titled “Inherited from”
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.
An event the CURRENT state does not declare (none of its own on keys,
nor an ancestor’s) is a no-op, as it is for Dialog.send: XState drops an
unhandled event, the position is answered unchanged, and nothing is
logged — the same for sendFrom.
Inherited from
Section titled “Inherited from”sendFrom?
Section titled “sendFrom?”
optionalsendFrom?:DialogSendFrom<R,E,K>
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.
The string form reads the event off the result — sendFrom: "next"
sends { type: result.next }, or stays put when the field is undefined.
Typed as a key whose value is E["type"] | undefined
(DialogSendFromKey), it needs no callback and so no ordering: an
inline (result) => … written ABOVE an inline-arrow execute still sees
unknown (TS18046), and the field name does not. The other way around
that rule is the second argument, DialogSendOptions.
Inherited from
Section titled “Inherited from”
optionalui?:ToolUi
How a page displays this tool’s calls — see ToolUi. Carried on the
wire declaration (ToolSchema.ui) the way messages is, so the default
client and a custom chrome render the same icon and label under aai dev
and on the platform.
Inherited from
Section titled “Inherited from”Omit.ui
optionalwhen?: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.
Omitted, the tool is legal in EVERY state and never refuses — a tool
that reads or resets the dialog, or one whose only job is the envelope and
the send. It still answers the { ...position, result } envelope and
still honours send/sendFrom.
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”Input
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”interruption?
Section titled “interruption?”
readonlyoptionalinterruption?:InterruptionTuning|"off"
How interruptible the agent is here — the agent’s own
PipelineTuning["interruption"], verbatim, overriding it field by field
while this state is active ("off": nothing cuts the agent off).
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”Input
Per-call options for Roster.handoff.
Properties
Section titled “Properties”
optionalnote?:string
What the next speaker should know that the transcript does not say — “the caller is verified”, “wants a refund on invoice 4471”. Rendered into the new speaker’s prompt section until the next handoff.
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 speaker that was on the line.
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 — its prompt section has already changed.
readonlyoptionalnote?:string
The note that travelled with it, when one did.
readonlyto:string
The speaker on the line now.
InterruptionTuning
Section titled “InterruptionTuning”Input
When the caller’s speech may cut the agent off, and what happens after.
As PipelineTuning["interruption"] it may also be "off": no interim and no
final ever cuts the agent off (both barge-in gates become unreachable), while
the caller’s words are still transcribed and answered once the agent
finishes. That spelling is for a dialog state (DialogStateSpec.interruption)
that must be heard in full — a disclosure — more than for a whole agent.
The one group a dialog state and a persona (SpeakerDef.interruption) may override, field by field, while they are active.
Properties
Section titled “Properties”backoffMs?
Section titled “backoffMs?”
optionalbackoffMs?:number
How long agent audio stays blocked after a real interruption, in ms
(Vapi’s backoffSeconds). SEQUENTIAL with
TurnTakingTuning.startSpeakingFloorMs, never cumulative: the two
are one deadline, max(floor, backoff).
Default Value
Section titled “Default Value”0 (DEFAULT_INTERRUPTION_BACKOFF_MS)
continuationWindowMs?
Section titled “continuationWindowMs?”
optionalcontinuationWindowMs?:number
When the caller starts speaking again within this many ms of a committed
turn, treat it as the REST of that turn: the reply already answering the
first fragment is cut, and the agent answers once the caller finishes.
Covers a caller who pauses mid-sentence — reading out a ZIP, recalling an
email — and gets split into two turns; without it the agent replies to
half the sentence first. 0 disables.
Default Value
Section titled “Default Value”3500 (DEFAULT_CONTINUATION_WINDOW_MS)
minDurationMs?
Section titled “minDurationMs?”
optionalminDurationMs?:number
Minimum sustained speech (ms since the utterance’s first interim
transcript) before an interim-triggered barge-in aborts the reply — a
duration gate beside minWords, mirroring LiveKit’s
min_interruption_duration. Committed turns are never gated; 0 disables.
Default Value
Section titled “Default Value”500 (DEFAULT_INTERRUPTION_MIN_DURATION_MS)
minWords?
Section titled “minWords?”
optionalminWords?:number
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”1 (DEFAULT_MIN_BARGE_IN_WORDS) — a one-word “Hello?”
probe must be able to stop the agent; minDurationMs is what keeps a
cough or an echo from doing so.
resumeFalseInterruption?
Section titled “resumeFalseInterruption?”
optionalresumeFalseInterruption?:boolean
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. The resume fires when the transcript stream goes quiet with no final, never on a deadline of its own.
Default Value
Section titled “Default Value”true; false disables recovery.
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 — the metrics.collected event itself, or a sample built by hand.
Parameters
Section titled “Parameters”sample
Section titled “sample”MetricsSample | { interrupted: boolean; latencyMs?: number; llm?: { durationMs: number; inputTokens?: number; outputTokens?: number; steps: number; ttftMs?: number; }; meta: SessionEventMeta; stt?: { endpointingMs?: number; }; tts?: { characters: number; ttfbMs?: number; }; type: "metrics.collected"; }
Type Literal
Section titled “Type Literal”{ interrupted: boolean; latencyMs?: number; llm?: { durationMs: number; inputTokens?: number; outputTokens?: number; steps: number; ttftMs?: number; }; meta: SessionEventMeta; stt?: { endpointingMs?: number; }; tts?: { characters: number; ttfbMs?: number; }; type: "metrics.collected"; }
interrupted
Section titled “interrupted”boolean
The reply was cut short; its stages are still reported.
latencyMs?
Section titled “latencyMs?”number
From the caller’s committed turn to the reply’s first audio.
{ durationMs: number; inputTokens?: number; outputTokens?: number; steps: number; ttftMs?: number; }
llm.durationMs
Section titled “llm.durationMs”number
llm.inputTokens?
Section titled “llm.inputTokens?”number
llm.outputTokens?
Section titled “llm.outputTokens?”number
llm.steps
Section titled “llm.steps”number
llm.ttftMs?
Section titled “llm.ttftMs?”number
{ endpointingMs?: number; }
stt.endpointingMs?
Section titled “stt.endpointingMs?”number
{ characters: number; ttfbMs?: number; }
tts.characters
Section titled “tts.characters”number
tts.ttfbMs?
Section titled “tts.ttfbMs?”number
"metrics.collected"
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”Input
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.
MetricsSample
Section titled “MetricsSample”Input
What MetricsCollector.collect reads off one frame — the measurements without the envelope, so a hook’s event and a transport’s body both fit.
Declared on its own rather than cut from MetricsCollectedEvent with
Omit: an author BUILDS one (or hands the event in), so it is an input, and
the event is a sealed output. Tying the two would make a field the session
adds to the event a required field every hand-built sample suddenly lacks.
metrics-collector.test.ts holds the event assignable to it.
Properties
Section titled “Properties”interrupted
Section titled “interrupted”interrupted:
boolean
The reply was cut short.
latencyMs?
Section titled “latencyMs?”
optionallatencyMs?:number
From the caller’s committed turn to the reply’s first audio.
optionalllm?:object
durationMs
Section titled “durationMs”durationMs:
number
inputTokens?
Section titled “inputTokens?”
optionalinputTokens?:number
outputTokens?
Section titled “outputTokens?”
optionaloutputTokens?:number
steps:
number
ttftMs?
Section titled “ttftMs?”
optionalttftMs?:number
optionalstt?:object
endpointingMs?
Section titled “endpointingMs?”
optionalendpointingMs?:number
optionaltts?:object
characters
Section titled “characters”characters:
number
ttfbMs?
Section titled “ttfbMs?”
optionalttfbMs?:number
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”Sealed
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”Input
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”Input
The per-REQUEST knobs every model loop this runtime runs takes — the agent’s own conversational loop and a SpeakerDef’s delegated one alike.
One declaration, extended by both, rather than a list each restates. 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 SpeakerDef omits: its guardrail budget is
maxRevisions.
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 SpeakerDef.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.
PipelinePhrases
Section titled “PipelinePhrases”Input
What the pipeline SAYS when a stage fails, so a provider outage hands the conversation back instead of going silent. Pipeline-only, like PipelineTuning; extended by AgentDef.
Extended by
Section titled “Extended by”Properties
Section titled “Properties”errorPhrase?
Section titled “errorPhrase?”
optionalerrorPhrase?:string
Phrase spoken when the turn’s LLM stream fails — 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)
startFailurePhrase?
Section titled “startFailurePhrase?”
optionalstartFailurePhrase?:string
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. 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)
PipelineTuning
Section titled “PipelineTuning”Input
The pipeline’s turn-taking tuning: three optional groups, extended by
AgentDef and present only on the pipeline member of agent().
import { agent } from "@alexkroman1/aai";
export default agent({ name: "Triage", turnTaking: { maxSilenceMs: 4500, userTurnLimit: { maxWords: 60 } }, interruption: { minWords: 3 }, silence: { nudge: { afterMs: 15_000 } },});Extended by
Section titled “Extended by”Properties
Section titled “Properties”interruption?
Section titled “interruption?”
optionalinterruption?:InterruptionTuning|"off"
When the caller may cut the agent off — see InterruptionTuning;
"off" means never.
silence?
Section titled “silence?”
optionalsilence?:SilenceTuning
Dead air and the silence nudge — see SilenceTuning.
turnTaking?
Section titled “turnTaking?”
optionalturnTaking?:TurnTakingTuning
When the caller’s turn ends — see TurnTakingTuning.
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”Input
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”Input
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”Input
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
RestoredToolCall
Section titled “RestoredToolCall”Sealed
One tool call a resumed conversation carries back — history.restored’s
toolCalls.
Properties
Section titled “Properties”afterMessageIndex
Section titled “afterMessageIndex”afterMessageIndex:
number
Index into messages the call follows; -1 means before any message.
args:
Record<string,unknown>
callId
Section titled “callId”callId:
string
name:
string
result?
Section titled “result?”
optionalresult?:string
status
Section titled “status”status:
"pending"|"done"
"pending" when no completion was recorded — it may have been in flight.
Roster
Section titled “Roster”Sealed
The roster the agent declares and every tool reaches for — what roster returns. A HANDLE, like Dialog: a handoff has to know the whole roster to name who it came FROM and refuse a target not on it.
Type Parameters
Section titled “Type Parameters”N extends string = string
The roster’s names, inferred from the literal names
speaker gives each entry, so desk.handoff(ctx, "biling") is a
compile error rather than a throw on a live call.
Methods
Section titled “Methods”active()
Section titled “active()”active(
ctx):SpeakerDef<N>
The speaker on the line now: position(ctx).speaker.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”SpeakerDef<N>
handoff()
Section titled “handoff()”handoff(
ctx,to,options?):HandoffResult
Put to on the line from the next model step on — one slot write. Return
(or fold in) the HandoffResult as the calling tool’s result.
Throws when to is not a speaking entry, or a dialog state PINS another
one (SpeakerPosition.pinnedBy); the minted handoff tool turns
both into a ToolRefusal for the model.
Parameters
Section titled “Parameters”N | SpeakerDef<N>
options?
Section titled “options?”Returns
Section titled “Returns”position()
Section titled “position()”position(
ctx):SpeakerPosition<N>
Who is on the line, and how they came to be. Throws when nobody speaks.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”Properties
Section titled “Properties”delegates
Section titled “delegates”
readonlydelegates: readonlySpeakerDef<N>[]
The entries that run off the line — what delegate routes between.
readonlylist: readonlySpeakerDef<N>[]
Every entry, in declaration order.
speaking
Section titled “speaking”
readonlyspeaking: readonlySpeakerDef<N>[]
The speaks: true entries, in order. The first is the ENTRY speaker.
RouteContext
Section titled “RouteContext”Sealed
What an agent({ routes }) handler is called with beside its request.
Methods
Section titled “Methods”clientTranscript()
Section titled “clientTranscript()”clientTranscript(
clientId,options?):Promise<ClientTranscript>
What clientId’s sessions said — the same read, and the same answer, as
stepClientTranscript on @alexkroman1/aai/step, bound to THIS runtime’s
log rather than to whichever runtime last published the step slot. The
shape a page picking a conversation to continue lists from: each session’s
id, startedAt, and its messages (the first is a preview, the count is
its length).
Rejects for a malformed client id. A backend that keeps no client log (the platform’s) answers no sessions.
Parameters
Section titled “Parameters”clientId
Section titled “clientId”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<ClientTranscript>
sessionTranscript()
Section titled “sessionTranscript()”sessionTranscript(
sessionId):Promise<readonlyClientTranscriptMessage[]>
What ONE session has said so far — its committed user and agent lines,
oldest first, the same lines onSessionEnd receives as transcript once
the session stops. Read from the session’s own log, flushed first, so a
LIVE session answers everything said up to now: the view a page polls
while a call this app placed is still going. Empty for an unknown session,
and for one that recorded nothing.
Keyed by session id rather than client id because a phone call names no
client. As with speech, a session id is not authorization: take it from
state your own code wrote, never from the request alone.
A spec builds a context with createRouteContext (@alexkroman1/aai/testing)
rather than by hand, so a field the runtime gains is not one every literal
has to learn.
Parameters
Section titled “Parameters”sessionId
Section titled “sessionId”string
Returns
Section titled “Returns”Promise<readonly ClientTranscriptMessage[]>
speech()
Section titled “speech()”speech(
sessionId):SessionSpeech|undefined
The speech of the LIVE session sessionId on this server: say a sentence
on that call, or stop the agent. See SessionSpeech. undefined
when no session by that id is live here: it ended, or it never existed.
A session id is not authorization. Routes are as open as the server
(see this module’s security note), so a handler that speaks into a call
must first establish that the request may: verify the webhook’s signature
(webhookRoute), and take the id from state your own code wrote (a tool
that registered the callback recorded ctx.sessionId), never from the
request alone.
Parameters
Section titled “Parameters”sessionId
Section titled “sessionId”string
Returns
Section titled “Returns”SessionSpeech | undefined
Properties
Section titled “Properties”env:
Readonly<Partial<Record<string,string>>>
The agent’s environment — the same view a tool reads as ctx.env.
signal
Section titled “signal”signal:
AbortSignal
Aborted when the caller goes away. Pass it to whatever you fetch with.
workflows
Section titled “workflows”workflows:
WorkflowClient
The same client a tool’s ctx.workflows is: start runs, and read them back
— find(workflow, clientId) for the runs a tool keyed by client,
recent(workflow) for the rest, lastLine(runId) for the newest progress.
RouteDef
Section titled “RouteDef”Input
What route takes.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1 = StandardSchemaV1<unknown, unknown>
Client
Section titled “Client”Client extends boolean = false
Properties
Section titled “Properties”
optionalbody?:S
A Standard Schema the JSON body must satisfy; the handler gets its output. A refused body (or none) is a 400 naming the issues.
handler
Section titled “handler”handler: (
req,ctx) =>unknown
The handler, with what the checks above guarantee typed in.
Parameters
Section titled “Parameters”ValidatedRouteRequest<InferSchemaOutput<S>, Client>
Returns
Section titled “Returns”unknown
requireClient?
Section titled “requireClient?”
optionalrequireClient?:Client
true: a request without a well-formed ?client= is a 400.
RouteRequest
Section titled “RouteRequest”Sealed
One request to an agent({ routes }) handler, already parsed.
Type Parameters
Section titled “Type Parameters”Body = unknown
Properties
Section titled “Properties”body:
Body
The request body parsed as JSON — for POST, PUT, PATCH and DELETE;
undefined for a GET, or for any request that sent no body. A body that
is not JSON is refused with a 400 before the handler runs.
clientId?
Section titled “clientId?”
optionalclientId?:string
The ?client= the request named, when it is a well-formed client id — the
same id a device’s voice socket and its WS /inbox are held under. A CLAIM:
nothing authenticates it (see this module’s security note).
headers
Section titled “headers”headers:
Record<string,string>
The request headers, names lower-cased: headers["webhook-signature"]. A
header sent more than once arrives as one value, joined with ", ". Plain
data, like the rest of the request.
May carry cookies and Authorization — see this module’s security note.
method
Section titled “method”method:
string
The HTTP method, upper-case: "GET", "POST", "PUT", "PATCH" or "DELETE".
params
Section titled “params”params:
Record<string,string>
The :name segments of the route’s pattern, decoded: { id: "42" }.
path:
string
The path the route matched, WITHOUT the /api prefix: "/memories/42".
query:
Record<string,string>
The query string, first value per key. ?client= is in it too.
rawBody?
Section titled “rawBody?”
optionalrawBody?:string
The request body exactly as received, as UTF-8 text — present whenever
body is, under the same cap and the same JSON-only rule.
These are the bytes a signature was computed over: verify a signed webhook
(an HMAC over ${id}.${timestamp}.${rawBody}, say) against THIS, never
against a re-serialization of body — JSON.stringify does not reproduce
the sender’s whitespace, key order or number spelling.
RouteResponse
Section titled “RouteResponse”Sealed
A route’s answer with a status of its own — what routeResponse makes.
Properties
Section titled “Properties”
readonlybody:unknown
Sent as JSON; undefined sends no body at all (a 204, say).
headers?
Section titled “headers?”
readonlyoptionalheaders?:Readonly<Record<string,string>>
Extra response headers, when the route set any (routeResponse’s headers).
status
Section titled “status”
readonlystatus:number
The HTTP status: 2xx, 4xx or 5xx.
RouteResponseOptions
Section titled “RouteResponseOptions”Input
The optional half of routeResponse.
Properties
Section titled “Properties”headers?
Section titled “headers?”
optionalheaders?:Readonly<Record<string,string>>
Response headers to send beside the JSON body — a Location for a 201, a
Retry-After for a 429, a Cache-Control. The body is always JSON, so a
Content-Type here is ignored in favour of the runtime’s own.
SessionContextArgs
Section titled “SessionContextArgs”Sealed
What AgentSessionLifecycle.sessionContext is called with.
Properties
Section titled “Properties”
optionalcall?:SessionCall
The phone call, for a session that arrived on WS /phone: the carrier, its
call id and the stream’s custom parameters (<Parameter>), from the
carrier’s start frame — which the runtime waits for before it asks you.
What the far end CLAIMED; check a parameter your app issued, and refuse
when it is not one. The same object sessionCall(ctx) reads.
clientId?
Section titled “clientId?”
optionalclientId?:string
The client the socket named with ?client=, when it named one — the key
the app’s own memory of that client is filed under. Absent for a browser
tab or a phone call that sent none.
env:
Readonly<Partial<Record<string,string>>>
The agent’s environment — the same view a tool reads as ctx.env.
sessionId
Section titled “sessionId”sessionId:
string
The session about to start (a fresh id, or the one a ?sessionId= resumed).
signal
Section titled “signal”signal:
AbortSignal
Aborted when the runtime stops waiting, SESSION_CONTEXT_TIMEOUT_MS after
the call. Pass it to whatever you fetch with; an answer that arrives after
it fired is dropped.
SessionEndContext
Section titled “SessionEndContext”Sealed
What AgentSessionLifecycle.onSessionEnd is called with.
Properties
Section titled “Properties”
optionalcall?:SessionCall
The phone call it was, for a WS /phone session — see SessionContextArgs.call.
clientId?
Section titled “clientId?”
optionalclientId?:string
The client it belonged to (?client=), when it named one.
env:
Readonly<Partial<Record<string,string>>>
The agent’s environment — the same view a tool reads as ctx.env.
lastEventIndex
Section titled “lastEventIndex”lastEventIndex:
number
The index of the last event this session’s log holds, already flushed — or
-1 for a session that recorded nothing. A session resumed and stopped
again ends with a HIGHER index, which is what makes it a new key.
sessionId
Section titled “sessionId”sessionId:
string
The session that stopped.
slots:
SlotStore
The session’s slot storage, as SessionEventContext.slots is — so a fact a
tool or a hook WROTE during the call (“went to voicemail”, the order taken)
is readable at its end through the slot that owns it: slot.get(ctx),
since a SessionEndContext is a SlotHolder. Reads only, in effect: the
session is over and nothing commits a write made here.
transcript
Section titled “transcript”transcript: readonly
ClientTranscriptMessage[]
What was SAID on this session, oldest first: every committed user and
agent line, the shape stepClientTranscript answers per session. Read
from the session’s own flushed log, so it is here for a session that
named no client (a phone call, a bare browser tab) as much as for one that
did — which is what lets an app that places calls record how the call
went without a client id to key a transcript read by.
COMMITTED lines only: an interrupted reply holds the words estimated
heard (the runtime’s own record), and a line the transport spoke because
the model could not (a recovery phrase) or a say with record: false
is not here — the same rule the model’s own history applies. Empty for a
session that recorded nothing, and for a log the backend could not read
(which is logged).
workflows
Section titled “workflows”workflows:
WorkflowClient
The same start() surface a tool’s ctx.workflows is. Start the run that
digests this session here, keyed so a second stop of the same session is a
no-op: { key: \${sessionId}:${lastEventIndex}` }`.
SessionEventMap
Section titled “SessionEventMap”Sealed
Every session event, keyed by its type.
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.
Properties
Section titled “Properties”agentTranscript.committed
Section titled “agentTranscript.committed”agentTranscript.committed:
object
The reply’s final text as the RECORD keeps it. Fires once per recorded
reply, never for an interrupted one — except when recovery or
recorded: false says otherwise.
meta:
SessionEventMeta
recorded?
Section titled “recorded?”
optionalrecorded?:false
recovery?
Section titled “recovery?”
optionalrecovery?:AgentTranscriptRecovery
text:
string
type:
"agentTranscript.committed"
agentTranscript.updated
Section titled “agentTranscript.updated”agentTranscript.updated:
object
The current reply’s transcript so far — a FULL-REPLACEMENT snapshot, which may be shorter than the one before it. Replace the text; never diff or append.
meta:
SessionEventMeta
text:
string
type:
"agentTranscript.updated"
audio.completed
Section titled “audio.completed”audio.completed:
object
All of this turn’s TTS audio has been sent — a turn BOUNDARY.
meta:
SessionEventMeta
type:
"audio.completed"
custom.emitted
Section titled “custom.emitted”custom.emitted:
object
An event the AGENT named, via ctx.send.
data:
unknown
event:
string
meta:
SessionEventMeta
type:
"custom.emitted"
error.reported
Section titled “error.reported”error.reported:
object
code:
SessionErrorCode
fatal:
boolean
Whether the session is over. False for a turn-level error it survives.
message
Section titled “message”message:
string
meta:
SessionEventMeta
type:
"error.reported"
guardrail.blocked
Section titled “guardrail.blocked”guardrail.blocked:
object
A guardrail stopped a turn and replacement was said instead.
direction
Section titled “direction”direction:
string&object|"input"|"output"
meta:
SessionEventMeta
replacement
Section titled “replacement”replacement:
string
type:
"guardrail.blocked"
history.restored
Section titled “history.restored”history.restored:
object
The conversation a RESUME restored; the client REPLACES its list from it.
messages
Section titled “messages”messages:
object[]
meta:
SessionEventMeta
toolCalls
Section titled “toolCalls”toolCalls:
RestoredToolCall[]
type:
"history.restored"
metrics.collected
Section titled “metrics.collected”metrics.collected:
object
What one reply cost, stage by stage. Durations are milliseconds.
interrupted
Section titled “interrupted”interrupted:
boolean
The reply was cut short; its stages are still reported.
latencyMs?
Section titled “latencyMs?”
optionallatencyMs?:number
From the caller’s committed turn to the reply’s first audio.
{
durationMs:number;inputTokens?:number;outputTokens?:number;steps:number;ttftMs?:number; }
meta:
SessionEventMeta
{
endpointingMs?:number; }
{
characters:number;ttfbMs?:number; }
type:
"metrics.collected"
provider.failedOver
Section titled “provider.failedOver”provider.failedOver:
object
A fallback([...]) stage switched provider.
from:
string
meta:
SessionEventMeta
reason
Section titled “reason”reason:
string
stage:
string&object|"stt"|"llm"|"tts"
to:
string
type:
"provider.failedOver"
reply.cancelled
Section titled “reply.cancelled”reply.cancelled:
object
The reply was abandoned — a barge-in, a client cancel, or a reset.
meta:
SessionEventMeta
type:
"reply.cancelled"
reply.completed
Section titled “reply.completed”reply.completed:
object
The reply finished on its own terms.
meta:
SessionEventMeta
type:
"reply.completed"
session.configured
Section titled “session.configured”session.configured:
object
The handshake: audio negotiation plus the session’s own id. First on every connection and sent at zero RTT.
audioFormat
Section titled “audioFormat”audioFormat:
string
meta:
SessionEventMeta
sampleRate
Section titled “sampleRate”sampleRate:
number
sessionId
Section titled “sessionId”sessionId:
string
Reconnect with ?sessionId=<id> to resume; the retained stream is read by it too.
ttsSampleRate
Section titled “ttsSampleRate”ttsSampleRate:
number
type:
"session.configured"
session.reset
Section titled “session.reset”session.reset:
object
The conversation was discarded and a new one begins (the transports greet on it).
meta:
SessionEventMeta
type:
"session.reset"
session.timedOut
Section titled “session.timedOut”session.timedOut:
object
Silence outlasted idleTimeoutMs. Informational: the server closes the socket.
meta:
SessionEventMeta
type:
"session.timedOut"
speech.started
Section titled “speech.started”speech.started:
object
The agent is YIELDING the floor (held back in pipeline mode until it really is).
meta:
SessionEventMeta
type:
"speech.started"
speech.stopped
Section titled “speech.stopped”speech.stopped:
object
meta:
SessionEventMeta
type:
"speech.stopped"
state.updated
Section titled “state.updated”state.updated:
object
The agent’s projected session state (see AgentDef.syncState); keep the LATEST.
meta:
SessionEventMeta
state:
Record<string,unknown>
type:
"state.updated"
tool.called
Section titled “tool.called”tool.called:
object
args:
Record<string,unknown>
meta:
SessionEventMeta
toolCallId
Section titled “toolCallId”toolCallId:
string
toolName
Section titled “toolName”toolName:
string
type:
"tool.called"
tool.completed
Section titled “tool.completed”tool.completed:
object
meta:
SessionEventMeta
result
Section titled “result”result:
string
toolCallId
Section titled “toolCallId”toolCallId:
string
type:
"tool.completed"
usage.updated
Section titled “usage.updated”usage.updated:
object
Cumulative model token usage for the session.
inputTokens
Section titled “inputTokens”inputTokens:
number
meta:
SessionEventMeta
outputTokens
Section titled “outputTokens”outputTokens:
number
steps:
number
totalTokens
Section titled “totalTokens”totalTokens:
number
type:
"usage.updated"
userTranscript.committed
Section titled “userTranscript.committed”userTranscript.committed:
object
The caller’s committed turn — the one that enters history.
meta:
SessionEventMeta
text:
string
type:
"userTranscript.committed"
userTranscript.updated
Section titled “userTranscript.updated”userTranscript.updated:
object
Interim user transcript — live captions while the user is still speaking.
eotConfidence?
Section titled “eotConfidence?”
optionaleotConfidence?:number
The STT service’s confidence, 0..1, that the user’s turn has ENDED as of this interim, when the provider reports one. Absent means “no opinion”.
meta:
SessionEventMeta
text:
string
type:
"userTranscript.updated"
userTurn.exceeded
Section titled “userTurn.exceeded”userTurn.exceeded:
object
The caller’s turn passed a userTurnLimit.
durationMs
Section titled “durationMs”durationMs:
number
limit:
string&object|"words"|"duration"
meta:
SessionEventMeta
type:
"userTurn.exceeded"
words:
number
SessionEventMeta
Section titled “SessionEventMeta”Sealed
The envelope every session event carries.
Properties
Section titled “Properties”at:
number
When the event was stamped — epoch milliseconds, the writer’s clock.
id:
string
This event’s identity: evt_ + a ULID — the key for idempotent ingestion, not a cursor.
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 — the factory the slot
was declared with, or a structuredClone of the plain value it was.
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>
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()”Call Signature
Section titled “Call Signature”tool<
P,R,E,K>(def,options?):DialogSendFromChecked<K,Awaited<R>,E,ToolDef<P,GatedOutput<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>.
Declaring dialog gates it (see SlotDialogGate): the refusal
runs before the read, send/sendFrom after a success, and the tool
answers the DialogToolResult envelope exactly as Dialog.tool
does — which is why that overload’s output is wrapped and async. A
require precondition runs before the body on either overload. A
field-name sendFrom naming a field that holds no event is refused at
the call (DialogSendFromGuard).
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
R = unknown
E extends object = { type: string; }
K extends string = string
Parameters
Section titled “Parameters”SlotToolDef<P, DeepReadonly<T>, R> & SlotDialogGate<R, E, K> & NoInfer<DialogSendFromGuard<K, Awaited<R>, E>>
options?
Section titled “options?”DialogSendOptions<Awaited<R>, E, K>
Returns
Section titled “Returns”DialogSendFromChecked<K, Awaited<R>, E, ToolDef<P, GatedOutput<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 }),});Call Signature
Section titled “Call Signature”tool<
P,R>(def):ToolDef<P,R>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
R = unknown
Parameters
Section titled “Parameters”SlotToolDef<P, DeepReadonly<T>, R> & UngatedDef
Returns
Section titled “Returns”ToolDef<P, R>
transact()
Section titled “transact()”transact<
Prep,R>(ctx,tx):Promise<ToolFailure|R>
An await ahead of a synchronous window, from inside a plain tool(): the
non-tool form of SlotPreparedToolDef.
prepare runs on the frozen value (and require, if declared, before it
and again right before the window); a ToolFailure from either ends
the transaction as its result, and nothing is stored. commit is the
window — handed the draft and what prepare produced — and its return is
the transaction’s; a ToolFailure it RETURNS stores nothing either,
like a throw.
Type Parameters
Section titled “Type Parameters”Prep
R
Parameters
Section titled “Parameters”SlotTransaction<T, Prep, R>
Returns
Section titled “Returns”Promise<ToolFailure | R>
Example
Section titled “Example”import { sessionSlot, tool, toolFailure } 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: (_args, ctx) => cartSlot.transact(ctx, { require: (cart) => (cart.items.length === 0 ? toolFailure("The cart is empty.") : undefined), prepare: async (cart) => Number((await ctx.generate({ prompt: `price ${cart.items}` })).text), commit: (cart, quote) => { cart.quote = quote; return { quote }; }, }),});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()”Call Signature
Section titled “Call Signature”updateTool<
P,Prep,R,R2,E,K>(def,options?):DialogSendFromChecked<K,Awaited<R2>,E,ToolDef<P,GatedOutput<R2>>>
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.
The four overloads are one pipeline with optional stages, in order:
the dialog refusal (dialog), require on the frozen value, the async
prepare and a second require, the synchronous window (execute, then
the slot’s after and caps), report on the stored value, and the
dialog’s send/sendFrom with the envelope. A tool declaring prepare
or dialog is therefore async and may answer a ToolFailure; one
declaring neither keeps the synchronous R it always had. See
SlotPreparedToolDef, SlotUpdateToolDef.report and
SlotDialogGate. A require refusal is a ToolFailure the
declared result type does not spell on the plain overload, like a thrown
error’s — the model-facing channel, not the client’s.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
Prep = unknown
R = unknown
R2 = R
E extends object = { type: string; }
K extends string = string
Parameters
Section titled “Parameters”SlotPreparedToolDef<P, T, Prep, R, R2> & SlotDialogGate<R2, E, K> & RejectThenable<R> & NoInfer<DialogSendFromGuard<K, Awaited<R2>, E>>
options?
Section titled “options?”DialogSendOptions<R2, E, K>
Returns
Section titled “Returns”DialogSendFromChecked<K, Awaited<R2>, E, ToolDef<P, GatedOutput<R2>>>
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 }; },});Call Signature
Section titled “Call Signature”updateTool<
P,Prep,R,R2>(def):ToolDef<P,Promise<ToolFailure|R2>>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
Prep = unknown
R = unknown
R2 = R
Parameters
Section titled “Parameters”SlotPreparedToolDef<P, T, Prep, R, R2> & RejectThenable<R> & UngatedDef
Returns
Section titled “Returns”ToolDef<P, Promise<ToolFailure | R2>>
Call Signature
Section titled “Call Signature”updateTool<
P,R,R2,E,K>(def,options?):DialogSendFromChecked<K,Awaited<R2>,E,ToolDef<P,GatedOutput<R2>>>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
R = unknown
R2 = R
E extends object = { type: string; }
K extends string = string
Parameters
Section titled “Parameters”SlotUpdateToolDef<P, T, R, R2> & SlotDialogGate<R2, E, K> & RejectThenable<R> & NoInfer<DialogSendFromGuard<K, Awaited<R2>, E>>
options?
Section titled “options?”DialogSendOptions<R2, E, K>
Returns
Section titled “Returns”DialogSendFromChecked<K, Awaited<R2>, E, ToolDef<P, GatedOutput<R2>>>
Call Signature
Section titled “Call Signature”updateTool<
P,R,R2>(def):ToolDef<P,R2|Extract<R,ToolFailure>>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined = ToolInputSchema
R = unknown
R2 = R
Parameters
Section titled “Parameters”SlotUpdateToolDef<P, T, R, R2> & RejectThenable<R> & UngatedDef
Returns
Section titled “Returns”ToolDef<P, R2 | Extract<R, ToolFailure>>
Properties
Section titled “Properties”
readonlyoptionalcaps?:Readonly<Partial<Record<string,number>>>
The caps the slot DECLARED (SessionSlotOptions.caps), when it
declared any — read back by expectSlotContract, so a spec need not
restate them. Typed as the record it is on the wire rather than
SlotCaps<T>, which is never for the erased T the SlotValueOf
constraint reads through.
durable
Section titled “durable”
readonlydurable:boolean
Whether this slot’s value is stored durably. true unless the slot
declared otherwise — see SessionSlotOptions.durable.
empty?
Section titled “empty?”
readonlyoptionalempty?: () =>T
The seeded slot’s blank (SessionSlotOptions.empty), when declared.
Returns
Section titled “Returns”T
readonlykey:K
The store key this slot occupies. Two slots must not share one.
projected
Section titled “projected”
readonlyprojected:StateProjection<V>
This slot’s view as a syncState projection — THE way to project a slot.
Built once, at declaration, so both ends 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, so the frame rendered before the first push
cannot describe a different view than the frames pushed after it. Being
built at declaration also makes it identity-stable, which useAgentState
memoizes its empty frame on.
Callable with nothing — cartSlot.projected() is the pre-first-tool-call
frame: the default (or SessionSlotOptions.empty), with after and
caps applied. With no SessionSlotOptions.view, it projects the
whole value; declare one to narrow it to what the page renders.
Example
Section titled “Example”import { agent, sessionSlot } from "@alexkroman1/aai";
const cartSlot = sessionSlot("cart", () => ({ items: [] as string[] }), { view: (cart) => ({ count: cart.items.length }), sync: true, // collected into the agent's `syncState`});
export default agent({ name: "Shop" });SessionSlotOptions
Section titled “SessionSlotOptions”Input
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 value 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 runs on EVERY store, not only update: on the default the first
get installs, on set, on reset, and on the frame projected()
renders with no argument. A derived field the hook computes was absent
from the default and present after the first update, so a page rendered
two shapes of one slot and a tool reading the default saw the field
missing. Caps still apply after it, on every path.
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.
A throw from it on a value the SLOT produced is framed: on the default
and on the empty frame the error names the slot and the phase (“failed
on the default”, “on the empty frame”), with the hook’s own error as its
cause, since a hook written for a populated value is most often tripped
by the blank the slot made for it. On a caller’s own value (set, a draft)
it is the caller’s error, unchanged.
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.
empty?
Section titled “empty?”
optionalempty?: () =>T
What SessionSlot.projected projects for a session that has not
touched the slot, INSTEAD of create().
For a SEEDED slot: create builds the catalogue or the demo accounts the
first get installs, and projected() with no argument — the frame a page
renders before its first push — would otherwise ship that seed to the
browser. empty() is the shape with nothing in it. Runs through after
and caps like any stored value.
Returns
Section titled “Returns”T
optionalsync?:boolean
Register SessionSlot.projected with agent(), which collects
every slot declared with sync: true into AgentDef.syncState — the
only way a slot reaches the browser, since syncState is not a parameter.
The slot is then declared ONCE, view and all, and the agent names nothing:
sessionSlot("cart", create, { view, sync: true }) is the whole wiring.
It works because agent.ts and shared.ts share one bundle: the
registry is module state in the SDK copy the bundle carries, and
agent() reads it when agent.ts runs, so the slot has to be declared in
a module agent.ts imports (shared.ts), not only in a tools/ file the
build loads afterwards.
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, and the projection is identity-stable for the life of
the module.
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 has ONE projection; a page that needs a second shape of the same slot derives it from this one.
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 }), sync: true,});
export default agent({ name: "Shop" });Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”V
SessionSpeech
Section titled “SessionSpeech”Sealed
A live session’s speech: say a sentence on the line, or stop the agent.
Reached as ctx.speech in an agent({ events }) handler, and as
ctx.speech(sessionId) in an agent({ routes }) handler. It stays usable
after the handler returns, so a timer the handler arms can speak through it.
It follows a session across a resume, and speaking through it after the
session has ended settles "dropped" rather than throwing.
Example
Section titled “Example”import { agent } from "@alexkroman1/aai";
export default agent({ name: "Kitchen timer", events: { "tool.called": (event, ctx) => { if (event.toolName !== "start_timer") return; setTimeout(async () => { const outcome = await ctx.speech.say("Your timer is done.", { interrupt: true }).done; if (outcome !== "played") console.log(`Timer line ${outcome}`); }, 60_000); }, },});Methods
Section titled “Methods”interrupt()
Section titled “interrupt()”interrupt():
boolean
Stop the agent: cut off the reply in flight or still playing, abort its
tools, and drop every reply queued behind it, exactly as the client’s
cancel() does. It emits reply.cancelled.
Returns false when there was nothing to interrupt (the agent is silent,
or the session has ended) and emits nothing then. S2S transports cannot
tell whether they are replying, so there it always interrupts and returns
true while the session is live.
Returns
Section titled “Returns”boolean
say(
text,options?):SpeechHandle
Speak text exactly as written, as a reply of its own. See this module’s
header for how it queues, how it is cut, and what history records.
Never throws: an ended session, blank text and an S2S transport are
reported through done.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”SilenceNudge
Section titled “SilenceNudge”Input
The silence nudge: after this much user silence the assistant takes a turn.
afterMs is REQUIRED because the prompt is its payload — a prompt with no
timeout is a declaration nothing ever reads, which used to be a run-time
guard and is now the shape.
Properties
Section titled “Properties”afterMs
Section titled “afterMs”afterMs:
number
Ms of user silence (no speech since the last reply finished) before the
assistant proactively takes a turn. Nudges are capped at
MAX_CONSECUTIVE_SILENCE_NUDGES (3) back-to-back until the user speaks.
prompt?
Section titled “prompt?”
optionalprompt?:string
Instruction injected as a synthetic user turn when afterMs elapses. Never shown as a user transcript.
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)
SilenceTuning
Section titled “SilenceTuning”Input
What the agent does about silence — its own, and the caller’s.
Properties
Section titled “Properties”deadAirCoverMs?
Section titled “deadAirCoverMs?”
optionaldeadAirCoverMs?:number
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 purely declarative — see DEAD_AIR_COVER_PHRASES.
Default Value
Section titled “Default Value”2400 (DEFAULT_DEAD_AIR_COVER_MS)
nudge?
Section titled “nudge?”
optionalnudge?:SilenceNudge
Take a turn after the CALLER has been silent this long — see SilenceNudge.
Default Value
Section titled “Default Value”unset — the behaviour is off.SlotDialogGate
Section titled “SlotDialogGate”Input
The DialogGate a slot-backed tool carries, with the dialog it is on.
A SessionSlot.tool or SessionSlot.updateTool declaring
dialog runs exactly what Dialog.tool runs around the body — the
refusal before, the transition and the { ...position, result } envelope
after, nothing sent on a ToolFailure — around its own read or update
window. E is read from the dialog handle alone (NoInfer on the gate’s
own fields): with send: E an inference site too, a misspelled event was
simply inferred INTO E, and Dialog<E> — whose every E sits in a
bivariant method parameter — still accepted the handle.
A misuse is still reported on the dialog: line, not at send: an
overload that fails every candidate is reported against the LAST one, the
ungated overload, where dialog is the first thing wrong. What that line
says is UngatedDef’s message, which names the gate fields to check.
Extends
Section titled “Extends”DialogGate<Awaited<R>,NoInfer<E>,K>
Type Parameters
Section titled “Type Parameters”R
The tool’s result as the gate reads it (reported, for an
updateTool).
E extends object
The dialog’s event union.
K extends string = string
Properties
Section titled “Properties”dialog
Section titled “dialog”dialog:
Dialog<E>
The dialog this tool is gated on.
reset?
Section titled “reset?”
optionalreset?:true
Start the dialog over BEFORE the body runs — for a setup tool (start_order,
new_claim) that begins a fresh pass through the conversation.
when, if declared, is checked against the position before the reset.
The reset itself is applied once every setup stage has passed — after a
slot tool’s require and prepare, right before the body — so a tool
refused by its own precondition leaves the conversation where it was.
The post-body send/sendFrom is sent to the RESET dialog and is not
re-checked against when (it was declared for the dialog this tool just
started over, which when says nothing about). The initial state’s
onEnter does not fire on a reset, the same as on a fresh session.
Inherited from
Section titled “Inherited from”
optionalsend?:NoInfer<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.
An event the CURRENT state does not declare (none of its own on keys,
nor an ancestor’s) is a no-op, as it is for Dialog.send: XState drops an
unhandled event, the position is answered unchanged, and nothing is
logged — the same for sendFrom.
Inherited from
Section titled “Inherited from”sendFrom?
Section titled “sendFrom?”
optionalsendFrom?:DialogSendFrom<Awaited<R>,NoInfer<E> &object,K>
DialogGate.sendFrom, with the function form’s return spelled so a
plain { type: "QUOTED" } literal keeps its literal.
On Dialog.tool E is fixed by the handle, so the arrow’s
contextual return type is the concrete union and the literal narrows.
Here E is a type parameter of the SAME call, and NoInfer<E>’s
apparent type is its constraint, { type: string } — so a bare
() => ({ type: "QUOTED" }) inferred { type: string } (a zero-parameter
arrow is checked before dialog has supplied E, and its return type is
cached), failed the gated overload, and the author was told dialog does
not exist on SlotToolDef. Intersecting { type: EventNameOf<NoInfer<E>> }
puts an INSTANTIABLE type (an indexed access over the type parameter,
constrained to string) in the type property’s contextual position,
which is a literal context; once E is known the intersection is E
itself, so a payload an event declares is still required and
{ type: "TYPO" } is still refused.
Overrides
Section titled “Overrides”
optionalwhen?: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.
Omitted, the tool is legal in EVERY state and never refuses — a tool
that reads or resets the dialog, or one whose only job is the envelope and
the send. It still answers the { ...position, result } envelope and
still honours send/sendFrom.
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”SlotPreparedToolDef
Section titled “SlotPreparedToolDef”Input
SlotUpdateToolDef with an ASYNC prepare ahead of the synchronous
window — the shape of a mutating tool that has to await first.
The window stays synchronous and the await moves in front of it, which is
the pattern SessionSlot.update’s doc prescribes by hand; declaring it
here is what lets a mutating tool stay ONE declaration. execute is handed
what prepare produced as its THIRD argument, ahead of ctx — so the
three-argument body of a tool without prepare is unchanged.
Extends
Section titled “Extends”Omit<SlotUpdateToolDef<P,T,R,R2>,"execute">
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined
T
Prep
What prepare hands the body.
R
R2 = R
Methods
Section titled “Methods”execute()
Section titled “execute()”execute(
args,draft,prepared,ctx):R
The synchronous body: the draft, then what prepare produced, then ctx.
Parameters
Section titled “Parameters”T
prepared
Section titled “prepared”Prep
Returns
Section titled “Returns”R
prepare()
Section titled “prepare()”prepare(
args,value,ctx):ToolFailure|Prep|Promise<ToolFailure|Prep>
The async half: handed the args and the FROZEN value (no draft exists yet),
it awaits what the body needs — a model call, an HTTP lookup — and answers
it, or a ToolFailure that becomes the tool’s result with no window
opened. require, if declared, is re-run on the fresh value afterwards.
The failure is read STRUCTURALLY (isToolFailure): any record with a
string error field is one, so a fetched payload that happens to carry
error: "" ends the tool here. Rename or strip that field before
answering it.
Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”ToolFailure | Prep | Promise<ToolFailure | Prep>
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
effect?
Section titled “effect?”
optionaleffect?:ToolEffect
What the tool does to the world. "write" declares something that changes
state the caller cannot take back by asking — a booking, an order, a
cancellation, a message sent, a ticket filed for someone to act on. In a
voice pipeline a declared write runs only on a caller turn that did not
start over the agent’s own audio: an “uh-huh” spoken over “Shall I cancel
it?” is the caller listening, not agreeing, and the model is told to ask
again instead.
"handoff" declares a tool that ends the agent’s part of the
conversation — a transfer to a person, an escalation queue. In a voice
pipeline it runs only when the caller asked for a person in one of their
last two turns, or the model calls it again after a further caller turn;
otherwise the model is told the transfer was NOT made and to keep helping.
A transfer ends the call, so one the caller didn’t ask for loses every
change still left to make.
"read" or no effect runs as an ordinary call. A cart or draft step that
a later tool confirms is not a write; the confirming tool is. An MCP tool’s
effect comes from its server’s readOnlyHint (see mcp/adapt.ts).
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Cancel a pending order.", inputSchema: z.object({ orderId: z.string() }), effect: "write", execute: ({ orderId }) => ({ cancelled: orderId }),});Inherited from
Section titled “Inherited from”Omit.effect
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 }).
| undefined is spelled out so a wrapper can forward its own optional
schema field (inputSchema: spec.inputSchema) under
exactOptionalPropertyTypes; an explicit undefined means the same as an
absent key.
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
report?
Section titled “report?”
optionalreport?: (stored,partial) =>R2
Complete the body’s result from the value ACTUALLY STORED — after the
slot’s after hook and caps have applied, which the draft inside the
window has not seen yet.
({ added }, stored) => ({ added, count: stored.items.length }): the count
a body reads off its draft is the count BEFORE a cap trimmed it, and a
derived field after recalculates is not on the draft when the body
returns. Reporting from the stored value is the only reading that cannot
disagree with the next get. A sendFrom on a gated tool sees the
reported value.
It runs INSIDE the window, on the value about to be stored, so a
ToolFailure it returns — or a throw — stores nothing, like the
body’s own; it may not write the slot. A report that answers a promise
is settled after the window closed, and a failure it then resolves to
cannot discard. It reads the value BEFORE the dialog’s send, so what an
onEnter hook writes to this slot during the move is not in stored.
Skipped when the body returned a ToolFailure, which is answered unchanged: a failure has no partial result to complete.
Parameters
Section titled “Parameters”stored
Section titled “stored”DeepReadonly<T>
partial
Section titled “partial”NoInfer<Exclude<R, ToolFailure>>
Returns
Section titled “Returns”R2
Inherited from
Section titled “Inherited from”Omit.report
require?
Section titled “require?”
optionalrequire?: (value,args) =>ToolFailure|undefined
A precondition on the FROZEN value, checked before the body — and before
an update window opens. A returned ToolFailure is the tool’s whole
result: no window, no prepare, nothing sent.
For the “is there an order yet”, “is the caller verified” check every mutating body used to open with, written once beside the body rather than as its first line, where the value in hand is already a draft. Handed the readonly value whichever method the tool was declared through.
With SlotPreparedToolDef.prepare it runs TWICE: once before the await, once more on the fresh value right before the window, so a state change during the await is caught.
Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”ToolFailure | undefined
Inherited from
Section titled “Inherited from”Omit.require
optionalui?:ToolUi
How a page displays this tool’s calls — see ToolUi. Carried on the
wire declaration (ToolSchema.ui) the way messages is, so the default
client and a custom chrome render the same icon and label under aai dev
and on the platform.
Inherited from
Section titled “Inherited from”Omit.ui
SlotToolDef
Section titled “SlotToolDef”Input
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">
Extended by
Section titled “Extended by”Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined
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
effect?
Section titled “effect?”
optionaleffect?:ToolEffect
What the tool does to the world. "write" declares something that changes
state the caller cannot take back by asking — a booking, an order, a
cancellation, a message sent, a ticket filed for someone to act on. In a
voice pipeline a declared write runs only on a caller turn that did not
start over the agent’s own audio: an “uh-huh” spoken over “Shall I cancel
it?” is the caller listening, not agreeing, and the model is told to ask
again instead.
"handoff" declares a tool that ends the agent’s part of the
conversation — a transfer to a person, an escalation queue. In a voice
pipeline it runs only when the caller asked for a person in one of their
last two turns, or the model calls it again after a further caller turn;
otherwise the model is told the transfer was NOT made and to keep helping.
A transfer ends the call, so one the caller didn’t ask for loses every
change still left to make.
"read" or no effect runs as an ordinary call. A cart or draft step that
a later tool confirms is not a write; the confirming tool is. An MCP tool’s
effect comes from its server’s readOnlyHint (see mcp/adapt.ts).
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Cancel a pending order.", inputSchema: z.object({ orderId: z.string() }), effect: "write", execute: ({ orderId }) => ({ cancelled: orderId }),});Inherited from
Section titled “Inherited from”Omit.effect
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 }).
| undefined is spelled out so a wrapper can forward its own optional
schema field (inputSchema: spec.inputSchema) under
exactOptionalPropertyTypes; an explicit undefined means the same as an
absent key.
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
require?
Section titled “require?”
optionalrequire?: (value,args) =>ToolFailure|undefined
A precondition on the FROZEN value, checked before the body — and before
an update window opens. A returned ToolFailure is the tool’s whole
result: no window, no prepare, nothing sent.
For the “is there an order yet”, “is the caller verified” check every mutating body used to open with, written once beside the body rather than as its first line, where the value in hand is already a draft. Handed the readonly value whichever method the tool was declared through.
With SlotPreparedToolDef.prepare it runs TWICE: once before the await, once more on the fresh value right before the window, so a state change during the await is caught.
Parameters
Section titled “Parameters”DeepReadonly<V>
Returns
Section titled “Returns”ToolFailure | undefined
optionalui?:ToolUi
How a page displays this tool’s calls — see ToolUi. Carried on the
wire declaration (ToolSchema.ui) the way messages is, so the default
client and a custom chrome render the same icon and label under aai dev
and on the platform.
Inherited from
Section titled “Inherited from”Omit.ui
SlotTransaction
Section titled “SlotTransaction”Input
What SessionSlot.transact takes: the async half, then the
synchronous window — SlotPreparedToolDef’s two halves for a caller
inside a plain tool() body.
Type Parameters
Section titled “Type Parameters”T
Prep
What prepare hands commit.
R
What commit returns.
Methods
Section titled “Methods”commit()
Section titled “commit()”commit(
draft,prepared):R
The synchronous window, handed the draft and what prepare produced. A
ToolFailure it returns stores nothing, like a throw — see
SlotUpdateToolDef.execute.
Parameters
Section titled “Parameters”T
prepared
Section titled “prepared”Prep
Returns
Section titled “Returns”R
prepare()
Section titled “prepare()”prepare(
value):ToolFailure|Prep|Promise<ToolFailure|Prep>
The await, handed the frozen value; a ToolFailure ends the transaction with no window.
Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”ToolFailure | Prep | Promise<ToolFailure | Prep>
Properties
Section titled “Properties”require?
Section titled “require?”
optionalrequire?: (value) =>ToolFailure|undefined
A precondition on the frozen value — see SlotToolDef.require. Run before prepare and again before commit.
Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”ToolFailure | undefined
SlotUpdateToolDef
Section titled “SlotUpdateToolDef”Input
The authoring shape of SessionSlot.updateTool: a SlotToolDef
over the mutable draft, plus report.
Extends
Section titled “Extends”SlotToolDef<P,T,R>
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema | undefined
T
R
What execute returns — the PARTIAL result, if report is
declared.
R2 = R
What the tool answers after report; R without one.
Methods
Section titled “Methods”execute()
Section titled “execute()”execute(
args,draft,ctx):R
The mutation body: handed the DRAFT, stored when it returns.
A ToolFailure it RETURNS stores nothing, exactly as a throw
does: the draft’s mutations are discarded, nothing is pushed to the
client, and the failure is the tool’s whole result. The gate’s rule is
that a failed tool did not do the thing, and the slot keeps the same
rule — a body that pushed an item and then answered toolFailure(…) has
declined, not half-succeeded.
Parameters
Section titled “Parameters”T
Returns
Section titled “Returns”R
Overrides
Section titled “Overrides”Properties
Section titled “Properties”description
Section titled “description”description:
string
Human-readable description shown to the LLM.
Inherited from
Section titled “Inherited from”effect?
Section titled “effect?”
optionaleffect?:ToolEffect
What the tool does to the world. "write" declares something that changes
state the caller cannot take back by asking — a booking, an order, a
cancellation, a message sent, a ticket filed for someone to act on. In a
voice pipeline a declared write runs only on a caller turn that did not
start over the agent’s own audio: an “uh-huh” spoken over “Shall I cancel
it?” is the caller listening, not agreeing, and the model is told to ask
again instead.
"handoff" declares a tool that ends the agent’s part of the
conversation — a transfer to a person, an escalation queue. In a voice
pipeline it runs only when the caller asked for a person in one of their
last two turns, or the model calls it again after a further caller turn;
otherwise the model is told the transfer was NOT made and to keep helping.
A transfer ends the call, so one the caller didn’t ask for loses every
change still left to make.
"read" or no effect runs as an ordinary call. A cart or draft step that
a later tool confirms is not a write; the confirming tool is. An MCP tool’s
effect comes from its server’s readOnlyHint (see mcp/adapt.ts).
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Cancel a pending order.", inputSchema: z.object({ orderId: z.string() }), effect: "write", execute: ({ orderId }) => ({ cancelled: orderId }),});Inherited from
Section titled “Inherited from”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 }).
| undefined is spelled out so a wrapper can forward its own optional
schema field (inputSchema: spec.inputSchema) under
exactOptionalPropertyTypes; an explicit undefined means the same as an
absent key.
Inherited from
Section titled “Inherited from”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”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”report?
Section titled “report?”
optionalreport?: (stored,partial) =>R2
Complete the body’s result from the value ACTUALLY STORED — after the
slot’s after hook and caps have applied, which the draft inside the
window has not seen yet.
({ added }, stored) => ({ added, count: stored.items.length }): the count
a body reads off its draft is the count BEFORE a cap trimmed it, and a
derived field after recalculates is not on the draft when the body
returns. Reporting from the stored value is the only reading that cannot
disagree with the next get. A sendFrom on a gated tool sees the
reported value.
It runs INSIDE the window, on the value about to be stored, so a
ToolFailure it returns — or a throw — stores nothing, like the
body’s own; it may not write the slot. A report that answers a promise
is settled after the window closed, and a failure it then resolves to
cannot discard. It reads the value BEFORE the dialog’s send, so what an
onEnter hook writes to this slot during the move is not in stored.
Skipped when the body returned a ToolFailure, which is answered unchanged: a failure has no partial result to complete.
Parameters
Section titled “Parameters”stored
Section titled “stored”DeepReadonly<T>
partial
Section titled “partial”NoInfer<Exclude<R, ToolFailure>>
Returns
Section titled “Returns”R2
require?
Section titled “require?”
optionalrequire?: (value,args) =>ToolFailure|undefined
A precondition on the FROZEN value, checked before the body — and before
an update window opens. A returned ToolFailure is the tool’s whole
result: no window, no prepare, nothing sent.
For the “is there an order yet”, “is the caller verified” check every mutating body used to open with, written once beside the body rather than as its first line, where the value in hand is already a draft. Handed the readonly value whichever method the tool was declared through.
With SlotPreparedToolDef.prepare it runs TWICE: once before the await, once more on the fresh value right before the window, so a state change during the await is caught.
Parameters
Section titled “Parameters”DeepReadonly<T>
Returns
Section titled “Returns”ToolFailure | undefined
Inherited from
Section titled “Inherited from”
optionalui?:ToolUi
How a page displays this tool’s calls — see ToolUi. Carried on the
wire declaration (ToolSchema.ui) the way messages is, so the default
client and a custom chrome render the same icon and label under aai dev
and on the platform.
Inherited from
Section titled “Inherited from”SpeakerDef
Section titled “SpeakerDef”Input
A speaker definition — what speaker returns, DelegateFn runs
off the line and a roster() hands the call to.
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: on ModelTuning that means
provider retries, and the guardrail budget is SpeakerDef.maxRevisions.
Extends
Section titled “Extends”Omit<ModelTuning,"maxRetries">
Extended by
Section titled “Extended by”Type Parameters
Section titled “Type Parameters”N extends string = string
The name, as a literal when speaker infers it — what
lets Roster.handoff refuse a misspelled target at compile time.
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 speaker is FOR, in one line, written for whoever is choosing
between them — the minted delegate and handoff tools’ descriptions are
these lines. REQUIRED of a roster entry (roster() refuses one without
it); ignored by ctx.delegate(def, …) in code, where the choice is made.
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 SpeakerDef.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 { speaker } from "@alexkroman1/aai";
const researcher = speaker({ 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?:SpeakerGuardrail
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 SpeakerDef.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 { speaker } from "@alexkroman1/aai";
const researcher = speaker({ 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,});interruption?
Section titled “interruption?”
optionalinterruption?:InterruptionTuning|"off"
How interruptible the agent is while this speaker is ON THE LINE — the
agent’s own interruption group, overriding it field by field (a dialog
state’s own interruption overrides this in turn: dialog → speaker →
agent). Read only for a speaks: true entry. Pipeline only.
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 speaker's guardrail budget is `maxRevisions`; a delegated run takes no provider-retry setting"
Not a field. Typed as a message naming SpeakerDef.maxRevisions, so
speaker({ 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 SpeakerDef.maxRevisions.
maxRevisions?
Section titled “maxRevisions?”
optionalmaxRevisions?:number
How many times a SpeakerDef.guardrail may send an answer back.
Default Value
Section titled “Default Value”1 (DEFAULT_GUARDRAIL_MAX_REVISIONS)
Not maxRetries: 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.
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:
N
What this speaker is called: the value of the delegate/handoff tool’s
argument, the id on its own requests, and what a log line names.
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 SpeakerDef.guardrail rejection is, with the schema’s own issues as the complaint. Declare it through speaker 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 { speaker } from "@alexkroman1/aai";import { z } from "zod";
const factChecker = speaker({ name: "fact-checker", systemPrompt: "Check ONE claim against what you can find.", schema: z.object({ verdict: z.enum(["confirmed", "contradicted", "unclear"]), detail: z.string(), }),});speaks?
Section titled “speaks?”
optionalspeaks?:boolean
Whether the CALLER hears this speaker. true puts it on the roster’s
handoff tool — it takes the call, over the same history and slots; absent
or false puts it on delegate — it runs off the line and hands back an
answer. The first speaking entry of a roster answers the call.
systemPrompt
Section titled “systemPrompt”systemPrompt:
string
The speaker’s instructions — on the line and off it.
Tell it to summarize — or, better, declare SpeakerDef.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”toolChoice?
Section titled “toolChoice?”
optionaltoolChoice?:ToolChoice
The model’s tool-choice policy while this speaker is ON THE LINE.
tools?
Section titled “tools?”
optionaltools?:Readonly<Record<string,ToolDef<ToolInputSchema|undefined,unknown>>>
The tools this subagent may call, by the name the model calls them by.
A MAP rather than the filesystem registration agent() uses: tools/
declares what every turn can reach, and this the narrower set this speaker
owns. Off the line, they are its delegated loop’s tools; on the line, the
roster gates them to the turns it is speaking (one owner per name). An
off-line speaker with no tools and no builtinTools is a pure reasoning
pass.
SpeakerPosition
Section titled “SpeakerPosition”Sealed
Who is on the line — what Roster.position answers, the shape
DialogPosition has for a dialog.
Type Parameters
Section titled “Type Parameters”N extends string = string
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.
pinnedBy?
Section titled “pinnedBy?”
readonlyoptionalpinnedBy?:object
The dialog PINNING this speaker, when a dialog state declares one. While a
pin is in force handoff to anyone else is refused.
dialog
Section titled “dialog”
readonlydialog:string
readonlystate:string
speaker
Section titled “speaker”
readonlyspeaker:SpeakerDef<N>
The speaker on the line now.
SpeechHandle
Section titled “SpeechHandle”Sealed
One utterance SessionSpeech.say queued: await done for when the
caller finished hearing it, or interrupt() to take it back.
Methods
Section titled “Methods”interrupt()
Section titled “interrupt()”interrupt():
void
Take this line back: still queued, it is dropped (done settles
"dropped"); already playing, the reply is cut exactly as
SessionSpeech.interrupt cuts one ("interrupted"). A no-op once
done has settled.
Returns
Section titled “Returns”void
Properties
Section titled “Properties”
readonlydone:Promise<SpeechOutcome>
Settles when the line is over, never rejects. "played" resolves once
PLAYBACK ends, estimated from the audio sent and corrected by the client’s
playback reports, not merely once synthesis ends (which runs faster than
real time).
Do not await it inside the reply it would follow. A say queues
behind the reply in flight, so a caller that holds that reply open while
waiting for done waits forever.
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).
StandardWebhookOptions
Section titled “StandardWebhookOptions”Input
What verifyStandardWebhook takes beside the request and secret.
Properties
Section titled “Properties”
optionalnowS?:number
For TESTS: “now”, in seconds since the epoch.
toleranceS?
Section titled “toleranceS?”
optionaltoleranceS?:number
How far webhook-timestamp may be from now, in seconds, before the
delivery is refused as a replay. Default 300 (five minutes), the spec’s.
StateProjection()
Section titled “StateProjection()”Sealed
One slot’s contribution to the agent_state frame — what
SessionSlot.projected is, and what AgentDef.syncState holds for a
slot declared sync: true.
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.
TurnTakingTuning
Section titled “TurnTakingTuning”Input
WHEN the caller’s turn ends and the agent’s begins.
Properties
Section titled “Properties”detection?
Section titled “detection?”
optionaldetection?:TurnDetectionMode
WHO decides that the caller’s turn is over: "auto" (the transcriber, on a
pause) or "manual" (the client — push-to-talk, via aai-ui’s
session.userTurn.start/.commit/.clear or usePushToTalk). Under
"manual" the caller’s speech never barges in by itself and preemptive
generation is skipped; a userTurnLimit still applies.
Default Value
Section titled “Default Value”"auto"
maxSilenceMs?
Section titled “maxSilenceMs?”
optionalmaxSilenceMs?:number
Pause tolerance for the default AssemblyAI STT stage, in ms — lowered onto
stt: assemblyAIStt({ maxTurnSilenceMs }). The knob to reach for: it
force-ends a turn regardless of content, so raising it is paid for by
hesitant speech alone. Refused beside an explicit stt descriptor.
Default Value
Section titled “Default Value”1500 (DEFAULT_MAX_TURN_SILENCE_MS)
minSilenceMs?
Section titled “minSilenceMs?”
optionalminSilenceMs?:number
End-of-turn CHECK window for the default AssemblyAI STT stage, in ms —
lowered by agent() onto stt: assemblyAIStt({ minTurnSilenceMs }), so
one knob costs one field rather than a whole stage descriptor. It taxes
EVERY finished utterance, which is why the pause-tolerance knob is
maxSilenceMs and not this; read DEFAULT_MIN_TURN_SILENCE_MS before
moving it. An explicit stt descriptor owns its own window, so this is
refused beside one.
Default Value
Section titled “Default Value”250 (DEFAULT_MIN_TURN_SILENCE_MS)
preemptiveGeneration?
Section titled “preemptiveGeneration?”
optionalpreemptiveGeneration?:boolean
Start generating the reply from a high-confidence INTERIM transcript, and adopt that stream when the committed final says the same thing. A speculation never reaches TTS, emits no frame, writes no history and never EXECUTES a tool, so the worst case is one extra billed LLM request.
Default Value
Section titled “Default Value”false — measured on a tool-calling agent and not worth its
cost there (net +8ms per turn, 44% of its LLM requests thrown away); see
DEFAULTS-CLAUDE.md. Turning it on by default is owed a tau2-bench run.
startSpeakingFloorMs?
Section titled “startSpeakingFloorMs?”
optionalstartSpeakingFloorMs?:number
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). A MINIMUM: a pipeline slower than this pays nothing.
Default Value
Section titled “Default Value”0 (DEFAULT_START_SPEAKING_FLOOR_MS)
userTurnLimit?
Section titled “userTurnLimit?”
optionaluserTurnLimit?:UserTurnLimit
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.TypedDelegateResult
Section titled “TypedDelegateResult”Sealed
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 SpeakerDef.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 SpeakerDef.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 SpeakerDef.expectedOutput.
Inherited from
Section titled “Inherited from”toolCalls
Section titled “toolCalls”toolCalls: readonly
DelegateToolCall[]
Every tool call this attempt made, in order.
Inherited from
Section titled “Inherited from”TypedSpeakerDef
Section titled “TypedSpeakerDef”Input
A SpeakerDef that declares a SpeakerDef.schema — what
speaker returns for one, so ctx.delegate types object.
Extends
Section titled “Extends”SpeakerDef<N>
Type Parameters
Section titled “Type Parameters”T
N extends string = string
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 speaker is FOR, in one line, written for whoever is choosing
between them — the minted delegate and handoff tools’ descriptions are
these lines. REQUIRED of a roster entry (roster() refuses one without
it); ignored by ctx.delegate(def, …) in code, where the choice is made.
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 SpeakerDef.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 { speaker } from "@alexkroman1/aai";
const researcher = speaker({ 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?:SpeakerGuardrail
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 SpeakerDef.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 { speaker } from "@alexkroman1/aai";
const researcher = speaker({ 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”interruption?
Section titled “interruption?”
optionalinterruption?:InterruptionTuning|"off"
How interruptible the agent is while this speaker is ON THE LINE — the
agent’s own interruption group, overriding it field by field (a dialog
state’s own interruption overrides this in turn: dialog → speaker →
agent). Read only for a speaks: true entry. Pipeline only.
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 speaker's guardrail budget is `maxRevisions`; a delegated run takes no provider-retry setting"
Not a field. Typed as a message naming SpeakerDef.maxRevisions, so
speaker({ 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 SpeakerDef.maxRevisions.
Inherited from
Section titled “Inherited from”maxRevisions?
Section titled “maxRevisions?”
optionalmaxRevisions?:number
How many times a SpeakerDef.guardrail may send an answer back.
Default Value
Section titled “Default Value”1 (DEFAULT_GUARDRAIL_MAX_REVISIONS)
Not maxRetries: 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.
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:
N
What this speaker is called: the value of the delegate/handoff tool’s
argument, the id on its own requests, and what a log line names.
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 SpeakerDef.guardrail rejection is, with the schema’s own issues as the complaint. Declare it through speaker 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 { speaker } from "@alexkroman1/aai";import { z } from "zod";
const factChecker = speaker({ 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”speaks?
Section titled “speaks?”
optionalspeaks?:boolean
Whether the CALLER hears this speaker. true puts it on the roster’s
handoff tool — it takes the call, over the same history and slots; absent
or false puts it on delegate — it runs off the line and hands back an
answer. The first speaking entry of a roster answers the call.
Inherited from
Section titled “Inherited from”systemPrompt
Section titled “systemPrompt”systemPrompt:
string
The speaker’s instructions — on the line and off it.
Tell it to summarize — or, better, declare SpeakerDef.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”toolChoice?
Section titled “toolChoice?”
optionaltoolChoice?:ToolChoice
The model’s tool-choice policy while this speaker is ON THE LINE.
Inherited from
Section titled “Inherited from”tools?
Section titled “tools?”
optionaltools?:Readonly<Record<string,ToolDef<ToolInputSchema|undefined,unknown>>>
The tools this subagent may call, by the name the model calls them by.
A MAP rather than the filesystem registration agent() uses: tools/
declares what every turn can reach, and this the narrower set this speaker
owns. Off the line, they are its delegated loop’s tools; on the line, the
roster gates them to the turns it is speaking (one owner per name). An
off-line speaker with no tools and no builtinTools is a pure reasoning
pass.
Inherited from
Section titled “Inherited from”UsageLimits
Section titled “UsageLimits”Input
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.delegateof aspeaker()— 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”Input
A cap on ONE user turn — see TurnTakingTuning.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 userTurn.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 (interruption.minWords and
interruption.minDurationMs 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 interruption.minWords is.
WebhookRouteOptions
Section titled “WebhookRouteOptions”Input
What webhookRoute takes.
Properties
Section titled “Properties”secretEnv
Section titled “secretEnv”secretEnv:
string
The agent-env variable holding the signing secret, e.g. "COMPOSIO_WEBHOOK_SECRET".
toleranceS?
Section titled “toleranceS?”
optionaltoleranceS?:number
As StandardWebhookOptions.toleranceS.
Type Aliases
Section titled “Type Aliases”AgentDefWith
Section titled “AgentDefWith”AgentDefWith<
M,W> =stringextends keyofW?ModeAgentDef<M> :Omit<ModeAgentDef<M>,"workflows"> & [keyofW] extends [never] ?object:object
Sealed
What agent() and workflowApp() return for a declaration in mode M
whose workflows map is W: ModeAgentDef, carrying W as declared
— its literal keys and each def’s own type — when one was declared; the
optional EMPTY map when none was (EmptyWorkflows, which is what the
runtime value is: absent); and the open map only when W was inferred as
the open map itself (an options bag typed Record<string, WorkflowDef>).
The one place the declared map survives the lowering: AgentDef types
workflows as the open Record<string, WorkflowDef>, which is right for
every reader of a definition and wrong for a page naming one of its
workflows by key — useWorkflowSubmit<typeof agentDef>("digets") typed the
name string and the output unknown, so the app-typed hooks were
unreachable from a template.
Type Parameters
Section titled “Type Parameters”M extends AgentMode
W extends WorkflowsMap
AgentGuardrail
Section titled “AgentGuardrail”AgentGuardrail = (
text,ctx) =>GuardrailVerdict|Promise<GuardrailVerdict>
Judge one piece of text — see AgentDeclaration.inputGuardrails and AgentDeclaration.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>
AgentMode
Section titled “AgentMode”AgentMode =
"pipeline"|"s2s"|"text"|"workflow-app"
Which kind of agent this is — the discriminant agent() is overloaded over,
and the mode an AgentDef and its serialized config carry.
"pipeline"(the DEFAULT, whenmodeis absent) — STT → LLM → TTS, each stage individually optional and filled from the all-AssemblyAI pipeline. The only mode with the turn-taking tuning, the phrases and guardrails."s2s"— one speech-to-speech provider socket (s2s: assemblyAIS2s()). Carries thes2sdescriptor and nothing pipeline-shaped: the service owns STT, the model loop and TTS."text"— no audio path at all; driven over a message list bycreateTextAgent(@alexkroman1/aai-runtime).llmis its one stage."workflow-app"— a page over the workflow HTTP API, with no session, no socket and no model.workflowApp()is this member withmodealready set.
Explicit for every mode but the default, and never derived from a field
going missing: a mode reachable by omission is one a config lands in when it
loses a field (see “Never let S2S be a fallback” in packages/aai/CLAUDE.md).
AgentParams
Section titled “AgentParams”AgentParams =
PipelineAgentParams|S2sAgentParams|TextAgentParams|WorkflowAppAgentParams
Everything agent() accepts: one member per AgentMode.
AgentPromptContext
Section titled “AgentPromptContext”AgentPromptContext =
string| ((ctx) =>string)
What agent({ promptContext }) accepts: text APPENDED under the agent’s
own instructions, or a function that answers it per session.
The field exists because a system-prompt.md is the right home for a
prompt and the wrong home for a value: an author with a file AND a line of
context to add (“Today’s specials: …”, “The caller’s account tier”) used to
import the file and compose a full prompt resolver — at which point
withSystemPrompt could no longer check that the file was read, and the
file was declared twice. promptContext is the sentence on its own: the
file stays undeclared and discovered, and the context lands under it (or
under whatever prompt the lowered def carries), separated by a blank line.
It is the ONE place an agent.ts puts prompt text; the prompt itself is
not a parameter.
A string is serialized into the agent’s config like the prompt is, so a listing or a build sees it; a function is resolved per session — synchronous, from the AgentSessionContext, re-asked per model request on the pipeline and per connection on S2S.
import { agent, sessionSlot } from "@alexkroman1/aai";
const caller = sessionSlot("caller", () => ({ tier: "standard" }));
export default agent({ name: "Concierge", // `system-prompt.md` beside this file stays the prompt; this is appended. promptContext: (ctx) => `The caller is on the ${caller.get(ctx).tier} tier.`,});AgentSystemPrompt
Section titled “AgentSystemPrompt”AgentSystemPrompt =
string| ((ctx) =>string)
What AgentDef.systemPrompt holds: the text, or a function that answers it
per request from the live AgentSessionContext.
Nothing in an agent.ts writes it. The build sets the discovered
system-prompt.md through withSystemPrompt (@alexkroman1/aai/manifest),
which is also how a host sets a prompt of its own on a lowered definition;
the per-request form an author reaches is AgentPromptContext, which
lands under the file. A resolver here is a HOST’s — a runtime that composes
the whole prompt per session — and is left exactly as written by
withSystemPrompt.
The function is 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.
A plain string is not called or wrapped; it reaches buildSystemPrompt
as written.
AgentTranscriptRecovery
Section titled “AgentTranscriptRecovery”AgentTranscriptRecovery =
"turn-failed"|"session-failed"|string&object
Why the transport spoke a reply ITSELF — agentTranscript.committed’s
recovery: "turn-failed" (one turn failed and the session lives on) or
"session-failed" (the last thing said before the session ends).
AnyDialog
Section titled “AnyDialog”AnyDialog =
Dialog<unknown>
Any dialog, whatever its machine and event union — what AgentDeclaration.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[] would be a list nothing
but a 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<{ 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-opus-5-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-6-luna"|"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.
BuiltinTool
Section titled “BuiltinTool”BuiltinTool =
"web_search"|"visit_webpage"|"get_page_design"|"fetch_json"|"run_code"|"think"|"remember"|"recall"|"calculate"|"open_meteo"|"brave_search"|"google_places"|"text_me"|"workflow_status"|"workflow_progress"|"workflow_cancel"|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)."open_meteo"— Current weather and a daily forecast for a place, from Open-Meteo. No API key."brave_search"— Web search through the Brave Search API. ReadsBRAVE_API_KEYfrom the agent env."google_places"— Find businesses and places (address, phone, hours, rating) with the Google Places API. ReadsGOOGLE_PLACES_API_KEYfrom the agent env."text_me"— Text the owner something too long to say, through Textbelt. ReadsTEXTBELT_KEYandSMS_TO_PHONE; the recipient is never the model’s choice (seeallowedSmsRecipientin@alexkroman1/aai/channels).TEXTBELT_LINKS=stripleaves links out, for a key Textbelt has not yet allowed to send them."workflow_status"— The caller’s most recent runs (three, newest first), each with a line to say —WorkflowDef.describeRun’s, or a sentence per status. Keyed by the session, which is what a tool’sstartrecords."workflow_progress"— The newest progress line the caller’s latest run has written (ctx.workflows.lastLine), or its status line."workflow_cancel"— Stop the caller’s newest run still going, answering{ cancelled, note }withWorkflowDef.cancelNoteor “already finished” / “nothing started yet” baked in. All three take an optionalworkflowname.
The three keyed builtins read their key from ctx.env on each
call and answer the model with an error naming the variable when it is
unset. A deploy checks the key without it being listed in requiredEnv
(BUILTIN_TOOL_ENV), the same rule an MCP server’s tokenEnv follows.
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.
CallBrief
Section titled “CallBrief”CallBrief =
object
Sealed
What a placed call tells the session that answers it, once verified.
Sealed as a ROUND TRIP: readCallBrief builds it, and
stepPlaceCall({ brief }) is handed one back.
Properties
Section titled “Properties”id:
string
The app’s own id for the call — the id its API answered with, say.
prompt
Section titled “prompt”prompt:
string
The task, as the caller wrote it. At most MAX_CALL_BRIEF_PROMPT_CHARS.
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
ClientInboxOptions
Section titled “ClientInboxOptions”ClientInboxOptions =
object
Input
What an agent declares about the audio it pushes to devices over WS /inbox.
Properties
Section titled “Properties”sampleRate?
Section titled “sampleRate?”
optionalsampleRate?:number
Samples per second the devices play pushed audio at — the default
stepSayOnClient synthesizes at when a call names none. An integer from
8000 to 48000. Omitted, speech is synthesized at stepSpeak’s own default
(24 kHz).
Match the device’s own output rate so it needs no resampler (an ESP32
speaker playing at 16 kHz declares 16_000).
ClientRunStatus
Section titled “ClientRunStatus”ClientRunStatus =
"waiting"|"running"|"completed"|"failed"|"cancelled"|string&object
A ClientRun’s status: a run’s own, with pending said as
waiting — the word a person reads for “queued, not started”.
ClientToolDef
Section titled “ClientToolDef”ClientToolDef<
P> =Omit<ToolDef<P>,"execute"> &object
Input
What clientTool takes: a ToolDef without execute, because the
page is the execute.
Type Declaration
Section titled “Type Declaration”timeoutMs?
Section titled “timeoutMs?”
optionaltimeoutMs?:number
How long the call waits for the page to answer before it fails, in ms.
Defaults to the agent’s ordinary tool deadline (TOOL_EXECUTION_TIMEOUT_MS,
30 000). Raise it for a handler that waits on the PERSON — a confirmation
dialog, a file picker — rather than on the browser.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
The tool’s input schema — what the page’s handler receives.
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>; }
Sealed
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 SpeakerDef.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 speaker is what picks the overload — a roster entry stays a plain SpeakerDef, 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>
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
DialogSendFrom
Section titled “DialogSendFrom”DialogSendFrom<
R,E,K> = ((result) =>E|undefined) |K
What DialogGate.sendFrom accepts: a function of the success result
answering the event (or undefined to stay put), or the NAME of a result
field holding that event’s type — see DialogSendFromKey.
NoInfer on R in the function arm is what makes the parameter mean
anything; the argument is on DialogGate.sendFrom. The failure arm is
subtracted because the gate returns a ToolFailure before either form
is reached — and subtracted OUTSIDE the NoInfer, deliberately:
Exclude<NoInfer<R>, …> never distributes once R is a union of object
types (a NoInfer over one stays a substitution type, which a conditional
does not split), so a slot tool whose body answered X | ToolFailure handed
its sendFrom the whole union.
The string arm is a bare K, and the key is CHECKED ON THE RESULT
(DialogSendFromChecked) rather than spelled as
DialogSendFromKey here. It used to be, and that spelling reads R
at the one moment R is not known: an execute with an unannotated
parameter ((args, ctx) => …, the ordinary voice tool) is context-sensitive,
so its return is inferred in a second pass — and a key type written over R
widened the literal "next" to string in the first, which failed every
such tool whether the key sat above execute, below it or in the second
argument. A naked K infers from the literal alone, and the check runs once
R is settled.
Type Parameters
Section titled “Type Parameters”R
What the tool’s execute returns, failure arm included.
E
The machine’s event union.
K extends string = string
The literal the field-name form was written as; string
when the function form (or nothing) was.
DialogSendFromChecked
Section titled “DialogSendFromChecked”DialogSendFromChecked<
K,R,E,T> =stringextendsK?T: [K] extends [DialogSendFromKey<Exclude<R,ToolFailure>,E>] ?T:DialogSendFromMisuse<K,DialogSendFromKey<Exclude<R,ToolFailure>,E>>
T — the tool a gated call answers — unless the field-name sendFrom
written as K names no field of the success result (R minus its failure
arm) holding an event of E, in which case DialogSendFromMisuse. string (no literal
was written) is the function form, or none, and passes through.
In RETURN position for the reason DialogSendFrom gives: a check
intersected into the parameter type is a conditional over R, which is
resolved for the contextual type before a context-sensitive execute has
settled R. DialogSendFromGuard is the parameter-position half
that gets around it, and is what makes the misuse fail at the CALL; this
stays as the type the failed call still reads as.
Type Parameters
Section titled “Type Parameters”K extends string
R
E
T
DialogSendFromGuard
Section titled “DialogSendFromGuard”DialogSendFromGuard<
K,R,E> =stringextendsK?unknown:unknownextendsR?unknown: [K] extends [DialogSendFromKey<Exclude<R,ToolFailure>,E>] ?unknown:object
The call-site half of DialogSendFromChecked: unknown (no
constraint) unless the field-name sendFrom written as K names no field
of the success result holding an event of E, in which case
{ sendFrom: DialogSendFromMisuse } — which the literal "count" cannot
satisfy, so the CALL fails on the sendFrom line.
Intersected into the def (and the second argument) of every gated tool
factory, always under NoInfer. The return-position check alone only bit
once the misuse type was assigned somewhere that wanted a ToolDef, and a
tools/<name>.ts default export never is: aai typecheck is plain tsc
over an untyped AgentDef, so sendFrom: "count" compiled and threw on
the first call.
Two arms keep it inert while R is still being inferred, which is what
DialogSendFromChecked’s doc meant by a parameter-position check
stopping inference. The contextual type of the sendFrom property is
computed BEFORE a context-sensitive execute (unannotated parameters) has
settled R, with R read as unknown; a conditional that resolved to the
misuse arm then would intersect never into that contextual type and widen
the literal "next" to string, failing every field-name sendFrom on
such a body. unknown extends R answers unknown for that pass, and the
applicability check that follows inference sees the settled R. The
NoInfer is the other half: a string literal inferred against the keyof
inside DialogSendFromKey is an inference site for R (TypeScript’s
LiteralKeyof priority), and without it R collected an empty-object
candidate from the misuse arm.
Type Parameters
Section titled “Type Parameters”K extends string
The literal the field-name form was written as; string
when the function form (or nothing) was.
R
What the tool’s execute returns, failure arm included.
E
The machine’s event union.
DialogSendFromKey
Section titled “DialogSendFromKey”DialogSendFromKey<
S,E> = keyof { [K in keyof S as S[K] extends EventNameOf<E> | undefined ? K : never]: never } &string
The keys of a success result S whose value names an event of E — the
STRING form of DialogGate.sendFrom.
A key qualifies when its value is assignable to E["type"] | undefined:
{ next?: "QUOTED" | "DECLINED" } offers "next", and a field typed plain
string offers nothing, because a string the machine does not know is an
event XState would silently ignore. Declare the field’s type (an as const
on the literal, or an annotated return) and the key appears.
Type Parameters
Section titled “Type Parameters”S
The tool’s SUCCESS result (the failure arm already removed).
E
The machine’s event union.
DialogSendFromMisuse
Section titled “DialogSendFromMisuse”DialogSendFromMisuse<
K,Keys> =object
Sealed
The compile error a sendFrom: "field" gets when field holds no event
name of the dialog — a message TYPE, on the pattern of SyncMutationMisuse:
what tsc prints names the rule and the fields that would have worked.
Type Parameters
Section titled “Type Parameters”K extends string
Keys extends string
Properties
Section titled “Properties”sendFrom names a result field that holds no event name of this dialog
Section titled “sendFrom names a result field that holds no event name of this dialog”
readonlysendFrom names a result field that holds no event name of this dialog:K
the fields that do
Section titled “the fields that do”
readonlythe fields that do:Keys
DialogSessionEventName
Section titled “DialogSessionEventName”DialogSessionEventName =
`@${SessionEventType}`
A session event as a dialog names it: the wire type under a leading @.
"@session.timedOut", "@speech.started", "@userTranscript.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 AgentDeclaration.dialogs.
EmptyWorkflows
Section titled “EmptyWorkflows”EmptyWorkflows =
Record<never,never>
What W is when a declaration names no workflows: the map with NO keys,
not the open one. Under the open map WorkflowNameOf<typeof agentDef> was
string, so useWorkflowSubmit<typeof agentDef>("typo") compiled against
a def that declared nothing and typed the run’s output unknown.
EndSessionOptions
Section titled “EndSessionOptions”EndSessionOptions =
object
Input
Options for endSession.
Properties
Section titled “Properties”afterReply?
Section titled “afterReply?”
optionalafterReply?:boolean
Let the current reply finish SPEAKING before the session ends. Default
true: the reply the calling tool belongs to is the one with the goodbye in
it, and ending before it is heard hangs up mid-sentence. false ends it now.
Bounded either way — a reply that never finishes does not hold the
connection open (aai-runtime’s END_SESSION_REPLY_TIMEOUT_MS).
EventNameOf
Section titled “EventNameOf”EventNameOf<
E> =E&object["type"]
The type names of a dialog’s event union — what a sendFrom: "field" key’s
value must be; string for an erased E (AnyDialog’s unknown), so every
string-valued field qualifies there. Exported because
DialogSendFromKey is spelled in it.
Type Parameters
Section titled “Type Parameters”E
GatedOutput
Section titled “GatedOutput”GatedOutput<
R> =Promise<DialogToolResult<Exclude<Awaited<R>,ToolFailure>> |ToolFailure>
Sealed
A gated tool’s declared output — what a slot tool declaring a dialog gate resolves: the dialog envelope over the body’s success value, or the refusal/failure. Named because every gated overload of SessionSlot.tool and SessionSlot.updateTool answers it.
Type Parameters
Section titled “Type Parameters”R
GenerateFn
Section titled “GenerateFn”GenerateFn = {<
S>(options):Promise<GenerateObjectResult<InferSchemaOutput<S>>>; (options):Promise<GenerateResult>; }
Sealed
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
Sealed
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
Input
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:Sextendsundefined?Record<string,never> :never
The output (validated) type of a Standard Schema — and, for undefined, the
empty record Record<string, never>.
ToolDef<P> admits P = undefined so a wrapper can forward an optional
inputSchema field verbatim under exactOptionalPropertyTypes; a tool with
no schema is called with no arguments, and the empty record says so — a body
reading args.anything off it is a compile error rather than an unknown it
has to narrow. Anything else that is not a schema stays never.
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
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
KeyedLock
Section titled “KeyedLock”KeyedLock = (
key,options?) =>Promise<() =>void> &object
Sealed
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
Input
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 }), speaker({ 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.
McpResolvable
Section titled “McpResolvable”McpResolvable<
T> =T| ((context) =>T|Promise<T>)
A value an author may write literally, or compute per connection from McpResolveContext.
Type Parameters
Section titled “Type Parameters”T
McpResolveContext
Section titled “McpResolveContext”McpResolveContext =
object
Sealed
What a RESOLVER is handed when a server’s url or headers is a function.
A resolver runs on the host, once per CONNECTION — once when a host connects
the agent’s servers (withMcpTools, where there is no caller yet, so
clientId is undefined), and once per stepMcp call from a workflow step,
with the clientId that step passed. The value is held for that connection
and never re-asked, so a resolver that creates something remote (a per-user
session URL) creates it once per connection, not per tool call.
Properties
Section titled “Properties”clientId
Section titled “clientId”
readonlyclientId:string|undefined
The client the connection acts for — stepMcp’s clientId option, which a
run usually carries from sessionClientId(ctx) in the tool that started
it. undefined at host start, where nobody is calling yet: a server that
only makes sense per user should THROW here, which costs that server’s
tools at host start and nothing else.
readonlyenv:Readonly<Partial<Record<string,string>>>
The agent’s env — the same record tokenEnv is read from. Never process.env.
signal
Section titled “signal”
readonlysignal:AbortSignal
Aborted when the connect budget runs out; pass it to any fetch the resolver makes.
McpServerConfig
Section titled “McpServerConfig”McpServerConfig =
object
Input
One MCP server an agent may take tools from.
Properties
Section titled “Properties”allowedTools?
Section titled “allowedTools?”
optionalallowedTools?: readonlystring[]
The REMOTE tool names this agent takes from the server; every other tool it publishes is not offered. Omitted, every tool is offered.
The scope knob for a server that publishes more than one agent should see (a connection manager the agent must not drive, a write tool a read-only agent has no use for). Applied before namespacing and before the pin, and a name listed here that the server does not publish is logged, since it is usually a typo or a renamed tool.
headers?
Section titled “headers?”
optionalheaders?:McpResolvable<Readonly<Record<string,string>>>
Extra request headers for this server, e.g. an x-api-key a vendor wants
instead of a bearer token.
HOST-ONLY in both spellings: toAgentConfig never serializes it, so a
header value never reaches a stored config, the browser or a log line.
Prefer the function form and read the value from env — a literal secret
in agent.ts is a secret in version control. Every header named here is
treated as a credential: it is dropped when a redirect leaves the server’s
origin, like authorization is. A tokenEnv bearer wins over an
authorization set here.
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:
McpResolvable<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.
A FUNCTION computes it per connection — a per-user session URL, say —
and what it returns is screened exactly as a literal is. A resolver is
host-only: toAgentConfig drops it from the serialized config, the way it
drops a systemPrompt resolver, so the wire records only that the server
exists.
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"|string&object
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.
ModeAgentDef
Section titled “ModeAgentDef”ModeAgentDef<
M> =AgentDef&object
Sealed
What agent() returns for a declaration in mode M: the one definition
type, with mode known. Each overload returns its own, so a reader of the
result can narrow on the mode it declared without re-deriving it.
Type Declaration
Section titled “Type Declaration”
readonlymode:M
Type Parameters
Section titled “Type Parameters”M extends AgentMode
PipelineAgentParams
Section titled “PipelineAgentParams”PipelineAgentParams =
SharedAgentParams&Pick<AgentDeclaration, keyofAgentModelTuning| keyofPipelineTuning| keyofPipelinePhrases| keyofAgentGuardrails> &object& {stt:SttProvider;turnTaking?:TurnTakingTuning&object; } | {stt?:undefined; }
Input
The PIPELINE member — mode: "pipeline", or no mode at all (the default).
Any subset of the stt/llm/tts triple; the unset stages run on the
default all-AssemblyAI pipeline. The only member with the
PipelineTuning groups, the phrases and the guardrails.
Type Declaration
Section titled “Type Declaration”
optionalllm?:LlmSpec
See AgentDeclaration.llm; a string is gateway model-id shorthand — a bare
id for the AssemblyAI LLM Gateway, "creator/model" for the Vercel AI
Gateway. Typed against the generated catalog so a typo is caught where
it is written, and widened by string & {} so a newer model compiles.
optionalmode?:"pipeline"
See AgentDeclaration.mode. Absent means "pipeline".
optionals2s?:undefined
optionaltts?:TtsProvider
See AgentDeclaration.tts. The voice is the descriptor’s own option
(assemblyAITts({ voice: "aai-michael" })); unset, the default stage
speaks ASSEMBLYAI_TTS_DEFAULT_VOICE.
PollFirstWait
Section titled “PollFirstWait”PollFirstWait =
object
Input
PollOptions.first — the one callback a poll parks on before it begins.
Properties
Section titled “Properties”schema?
Section titled “schema?”
optionalschema?:StandardSchemaV1
What the callback must look like — WaitForOptions.schema. The payload is
checked and then DISCARDED: a poll’s answer is the check’s value, and a
callback that carries the answer is a ctx.waitFor without a poll.
timeoutMs
Section titled “timeoutMs”timeoutMs:
number
How long to wait for the callback before polling anyway.
waitFor
Section titled “waitFor”waitFor:
string|undefined
The hook token, as ctx.waitFor takes it — derived, so a tool can signal
it. undefined means NO callback was issued — what
stepWebhookUrl(token, { optional: true }) answers on a deployment with
no public URL — and the poll then checks immediately rather than parking
for timeoutMs on a callback nothing can deliver (woken: false).
PollOptions
Section titled “PollOptions”PollOptions<
T> =object
Input
Options for ctx.poll — check something, and wait between checks, until it
is done or the budget runs out.
import type { WorkflowContext } from "@alexkroman1/aai";
declare function callStatus(sid: string): Promise<{ over: boolean; outcome?: string }>;
export async function followCall(input: { sid: string }, ctx: WorkflowContext) { const call = await ctx.poll("check", () => callStatus(input.sid), { everyMs: 10_000, maxMs: 15 * 60_000, done: (status) => status.over, }); return call.done ? call.value.outcome : "still going";}import type { WorkflowContext } from "@alexkroman1/aai";
declare function callStatus(sid: string): Promise<{ over: boolean; outcome?: string }>;
export async function followCall(input: { sid: string }, ctx: WorkflowContext) { const call = await ctx.poll("check", () => callStatus(input.sid), { everyMs: 10_000, maxMs: 15 * 60_000, done: (status) => status.over, }); return call.done ? call.value.outcome : "still going";}Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”done: (
value) =>boolean
Is this check’s value the answer? Called on the JOURNALED value, so it must be a pure function of it — the same verdict on every replay.
Parameters
Section titled “Parameters”T
Returns
Section titled “Returns”boolean
everyMs
Section titled “everyMs”everyMs:
number
The durable wait between two checks, in milliseconds. At least 1.
first?
Section titled “first?”
optionalfirst?:PollFirstWait
Park on a callback ONCE before the first check — ctx.waitFor(waitFor, { timeoutMs, schema }) ahead of the loop, journaled exactly as that call is.
The shape a “submit, then poll” body takes: the far side promises a webhook and usually keeps it, so the body parks on the token until the callback lands or the window closes, and only then starts checking. Without this the body branched on the journaled submit result to decide whether to wait, and the branch was the thing that diverged between walks. Whether the callback arrived is reported on PollResult.woken, so the body reads the poll’s own answer instead.
maxAttempts?
Section titled “maxAttempts?”
optionalmaxAttempts?:number
Each check’s StepOptions.maxAttempts.
maxMs:
number
The waiting budget, in milliseconds: the poll sleeps at most
floor(maxMs / everyMs) times, so it makes at most one more check than
that. A COUNT rather than a clock, so a replay walks the same number of
checks however long each one took.
maxWaits?
Section titled “maxWaits?”
optionalmaxWaits?:number
Each check’s StepOptions.maxWaits — a far side’s dated Retry-After on
a check is waited out of this budget rather than spending an attempt.
onSlow?
Section titled “onSlow?”
optionalonSlow?:string
The line PollOptions.slowAfterMs writes. Defaults to a sentence
naming the poll: "Still waiting on <name>.".
slowAfterMs?
Section titled “slowAfterMs?”
optionalslowAfterMs?:number
After this much polling, say so ONCE: onSlow is written as a progress
line (stepReport) in its own journaled step named <name>:slow, so a
replay does not repeat it.
Measured as a COUNT, like the budget: the line is written once the sleeps
taken reach ceil(slowAfterMs / everyMs), which is the same instant on
every walk however long each check took. Nothing is written when the poll
is done before then.
PollResult
Section titled “PollResult”PollResult<
T> =object
Sealed
What ctx.poll (and stepPollUntil) resolve with.
Type Parameters
Section titled “Type Parameters”T
Properties
Section titled “Properties”checks
Section titled “checks”checks:
number
How many checks ran.
done:
boolean
true when done(value) held; false when the budget ran out first.
value:
T
The last check’s value — the answer when done, the latest reading when not.
woken?
Section titled “woken?”
optionalwoken?:boolean
Whether PollOptions.first’s callback arrived before its window
closed: true when the poll was woken by it, false when it timed out and
the checks began anyway. Absent when the poll declared no first.
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
RouteHandler
Section titled “RouteHandler”RouteHandler = (
req,ctx) =>unknown
One agent({ routes }) handler. Its return value is the response body, sent
as JSON with status 200 (undefined is sent as null); return
routeResponse for any other status. A thrown routeError(status, message) answers that status with { error: message }; any other throw is a
500 whose body is { error: <the message> } — never the stack.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”unknown
S2sAgentParams
Section titled “S2sAgentParams”S2sAgentParams =
SharedAgentParams&object
Input
The S2S member — mode: "s2s" and the s2s descriptor, and nothing
pipeline-shaped: the service runs STT, the model loop and TTS, so the
pipeline stages, their tuning and the model-request knobs are all absent.
Type Declaration
Section titled “Type Declaration”mode:
"s2s"
s2s:
S2sProvider
See AgentDeclaration.s2s.
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.
SayOnClientNotice
Section titled “SayOnClientNotice”SayOnClientNotice =
object
Input
What ctx.sayOnClient says: stepSayOnClient’s options (from
@alexkroman1/aai/step) with the delivery id optional — it defaults to the
run id — plus the step’s maxAttempts.
Spelled out rather than derived with Omit, so the root surface that
WorkflowContext lives on does not reach into /step’s types;
workflow-say-on-client.test-d.ts pins the two key sets together.
Properties
Section titled “Properties”ackTimeoutMs?
Section titled “ackTimeoutMs?”
optionalackTimeoutMs?:number
How long to wait for the device’s ack — see stepNotifyClient.
optionaldata?:Record<string,unknown>
Anything JSON-serializable the device reads alongside event; said is set on top.
event:
string
What the device should do with it, e.g. "reminder".
optionalid?:string
Identifies the DELIVERY, so the device drops a repeat. Defaults to the run
id — right for a run’s one announcement. A run that announces twice gives
the second its own (${ctx.runId}:progress), or the device drops it.
language?
Section titled “language?”
optionallanguage?:string
Spoken language, as stepSpeak takes it.
maxAttempts?
Section titled “maxAttempts?”
optionalmaxAttempts?:number
The step’s attempt budget, which is the redelivery budget. Defaults to
DEFAULT_CLIENT_DELIVERY_ATTEMPTS, an hour at the 30-second retry.
retryAfterMs?
Section titled “retryAfterMs?”
optionalretryAfterMs?:number
When the step’s next attempt runs after the device was unreachable.
sampleRate?
Section titled “sampleRate?”
optionalsampleRate?:number
Samples per second; defaults to the agent’s clientInbox.sampleRate.
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
Abort the synthesis and the delivery.
text:
string
What to say. Also sent as data.said.
voice?
Section titled “voice?”
optionalvoice?:string
Voice id, as stepSpeak takes it.
SayOptions
Section titled “SayOptions”SayOptions =
object
Input
Options for SessionSpeech.say.
Properties
Section titled “Properties”interrupt?
Section titled “interrupt?”
optionalinterrupt?:boolean
Cut off whatever the agent is saying (and drop whatever is queued) and
speak this next, rather than waiting its turn. The cut is exactly
SessionSpeech.interrupt’s. Default false.
interruptible?
Section titled “interruptible?”
optionalinterruptible?:boolean
false to keep the CALLER from cutting this line off: their speech while
it plays is held back as if a dialog state had declared bargeIn: "off",
and answered once the line is over. For a sentence that must be heard
whole, such as a disclosure or a final goodbye.
Code can still cut it: SessionSpeech.interrupt, the handle’s own
interrupt(), and the client’s cancel() all work as usual, as does a
typed turn. Default true.
record?
Section titled “record?”
optionalrecord?:boolean
false to keep this line out of the conversation: it is spoken and
captioned (its agentTranscript.committed carries recorded: false),
but it enters neither the model’s history nor ctx.messages, and a
resumed session does not remember it. For a line the model should not
treat as something it said, such as a hold message (“one moment while I
check”). Default true.
SessionCall
Section titled “SessionCall”SessionCall =
object
Sealed
A phone session’s call identity, as the carrier’s start frame reported it.
Plain data, and the same shape on both sides of the wire: what the runtime’s
phone bridge, an eval and createToolContext({ call }) hand in is what
sessionContext, onSessionEnd and sessionCall(ctx) read back, frozen.
The brief stepPlaceCall({ brief }) signed onto the parameters is read
with readCallBrief(call, secret), the one reader.
Properties
Section titled “Properties”callId?
Section titled “callId?”
readonlyoptionalcallId?:string
The carrier’s id for the call: Twilio’s CallSid (CA…), Telnyx’s
call_control_id. Absent when the frame carried none.
carrier
Section titled “carrier”
readonlycarrier:string
The carrier that opened the stream — the ?carrier= value ("twilio", "telnyx").
parameters
Section titled “parameters”
readonlyparameters:Readonly<Record<string,string>>
The stream’s custom parameters — Twilio’s (and TeXML’s) <Parameter>
elements, by name. Telnyx Call Control’s client_state, when set, is here
as client_state, still base64 as Telnyx sends it. Empty when there were
none. Bounded by the bridge: at most 32 entries, each name plus value
under 500 characters (Twilio’s own limit), non-string values dropped.
SessionContext
Section titled “SessionContext”SessionContext =
object
Sealed
What AgentSessionLifecycle.sessionContext may answer.
Properties
Section titled “Properties”greeting?
Section titled “greeting?”
optionalgreeting?:string
The greeting for THIS session, spoken instead of agent({ greeting }) —
for an opening only known per connect, like an outbound call that names
who it is calling for:
import { agent } from "@alexkroman1/aai";
agent({ name: "Reminder calls", greeting: "Hi, this is an AI assistant.", telephony: ["twilio"], sessionContext: ({ call }) => ({ greeting: `Hi, this is an AI assistant calling on behalf of ${call?.parameters.for ?? "a customer"}. Do you have a moment?`, }),});It replaces the TEXT, never the decision: a session that would not greet —
a resume (?sessionId=) — still does not, and one that would speaks this
exactly as it speaks the agent’s (synthesized as written, no model call,
recorded in history as the agent’s opening line, spoken again after a
client reset). An empty string means no greeting this session. Control
characters become spaces, and the text is trimmed and cut at
MAX_SESSION_GREETING_CHARS (500). Absent — or a throw, or an answer past
the SESSION_CONTEXT_TIMEOUT_MS deadline — keeps the agent’s greeting.
Every transport honours it: the pipeline and OpenAI Realtime read it when the greeting fires, AssemblyAI S2S when it sends its session config — both after this hook has answered.
historySince?
Section titled “historySince?”
optionalhistorySince?:number
Epoch ms. The client’s prior sessions are loaded into the new one verbatim
only from this instant on — sessions whose last event is older are left
out, and so are older events of a session that straddles it. Set it to the
point your instructions already summarize up to, so the model is not told
the same thing twice. Absent, the runtime’s own budget decides.
instructions?
Section titled “instructions?”
optionalinstructions?:string
Text appended to the system prompt for the WHOLE session, after the agent’s own prompt — a memory profile, a summary of older conversations, open reminders. Asked once, so it is byte-stable across the session’s requests and the provider’s prompt cache keeps working.
location?
Section titled “location?”
optionallocation?:string
Where this client is — the street address the app has on file for it.
Recorded as the session’s location, REPLACING the one the socket reported
with ?location=, so google_places, open_meteo and
sessionClientLocation(ctx) use the app’s answer: an address a person
typed into the app’s settings beats whatever a device was flashed with.
Held to the socket’s rule: control characters are stripped, and one over 200 characters is ignored. Personal data — the runtime never logs it.
refuse?
Section titled “refuse?”
optionalrefuse?:string
Refuse the session: the reason, for your logs. The runtime closes it before
the greeting and before any model call — a WebSocket client with a 1008
(policy violation) close carrying this reason, a phone call by closing the
carrier’s stream, which hangs up. Logged once, with the session id and this
reason and nothing else; never sent to the model. onSessionEnd does not
fire for a refused session.
For a server reachable from outside — a phone agent behind a tunnel — this is what keeps a stranger who found the URL from spending your model:
import { agent } from "@alexkroman1/aai";
agent({ name: "Reminder calls", telephony: ["twilio"], async sessionContext({ call, env, signal }) { const id = call?.parameters.call; if (!id) return { refuse: "not a placed call" }; const res = await fetch(`${env.CALLS_URL}/calls/${id}`, { signal }); if (!res.ok) return { refuse: "unknown call" }; return { instructions: `This call is about: ${(await res.json()).topic}` }; },});A throw or a timeout is NOT a refusal — the session starts without context,
as it always has — so an app that must refuse on doubt catches its own
failures and returns refuse. Trimmed and capped at 200 characters; an
empty string is ignored.
SessionErrorCode
Section titled “SessionErrorCode”SessionErrorCode =
"stt"|"llm"|"tts"|"tool"|"protocol"|"connection"|"audio"|"internal"|string&object
What a session reports when a SUBSYSTEM fails — the code of an
error.reported event. Open: a new subsystem’s code is not a break.
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 cannot change the turn in flight: what the model is told, which tool
runs, what the reply says.
speech is here, and it does not cross that line. A say is a reply of
its OWN, queued behind the one in flight and spoken verbatim, and it appears
on the stream as a reply like any other, so the log still describes
everything that happened. Without it, “say something when X happens” was
possible only for the handful of X the SDK had scoped a knob for (greeting,
silence, filler, a run landing). speech.interrupt() is the client’s
cancel(), which the stream already records. See session-speech.ts.
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.
speech
Section titled “speech”speech:
SessionSpeech
Say a sentence on this session’s line, or stop the agent: see SessionSpeech. Usable after the handler returns, so a timer it arms can speak.
A say queues behind the reply in flight, and many events fire DURING a
reply, so a handler must not hold anything that reply waits on until the
line’s done settles.
A line a handler says emits events that reach the handlers again. A
handler that speaks on every agentTranscript.committed hears its own
line and speaks forever. Decide from an event the line cannot produce, or
check the event first: see “A handler that speaks can hear itself” in
session-speech.ts.
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 AnySessionEvent = SessionEvent
Parameters
Section titled “Parameters”E
Returns
Section titled “Returns”unknown
SessionEventHandlers
Section titled “SessionEventHandlers”SessionEventHandlers =
{ [K in SessionEventType]?: SessionEventHandler<SessionEvent<K>> }&object
Input
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 any event by its ENVELOPE
(AnySessionEvent: type and meta) — the right shape for the
handlers that motivate it (a log line, a metrics counter), and open, so a new
event reaching a wildcard handler is not a type change. Read a member’s
fields from its own per-name handler.
Type Declaration
Section titled “Type Declaration”*?
optional*?:SessionEventHandler<AnySessionEvent>
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"];SharedAgentParams
Section titled “SharedAgentParams”SharedAgentParams =
Omit<AgentDeclaration,DefaultedAgentField|"systemPrompt"|"syncState"|"mode"|ProviderField|PipelineOnlyField| keyofAgentModelTuning> &Partial<Pick<AgentDeclaration,"greeting"|"maxSteps">> &object
Input
What every SESSION member shares — pipeline, S2S and text: everything on AgentDeclaration minus the mode-owned fields, with the defaulted ones optional.
The model-loop knobs (AgentModelTuning) are subtracted here and re-added by the two members whose runtime assembles the model request; S2S runs the model inside the provider’s service, so it never has them.
Type Declaration
Section titled “Type Declaration”tools?
Section titled “tools?”
optionaltools?:InlineToolsMisuse
Not a field — see InlineToolsMisuse.
SleepOptions
Section titled “SleepOptions”SleepOptions =
object
Input
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.
Defaults to the sleep’s label, so ctx.workflows.wakeUp(runId, { correlationIds: ["review-window"] }) ends the wait a body wrote as
ctx.sleep("review-window", …) with nothing further declared — the label
is already the wait’s name to a reader, and a wake addressed by it is the
ordinary case. A bare wakeUp naming no ids still ends every outstanding
sleep on the run.
Pass one explicitly to make two differently-labelled waits share an id — a review window and a retry backoff a single “do it now” should both end — or to give a loop’s iterations, which share a label already, a different grouping than the label gives them.
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
SlotDraftOf
Section titled “SlotDraftOf”SlotDraftOf<
S> =SextendsSessionSlot<string, infer T,unknown> ?T:never
The mutable draft a slot hands a mutator — the slot’s own T, read off the
handle: (cart: SlotDraftOf<typeof cartSlot>) => … for a helper called
inside update/updateTool.
Type Parameters
Section titled “Type Parameters”S extends SessionSlot<string, unknown, unknown>
SlotHolder
Section titled “SlotHolder”SlotHolder =
object
Sealed
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
Sealed
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,options?):void
Store this session’s value for key.
options.durable is the slot’s own declaration (default true). 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
options?
Section titled “options?”durable?
Section titled “durable?”boolean
Returns
Section titled “Returns”void
SlotValueOf
Section titled “SlotValueOf”SlotValueOf<
S> =SextendsSessionSlot<string, infer T,unknown> ?DeepReadonly<T> :never
The frozen value a slot hands a reader — DeepReadonly<T> for the slot’s
own T, read off the handle so a helper’s signature names the slot rather
than restating its type: (cart: SlotValueOf<typeof cartSlot>) => ….
For a helper declared AFTER the slot. The slot’s type depends on its
view, so a view written inline cannot name SlotValueOf<typeof thisSlot>
for its own parameter — that is typeof thisSlot referring to itself
(TS7022). Inside the declaration the parameter is already typed:
view: (cart) => … sees DeepReadonly<T> with no annotation at all.
Type Parameters
Section titled “Type Parameters”S extends SessionSlot<string, unknown, unknown>
SpeakerGuardrail
Section titled “SpeakerGuardrail”SpeakerGuardrail = (
answer) =>GuardrailVerdict|Promise<GuardrailVerdict>
Judge one attempt — see SpeakerDef.guardrail.
Parameters
Section titled “Parameters”answer
Section titled “answer”Returns
Section titled “Returns”GuardrailVerdict | Promise<GuardrailVerdict>
SpeechOutcome
Section titled “SpeechOutcome”SpeechOutcome =
"played"|"interrupted"|"dropped"|string&object
How one SessionSpeech.say ended.
"played": synthesized and played out to the end, so the caller heard it."interrupted": it started and was cut off: a barge-in, aninterrupt(), or the session ending mid-line. History holds the heard prefix."dropped": it never started. The session ended, the line was empty, an interrupt stranded it in the queue (an interrupt, from the client or from code, discards EVERY queued reply, queuedsays included), or the session’s transport cannot speak verbatim text — an S2S agent, said once at session start. See this module’s header.
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
StepOptions
Section titled “StepOptions”StepOptions<
S> =object
Input
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.
maxWaits?
Section titled “maxWaits?”
optionalmaxWaits?:number
How many times this step may WAIT OUT a far side’s own Retry-After
without spending an attempt.
Unset, every retryable failure consumes an attempt — today’s behaviour, and
the default is deliberately not flipped. Set, a RetryableError whose
retryAfter is still in the FUTURE when it is caught (what orFail(step*)
raises on a 429 or a 503 carrying the header, and what stepGenerate
carries off a rate-limited gateway) is a WAIT rather than a try: the step
backs off as before, and the attempt budget is untouched. A retryable
failure whose delay has already passed, and an unclassified throw, still
spend an attempt — those are the step failing, not the far side asking it
to come back later.
The distinction is the one a fan-out hits: N segments meet one rate limit
together, and a budget of three attempts is spent on three Retry-Afters
before the step has failed once on its own account. This bounds the waits
SEPARATELY, so maxAttempts stays what its name says. Once the waits are
spent, the next such failure spends an attempt as before, so a far side
that never stops asking cannot hold a step forever.
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/src/sdk/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
Input
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.
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"> &Pick<AgentDeclaration, keyofAgentModelTuning> &object
Input
The TEXT member — mode: "text", optionally an llm, and nothing from the
audio half of the agent shape.
Type Declaration
Section titled “Type Declaration”
optionalllm?:LlmSpec
See AgentDeclaration.llm; a model-id string works as on the pipeline member — the one provider stage a text agent has.
mode:
"text"
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. A DEMANDING state value lets go for the rest of the reply once a step sent with it has made the call it demanded. - 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, in each reply, until a step sent with it makes the call it demands — then the agent’s own setting applies for the rest of that reply (a step that answered in text under a standing demand would fail). 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
Input
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.
The 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. - Where the session is —
turn, how many user turns have been committed, andnow(), the clock; both pinnable the wayrandomis.
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 }; },});Methods
Section titled “Methods”now():
number
The current time as epoch milliseconds — the SEAM a tool reaches for
instead of Date.now(), on the terms random sets: in production it IS
Date.now, and what it buys is a spec that can say what time it is.
createToolContext({ now: () => 1_700_000_000_000 }) makes a deadline, an
opening-hours check or a “how long since” an exact assertion rather than a
window around the wall clock.
Not journaled either: a WORKFLOW body replays, and WorkflowContext.now()
is the different mechanism that makes a run re-read the same instant.
Returns
Section titled “Returns”number
Remarks
Section titled “Remarks”The FOURTEENTH field, and the other that raised rule 24 from twelve. It
does NOT pass the rule’s “unreachable any other way” test — Date.now()
is right there — and is admitted on the argument random was: a seam is
worth a field when the alternative is a tool whose behaviour a spec cannot
state. Every opening-hours check, booking cutoff and “how long ago” in the
shipped templates read the wall clock directly and none could be pinned
without fake timers over the whole runtime; the deadlineAt budget
example on this very type subtracts Date.now() and is untestable for the
same reason. The cost the rule prices — a promise every host and double
must keep — is one line in each (now: Date.now).
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 AgentDeclaration.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 aai-runtime/src/tools/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.
speech
Section titled “speech”speech:
SessionSpeech
Say a sentence on this session’s line, or stop the agent: see SessionSpeech.
Not how a tool speaks in its own reply: that is messages on the tool,
which is timed to the call and costs no extra reply. speech is for what
lands LATER. A tool that arms a timer, or registers a callback that fires
after it returns, speaks through it then.
A say from inside execute queues behind the reply that is waiting on
this very call, so the tool must NOT await done before it returns, or it
waits until its own timeout. A sessionless context (a workflow step’s
stepDelegate) holds one that settles every line "dropped".
Remarks
Section titled “Remarks”The TWELFTH field on this type, and the one that raised guard-invariants
rule 24 from eleven. It passes the rule’s test: it is per-SESSION and it
cannot be reached any other way. ctx.sessionId names a session but
reaches nothing, and a process-wide lookup keyed on it would bind to
whichever runtime last published the slot (two runtimes share a process
under aai dev and in a guest), which is why RouteContext.clientTranscript
is bound to the context as well. The second reason is structural: an
events handler’s context carries speech, and a ToolContext has always
been a superset of that context. Authors pass a createToolContext() to a
handler in a spec (eight templates did), so leaving the field off this type
would have broken every one of those specs.
turn:
number
How many user turns this session has COMMITTED so far, counting the one
this call answers — 1 for a tool called while the first reply is being
composed, 0 only from a context that belongs to no turn (a sessionless
caller, a workflow step’s stepDelegate).
A turn is a userTranscript.committed event, the same unit a
sessionSlot hook or an eval counts, so “ask once, on the third turn” is
ctx.turn === 3 rather than a counter a tool keeps in a slot. A resume
restores the count from the session’s history, so it is a property of the
SESSION and not of the socket. A typed turn (sendText) commits like a
spoken one.
Pinned in a spec with createToolContext({ turn }), which defaults to 0.
Remarks
Section titled “Remarks”The THIRTEENTH field on this type, and one of the two that raised
guard-invariants rule 24 from twelve. It passes the rule’s test: it is
per-CALL (the count at the instant the call runs, which the next call of
the same reply may already have moved past) and it cannot be reached any
other way — ctx.messages is a token-bounded WINDOW of the conversation
(transports/pipeline/history/retention.ts), so counting its user
messages reads a number that stops growing once the oldest turns are
evicted, and the session’s own counter lives in the runtime’s session
directory, which no tool body can see. Before it existed the shipped
answer was a slot holding a counter a tool incremented by hand, which
counted CALLS rather than turns.
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
Input
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 | undefined = ToolInputSchema | undefined
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. undefined is admitted so a wrapper can forward an optional
schema field verbatim, and args is then Record<string, never> (see
InferSchemaOutput). The default is the WIDEST reading — any
schema or none — so a bare ToolDef is what every registry holds and
every ToolDef<P> is assignable to it; a tool without inputs needs no
explicit type argument either way.
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 (16000) 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
aai-runtime/src/tools/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.
effect?
Section titled “effect?”
optionaleffect?:ToolEffect
What the tool does to the world. "write" declares something that changes
state the caller cannot take back by asking — a booking, an order, a
cancellation, a message sent, a ticket filed for someone to act on. In a
voice pipeline a declared write runs only on a caller turn that did not
start over the agent’s own audio: an “uh-huh” spoken over “Shall I cancel
it?” is the caller listening, not agreeing, and the model is told to ask
again instead.
"handoff" declares a tool that ends the agent’s part of the
conversation — a transfer to a person, an escalation queue. In a voice
pipeline it runs only when the caller asked for a person in one of their
last two turns, or the model calls it again after a further caller turn;
otherwise the model is told the transfer was NOT made and to keep helping.
A transfer ends the call, so one the caller didn’t ask for loses every
change still left to make.
"read" or no effect runs as an ordinary call. A cart or draft step that
a later tool confirms is not a write; the confirming tool is. An MCP tool’s
effect comes from its server’s readOnlyHint (see mcp/adapt.ts).
Example
Section titled “Example”import { tool } from "@alexkroman1/aai";import { z } from "zod";
export default tool({ description: "Cancel a pending order.", inputSchema: z.object({ orderId: z.string() }), effect: "write", execute: ({ orderId }) => ({ cancelled: orderId }),});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 }).
| undefined is spelled out so a wrapper can forward its own optional
schema field (inputSchema: spec.inputSchema) under
exactOptionalPropertyTypes; an explicit undefined means the same as an
absent key.
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."); },});
optionalui?:ToolUi
How a page displays this tool’s calls — see ToolUi. Carried on the
wire declaration (ToolSchema.ui) the way messages is, so the default
client and a custom chrome render the same icon and label under aai dev
and on the platform.
ToolDelayedMessage
Section titled “ToolDelayedMessage”ToolDelayedMessage =
object
Input
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.
ToolEffect
Section titled “ToolEffect”ToolEffect =
"read"|"write"|"handoff"
What a tool does to the world — see ToolDef.effect. Carried on the wire as
ToolSchema.effect.
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.
ToolMap
Section titled “ToolMap”ToolMap =
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) and SpeakerDef.tools.
Named once so the two 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>>.
ToolMessageCondition
Section titled “ToolMessageCondition”ToolMessageCondition =
object
Input
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
Sealed
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
Input
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.
ToolRefusal
Section titled “ToolRefusal”ToolRefusal =
ToolFailure&object
Sealed
A ToolFailure the RUNTIME or a gate answered before the body ran, its
reason saying which. Still a ToolFailure (isToolFailure is true); the
field is not on ToolFailure itself, so an author’s own { error } literal
keeps narrowing out of a T | ToolFailure union.
Type Declaration
Section titled “Type Declaration”reason
Section titled “reason”reason:
ToolRefusalReason
ToolRefusalReason
Section titled “ToolRefusalReason”ToolRefusalReason =
"unknown_tool"|"invalid_arguments"|"cancelled"|"persona"|"dialog"|"roster"|string&object
Why a call was REFUSED rather than run — the one discriminant every gate and dispatcher answers with, so a spec or a client switches on one field instead of matching four sentences.
"unknown_tool"— no toolset advertises the name."invalid_arguments"— the arguments failed the tool’s input schema."cancelled"— the turn was cut before the body started."persona"— a roster entry’s tool, called while another one speaks, or a handoff a dialog pin forbids."dialog"— adialog.tool, called outside itswhenstates."roster"— a handoff or delegation naming nobody the roster can route to.
ToolStartMessage
Section titled “ToolStartMessage”ToolStartMessage =
object
Input
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.
ToolUi
Section titled “ToolUi”ToolUi =
object
Input
How a tool call is DISPLAYED by a page — the icon and the label a client shows while the call runs and once it lands. Nothing here reaches the model.
Declared on the tool rather than in a map on the page because the tool is
the one that knows what it does: a page that named every tool twice (once
here, once in a mountClient({ tools }) map) drifted the moment a tool was
renamed, and a custom chrome had to restate the map or show raw names. The
page has no such map; this declaration is the one place the icon lives.
Properties
Section titled “Properties”
optionalicon?:string
A short glyph or emoji drawn beside the call — "🍕", "🔎".
label?
Section titled “label?”
optionallabel?:string
The words shown in place of the tool’s name — "Adding pizza".
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 TurnTakingTuning.detection), 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.
UngatedDef
Section titled “UngatedDef”UngatedDef =
object
Sealed
What the UNGATED overloads of SessionSlot.tool and
SessionSlot.updateTool say about dialog: a message, so the one
line tsc prints for a gated def that fits no overload names the rule.
A call matching no overload is reported against the LAST one, and that is
the ungated overload (the gated one must come first: a zero-parameter
sendFrom arrow’s return type is cached by whichever overload checks it
first, and only the gated one has the event union to narrow it against).
Its complaint used to be that dialog “does not exist in type
SlotToolDef”, which pointed the author at the wrong field; now the
dialog line says that the gated overload refused the def and which
fields to check. A def VARIABLE carrying a dialog is refused by it too,
where a plain Omit would have let a non-fresh object match the ungated
overload and lose its envelope.
@sealed on the pattern of DialogSendFromMisuse and SyncMutationMisuse:
a message TYPE is the SDK’s to compose and an author’s only to read, in what
tsc prints — no author builds a value of it. (check:api-shape reaches it
both ways, as it does every type in a SessionSlot method’s parameter:
expectSlotContract(slot) enters the handle as input, which flips them.)
Properties
Section titled “Properties”dialog?
Section titled “dialog?”
readonlyoptionaldialog?:"this def declares `dialog`, so it is a GATED tool, and the gated overload refused it: one of `when`, `send`, `sendFrom` or `reset` does not fit that dialog, or `report` was declared on `tool`"
ValidatedRouteRequest
Section titled “ValidatedRouteRequest”ValidatedRouteRequest<
Body,Client> =RouteRequest<Body> &Clientextendstrue?object:unknown
The request a route handler receives: body already validated (the
schema’s OUTPUT), and clientId a string when requireClient is set.
Type Parameters
Section titled “Type Parameters”Body
Client
Section titled “Client”Client extends boolean
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
Input
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
Input
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.
WithInstruction
Section titled “WithInstruction”WithInstruction<
T> =T&object
Sealed
A tool result carrying a per-outcome instruction to the model — what withInstruction returns. The result’s own fields are untouched; the instruction is one more key, reserved, that the runtime removes before a page or a later tool reads the value.
Type Declaration
Section titled “Type Declaration”$instruction
Section titled “$instruction”
readonly$instruction:string
Type Parameters
Section titled “Type Parameters”T extends Record<string, unknown>
WorkflowAppAgentParams
Section titled “WorkflowAppAgentParams”WorkflowAppAgentParams =
Omit<SharedAgentParams,"sttPrompt"|"telephony"|WorkflowAppOnlyField|"workflows"> &object
Input
The WORKFLOW-APP member — mode: "workflow-app", the workflows that ARE the
product, and nothing from the session half of the agent shape.
workflowApp() is this member with mode already set.
workflows is optional here as on AgentDeclaration: a workflow app
whose every workflow is a workflows/<name>.ts default export declares
none in agent.ts — discovery registers them, so the form it serves is
not empty.
Type Declaration
Section titled “Type Declaration”mode:
"workflow-app"
workflows?
Section titled “workflows?”
optionalworkflows?:AgentDeclaration["workflows"]
See AgentDeclaration.workflows — the whole product.
WorkflowClient
Section titled “WorkflowClient”WorkflowClient =
object
Sealed
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>
cancelAll()
Section titled “cancelAll()”Call Signature
Section titled “Call Signature”cancelAll<
P,R>(workflow,key):Promise<number>
Cancel every run of workflow started with this correlation key that has
not finished yet, and resolve how many THIS call ended.
find plus a cancel per pending or running run — “cancel my reminders”.
A run that finished between the two is not counted, and 0 is an answer
rather than an error. Reads at most MAX_WORKFLOW_FIND_LIMIT runs.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
string
Returns
Section titled “Returns”Promise<number>
Call Signature
Section titled “Call Signature”cancelAll(
workflow,key):Promise<number>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
Returns
Section titled “Returns”Promise<number>
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[]>
findByKey()
Section titled “findByKey()”findByKey(
key,options?):Promise<WorkflowRunSnapshot[]>
Runs started with this correlation key across EVERY workflow this agent
declares, newest first — find for each, merged.
What a page listing “everything running for this caller” wants, where one
find per workflow was six lookups and a hand-written merge that had to be
edited whenever a workflow was added. since and statuses filter the
merged list; limit caps it.
Parameters
Section titled “Parameters”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>
latest()
Section titled “latest()”Call Signature
Section titled “Call Signature”latest<
P,R>(workflow,key?):Promise<WorkflowRunSnapshot<R> |undefined>
The NEWEST run of workflow started with key, or undefined when there
is none — find with a limit of one, unwrapped.
What a *_status tool holds: “how is that going” is a question about the
latest run, and every such tool wrote (await find(def, key, { limit: 1 }))[0].
key defaults to "session" — the session id on a session-scoped client
(see StartOptions.key).
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
string
Returns
Section titled “Returns”Promise<WorkflowRunSnapshot<R> | undefined>
Call Signature
Section titled “Call Signature”latest(
workflow,key?):Promise<WorkflowRunSnapshot|undefined>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
Returns
Section titled “Returns”Promise<WorkflowRunSnapshot | undefined>
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”live()
Section titled “live()”Call Signature
Section titled “Call Signature”live<
P,R>(workflow,key?):Promise<WorkflowRunSnapshot<R> |undefined>
The newest run of workflow started with key that has NOT finished —
pending or running — or undefined when none is in flight.
The read a “stop that” or “is it still going” tool wants, and the one a
latest cannot answer alone: the newest run may have finished while an
older one is still parked on a wait. Reads at most MAX_WORKFLOW_FIND_LIMIT
runs. key defaults to "session", as latest’s does.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
string
Returns
Section titled “Returns”Promise<WorkflowRunSnapshot<R> | undefined>
Call Signature
Section titled “Call Signature”live(
workflow,key?):Promise<WorkflowRunSnapshot|undefined>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
Returns
Section titled “Returns”Promise<WorkflowRunSnapshot | undefined>
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/src/sdk/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?,options?):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.
Unless the run has not got there YET, which options.holdMs is for: a
tool that starts a run and answers its first wait in the same turn lands
before the body reaches ctx.waitFor, and a false there is an answer
lost rather than a run that moved on. With a hold the payload is kept for
that long and delivered when the wait is claimed, and the call resolves
true for having been taken. See SignalOptions.holdMs.
Parameters
Section titled “Parameters”string
payload?
Section titled “payload?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<boolean>
signalRun()
Section titled “signalRun()”Call Signature
Section titled “Call Signature”signalRun<
P,R>(workflow,key,label,payload?,options?):Promise<boolean>
signal for a wait the body wrote as ctx.waitForSignal(label):
the token is derived here from the workflow’s declared name, key and
label, exactly as the body derived it, so neither side spells it.
key is the run’s StartOptions.key, and "session" means the session
id on a session-scoped client as it does everywhere else. Holds the payload
for DEFAULT_SIGNAL_HOLD_MS by default — the tool that signals a run it
just started is the ordinary caller — and options.holdMs overrides it
(0 to hold nothing).
A hold stands only for a run of workflow under key that is still
going (pending or running). The token carries no run id, so a hold
for a run that finished, timed out or was cancelled would be taken by the
NEXT run under the key as its own answer; with no live run nothing is held
and the call answers false, as signal does for a token nobody
waits on.
The same token is what makes ONE live run per (workflow, key) the rule
for a body that waits on a signal: a second concurrent run under the same
key fails at its first ctx.waitForSignal (the refusal names the label
and key). Start it under a distinct key.
Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema
R
Parameters
Section titled “Parameters”workflow
Section titled “workflow”WorkflowDef<P, R>
string
string
payload?
Section titled “payload?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<boolean>
Call Signature
Section titled “Call Signature”signalRun(
workflow,key,label,payload?,options?):Promise<boolean>
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
string
payload?
Section titled “payload?”unknown
options?
Section titled “options?”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
Sealed
The handle a workflow body receives as its second argument.
import type { WorkflowContext } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";
export async function researchFlow( input: { topic: string }, ctx: WorkflowContext,) { const brief = await ctx.step("writeBrief", () => stepGenerate(`Brief: ${input.topic}`)); const notes = await ctx.step("investigate", () => stepGenerate(`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.
poll()
Section titled “poll()”poll<
T,Name>(name,check,options):Promise<PollResult<T>>
Check something until it is done, sleeping DURABLY between checks: each check
is ctx.step(name, check), each wait ctx.sleep(name, everyMs), so the loop
journals exactly what the hand-written one did. Resolves { value, done, checks } — done: false with the latest reading once floor(maxMs / everyMs)
sleeps are spent. name is a string LITERAL, as a step’s is. See PollOptions.
Type Parameters
Section titled “Type Parameters”T
Name extends string
Parameters
Section titled “Parameters”Name & Literal<Name>
() => T | Promise<T>
options
Section titled “options”PollOptions<T>
Returns
Section titled “Returns”Promise<PollResult<T>>
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>
report()
Section titled “report()”report<
Name>(name,line):Promise<void>
Write one progress line from the BODY, as ONE journaled step name (a
string LITERAL): ctx.step(name, () => stepReport(line)) under one name.
stepReport itself belongs inside a step, because the body is replayed and
a line written there is re-emitted on every delivery; this is the step that
every narrating body wrote around it. The line is written on the walk that
first reaches it and answered from the journal on every later one.
Type Parameters
Section titled “Type Parameters”Name extends string
Parameters
Section titled “Parameters”Name & Literal<Name>
string
Returns
Section titled “Returns”Promise<void>
sayOnClient()
Section titled “sayOnClient()”sayOnClient<
Name>(name,clientId,notice):Promise<string>
Say notice.text on device clientId as ONE step name (a string LITERAL):
ctx.step(name, () => stepSayOnClient(clientId, …)) with the delivery id
defaulting to the run id and maxAttempts to DEFAULT_CLIENT_DELIVERY_ATTEMPTS,
journaled as that step was. Resolves the text said. A run’s FAILURE is
sayFailureOnClient (/step) in workflow({ onFailure }).
Type Parameters
Section titled “Type Parameters”Name extends string
Parameters
Section titled “Parameters”Name & Literal<Name>
clientId
Section titled “clientId”string
notice
Section titled “notice”Returns
Section titled “Returns”Promise<string>
signalToken()
Section titled “signalToken()”signalToken(
label):string
The hook token WorkflowContext.waitForSignal waits on for label
— <workflow>:<key>:<label> — for a step to hand to a third party:
stepWebhookUrl(ctx.signalToken("transcript")).
THROWS when the run has no WorkflowContext.key: a token derived from nothing is a wait nobody can answer. Synchronous and pure, so it is safe outside a step — it reads only what the run already is.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string
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.
import type { WorkflowContext } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";
export async function publishFlow(input: { topic: string }, ctx: WorkflowContext) { const draft = await ctx.step("draft", () => stepGenerate(`Draft a post on ${input.topic}`)); await ctx.sleep("review-window", 6 * 60 * 60 * 1000, { correlationId: "review" }); await ctx.step("publish", () => publish(input.topic, draft));}
declare function publish(topic: string, draft: string): Promise<void>; // your own siteType 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.
DEFAULTS to label, so the wait a body wrote as "review-window" is the
one a tool’s wakeUp(runId, { correlationIds: ["review-window"] }) ends
with nothing further declared. The two are still different questions —
label decides which JOURNAL ROW this wait is, correlationId which
waits one wakeUp ends — and an explicit id is how they come apart: a
schedule polled in a loop already shares one label across its
iterations, and two independent waits that one “do it now” should both
end give each its own label and 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 createServerForRuntime 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 aai-runtime/src/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.export const approvalToken = (id: string): string => `approve-${id}`;
// file: workflows/publish.tsimport type { WorkflowContext } from "@alexkroman1/aai";import { approvalToken } from "../approval.ts";
export async function publishFlow(input: { id: string }, ctx: WorkflowContext) { const approval = await ctx.waitFor<{ approved: boolean }>(approvalToken(input.id)); if (!approval.approved) return { published: false }; return { published: true };}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>
waitForSignal()
Section titled “waitForSignal()”Call Signature
Section titled “Call Signature”waitForSignal<
S>(label,options):Promise<InferSchemaOutput<S> |undefined>
WorkflowContext.waitFor on a token DERIVED from the run’s own key
— ctx.signalToken(label) — so the tool that answers it writes
ctx.workflows.signalRun(def, key, label, payload) and neither side spells
the token. Journaled as hook!<workflow>:<key>:<label>#<occurrence>, with
exactly waitFor’s options and overloads: a schema checks the payload
and decides the type, a timeoutMs adds the undefined arm.
THROWS when the run has no key, as WorkflowContext.signalToken does.
The token carries no run id, so one live wait holds it at a time: a second
concurrent run of this workflow under the SAME key fails here, with a
refusal naming the label and key — start it under a distinct
StartOptions.key.
Type Parameters
Section titled “Type Parameters”S extends StandardSchemaV1<unknown, unknown>
Parameters
Section titled “Parameters”string
options
Section titled “options”WaitForOptions<S> & WaitForSchemaOptions<S>
Returns
Section titled “Returns”Promise<InferSchemaOutput<S> | undefined>
Call Signature
Section titled “Call Signature”waitForSignal<
S>(label,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”waitForSignal<
T>(label):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”waitForSignal<
T>(label,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”
readonlykey:string|undefined
The run’s StartOptions.key, or undefined for a run started with none.
What WorkflowContext.signalToken and
WorkflowContext.waitForSignal derive their token from, and what a
body hands a step that must address THIS run from outside — the key is
what a tool’s ctx.workflows.latest(def) looks the run up by again. A
tool’s start carries the session id by default; a body or a route that
started this run passed one explicitly or there is none.
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 }).
WorkflowDeclaration
Section titled “WorkflowDeclaration”WorkflowDeclaration<
P,R> =object
Sealed
A workflow declared WITHOUT its body yet — what workflow({ input, output })
answers, and whose body hands back the finished WorkflowDef.
The two-phase form exists for one error, TS7022
Section titled “The two-phase form exists for one error, TS7022”A body in workflows/digest.ts annotates its parameter
WorkflowInputOf<typeof digest>, and digest in agent.ts is
workflow({ input, run: digestFlow }) — so typeof digest is inferred from
digestFlow, whose parameter names typeof digest. The compiler reports the
cycle against the def. Here typeof digest is fixed by the DECLARATION
alone: body is not generic, so its return type is WorkflowDef<P, R> with
both already decided, and checking the body against it needs nothing from
the body’s own signature to name the def.
The cost is stated rather than hidden: with no output schema, R is
unknown — the one thing the single-phase form could still infer from the
body. Declare output to type a completed run’s result, which is the
documented way to type it in either form.
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", input: z.object({ topic: z.string() }), output: z.object({ headline: z.string() }),}).body(digestFlow);
export default agent({ name: "Researcher", workflows: { digest } });
// file: workflows/digest.tsimport type { WorkflowContext, WorkflowInputOf } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";import type { digest } from "../agent.ts";
export async function digestFlow(input: WorkflowInputOf<typeof digest>, ctx: WorkflowContext) { return { headline: await ctx.step("research", () => stepGenerate(`Headline: ${input.topic}`)) };}Type Parameters
Section titled “Type Parameters”P extends ToolInputSchema = ToolInputSchema
R = unknown
Methods
Section titled “Methods”body()
Section titled “body()”body(
run):WorkflowDef<P,R>
Attach the body, answering the finished definition.
Parameters
Section titled “Parameters”WorkflowBody<InferSchemaOutput<P>, R>
Returns
Section titled “Returns”WorkflowDef<P, R>
describeRun()?
Section titled “describeRun()?”
optionaldescribeRun(run):string
Parameters
Section titled “Parameters”Returns
Section titled “Returns”string
Properties
Section titled “Properties”cancelNote?
Section titled “cancelNote?”
optionalcancelNote?:string
description?
Section titled “description?”
optionaldescription?:string
input?
Section titled “input?”
optionalinput?:P
onFailure?
Section titled “onFailure?”
optionalonFailure?:WorkflowFailureHandler<InferSchemaOutput<P>>
output?
Section titled “output?”
optionaloutput?:StandardSchemaV1<unknown,R>
uploads?
Section titled “uploads?”
optionaluploads?: readonlystring[]
WorkflowDef
Section titled “WorkflowDef”WorkflowDef<
P,R> =object
Input
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.
Methods
Section titled “Methods”describeRun()?
Section titled “describeRun()?”
optionaldescribeRun(run):string
One spoken line about a run of this workflow — what the workflow_status
builtin says for each run it lists, and what a route handler answers
through describeRun.
The AUTHOR’s sentence rather than the framework’s, because a status line
is product copy: “your transcript is ready” reads where “run wrun_… is
completed” does not. Handed the typed snapshot, so a completed run’s
output is readable in it. Absent, describeRun answers a plain
sentence per status.
Parameters
Section titled “Parameters”Returns
Section titled “Returns”string
Properties
Section titled “Properties”cancelNote?
Section titled “cancelNote?”
optionalcancelNote?:string
What the workflow_cancel builtin says once it has stopped a run of this
workflow — "Okay, I've stopped the digest." Absent, it names the workflow.
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.
onFailure?
Section titled “onFailure?”
optionalonFailure?:WorkflowFailureHandler<InferSchemaOutput<P>>
What to do when a run FAILS for good — say so on the channel its result would have used — before the failure is recorded.
Runs as one journaled step named onFailure, with the error the body
threw, only when the engine has classified the throw as the run failing:
never for a suspension, a cancel, a journal outage it will retry, or a
divergence refusal. So it replaces the body-wide try/catch that
announced and re-threw, which could announce a run that then resumed and
succeeded. Pass { run, maxAttempts } to give the step a retry budget. A
hook that fails is logged; the run’s own failure is still what is recorded.
import { workflow } from "@alexkroman1/aai";import { DEFAULT_CLIENT_DELIVERY_ATTEMPTS, stepSayOnClient } from "@alexkroman1/aai/step";import { spokenErrorReason } from "@alexkroman1/aai/utils";import { z } from "zod";
declare function research(topic: string): Promise<string>;
export const digest = workflow({ input: z.object({ clientId: z.string(), topic: z.string() }), run: async ({ topic }) => ({ summary: await research(topic) }), onFailure: { run: async (err, { runId, input }) => { const text = `Sorry, I couldn't finish that: ${spokenErrorReason(err)}`; await stepSayOnClient(input.clientId, { id: `${runId}:failed`, event: "research", text }); }, maxAttempts: DEFAULT_CLIENT_DELIVERY_ATTEMPTS, },});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.
WorkflowFailureContext
Section titled “WorkflowFailureContext”WorkflowFailureContext<
I> =object
Sealed
What an onFailure hook is told about the run that failed.
Type Parameters
Section titled “Type Parameters”I = unknown
Properties
Section titled “Properties”
readonlyinput:I
The run’s validated input.
readonlyrunId:string
The failed run’s id — the same value ctx.runId had.
workflow
Section titled “workflow”
readonlyworkflow:string
Key the workflow is declared under in agent({ workflows }).
WorkflowFailureHandler
Section titled “WorkflowFailureHandler”WorkflowFailureHandler<
I> =WorkflowFailureHook<I> | {maxAttempts?:number;run:WorkflowFailureHook<I>; }
WorkflowDef.onFailure: the hook alone, or the hook with the step’s
maxAttempts (a device announcement wants DEFAULT_CLIENT_DELIVERY_ATTEMPTS).
Type Parameters
Section titled “Type Parameters”I = unknown
Union Members
Section titled “Union Members”Type Literal
Section titled “Type Literal”{ maxAttempts?: number; run: WorkflowFailureHook<I>; }
maxAttempts?
Section titled “maxAttempts?”
optionalmaxAttempts?:number
The hook step’s maxAttempts. Defaults to DEFAULT_STEP_MAX_ATTEMPTS.
run:
WorkflowFailureHook<I>
The hook.
WorkflowFailureHook
Section titled “WorkflowFailureHook”WorkflowFailureHook<
I> = (error,context) =>Promise<void> |void
A workflow’s failure hook: runs as ONE step, with the error the body threw. Whatever it returns is discarded; if it throws past its retries the hook’s failure is logged and the RUN’s failure is still what is recorded.
Type Parameters
Section titled “Type Parameters”I = unknown
Parameters
Section titled “Parameters”Error
context
Section titled “context”Returns
Section titled “Returns”Promise<void> | void
WorkflowInputOf
Section titled “WorkflowInputOf”WorkflowInputOf<
D> =Dextendsobject?I:DextendsStandardSchemaV1<unknown, infer O> ?O:Dextendsobject?I:never
A workflow’s INPUT type — what its declared schema parses to, which is exactly what the body’s parameter should be.
The reason it exists is that nothing checks a hand-written parameter.
WorkflowBody takes its input as a function PARAMETER, so it is
contravariant: a body declaring a WIDER shape than the schema produces is
assignable, and a body declaring the same shape with a field’s optionality or
a default’s type subtly different is assignable too. Both compile. A
z.number().default(5) against a body that writes input.limit ?? 3 is the
sharp version — the schema guarantees limit is present, the ?? is dead,
and the two numbers disagree with nothing to report it.
It reads the parameter WorkflowDef.run declares, which IS the schema’s
output (InferSchemaOutput<P>), by matching run’s shape — see
WorkflowOutputOf for why a reading matches a shape.
Two details a restated shape gets wrong by hand, both of which this gets
right for free. A zod .optional() infers a property that may be PRESENT AND
undefined, which under exactOptionalPropertyTypes is ?: T | undefined
and not ?: T — two templates carry the same four-line comment explaining
that, which is a comment z.infer makes unnecessary. And a .default() makes
the OUTPUT property required while the input stays optional, so a body reading
it needs no fallback at all.
Like WorkflowOutputOf, it needs no build step: import type is
erased, so a body in workflows/ naming WorkflowInputOf<typeof theDef>
through a type-only import of ../agent.ts drags no runtime cycle behind it.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”import { agent, workflow, type WorkflowDef } from "@alexkroman1/aai";import { z } from "zod";import { digestFlow } from "./workflows/digest.ts";
const digestInput = z.object({ topic: z.string(), limit: z.number().default(5) });
// ANNOTATED, so the body may name `typeof digest` without a cycle (below).export const digest: WorkflowDef<typeof digestInput, string> = workflow({ description: "Digest a topic", input: digestInput, run: digestFlow,});
export default agent({ name: "Digest", workflows: { digest } });
// file: workflows/digest.ts// `import type` is erased, so there is no cycle.import type { WorkflowContext, WorkflowInputOf } from "@alexkroman1/aai";import { stepGenerate } from "@alexkroman1/aai/step";import type { digest } from "../agent.ts";
export async function digestFlow(input: WorkflowInputOf<typeof digest>, ctx: WorkflowContext) { // `limit` is `number`, not `number | undefined` — the default already ran. return await ctx.step("research", () => stepGenerate(`${input.limit} facts about ${input.topic}`), );}It also reads a SCHEMA, and a bodyless declaration, for the cycle’s sake
Section titled “It also reads a SCHEMA, and a bodyless declaration, for the cycle’s sake”WorkflowInputOf<typeof def> inside the body’s own module is the obvious
spelling and it is TS7022: typeof def needs the body’s signature when the
body is what workflow({ run }) infers from, and the body’s parameter names
typeof def. Two spellings avoid it, and both are read here:
- The input schema itself —
WorkflowInputOf<typeof digestInput>for a namedz.object(…)const, which is the schema’s OUTPUT type exactly as the def reading answers it. The schema is what the def reads anyway, so the body names the same declaration one step earlier. - A two-phase declaration —
workflow({ input, output }).body(digestFlow).typeof defthere is fixed by the declaration alone (seeWorkflowDeclaration), so a body annotatedWorkflowInputOf<typeof def>resolves; and the declaration BEFORE.bodyreads here too, as what itsbodywill accept.
Published from @alexkroman1/aai as well as @alexkroman1/aai/workflow-api.
The root is the one an author wants: this annotation lives in a
workflows/*.ts body, next to the workflow() that declared it.
WorkflowOutputOf
Section titled “WorkflowOutputOf”WorkflowOutputOf<
D> =Dextendsobject?Awaited<unknownextendsO?R:O> :never
A workflow’s OUTPUT type, for a page that polls its runs.
This is the end-to-end typing a static page would otherwise be missing.
useWorkflowRun<R> makes run.status === "completed" narrow to a typed
run.output, and without this the page has to name R by hand — restating a
shape the agent module already declares, with nothing checking the two agree.
It needs no build step and no generated .d.ts, because the reason a page
“cannot import the agent” does not survive contact with import type: a
type-only import is ERASED, so it drags no server graph into the browser
bundle.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”import { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const transcribe = workflow({ description: "Transcribe one upload", input: z.object({ uploadId: z.string() }), output: z.object({ text: z.string() }), run: async (input, ctx) => ({ text: await ctx.step("transcribe", () => `(transcript of ${input.uploadId})`), }),});
export default agent({ name: "Transcriber", workflows: { transcribe } });
// file: client.tsx// `import type` is erased, so nothing server-side is bundled.import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";import { useWorkflowRun } from "@alexkroman1/aai-ui";import type { transcribe } from "./agent.ts";
export function Transcript({ runId }: { runId: string }) { const { run } = useWorkflowRun<WorkflowOutputOf<typeof transcribe>>(runId); return run?.status === "completed" ? <p>{run.output.text}</p> : null; // typed}It reads the declared SCHEMA first, and that is what breaks a cycle
Section titled “It reads the declared SCHEMA first, and that is what breaks a cycle”The DECLARATION is the better source of this type, and the worse one used to
be the only one. Deriving R from the body means typeof theDef needs the
body’s signature — while a body annotated WorkflowInputOf<typeof theDef>
needs typeof theDef, which is TS7022 reported against agent.ts. The
documented way out is to ANNOTATE the declaration, and an annotation whose
R comes from a schema (WorkflowDef<typeof digestInput, z.infer<typeof digestOutput>>) states the output type once, in the schema, rather than
naming it a second time by hand.
That annotated shape is also what the second reading gets WRONG, which is
the other half of this rewrite. D extends WorkflowDef<ToolInputSchema, infer R> is an assignability test over the whole def, and run’s input is a
function PARAMETER — so a def carrying an input schema is not assignable to
one taking the open Record<string, unknown>, and the conditional silently
fell to never. It is the same contravariance AnyWorkflowDef was
written for, reached by the other route, and it is why the test below matches
run as (input: never, ctx: never) => infer R — never is assignable to
every parameter type.
It matches a SHAPE, not a named declaration
Section titled “It matches a SHAPE, not a named declaration”Both readings test run’s signature structurally rather than naming
WorkflowDef, WorkflowBody or WorkflowContext. A reading answers the
same type either way — WorkflowDef.run IS (input: InferSchemaOutput<P>, ctx: WorkflowContext) => … — but a reading that names the declaration
carries it (and everything WorkflowContext reaches) into the contract of
every capability that publishes the reading, so a new member on the context
a body receives moved a PAGE’s type.
unknown extends O is how “declared nothing” is told from “declared a
schema”: a def with no output schema still HAS the optional property in its
type, carrying R — so the two readings agree, and the fallback only ever
fires for a def-shaped object that names no output at all.
Awaited because a body may be sync or async and the snapshot always holds
the settled value.
On @alexkroman1/aai/workflow-api only, unlike its two siblings: its reader
is a page. Both templates that name it are a client.tsx parameterizing
useWorkflowRun<…>, and a *_status tool wants WorkflowRunOf, which
composes this in already.
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 { agent, workflow } from "@alexkroman1/aai";import { z } from "zod";
export const research = workflow({ description: "Research a topic", input: z.object({ topic: z.string() }), output: z.object({ summary: z.string() }), run: async (input) => ({ summary: `Everything about ${input.topic}.` }),});
export default agent({ name: "Researcher", workflows: { research } });
// file: tools/research_status.tsimport { tool, type WorkflowRunOf } from "@alexkroman1/aai";import { isTerminal } from "@alexkroman1/aai/workflow-api";import { z } from "zod";import { research } from "../agent.ts";
export default tool({ description: "How the research is going", inputSchema: z.object({ runId: z.string() }), execute: async ({ runId }, ctx) => { const run = await ctx.workflows.get(runId, research); return run ? describe(run) : "no such run"; },});
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.
WorkflowsMap
Section titled “WorkflowsMap”WorkflowsMap =
NonNullable<AgentDef["workflows"]>
The workflows an agent may declare — AgentDef["workflows"] without the
undefined. The constraint every W is inferred under.
Variables
Section titled “Variables”CALL_BRIEF_CHUNK_CHARS
Section titled “CALL_BRIEF_CHUNK_CHARS”
constCALL_BRIEF_CHUNK_CHARS:number
Characters of prompt per <Parameter> — under Twilio’s 500 with the name.
CALL_BRIEF_SECRET_ENV
Section titled “CALL_BRIEF_SECRET_ENV”
constCALL_BRIEF_SECRET_ENV:"CALLS_API_KEY"="CALLS_API_KEY"
The default agent-env variable a brief is signed with — see stepPlaceCall’s briefSecretEnv.
DEFAULT_GUARDRAIL_MAX_REVISIONS
Section titled “DEFAULT_GUARDRAIL_MAX_REVISIONS”
constDEFAULT_GUARDRAIL_MAX_REVISIONS:number
How many times a SpeakerDef.guardrail may send an answer back when the subagent names no SpeakerDef.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_SIGNAL_HOLD_MS
Section titled “DEFAULT_SIGNAL_HOLD_MS”
constDEFAULT_SIGNAL_HOLD_MS:number
How long WorkflowClient.signalRun holds a payload for a wait the run has
not reached yet — see SignalOptions.holdMs.
Five minutes: long enough for a body that transcribes or uploads before it parks, and short enough that a payload for a run that never parks is not held for the life of the process.
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 the agent keeps no system-prompt.md.
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, have a spoken name, email or code spelled and read back before using it, normalize spoken identifiers.## TOOLS— never fabricate, act first and ask second, report results rather than intentions, and the mis-hearing retry ladder.
Your system-prompt.md does NOT replace any of it — it is APPENDED.
buildSystemPrompt always emits these sections and then adds the file’s
text last, under a header saying it overrides them where they conflict. So
write only your own domain rules — Only discuss items in the catalog. is a
complete file — and nothing in agent.ts: the prompt is not a parameter.
Do not interpolate this constant into that file. 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 hands a TASK off by.
HANDOFF_TOOL_NAME
Section titled “HANDOFF_TOOL_NAME”
constHANDOFF_TOOL_NAME:"handoff"="handoff"
The name the model hands the CALL off by.
INSTRUCTION_KEY
Section titled “INSTRUCTION_KEY”
constINSTRUCTION_KEY:"$instruction"="$instruction"
The key withInstruction writes — reserved, and never a tool’s own.
MAX_CALL_BRIEF_PROMPT_CHARS
Section titled “MAX_CALL_BRIEF_PROMPT_CHARS”
constMAX_CALL_BRIEF_PROMPT_CHARS:number
The longest task prompt a brief carries — nine chunks, well inside 32 parameters.
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_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 aai-runtime/src/mcp/tools.ts).
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>
WORKFLOW_APP_REQUIRED_ENV
Section titled “WORKFLOW_APP_REQUIRED_ENV”
constWORKFLOW_APP_REQUIRED_ENV: readonlystring[]
What workflowApp declares as requiredEnv when the app names none:
the AssemblyAI key, ["ASSEMBLYAI_API_KEY"].
A workflow app declares no providers, so nothing else in its config names a
credential — and every shipped workflow app transcribes, generates or speaks
through that one key (stepTranscribe*, stepGenerate, stepSpeak all read
it by default). A voice agent() keeps no such default: its providers already
declare theirs, and requiredEnv there is for what the TOOLS need. An app
that genuinely needs no key — one whose steps only fetch — opts out with
requiredEnv: [], and one that names its own list replaces this rather than
extending it.