Skip to content

index

The AAI voice-agent SDK — the AUTHORING surface, and only that.

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.

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:

  1. A type (or catalog) a root signature is spelled in — the stage types AgentDef names, LlmSpec, AssemblyAIGatewayModel, the voice catalog, the three …Of<typeof def> readings, ToolFailure and the helpers a tool body calls beside it — so an author never needs a second import to write down what agent(), tool() or workflow() asked for.
  2. A MODE preset — assemblyAIPipeline and assemblyAIS2s, 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.

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.

string

number

string

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.

import { addDays } from "@alexkroman1/aai";
addDays("2026-06-08", 3); // "2026-06-11"
addDays("2026-01-01", -1); // "2025-12-31"

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

W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows

PipelineAgentParams & object

AgentDefWith<"pipeline", W>

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

agent<W>(def): AgentDefWith<"s2s", W>

Define a speech-to-speech agent: mode: "s2s" and its s2s descriptor. See S2sAgentParams.

W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows

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

AgentDefWith<"s2s", W>

import { agent, assemblyAIS2s } from "@alexkroman1/aai";
export default agent({ name: "Concierge", mode: "s2s", s2s: assemblyAIS2s() });

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.

W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows

Omit<SharedAgentParams, "sttPrompt" | "telephony"> & Pick<AgentDeclaration, keyof AgentModelTuning> & object & object

AgentDefWith<"text", W>

import { agent } from "@alexkroman1/aai";
export default agent({
name: "Docs Assistant",
mode: "text",
promptContext: "The docs cover the v2 API only.",
});

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.

W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows

Omit<WorkflowAppAgentParams, "workflows"> & object

AgentDefWith<"workflow-app", W>

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

AgentParams

AgentDef


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.

ApiKeyRouteOptions

RouteHandler

RouteHandler

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

AssemblyAIPipelineOptions

object

llm: LlmProvider

stt: SttProvider

tts: TtsProvider


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.

AssemblyAIS2sOptions

S2sProvider

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

ClientRunsRoutesOptions

The path, the window and the three per-run choices; see ClientRunsRoutesOptions.

Record<string, RouteHandler>

The two handlers, keyed "GET <path>" and "DELETE <path>/:runId".

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

P extends ToolInputSchema = ToolInputSchema

ClientToolDef<P>

ToolDef<P>

`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,
});
client.tsx
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(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.

string

The argument, named as the model and the caller should hear it ("the pickup time").

ZodString

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

string

string

Promise<boolean>

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

Create a KeyedLock.

Prefer withLock at call sites — it releases in every outcome, which a bare lock() leaves to the caller’s finally.

KeyedLock


createMetricsCollector(options?): MetricsCollector

Create a collector — see this module’s doc for an example.

MetricsCollectorOptions

MetricsCollector


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.

number

RandomSource

import { createSeededRandom, shuffled } from "@alexkroman1/aai";
const random = createSeededRandom(42);
shuffled(["a", "b", "c"], random); // the same order on every run

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.

string

string

number

RangeError if either argument is not a date isIsoDate accepts.

import { daysBetween } from "@alexkroman1/aai";
daysBetween("2026-06-08", "2026-06-11"); // 3
daysBetween("2026-06-08", "2026-06-08"); // 0
daysBetween("2026-06-11", "2026-06-08"); // -3

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

AnyWorkflowDef<unknown> | undefined

WorkflowRunSnapshot

string


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.

E extends object

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.

DialogMachine<E>

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.

DialogOptions

Dialog<E>

shared.ts
// The one place the dialog is declared.
import { dialog } from "@alexkroman1/aai";
import { setup } from "xstate";
const machine = setup({
types: {} as { events: { type: "VERIFIED" } | { type: "QUOTED" } },
}).createMachine({
id: "claim",
initial: "verifying",
states: {
verifying: {
meta: { instruction: "Get the caller's policy number and verify it." },
on: { VERIFIED: "quoting" },
},
quoting: {
meta: { instruction: "Read the excess disclosure, then quote." },
on: { QUOTED: "done" },
},
done: { type: "final" },
},
});
export const claim = dialog("claim", machine);
// 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 },
},
});

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.

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.

S extends DialogSpec

string

S

DialogOptions

Dialog<DialogEvent<S>>


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.

Pick<ToolContext, "sessionId">

EndSessionOptions

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

Extract a detailed error string (message + stack) for diagnostic logging.

unknown

string


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.

unknown

string


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.

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.

A extends readonly unknown[]

R

(…args) => Promise<R>

(…args) => Promise<ToolFailure | R>

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

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.

A extends readonly unknown[]

R

(…args) => R

(…args) => ToolFailure | R

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

string

Promise<string>


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.

unknown

string | undefined


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.

string

boolean

import { isClockTime } from "@alexkroman1/aai";
isClockTime("19:30"); // true
isClockTime("04:45"); // true
isClockTime("4:45"); // false — not zero-padded
isClockTime("24:00"); // false — midnight is 00:00

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.

string

boolean

import { isIsoDate } from "@alexkroman1/aai";
isIsoDate("2026-06-08"); // true
isIsoDate("2026-02-30"); // false — February has no 30th
isIsoDate("6/8/2026"); // false

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.

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.

ZodString

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

unknown

value is Record<string, unknown>

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

unknown

value is ToolFailure

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

string

string

string


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.

string

MintCodeOptions

string

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.

import { mintCode } from "@alexkroman1/aai";
mintCode("HTL"); // e.g. "HTL-7K2M"
mintCode("RES", { taken: new Set(["RES-7K2M"]) });

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.

number

How many digits. Default 6. An integer from 1 to 32.

string

RangeError if digits is not an integer in that range.

import { hashCode, mintDigitCode } from "@alexkroman1/aai";
const code = mintDigitCode(); // e.g. "048190"
const stored = await hashCode(code); // keep this, say `code`

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.

T extends object

T

{ [K in string | number | symbol]?: unknown extends T[K] ? NonNullable<unknown> | null : Exclude<T[K], undefined> }

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

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.

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 a ToolFailure, which abandons the enclosing failable with that exact failure.
  • A function (orFail(stepGenerate)): the same function, except that what it throws — or a non-2xx Response it resolves to — leaves as a FatalError, or a RetryableError carrying the far side’s own Retry-After (toStepError on @alexkroman1/aai/step-errors is the verdict; anything it cannot classify is rethrown unchanged). This is how a workflows/ step calls a /step primitive — orFail(stepFetch), orFail(stepGenerateJson), orFail(stepTranscribeSubmit), orFail(sendToChannel) — and it replaced the eight *OrFail twins 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).

A extends readonly unknown[]

R

(…args) => Promise<R>

(…args) => Promise<R>

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.

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

A 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." });
}

orFail<T>(value): T

The value arm of orFail: value, unless it is a ToolFailure, which abandons the enclosing failable with that exact failure.

T

ToolFailure | T

T

A private sentinel, caught by the enclosing failable.


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.

T

readonly T[]

RandomSource

T | undefined

import { pickOne } from "@alexkroman1/aai";
pickOne(["north", "south"], () => 0); // "north"
pickOne([]); // undefined

procedure<M>(machine): Procedure<M>

Wrap a machine so a tool body can run it without touching an actor.

M extends AnyStateMachine

M

An ordinary XState machine. Give it an output — that is what Procedure.run resolves with, and a machine with none resolves undefined.

Procedure<M>

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

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

T

T[]

T

number

T[]

import { pushCapped } from "@alexkroman1/aai";
const log: string[] = ["a", "b", "c"];
pushCapped(log, "d", 3); // ["b", "c", "d"]

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.

number

RandomSource

number

import { randomInt } from "@alexkroman1/aai";
randomInt(6); // 0..5
randomInt(6, () => 0.5); // 3

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.

Readonly<Record<string, string>>

string | undefined

Promise<CallBrief | undefined>

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

Readonly<Partial<Record<string, string>>>

string

string


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

Pick<ToolContext, "sessionId">

string

The sentence the model reads when there is no client id.

string | ToolFailure


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:

  1. No candidates — say so, rather than reporting a failed match against an empty list.
  2. 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.
  3. 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.
  4. 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.
  5. Exactly one candidate left — it is what they meant.
  6. 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.

T

readonly T[]

string

ResolveOneOptions<T>

T | ToolFailure

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

Response

string

Promise<string>

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

N extends string

readonly SpeakerDef<N>[]

Roster<N>


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.

S extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>

Client extends boolean = false

RouteDef<S, Client>

RouteHandler

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

number

string

RouteError

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

number

unknown

RouteResponseOptions

RouteResponse


safeJsonParse(text): unknown

Parse JSON, returning undefined on malformed input. JSON cannot encode undefined, so the sentinel is unambiguous.

string

unknown


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

Pick<ToolContext, "sessionId">

SessionCall | undefined


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

Pick<ToolContext, "sessionId">

string | undefined


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.

Pick<ToolContext, "sessionId">

string | undefined


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

Pick<ToolContext, "sessionId">

string | undefined


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.

K extends string

T

After = void

V = DeepReadonly<T>

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.

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.

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

SessionSlot<K, T, V>

shared.ts
// The one place the slot is declared, view included.
import { sessionSlot } from "@alexkroman1/aai";
export type Cart = { items: string[] };
export const cartSlot = sessionSlot("cart", (): Cart => ({ items: [] }), {
view: (cart) => ({ count: cart.items.length }),
});
// 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<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.

T

readonly T[]

RandomSource

T[]

import { shuffled } from "@alexkroman1/aai";
shuffled([1, 2, 3], () => 0); // a new array; the input is untouched

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.

CallBrief

string

Promise<Record<string, string>>

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

N extends string

S extends StandardSchemaV1<unknown, unknown>

SpeakerDef<N> & object

TypedSpeakerDef<InferSchemaOutput<S>, N>

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.

N extends string

SpeakerDef<N>

SpeakerDef<N>


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.

string

string

import { spokenAlphanumeric } from "@alexkroman1/aai";
spokenAlphanumeric("rs 44-17"); // "RS4417"
spokenAlphanumeric("#W 586 6402"); // "W5866402"

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.

string

string

import { spokenDate } from "@alexkroman1/aai";
spokenDate("2026-06-08"); // "Monday, June 8"
spokenDate("not a date"); // "not a date"

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.

string

string

import { spokenDigits } from "@alexkroman1/aai";
spokenDigits("that's 864-219-75"); // "86421975"

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.

number

string

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

string

number | undefined

import { spokenOrdinal } from "@alexkroman1/aai";
spokenOrdinal("cancel the second one"); // 1
spokenOrdinal("cancel the last one"); // -1
spokenOrdinal("cancel my order"); // undefined

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.

string

string

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"

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.

stripInstruction<T>(result): Omit<T, typeof INSTRUCTION_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.

T extends Record<string, unknown>

T

Omit<T, typeof INSTRUCTION_KEY>

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.

unknown

unknown


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.

P extends ToolInputSchema | undefined = ToolInputSchema

R = unknown

ToolDef<P, R>

ToolDef<P, R>

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

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

string

ToolFailure

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(reason, message): ToolRefusal

Build a ToolRefusal.

ToolRefusalReason

string

ToolRefusal


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.

Pick<RouteRequest, "headers" | "rawBody">

string

StandardWebhookOptions

Promise<boolean>

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

WebhookRouteOptions

RouteHandler

RouteHandler

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

T extends Record<string, unknown>

T

string

WithInstruction<T>

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

P extends ToolInputSchema = ToolInputSchema

O extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>

Omit<WorkflowDef<P, InferSchemaOutput<O>>, "output"> & object

WorkflowDef<P, InferSchemaOutput<O>>

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.

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.

workflows/digest.ts
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.ts
import { agent, workflow } from "@alexkroman1/aai";
import { z } from "zod";
import { digestFlow } from "./workflows/digest.ts";
export const digest = workflow({
description: "Research a topic overnight and store the result",
input: z.object({ topic: z.string() }),
output: z.object({ topic: z.string(), headline: z.string() }),
run: digestFlow,
});
export default agent({
name: "Researcher",
workflows: { digest },
});
// file: tools/research.ts
import { tool } from "@alexkroman1/aai";
import { z } from "zod";
import { digest } from "../agent.ts";
export default tool({
description: "Kick off overnight research on a topic",
inputSchema: z.object({ topic: z.string() }),
execute: async ({ topic }, ctx) => {
// The workflow itself, not its name: typed input, and a typo is a
// compile error. `key` is what lets a later turn find this run.
const runId = await ctx.workflows.start(digest, { topic }, { key: ctx.sessionId });
return `Working on it — run ${runId}.`;
},
});

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.

P extends ToolInputSchema = ToolInputSchema

R = unknown

WorkflowDef<P, R>

WorkflowDef<P, R>

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.

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.

workflows/digest.ts
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.ts
import { agent, workflow } from "@alexkroman1/aai";
import { z } from "zod";
import { digestFlow } from "./workflows/digest.ts";
export const digest = workflow({
description: "Research a topic overnight and store the result",
input: z.object({ topic: z.string() }),
output: z.object({ topic: z.string(), headline: z.string() }),
run: digestFlow,
});
export default agent({
name: "Researcher",
workflows: { digest },
});
// file: tools/research.ts
import { tool } from "@alexkroman1/aai";
import { z } from "zod";
import { digest } from "../agent.ts";
export default tool({
description: "Kick off overnight research on a topic",
inputSchema: z.object({ topic: z.string() }),
execute: async ({ topic }, ctx) => {
// The workflow itself, not its name: typed input, and a typo is a
// compile error. `key` is what lets a later turn find this run.
const runId = await ctx.workflows.start(digest, { topic }, { key: ctx.sessionId });
return `Working on it — run ${runId}.`;
},
});

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.

P extends ToolInputSchema = ToolInputSchema

O extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>

Omit<WorkflowDef<P, InferSchemaOutput<O>>, "output" | "run"> & object

WorkflowDeclaration<P, InferSchemaOutput<O>>

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.

P extends ToolInputSchema = ToolInputSchema

Omit<WorkflowDef<P, unknown>, "run">

WorkflowDeclaration<P, unknown>

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.

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.

workflows/digest.ts
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.ts
import { agent, workflow } from "@alexkroman1/aai";
import { z } from "zod";
import { digestFlow } from "./workflows/digest.ts";
export const digest = workflow({
description: "Research a topic overnight and store the result",
input: z.object({ topic: z.string() }),
output: z.object({ topic: z.string(), headline: z.string() }),
run: digestFlow,
});
export default agent({
name: "Researcher",
workflows: { digest },
});
// file: tools/research.ts
import { tool } from "@alexkroman1/aai";
import { z } from "zod";
import { digest } from "../agent.ts";
export default tool({
description: "Kick off overnight research on a topic",
inputSchema: z.object({ topic: z.string() }),
execute: async ({ topic }, ctx) => {
// The workflow itself, not its name: typed input, and a typo is a
// compile error. `key` is what lets a later turn find this run.
const runId = await ctx.workflows.start(digest, { topic }, { key: ctx.sessionId });
return `Working on it — run ${runId}.`;
},
});

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

W extends Readonly<Record<string, WorkflowDef>> = EmptyWorkflows

Omit<WorkflowAppAgentParams, "workflows" | "mode"> & object

AgentDefWith<"workflow-app", W>

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

Thrown when an acquire deadline lapses before the key came free.

  • Error

new KeyedLockTimeoutError(key, timeoutMs, options?): KeyedLockTimeoutError

string

number

ErrorOptions

KeyedLockTimeoutError

Error.constructor

readonly key: string


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.

  • Error

new ProcedureNotFinishedError(procedure, aborted): ProcedureNotFinishedError

string

boolean

ProcedureNotFinishedError

Error.constructor

readonly aborted: boolean

Whether the run’s signal is what ended it.

readonly procedure: string

The machine’s id, so a log names which procedure stopped.


A route’s refusal, THROWN: the route answers status with { error: message }. Build one with routeError.

  • Error

new RouteError(status, message): RouteError

Throws a RangeError for a status that is not 4xx or 5xx.

number

string

RouteError

Error.constructor

readonly status: number

The HTTP status the route answers: 4xx or 5xx.

Input

The device-inbox half of an agent declaration — see this module’s header.

optional clientInbox?: 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 } });

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.

optional builtinTools?: readonly BuiltinTool[]

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.

["think"] (DEFAULT_BUILTIN_TOOLS)

optional clientInbox?: 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 } });

AgentClientInbox.clientInbox

optional dialogs?: readonly AnyDialog[]

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.

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

"Sorry, I had a problem just then. Could you say that again?" (DEFAULT_ERROR_PHRASE)

PipelinePhrases.errorPhrase

optional events?: 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.id is 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.

AgentObservation.events

greeting: string

Sentence spoken when a session starts. Set "" to start silent.

"Hey there! I'm an AI voice assistant. What can I help you with?" (DEFAULT_GREETING)

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

300_000 (5 minutes, DEFAULT_IDLE_TIMEOUT_MS)

optional inputGuardrails?: readonly AgentGuardrail[]

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.

AgentGuardrails.inputGuardrails

optional interruption?: InterruptionTuning | "off"

When the caller may cut the agent off — see InterruptionTuning; "off" means never.

PipelineTuning.interruption

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

AgentListing.listing

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

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

AgentModelTuning.maxOutputTokens

optional maxRetries?: number

How many times a FAILED provider call is retried before the step is given up on.

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.

AgentModelTuning.maxRetries

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.

10 (DEFAULT_MAX_STEPS)

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

optional mode?: AgentMode

Which kind of agent this is — see AgentMode, which says what each mode has.

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

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

SessionEndContext

unknown

AgentSessionLifecycle.onSessionEnd

optional outputGuardrails?: readonly AgentGuardrail[]

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.

AgentGuardrails.outputGuardrails

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

AgentInstructions.promptContext

optional requiredEnv?: readonly string[]

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.

optional resetToolChoice?: boolean

Put toolChoice back to "auto" after the FIRST step of a reply.

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.

AgentModelTuning.resetToolChoice

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

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

AgentRoutes.routes

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

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

SessionContextArgs

SessionContext | Promise<SessionContext | undefined> | undefined

AgentSessionLifecycle.sessionContext

optional silence?: SilenceTuning

Dead air and the silence nudge — see SilenceTuning.

PipelineTuning.silence

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

"I am sorry, I am having trouble with my connection and cannot hear you. Please hang up and call back." (DEFAULT_START_FAILURE_PHRASE)

PipelinePhrases.startFailurePhrase

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

optional sttPrompt?: string

Bias prompt for transcription — use it to teach the transcriber the agent’s own vocabulary (product names, spelled-out identifiers).

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

optional syncState?: 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" });

AgentObservation.syncState

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

optional telephony?: TelephonyAccess

Which phone carriers may open a media stream against this agent — and so whether WS /phone is served at all.

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

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

AgentModelTuning.temperature

optional toolChoice?: ToolChoice

How the LLM selects tools each step.

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

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

optional turnTaking?: TurnTakingTuning

When the caller’s turn ends — see TurnTakingTuning.

PipelineTuning.turnTaking

optional usageLimits?: UsageLimits

Bound what one session may spend — see UsageLimits.

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.

AgentModelTuning.usageLimits

optional voicePresets?: readonly VoicePresetName[]

Opt-in prompt presets — named reliability behaviours, composed into the system prompt above your own instructions.

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.

AgentVoicePresets.voicePresets

optional workflows?: Readonly<Record<string, WorkflowDef>>

Durable workflows this agent may start, keyed by workflow name.

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.


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.

optional builtinTools?: readonly BuiltinTool[]

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.

["think"] (DEFAULT_BUILTIN_TOOLS)

AgentDeclaration.builtinTools

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

optional dialogs?: readonly AnyDialog[]

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.

AgentDeclaration.dialogs

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

"Sorry, I had a problem just then. Could you say that again?" (DEFAULT_ERROR_PHRASE)

AgentDeclaration.errorPhrase

optional events?: 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.id is 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.

AgentDeclaration.events

greeting: string

Sentence spoken when a session starts. Set "" to start silent.

"Hey there! I'm an AI voice assistant. What can I help you with?" (DEFAULT_GREETING)

AgentDeclaration.greeting

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

300_000 (5 minutes, DEFAULT_IDLE_TIMEOUT_MS)

AgentDeclaration.idleTimeoutMs

optional inputGuardrails?: readonly AgentGuardrail[]

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.

AgentDeclaration.inputGuardrails

optional interruption?: InterruptionTuning | "off"

When the caller may cut the agent off — see InterruptionTuning; "off" means never.

AgentDeclaration.interruption

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

AgentDeclaration.listing

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

AgentDeclaration.llm

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

AgentDeclaration.maxOutputTokens

optional maxRetries?: number

How many times a FAILED provider call is retried before the step is given up on.

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.

AgentDeclaration.maxRetries

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.

10 (DEFAULT_MAX_STEPS)

AgentDeclaration.maxSteps

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

AgentDeclaration.mcpServers

optional mode?: AgentMode

Which kind of agent this is — see AgentMode, which says what each mode has.

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

AgentDeclaration.mode

name: string

Display name shown by the default client UI.

AgentDeclaration.name

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

SessionEndContext

unknown

AgentDeclaration.onSessionEnd

optional outputGuardrails?: readonly AgentGuardrail[]

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.

AgentDeclaration.outputGuardrails

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

AgentDeclaration.promptContext

optional requiredEnv?: readonly string[]

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.

AgentDeclaration.requiredEnv

optional resetToolChoice?: boolean

Put toolChoice back to "auto" after the FIRST step of a reply.

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.

AgentDeclaration.resetToolChoice

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

AgentDeclaration.roster

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

AgentDeclaration.routes

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

AgentDeclaration.s2s

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

SessionContextArgs

SessionContext | Promise<SessionContext | undefined> | undefined

AgentDeclaration.sessionContext

optional silence?: SilenceTuning

Dead air and the silence nudge — see SilenceTuning.

AgentDeclaration.silence

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

"I am sorry, I am having trouble with my connection and cannot hear you. Please hang up and call back." (DEFAULT_START_FAILURE_PHRASE)

AgentDeclaration.startFailurePhrase

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

AgentDeclaration.stt

optional sttPrompt?: string

Bias prompt for transcription — use it to teach the transcriber the agent’s own vocabulary (product names, spelled-out identifiers).

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

AgentDeclaration.sttPrompt

optional syncState?: 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" });

AgentDeclaration.syncState

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

AgentDeclaration.systemPrompt

optional telephony?: TelephonyAccess

Which phone carriers may open a media stream against this agent — and so whether WS /phone is served at all.

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

AgentDeclaration.telephony

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

AgentDeclaration.temperature

optional toolChoice?: ToolChoice

How the LLM selects tools each step.

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

AgentDeclaration.toolChoice

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.

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

AgentDeclaration.tts

optional turnTaking?: TurnTakingTuning

When the caller’s turn ends — see TurnTakingTuning.

AgentDeclaration.turnTaking

optional usageLimits?: UsageLimits

Bound what one session may spend — see UsageLimits.

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.

AgentDeclaration.usageLimits

optional voicePresets?: readonly VoicePresetName[]

Opt-in prompt presets — named reliability behaviours, composed into the system prompt above your own instructions.

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.

AgentDeclaration.voicePresets

optional workflows?: Readonly<Record<string, WorkflowDef>>

Durable workflows this agent may start, keyed by workflow name.

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.

AgentDeclaration.workflows


Input

The two guardrail fields on AgentDef — see this module’s header for what each can actually prevent.

optional inputGuardrails?: readonly AgentGuardrail[]

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.

optional outputGuardrails?: readonly AgentGuardrail[]

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.


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.

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


Input

The listing field group — see this module’s header.

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


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.

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

ModelTuning.maxOutputTokens

optional maxRetries?: number

How many times a FAILED provider call is retried before the step is given up on.

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.

ModelTuning.maxRetries

optional resetToolChoice?: boolean

Put toolChoice back to "auto" after the FIRST step of a reply.

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.

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

ModelTuning.temperature

optional usageLimits?: UsageLimits

Bound what one session may spend — see UsageLimits.

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.


Input

The observe-only half of an agent declaration — see this module’s header.

optional events?: 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.id is 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.

optional syncState?: 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" });

Input

The routes field of an agent declaration — see this module’s header.

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


Sealed

The session a per-session author function is running for.

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


Input

The session-bracketing half of an agent declaration — see this module’s header.

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

SessionEndContext

unknown

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

SessionContextArgs

SessionContext | Promise<SessionContext | undefined> | undefined


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.

optional voicePresets?: readonly VoicePresetName[]

Opt-in prompt presets — named reliability behaviours, composed into the system prompt above your own instructions.

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.


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.

readonly meta: SessionEventMeta

readonly type: string


Input

What apiKeyRoute takes.

env: string

The agent-env variable holding the key, e.g. "CALLS_API_KEY".

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


Input

optional maxTurnSilenceMs?: number

See AssemblyAIPipelineOptions.minTurnSilenceMs.

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

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

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


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.

optional apiKeyEnv?: string

Env var holding this stage’s credential, replacing the provider default. Names a VARIABLE, not a key.

ProviderCredentialOptions.apiKeyEnv

optional keyterms?: readonly string[]

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.

optional languages?: readonly string[]

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.

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


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.


Sealed

One row of the list clientRunsRoutes answers — plain JSON, what a page renders a run from.

createdAt: number

When the run was created, as epoch ms — the list is in this order, oldest first.

optional detail?: 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: ClientRunStatus

Where it has got to; see ClientRunStatus.

title: string

The run’s label (StartOptions.label), else its workflow’s key.

workflow: string

The workflow’s key in agent({ workflows }).


Sealed

The body the list route answers.

runs: ClientRun[]

The client’s runs, oldest first.


Input

Options for clientRunsRoutes.

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

WorkflowRunSnapshot

string | undefined

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

WorkflowRunSnapshot

boolean

optional limit?: number

Most runs read per request, newest first, before filtering. Default 100 (the ceiling).

optional path?: string

The routes’ path, as an agent({ routes }) key spells it: the list is GET <path>, the cancel DELETE <path>/:runId. Default "/tasks".

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

WorkflowRunSnapshot

boolean

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


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.

steps: number

How many steps this attempt took, including the final answering step.

text: string

The subagent’s final message — see SpeakerDef.expectedOutput.

toolCalls: readonly DelegateToolCall[]

Every tool call this attempt made, in order.


Input

Per-call options for DelegateFn.

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

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


Sealed

What one delegated run returns: the accepted attempt, plus what getting there took.

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.

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

DelegateAnswer.steps

text: string

The subagent’s final message — see SpeakerDef.expectedOutput.

DelegateAnswer.text

toolCalls: readonly DelegateToolCall[]

Every tool call this attempt made, in order.

DelegateAnswer.toolCalls


Sealed

One tool call a subagent made, as reported back to the caller.

input: unknown

The arguments it was called with.

name: string

The tool’s name, as the subagent’s model called it.


Sealed

A dialog statechart bound to a session, created by dialog.

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.

matches(ctx, state): boolean

Whether the active state matches state, as when spells it.

SlotHolder

string

boolean

position(ctx): DialogPosition

Where this session’s conversation currently is.

SlotHolder

DialogPosition

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.

V

(position) => V

StateProjection<V>

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

SlotHolder

SessionEvent

DialogPosition

reset(ctx): DialogPosition

Discard this session’s progress and start the dialog over.

SlotHolder

DialogPosition

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.

SlotHolder

E

DialogPosition

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.

SlotHolder

DialogTimeout | undefined

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.

P extends ToolInputSchema | undefined = ToolInputSchema

R = unknown

K extends string = string

DialogToolDef<P, R, E, K> & NoInfer<DialogSendFromGuard<K, R, E>>

DialogSendOptions<R, E, K> & NoInfer<DialogSendFromGuard<K, R, E>>

DialogSendFromChecked<K, R, E, ToolDef<P, Promise<ToolFailure | DialogToolResult<R>>>>

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.

SlotHolder

DialogVoiceConfig | undefined

readonly key: string

The store key this dialog’s snapshot occupies. Two flows must not share one.

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


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

R

What the tool’s execute returns, as sendFrom reads it.

E

The machine’s event union.

K extends string = string

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

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

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

optional when?: string | readonly string[]

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.


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.

E extends object = { type: string; }

The events the machine accepts.

transition(snapshot, event, actorScope): unknown

never

E

never

unknown

readonly id: string


Input

Options for dialog.

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


Sealed

Where a dialog currently is.

readonly done: boolean

Whether the machine has reached a final state.

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

readonly optional persona?: string

The persona the active state pins, when it declares one — see DialogStateSpec.persona. Same depth rule as instruction.

readonly state: string

The active state as a dotted path — "verifying", or "quote.pending" for a nested one. Parallel regions are joined with ",".


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.

R

What the tool’s execute returns.

E

The machine’s event union.

K extends string = string

sendFrom: DialogSendFrom<R, E, K>

The transition the result decides — see DialogGate.sendFrom.


Input

A dialog’s shape as a plain state map — the argument to the dialog overload that takes no XState machine. See DialogStateSpec.

initial: string

Which state a fresh dialog starts in.

states: Record<string, DialogStateSpec>

The states, keyed by the name when and DialogPosition.state use.


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.

optional final?: true

Whether reaching this state ENDS the dialog — XState’s type: "final".

optional initial?: string

For a state with states: which child it starts in.

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

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

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

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

SlotHolder

void

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

optional states?: Record<string, DialogStateSpec>

Nested states, addressed as parent.child by when and by matches.

optional temperature?: number

The model’s sampling temperature while this state is active.

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

optional toolChoice?: ToolChoice

The model’s tool-choice policy while this state is active.

optional voice?: string

The TTS voice for this phase of the call — a different voice for the disclosure than for the chat, say. See DialogVoiceConfig.


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.

readonly afterMs: number

DialogTimeoutSpec.afterMs, from the state in force.

readonly event: object

The event to send when the deadline passes.

readonly type: string


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.

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.


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.

P extends ToolInputSchema | undefined

The tool’s input schema.

R

What execute returns.

E

The machine’s event union.

K extends string = string

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.

InferSchemaOutput<P>

ToolContext

ToolFailure | R | Promise<ToolFailure | R>

description: string

Human-readable description shown to the LLM.

Omit.description

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

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

Omit.effect

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

Omit.inputSchema

optional messages?: 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:

  • delayed is a LADDER when the timings differ and VARIANTS when they match. Two entries at afterMs: 3000 are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.
  • A complete/failed entry with role: "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.

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

Omit.messages

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

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

Omit.onError

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

DialogGate.reset

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

DialogGate.send

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

DialogGate.sendFrom

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

Omit.ui

optional when?: string | readonly string[]

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.

DialogGate.when


Sealed

What a Dialog.tool answers on success.

R

The author’s own execute return type, under result.

readonly done: boolean

Whether the machine has reached a final state.

DialogPosition.done

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

DialogPosition.instruction

readonly optional persona?: string

The persona the active state pins, when it declares one — see DialogStateSpec.persona. Same depth rule as instruction.

DialogPosition.persona

readonly result: R

Whatever the tool’s own execute returned.

readonly state: string

The active state as a dotted path — "verifying", or "quote.pending" for a nested one. Parallel regions are joined with ",".

DialogPosition.state


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.

readonly optional interruption?: 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).

readonly optional temperature?: number

The model’s sampling temperature while this state is active.

readonly optional toolChoice?: ToolChoice

The model’s tool-choice policy while this state is active.

readonly optional voice?: string

The TTS voice for this phase of the call.


Input

Per-call options for Roster.handoff.

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


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.

readonly from: string

The speaker that was on the line.

readonly handoff: true

Always true: a discriminant a client or a spec can switch on.

readonly instruction: string

What the MODEL should do next — its prompt section has already changed.

readonly optional note?: string

The note that travelled with it, when one did.

readonly to: string

The speaker on the line now.


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.

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

0 (DEFAULT_INTERRUPTION_BACKOFF_MS)

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

3500 (DEFAULT_CONTINUATION_WINDOW_MS)

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

500 (DEFAULT_INTERRUPTION_MIN_DURATION_MS)

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

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.

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

true; false disables recovery.


Sealed

A running summary of metrics.collected frames — see createMetricsCollector.

collect(sample): void

Fold in one reply’s metrics — the metrics.collected event itself, or a sample built by hand.

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

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

boolean

The reply was cut short; its stages are still reported.

number

From the caller’s committed turn to the reply’s first audio.

{ durationMs: number; inputTokens?: number; outputTokens?: number; steps: number; ttftMs?: number; }

number

number

number

number

number

SessionEventMeta

{ endpointingMs?: number; }

number

{ characters: number; ttfbMs?: number; }

number

number

"metrics.collected"

void

reset(): void

Forget everything collected.

void

summary(): MetricsSummary

The summary so far. A fresh object each call; safe to keep.

MetricsSummary


Input

Options for createMetricsCollector.

optional maxSamples?: number

How many recent samples per measurement the percentiles are computed over. Default 1000.


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.

interrupted: boolean

The reply was cut short.

optional latencyMs?: number

From the caller’s committed turn to the reply’s first audio.

optional llm?: object

durationMs: number

optional inputTokens?: number

optional outputTokens?: number

steps: number

optional ttftMs?: number

optional stt?: object

optional endpointingMs?: number

optional tts?: object

characters: number

optional ttfbMs?: number


Sealed

Everything collected so far. A stat is absent until its first sample, never a row of zeroes.

interrupted: number

Of those, how many were cut short.

optional latencyMs?: MetricStat

Committed caller turn → first reply audio.

optional llmDurationMs?: MetricStat

Request → the stream settling.

llmInputTokens: number

Totals — sums, not distributions.

llmOutputTokens: number

llmSteps: number

optional llmTtftMs?: MetricStat

Request → the model’s first content part.

replies: number

Replies collected.

optional sttEndpointingMs?: MetricStat

Last partial with words → committed final.

ttsCharacters: number

optional ttsTtfbMs?: MetricStat

First text into TTS → its first audio.


Sealed

One measurement’s distribution. Durations are milliseconds.

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.


Input

Options for mintCode.

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

optional random?: () => 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.

number

optional taken?: ReadonlySet<string>

Codes already issued. A generated code that collides is discarded and another drawn, so the caller does not have to loop.


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.

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

optional maxRetries?: number

How many times a FAILED provider call is retried before the step is given up on.

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.

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


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.

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

"Sorry, I had a problem just then. Could you say that again?" (DEFAULT_ERROR_PHRASE)

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

"I am sorry, I am having trouble with my connection and cannot hear you. Please hang up and call back." (DEFAULT_START_FAILURE_PHRASE)


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

optional interruption?: InterruptionTuning | "off"

When the caller may cut the agent off — see InterruptionTuning; "off" means never.

optional silence?: SilenceTuning

Dead air and the silence nudge — see SilenceTuning.

optional turnTaking?: TurnTakingTuning

When the caller’s turn ends — see TurnTakingTuning.


Sealed

A machine that can be run as a unit of work, created by procedure.

M extends AnyStateMachine

The XState machine.

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.

InputFrom<M>

ProcedureRunOptions

Promise<OutputFrom<M>>

readonly machine: M

The machine itself, for a caller that wants to inspect or visualize it.


Input

Options for one Procedure.run.

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


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.

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.

optional apiKeyEnv?: string

Env var holding this stage’s credential, replacing the provider default. Names a VARIABLE, not a key.


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.

Kind extends string

Options

readonly kind: Kind

readonly options: Options


Input

Options for resolveOne.

T

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

T

string

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.

T

string

optional label?: string

What the candidates are called, for the failure sentences. Defaults to "option". Singular: the plural is formed with s.

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

T

string

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

T

string

number


Sealed

One tool call a resumed conversation carries back — history.restored’s toolCalls.

afterMessageIndex: number

Index into messages the call follows; -1 means before any message.

args: Record<string, unknown>

callId: string

name: string

optional result?: string

status: "pending" | "done"

"pending" when no completion was recorded — it may have been in flight.


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.

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.

active(ctx): SpeakerDef<N>

The speaker on the line now: position(ctx).speaker.

SlotHolder

SpeakerDef<N>

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.

SlotHolder

N | SpeakerDef<N>

HandoffOptions

HandoffResult

position(ctx): SpeakerPosition<N>

Who is on the line, and how they came to be. Throws when nobody speaks.

SlotHolder

SpeakerPosition<N>

readonly delegates: readonly SpeakerDef<N>[]

The entries that run off the line — what delegate routes between.

readonly list: readonly SpeakerDef<N>[]

Every entry, in declaration order.

readonly speaking: readonly SpeakerDef<N>[]

The speaks: true entries, in order. The first is the ENTRY speaker.


Sealed

What an agent({ routes }) handler is called with beside its request.

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.

string

StepClientTranscriptOptions

Promise<ClientTranscript>

sessionTranscript(sessionId): Promise<readonly ClientTranscriptMessage[]>

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.

string

Promise<readonly ClientTranscriptMessage[]>

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.

string

SessionSpeech | undefined

env: Readonly<Partial<Record<string, string>>>

The agent’s environment — the same view a tool reads as ctx.env.

signal: AbortSignal

Aborted when the caller goes away. Pass it to whatever you fetch with.

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.


Input

What route takes.

S extends StandardSchemaV1 = StandardSchemaV1<unknown, unknown>

Client extends boolean = false

optional body?: 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: (req, ctx) => unknown

The handler, with what the checks above guarantee typed in.

ValidatedRouteRequest<InferSchemaOutput<S>, Client>

RouteContext

unknown

optional requireClient?: Client

true: a request without a well-formed ?client= is a 400.


Sealed

One request to an agent({ routes }) handler, already parsed.

Body = unknown

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.

optional clientId?: 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: 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: string

The HTTP method, upper-case: "GET", "POST", "PUT", "PATCH" or "DELETE".

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.

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


Sealed

A route’s answer with a status of its own — what routeResponse makes.

readonly body: unknown

Sent as JSON; undefined sends no body at all (a 204, say).

readonly optional headers?: Readonly<Record<string, string>>

Extra response headers, when the route set any (routeResponse’s headers).

readonly status: number

The HTTP status: 2xx, 4xx or 5xx.


Input

The optional half of routeResponse.

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


Sealed

What AgentSessionLifecycle.sessionContext is called with.

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

optional clientId?: 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: string

The session about to start (a fresh id, or the one a ?sessionId= resumed).

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.


Sealed

What AgentSessionLifecycle.onSessionEnd is called with.

optional call?: SessionCall

The phone call it was, for a WS /phone session — see SessionContextArgs.call.

optional clientId?: 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: 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: 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: 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: 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}` }`.


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.

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

optional recorded?: false

optional recovery?: AgentTranscriptRecovery

text: string

type: "agentTranscript.committed"

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

All of this turn’s TTS audio has been sent — a turn BOUNDARY.

meta: SessionEventMeta

type: "audio.completed"

custom.emitted: object

An event the AGENT named, via ctx.send.

data: unknown

event: string

meta: SessionEventMeta

type: "custom.emitted"

error.reported: object

code: SessionErrorCode

fatal: boolean

Whether the session is over. False for a turn-level error it survives.

message: string

meta: SessionEventMeta

type: "error.reported"

guardrail.blocked: object

A guardrail stopped a turn and replacement was said instead.

direction: string & object | "input" | "output"

meta: SessionEventMeta

replacement: string

type: "guardrail.blocked"

history.restored: object

The conversation a RESUME restored; the client REPLACES its list from it.

messages: object[]

meta: SessionEventMeta

toolCalls: RestoredToolCall[]

type: "history.restored"

metrics.collected: object

What one reply cost, stage by stage. Durations are milliseconds.

interrupted: boolean

The reply was cut short; its stages are still reported.

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

A fallback([...]) stage switched provider.

from: string

meta: SessionEventMeta

reason: string

stage: string & object | "stt" | "llm" | "tts"

to: string

type: "provider.failedOver"

reply.cancelled: object

The reply was abandoned — a barge-in, a client cancel, or a reset.

meta: SessionEventMeta

type: "reply.cancelled"

reply.completed: object

The reply finished on its own terms.

meta: SessionEventMeta

type: "reply.completed"

session.configured: object

The handshake: audio negotiation plus the session’s own id. First on every connection and sent at zero RTT.

audioFormat: string

meta: SessionEventMeta

sampleRate: number

sessionId: string

Reconnect with ?sessionId=<id> to resume; the retained stream is read by it too.

ttsSampleRate: number

type: "session.configured"

session.reset: object

The conversation was discarded and a new one begins (the transports greet on it).

meta: SessionEventMeta

type: "session.reset"

session.timedOut: object

Silence outlasted idleTimeoutMs. Informational: the server closes the socket.

meta: SessionEventMeta

type: "session.timedOut"

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

meta: SessionEventMeta

type: "speech.stopped"

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

args: Record<string, unknown>

meta: SessionEventMeta

toolCallId: string

toolName: string

type: "tool.called"

tool.completed: object

meta: SessionEventMeta

result: string

toolCallId: string

type: "tool.completed"

usage.updated: object

Cumulative model token usage for the session.

inputTokens: number

meta: SessionEventMeta

outputTokens: number

steps: number

totalTokens: number

type: "usage.updated"

userTranscript.committed: object

The caller’s committed turn — the one that enters history.

meta: SessionEventMeta

text: string

type: "userTranscript.committed"

userTranscript.updated: object

Interim user transcript — live captions while the user is still speaking.

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

The caller’s turn passed a userTurnLimit.

durationMs: number

limit: string & object | "words" | "duration"

meta: SessionEventMeta

type: "userTurn.exceeded"

words: number


Sealed

The envelope every session event carries.

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.


Sealed

A named slot of per-session state, created by sessionSlot.

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.

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.

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.

SlotHolder

DeepReadonly<T>

reset(ctx): DeepReadonly<T>

Discard this session’s value and install a fresh default, and return it.

SlotHolder

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.

SlotHolder

T

DeepReadonly<T>

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.

SlotHolder

T

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

P extends ToolInputSchema | undefined = ToolInputSchema

R = unknown

E extends object = { type: string; }

K extends string = string

SlotToolDef<P, DeepReadonly<T>, R> & SlotDialogGate<R, E, K> & NoInfer<DialogSendFromGuard<K, Awaited<R>, E>>

DialogSendOptions<Awaited<R>, E, K>

DialogSendFromChecked<K, Awaited<R>, E, ToolDef<P, GatedOutput<R>>>

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

tool<P, R>(def): ToolDef<P, R>

P extends ToolInputSchema | undefined = ToolInputSchema

R = unknown

SlotToolDef<P, DeepReadonly<T>, R> & UngatedDef

ToolDef<P, R>

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.

Prep

R

SlotHolder

SlotTransaction<T, Prep, R>

Promise<ToolFailure | R>

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

R

SlotHolder

(draft) => R

RejectThenableResult<R>

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.

P extends ToolInputSchema | undefined = ToolInputSchema

Prep = unknown

R = unknown

R2 = R

E extends object = { type: string; }

K extends string = string

SlotPreparedToolDef<P, T, Prep, R, R2> & SlotDialogGate<R2, E, K> & RejectThenable<R> & NoInfer<DialogSendFromGuard<K, Awaited<R2>, E>>

DialogSendOptions<R2, E, K>

DialogSendFromChecked<K, Awaited<R2>, E, ToolDef<P, GatedOutput<R2>>>

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

updateTool<P, Prep, R, R2>(def): ToolDef<P, Promise<ToolFailure | R2>>

P extends ToolInputSchema | undefined = ToolInputSchema

Prep = unknown

R = unknown

R2 = R

SlotPreparedToolDef<P, T, Prep, R, R2> & RejectThenable<R> & UngatedDef

ToolDef<P, Promise<ToolFailure | R2>>

updateTool<P, R, R2, E, K>(def, options?): DialogSendFromChecked<K, Awaited<R2>, E, ToolDef<P, GatedOutput<R2>>>

P extends ToolInputSchema | undefined = ToolInputSchema

R = unknown

R2 = R

E extends object = { type: string; }

K extends string = string

SlotUpdateToolDef<P, T, R, R2> & SlotDialogGate<R2, E, K> & RejectThenable<R> & NoInfer<DialogSendFromGuard<K, Awaited<R2>, E>>

DialogSendOptions<R2, E, K>

DialogSendFromChecked<K, Awaited<R2>, E, ToolDef<P, GatedOutput<R2>>>

updateTool<P, R, R2>(def): ToolDef<P, R2 | Extract<R, ToolFailure>>

P extends ToolInputSchema | undefined = ToolInputSchema

R = unknown

R2 = R

SlotUpdateToolDef<P, T, R, R2> & RejectThenable<R> & UngatedDef

ToolDef<P, R2 | Extract<R, ToolFailure>>

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

readonly durable: boolean

Whether this slot’s value is stored durably. true unless the slot declared otherwise — see SessionSlotOptions.durable.

readonly optional empty?: () => T

The seeded slot’s blank (SessionSlotOptions.empty), when declared.

T

readonly key: K

The store key this slot occupies. Two slots must not share one.

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

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

Input

Options for sessionSlot.

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.

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

optional caps?: 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 } },
);

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

optional empty?: () => 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.

T

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

optional view?: (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" });

DeepReadonly<T>

V


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.

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

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.

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.

string

SayOptions

SpeechHandle


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.

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.

optional prompt?: string

Instruction injected as a synthetic user turn when afterMs elapses. Never shown as a user transcript.

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


Input

What the agent does about silence — its own, and the caller’s.

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

2400 (DEFAULT_DEAD_AIR_COVER_MS)

optional nudge?: SilenceNudge

Take a turn after the CALLER has been silent this long — see SilenceNudge.

unset — the behaviour is off.

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.

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

dialog: Dialog<E>

The dialog this tool is gated on.

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

DialogGate.reset

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

DialogGate.send

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

DialogGate.sendFrom

optional when?: string | readonly string[]

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.

DialogGate.when


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.

P extends ToolInputSchema | undefined

T

Prep

What prepare hands the body.

R

R2 = R

execute(args, draft, prepared, ctx): R

The synchronous body: the draft, then what prepare produced, then ctx.

InferSchemaOutput<P>

T

Prep

ToolContext

R

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.

InferSchemaOutput<P>

DeepReadonly<T>

ToolContext

ToolFailure | Prep | Promise<ToolFailure | Prep>

description: string

Human-readable description shown to the LLM.

Omit.description

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

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

Omit.effect

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

Omit.inputSchema

optional messages?: 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:

  • delayed is a LADDER when the timings differ and VARIANTS when they match. Two entries at afterMs: 3000 are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.
  • A complete/failed entry with role: "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.

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

Omit.messages

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

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

Omit.onError

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

DeepReadonly<T>

NoInfer<Exclude<R, ToolFailure>>

R2

Omit.report

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

DeepReadonly<T>

InferSchemaOutput<P>

ToolFailure | undefined

Omit.require

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

Omit.ui


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.

P extends ToolInputSchema | undefined

V

What execute is handed: a deep-frozen DeepReadonly<T> from SessionSlot.tool, a mutable draft from SessionSlot.updateTool.

R

execute(args, value, ctx): R

The tool body, handed this session’s slot value alongside the usual args.

InferSchemaOutput<P>

V

ToolContext

R

description: string

Human-readable description shown to the LLM.

Omit.description

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

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

Omit.effect

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

Omit.inputSchema

optional messages?: 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:

  • delayed is a LADDER when the timings differ and VARIANTS when they match. Two entries at afterMs: 3000 are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.
  • A complete/failed entry with role: "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.

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

Omit.messages

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

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

Omit.onError

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

DeepReadonly<V>

InferSchemaOutput<P>

ToolFailure | undefined

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

Omit.ui


Input

What SessionSlot.transact takes: the async half, then the synchronous window — SlotPreparedToolDef’s two halves for a caller inside a plain tool() body.

T

Prep

What prepare hands commit.

R

What commit returns.

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.

T

Prep

R

prepare(value): ToolFailure | Prep | Promise<ToolFailure | Prep>

The await, handed the frozen value; a ToolFailure ends the transaction with no window.

DeepReadonly<T>

ToolFailure | Prep | Promise<ToolFailure | Prep>

optional require?: (value) => ToolFailure | undefined

A precondition on the frozen value — see SlotToolDef.require. Run before prepare and again before commit.

DeepReadonly<T>

ToolFailure | undefined


Input

The authoring shape of SessionSlot.updateTool: a SlotToolDef over the mutable draft, plus report.

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.

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.

InferSchemaOutput<P>

T

ToolContext

R

SlotToolDef.execute

description: string

Human-readable description shown to the LLM.

SlotToolDef.description

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

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

SlotToolDef.effect

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

SlotToolDef.inputSchema

optional messages?: 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:

  • delayed is a LADDER when the timings differ and VARIANTS when they match. Two entries at afterMs: 3000 are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.
  • A complete/failed entry with role: "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.

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

SlotToolDef.messages

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

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

SlotToolDef.onError

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

DeepReadonly<T>

NoInfer<Exclude<R, ToolFailure>>

R2

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

DeepReadonly<T>

InferSchemaOutput<P>

ToolFailure | undefined

SlotToolDef.require

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

SlotToolDef.ui


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.

N extends string = string

The name, as a literal when speaker infers it — what lets Roster.handoff refuse a misspelled target at compile time.

optional builtinTools?: readonly BuiltinTool[]

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.

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

optional expectedOutput?: 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.",
});

optional guardrail?: 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,
});

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

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

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

ModelTuning.maxOutputTokens

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

optional maxRevisions?: number

How many times a SpeakerDef.guardrail may send an answer back.

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.

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

optional schema?: 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(),
}),
});

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

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

ModelTuning.temperature

optional toolChoice?: ToolChoice

The model’s tool-choice policy while this speaker is ON THE LINE.

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


Sealed

Who is on the line — what Roster.position answers, the shape DialogPosition has for a dialog.

N extends string = string

readonly optional from?: string

Who handed off to it, when a handoff has happened this session.

readonly optional note?: string

The HandoffOptions.note that came with that handoff.

readonly optional pinnedBy?: object

The dialog PINNING this speaker, when a dialog state declares one. While a pin is in force handoff to anyone else is refused.

readonly dialog: string

readonly state: string

readonly speaker: SpeakerDef<N>

The speaker on the line now.


Sealed

One utterance SessionSpeech.say queued: await done for when the caller finished hearing it, or interrupt() to take it back.

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.

void

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


One validation issue in a failed Standard Schema result.

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

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

readonly message: string

readonly optional path?: readonly (PropertyKey | { key: PropertyKey; })[]


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.

Input = unknown

The type the schema accepts for validation.

Output = Input

The type validation produces.

readonly ~standard: object

The Standard Schema properties object.

{ input: Input; output: Output; }

Inferred types, when the vendor exposes them.

(value) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>

Validate value, returning the typed value or issues.

readonly vendor: string

The vendor name, e.g. "zod", "arktype", "valibot".

readonly version: 1

The version of the standard implemented (always 1).


Input

What verifyStandardWebhook takes beside the request and secret.

optional nowS?: number

For TESTS: “now”, in seconds since the epoch.

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


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.

V = unknown

StateProjection(value?): V

Project a stored value, or the slot’s default when there is none.

unknown

V

readonly create: () => unknown

The slot’s default, for a session that has not touched it.

unknown

readonly key: string

The slot key whose value this projects.


Input

WHEN the caller’s turn ends and the agent’s begins.

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

"auto"

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

1500 (DEFAULT_MAX_TURN_SILENCE_MS)

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

250 (DEFAULT_MIN_TURN_SILENCE_MS)

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

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.

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

0 (DEFAULT_START_SPEAKING_FLOOR_MS)

optional userTurnLimit?: UserTurnLimit

Cap ONE user turn’s length — by words heard, by elapsed time, or both — see UserTurnLimit.

unset — no cap on a single user turn's length.

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.

T

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.

DelegateResult.accepted

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

DelegateResult.complaint

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

DelegateResult.revisions

steps: number

How many steps this attempt took, including the final answering step.

DelegateResult.steps

text: string

The subagent’s final message — see SpeakerDef.expectedOutput.

DelegateResult.text

toolCalls: readonly DelegateToolCall[]

Every tool call this attempt made, in order.

DelegateResult.toolCalls


Input

A SpeakerDef that declares a SpeakerDef.schema — what speaker returns for one, so ctx.delegate types object.

T

N extends string = string

optional builtinTools?: readonly BuiltinTool[]

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.

SpeakerDef.builtinTools

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

SpeakerDef.description

optional expectedOutput?: 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.",
});

SpeakerDef.expectedOutput

optional guardrail?: 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,
});

SpeakerDef.guardrail

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

SpeakerDef.interruption

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

SpeakerDef.llm

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

ModelTuning.maxOutputTokens

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

SpeakerDef.maxRetries

optional maxRevisions?: number

How many times a SpeakerDef.guardrail may send an answer back.

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.

SpeakerDef.maxRevisions

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

SpeakerDef.maxSteps

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.

SpeakerDef.name

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

SpeakerDef.schema

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

SpeakerDef.speaks

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.

SpeakerDef.systemPrompt

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

ModelTuning.temperature

optional toolChoice?: ToolChoice

The model’s tool-choice policy while this speaker is ON THE LINE.

SpeakerDef.toolChoice

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

SpeakerDef.tools


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.

Every model request the runtime makes for this session:

  • the conversational loop — every step of every turn;
  • ctx.generate from a tool body;
  • ctx.delegate of a speaker() — 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.

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

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


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.

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

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


Input

What webhookRoute takes.

secretEnv: string

The agent-env variable holding the signing secret, e.g. "COMPOSIO_WEBHOOK_SECRET".

optional toleranceS?: number

As StandardWebhookOptions.toleranceS.

AgentDefWith<M, W> = string extends keyof W ? ModeAgentDef<M> : Omit<ModeAgentDef<M>, "workflows"> & [keyof W] 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.

M extends AgentMode

W extends WorkflowsMap


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.

string

AgentSessionContext

GuardrailVerdict | Promise<GuardrailVerdict>


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, when mode is 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 the s2s descriptor and nothing pipeline-shaped: the service owns STT, the model loop and TTS.
  • "text" — no audio path at all; driven over a message list by createTextAgent (@alexkroman1/aai-runtime). llm is its one stage.
  • "workflow-app" — a page over the workflow HTTP API, with no session, no socket and no model. workflowApp() is this member with mode already 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 = PipelineAgentParams | S2sAgentParams | TextAgentParams | WorkflowAppAgentParams

Everything agent() accepts: one member per AgentMode.


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 = 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 = "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 = 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 = "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 = "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 with remember.
  • "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. Reads BRAVE_API_KEY from the agent env.
  • "google_places" — Find businesses and places (address, phone, hours, rating) with the Google Places API. Reads GOOGLE_PLACES_API_KEY from the agent env.
  • "text_me" — Text the owner something too long to say, through Textbelt. Reads TEXTBELT_KEY and SMS_TO_PHONE; the recipient is never the model’s choice (see allowedSmsRecipient in @alexkroman1/aai/channels). TEXTBELT_LINKS=strip leaves 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’s start records.
  • "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 } with WorkflowDef.cancelNote or “already finished” / “nothing started yet” baked in. All three take an optional workflow name.

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

id: string

The app’s own id for the call — the id its API answered with, say.

prompt: string

The task, as the caller wrote it. At most MAX_CALL_BRIEF_PROMPT_CHARS.


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.

K extends keyof ClientEventMap | string & object

K

K extends keyof ClientEventMap ? ClientEventMap[K] : unknown

void


ClientInboxOptions = object

Input

What an agent declares about the audio it pushes to devices over WS /inbox.

optional sampleRate?: 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 = "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<P> = Omit<ToolDef<P>, "execute"> & object

Input

What clientTool takes: a ToolDef without execute, because the page is the execute.

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

P extends ToolInputSchema = ToolInputSchema

The tool’s input schema — what the page’s handler receives.


DeepReadonly<T> = T extends (…args) => unknown ? T : T extends readonly infer E[] ? readonly DeepReadonly<E>[] : T extends object ? { 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.

T


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.

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

<T>(subagent, options): Promise<TypedDelegateResult<T>>

T

TypedSpeakerDef<T>

DelegateOptions

Promise<TypedDelegateResult<T>>

(subagent, options): Promise<DelegateResult>

SpeakerDef

DelegateOptions

Promise<DelegateResult>


DialogEvent<S> = Exclude<DialogEventNames<S["states"]>, `@${string}`> extends infer N ? N extends string ? 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.

S extends DialogSpec


DialogEventNames<M> = M extends Record<string, unknown> ? M[keyof M] extends infer C ? C extends unknown ? C extends object ? Extract<keyof O, string> : never | C extends object ? 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.

M


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.

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<K, R, E, T> = string extends K ? 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.

K extends string

R

E

T


DialogSendFromGuard<K, R, E> = string extends K ? unknown : unknown extends R ? 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.

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

S

The tool’s SUCCESS result (the failure arm already removed).

E

The machine’s event union.


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.

K extends string

Keys extends string

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”

readonly sendFrom names a result field that holds no event name of this dialog: K

readonly the fields that do: Keys


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

Input

Options for endSession.

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

E


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.

R


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.

<S>(options): Promise<GenerateObjectResult<InferSchemaOutput<S>>>

S extends StandardSchemaV1<unknown, unknown>

GenerateOptions & object

Promise<GenerateObjectResult<InferSchemaOutput<S>>>

(options): Promise<GenerateResult>

GenerateOptions

Promise<GenerateResult>


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.

T

object: T

The schema-validated object. Always present on this overload.

text: string

The generated text — the JSON-stringified object.


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.

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

optional maxOutputTokens?: number

Cap on generated tokens passed through to the provider.

prompt: string

The user prompt for this call.

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

optional system?: string

Optional system prompt.

optional temperature?: number

Sampling temperature passed through to the provider.


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.

optional object?: 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 = 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<S> = S extends StandardSchemaV1<unknown, infer O> ? O : S extends undefined ? 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.

S


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 }

T extends ToolDef


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.

T extends ToolDef


KeyedLock = (key, options?) => Promise<() => void> & object

Sealed

readonly size: number

Number of keys currently held or queued. Exposed for tests and metrics.


KeyedLockOptions = object

Input

optional timeoutMs?: number

Give up waiting after this long and reject with KeyedLockTimeoutError. Omit to wait indefinitely.


LlmProvider = ProviderDescriptor<string, LlmDescriptorOptions> & object

Descriptor for an LLM provider. Returned by factories like llm({ provider: "anthropic", ... }) from @alexkroman1/aai/llm.

readonly optional __stage?: "llm"

Compile-time stage tag; never present at runtime.


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<T> = T | ((context) => T | Promise<T>)

A value an author may write literally, or compute per connection from McpResolveContext.

T


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.

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

readonly env: Readonly<Partial<Record<string, string>>>

The agent’s env — the same record tokenEnv is read from. Never process.env.

readonly signal: AbortSignal

Aborted when the connect budget runs out; pass it to any fetch the resolver makes.


McpServerConfig = object

Input

One MCP server an agent may take tools from.

optional allowedTools?: readonly string[]

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.

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

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

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

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.

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

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

readonly mode: M

M extends AgentMode


PipelineAgentParams = SharedAgentParams & Pick<AgentDeclaration, keyof AgentModelTuning | keyof PipelineTuning | keyof PipelinePhrases | keyof AgentGuardrails> & 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.

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

optional mode?: "pipeline"

See AgentDeclaration.mode. Absent means "pipeline".

optional s2s?: undefined

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

Input

PollOptions.first — the one callback a poll parks on before it begins.

optional schema?: 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: number

How long to wait for the callback before polling anyway.

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

T

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.

T

boolean

everyMs: number

The durable wait between two checks, in milliseconds. At least 1.

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

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

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

optional onSlow?: string

The line PollOptions.slowAfterMs writes. Defaults to a sentence naming the poll: "Still waiting on <name>.".

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

Sealed

What ctx.poll (and stepPollUntil) resolve with.

T

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.

optional woken?: 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 = () => number

A source of uniform floats in [0, 1) — Math.random’s contract, and the one a caller substitutes.

number


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.

RouteRequest

RouteContext

unknown


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.

mode: "s2s"

See AgentDeclaration.mode.

s2s: S2sProvider

See AgentDeclaration.s2s.


S2sProvider = ProviderDescriptor<string, Record<string, unknown>> & object

Descriptor for an S2S provider. Returned by assemblyAIS2s(...) (root export) or openAIS2s(...) from @alexkroman1/aai/s2s.

readonly optional __stage?: "s2s"

Compile-time stage tag; never present at runtime.


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.

optional ackTimeoutMs?: number

How long to wait for the device’s ack — see stepNotifyClient.

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

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

optional language?: string

Spoken language, as stepSpeak takes it.

optional maxAttempts?: number

The step’s attempt budget, which is the redelivery budget. Defaults to DEFAULT_CLIENT_DELIVERY_ATTEMPTS, an hour at the 30-second retry.

optional retryAfterMs?: number

When the step’s next attempt runs after the device was unreachable.

optional sampleRate?: number

Samples per second; defaults to the agent’s clientInbox.sampleRate.

optional signal?: AbortSignal

Abort the synthesis and the delivery.

text: string

What to say. Also sent as data.said.

optional voice?: string

Voice id, as stepSpeak takes it.


SayOptions = object

Input

Options for SessionSpeech.say.

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

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

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

readonly optional callId?: string

The carrier’s id for the call: Twilio’s CallSid (CA…), Telnyx’s call_control_id. Absent when the frame carried none.

readonly carrier: string

The carrier that opened the stream — the ?carrier= value ("twilio", "telnyx").

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

Sealed

What AgentSessionLifecycle.sessionContext may answer.

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

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

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

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

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

K extends SessionEventType = SessionEventType


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.

K extends SessionEventType = SessionEventType


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.

env: Readonly<Partial<Record<string, string>>>

Environment variables available to this agent (from .env under aai dev, aai secret in production).

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

E extends AnySessionEvent = SessionEvent

E

SessionEventContext

unknown


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.

*?

optional *?: SessionEventHandler<AnySessionEvent>

Runs for every event, AFTER the typed handler for that event.


SessionEventType = Extract<keyof SessionEventMap, 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 = Omit<AgentDeclaration, DefaultedAgentField | "systemPrompt" | "syncState" | "mode" | ProviderField | PipelineOnlyField | keyof AgentModelTuning> & 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.

optional tools?: InlineToolsMisuse

Not a field — see InlineToolsMisuse.


SleepOptions = object

Input

Per-sleep options.

optional correlationId?: 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<T> = T extends object ? { 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.

T


SlotDraftOf<S> = S extends SessionSlot<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.

S extends SessionSlot<string, unknown, unknown>


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.

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

readonly slots: SlotStore

This session’s slot storage.


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.

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.

string

unknown

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.

string

unknown

boolean

void


SlotValueOf<S> = S extends SessionSlot<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.

S extends SessionSlot<string, unknown, unknown>


SpeakerGuardrail = (answer) => GuardrailVerdict | Promise<GuardrailVerdict>

Judge one attempt — see SpeakerDef.guardrail.

DelegateAnswer

GuardrailVerdict | Promise<GuardrailVerdict>


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, an interrupt(), 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, queued says included), or the session’s transport cannot speak verbatim text — an S2S agent, said once at session start. See this module’s header.

StandardSchemaResult<Output> = { issues?: undefined; value: Output; } | { issues: readonly StandardSchemaIssue[]; }

A successful or failed Standard Schema validation.

Output


StepOptions<S> = object

Input

Per-step overrides. Everything here has a default that is right for most steps; passing nothing is the common case.

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.

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

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

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

schema: S

The shape — see StepOptions.schema.

S extends StandardSchemaV1 = StandardSchemaV1


SttProvider = ProviderDescriptor<string, Record<string, unknown>> & object

Descriptor for an STT provider. Returned by factories like assemblyAIStt(...) from @alexkroman1/aai/stt.

readonly optional __stage?: "stt"

Compile-time stage tag; never present at runtime.


TelephonyAccess = boolean | readonly TelephonyCarrier[]

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 = "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 = Omit<SharedAgentParams, "sttPrompt" | "telephony"> & Pick<AgentDeclaration, keyof AgentModelTuning> & object

Input

The TEXT member — mode: "text", optionally an llm, and nothing from the audio half of the agent shape.

optional llm?: LlmSpec

See AgentDeclaration.llm; a model-id string works as on the pipeline member — the one provider stage a text agent has.

mode: "text"

See AgentDeclaration.mode.


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:

  1. 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 unless resetToolChoice: false says otherwise — see the "required" arm below.
  2. The turn — in text mode a caller may override it for one turn (stream({ toolChoice })). A voice session has no such caller.
  3. The dialog state — a dialog() state may carry toolChoice, 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.
  4. 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 whole maxSteps budget 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, so agent({ resetToolChoice }) — true unless you set it, the same default as OpenAI’s Agents SDK ships as reset_tool_choice — puts the choice back to "auto" from the second step on. resetToolChoice: false is 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’s toolChoice (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 = 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.

content: string

Spoken verbatim under role: "assistant"; told to the model under "system".

optional role?: "assistant" | "system"

Defaults to "assistant".

optional when?: ToolMessageCondition[]

Conditions on the call’s arguments — see ToolMessageCondition.


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 = 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) and sessionId.
  • Its own deadline — signal and deadlineAt, 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 — generate for one prompt, delegate for 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 of Math.random.
  • Where the session is — turn, how many user turns have been committed, and now(), the clock; both pinnable the way random is.

There is no ctx.db and no ctx.state: the platform hands tool code no database, and a session’s state lives in sessionSlots.

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.

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

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.

number

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

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() : [] };
},
});

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

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: 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: readonly Message[]

Read-only snapshot of conversation messages so far.

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.

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

Unique identifier for the current session. Useful for correlating logs across concurrent sessions.

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.

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

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.

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

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

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

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

InferSchemaOutput<P>

ToolContext

R

description: string

Human-readable description shown to the LLM.

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

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

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

optional messages?: 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:

  • delayed is a LADDER when the timings differ and VARIANTS when they match. Two entries at afterMs: 3000 are two phrasings of one rung, one of which is drawn; entries at 3000 and 8000 are two rungs.
  • A complete/failed entry with role: "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.

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

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

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

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

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

What is spoken.

optional when?: ToolMessageCondition[]

Conditions on the call’s arguments — see ToolMessageCondition.


ToolEffect = "read" | "write" | "handoff"

What a tool does to the world — see ToolDef.effect. Carried on the wire as ToolSchema.effect.


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.

unknown

ToolContext

ToolFailure | string


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.

error: string


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

arg: string

The argument’s key, as the model sends it.

optional op?: ToolConditionOperator

Defaults to "eq".

value: string | number | boolean | null

The value to compare against.


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.

optional complete?: ToolCompletionMessage[]

What a settled call says — see ToolCompletionMessage.

optional delayed?: ToolDelayedMessage[]

The delay ladder — see ToolDelayedMessage.

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

optional start?: ToolStartMessage[]

Spoken as the call begins — see ToolStartMessage.


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

optional complete?: string | readonly (string | ToolCompletionMessage)[]

A bare string is { role: "assistant", content }.

optional delayed?: readonly ToolDelayedMessage[]

No shorthand: a rung without its afterMs is not a rung.

optional failed?: string | readonly (string | ToolCompletionMessage)[]

A bare string is { role: "assistant", content }.

optional start?: boolean | string | readonly (string | ToolStartMessage)[]

true draws one of the five default hold lines per invocation.


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.

reason: 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" — a dialog.tool, called outside its when states.
  • "roster" — a handoff or delegation naming nobody the roster can route to.

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.

optional blocking?: 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: string

What is spoken.

optional when?: ToolMessageCondition[]

Conditions on the call’s arguments — see ToolMessageCondition.


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.

optional icon?: string

A short glyph or emoji drawn beside the call — "🍕", "🔎".

optional label?: string

The words shown in place of the tool’s name — "Adding pizza".


TtsProvider = ProviderDescriptor<string, Record<string, unknown>> & object

Descriptor for a TTS provider. Returned by factories like cartesiaTts(...) from @alexkroman1/aai/tts.

readonly optional __stage?: "tts"

Compile-time stage tag; never present at runtime.


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

readonly optional dialog?: "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<Body, Client> = RouteRequest<Body> & Client extends true ? object : unknown

The request a route handler receives: body already validated (the schema’s OUTPUT), and clientId a string when requireClient is set.

Body

Client extends boolean


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

S extends StandardSchemaV1 = StandardSchemaV1

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

S extends StandardSchemaV1 = StandardSchemaV1

schema: S

The shape the payload must have — see WaitForOptions.schema.


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.

readonly $instruction: string

T extends Record<string, unknown>


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.

mode: "workflow-app"

See AgentDeclaration.mode.

optional workflows?: AgentDeclaration["workflows"]

See AgentDeclaration.workflows — the whole product.


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.

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.

string

Promise<boolean>

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.

P extends ToolInputSchema

R

WorkflowDef<P, R>

string

Promise<number>

cancelAll(workflow, key): Promise<number>

string

string

Promise<number>

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.

P extends ToolInputSchema

R

WorkflowDef<P, R>

string

FindOptions

Promise<WorkflowRunSnapshot<R>[]>

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

string

string

FindOptions

Promise<WorkflowRunSnapshot[]>

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.

string

FindByKeyOptions

Promise<WorkflowRunSnapshot[]>

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.

R

string

AnyWorkflowDef<R>

Promise<WorkflowRunSnapshot<R> | undefined>

get(runId): Promise<WorkflowRunSnapshot | undefined>

string

Promise<WorkflowRunSnapshot | undefined>

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.

string

StreamOptions

Promise<unknown>

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

P extends ToolInputSchema

R

WorkflowDef<P, R>

string

Promise<WorkflowRunSnapshot<R> | undefined>

latest(workflow, key?): Promise<WorkflowRunSnapshot | undefined>

string

string

Promise<WorkflowRunSnapshot | undefined>

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

WorkflowSummary[]

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.

P extends ToolInputSchema

R

WorkflowDef<P, R>

string

Promise<WorkflowRunSnapshot<R> | undefined>

live(workflow, key?): Promise<WorkflowRunSnapshot | undefined>

string

string

Promise<WorkflowRunSnapshot | undefined>

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.

string

string

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.

P extends ToolInputSchema

R

WorkflowDef<P, R>

FindOptions

Promise<WorkflowRunSnapshot<R>[]>

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

string

FindOptions

Promise<WorkflowRunSnapshot[]>

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.

string

unknown

SignalOptions

Promise<boolean>

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.

P extends ToolInputSchema

R

WorkflowDef<P, R>

string

string

unknown

SignalOptions

Promise<boolean>

signalRun(workflow, key, label, payload?, options?): Promise<boolean>

string

string

string

unknown

SignalOptions

Promise<boolean>

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.

P extends ToolInputSchema

R

WorkflowDef<P, R>

InferSchemaOutput<P>

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.

StartOptions

Promise<string>

start(workflow, input?, options?): Promise<string>

string

unknown

StartOptions

Promise<string>

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.

string

StreamOptions

Promise<ReadableStream<unknown>>

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.

string

StreamOptions

Promise<number>

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.

string

WakeUpOptions

Promise<number>


WorkflowContext = object

Sealed

The handle a workflow body receives as its second argument.

workflows/research.ts
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.

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.

Promise<number>

Epoch milliseconds, as Date.now() answers them.

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.

T

Name extends string

Name & Literal<Name>

() => T | Promise<T>

PollOptions<T>

Promise<PollResult<T>>

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.

Promise<number>

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.

Name extends string

Name & Literal<Name>

string

Promise<void>

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

Name extends string

Name & Literal<Name>

string

SayOnClientNotice

Promise<string>

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.

string

string

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 site

Label extends string

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.

SleepOptions

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.

Promise<void>

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.

S extends StandardSchemaV1<unknown, unknown>

Name extends string

Name & Literal<Name>

() => unknown

StepSchemaOptions<S>

Promise<InferSchemaOutput<S>>

step<T, Name>(name, fn, options?): Promise<T>

T

Name extends string

Name & Literal<Name>

() => T | Promise<T>

StepOptions

Promise<T>

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.

Promise<string>

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.

approval.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.ts
import 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.

S extends StandardSchemaV1<unknown, unknown>

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.

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.

Promise<InferSchemaOutput<S> | undefined>

waitFor<S>(token, options): Promise<InferSchemaOutput<S>>

S extends StandardSchemaV1<unknown, unknown>

string

WaitForSchemaOptions<S>

Promise<InferSchemaOutput<S>>

waitFor<T>(token): Promise<T>

T = unknown

string

Promise<T>

waitFor<T>(token, options): Promise<T | undefined>

T = unknown

string

WaitForOptions

Promise<T | undefined>

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.

S extends StandardSchemaV1<unknown, unknown>

string

WaitForOptions<S> & WaitForSchemaOptions<S>

Promise<InferSchemaOutput<S> | undefined>

waitForSignal<S>(label, options): Promise<InferSchemaOutput<S>>

S extends StandardSchemaV1<unknown, unknown>

string

WaitForSchemaOptions<S>

Promise<InferSchemaOutput<S>>

waitForSignal<T>(label): Promise<T>

T = unknown

string

Promise<T>

waitForSignal<T>(label, options): Promise<T | undefined>

T = unknown

string

WaitForOptions

Promise<T | undefined>

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

readonly runId: string

This run’s id — the same value ctx.workflows.start() resolved to.

readonly workflow: string

Key the workflow is declared under in agent({ workflows }).


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.

agent.ts
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.ts
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) {
return { headline: await ctx.step("research", () => stepGenerate(`Headline: ${input.topic}`)) };
}

P extends ToolInputSchema = ToolInputSchema

R = unknown

body(run): WorkflowDef<P, R>

Attach the body, answering the finished definition.

WorkflowBody<InferSchemaOutput<P>, R>

WorkflowDef<P, R>

optional describeRun(run): string

WorkflowRunSnapshot<R>

string

optional cancelNote?: string

optional description?: string

optional input?: P

optional onFailure?: WorkflowFailureHandler<InferSchemaOutput<P>>

optional output?: StandardSchemaV1<unknown, R>

optional uploads?: readonly string[]


WorkflowDef<P, R> = object

Input

Definition of one durable workflow: its schema, its description, and the function that is its body.

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.

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

WorkflowRunSnapshot<R>

string

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

optional description?: string

What this workflow does. Not shown to an LLM — workflows are started by code, not chosen by a model.

optional input?: P

Schema for the run input, validated at start() so a bad payload fails at the call site.

optional onFailure?: 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,
},
});

optional output?: 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 completed with 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, and useWorkflowRun<R>’s run.output is otherwise an unchecked CLAIM about everything that happened in between.
  • WorkflowOutputOf reads THIS, so a page’s type comes from the declaration rather than from inferring the body — which is what lets an annotated agent.ts resolve 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.

optional uploads?: readonly string[]

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

Sealed

What an onFailure hook is told about the run that failed.

I = unknown

readonly input: I

The run’s validated input.

readonly runId: string

The failed run’s id — the same value ctx.runId had.

readonly workflow: string

Key the workflow is declared under in agent({ workflows }).


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

I = unknown

WorkflowFailureHook<I>


{ maxAttempts?: number; run: WorkflowFailureHook<I>; }

optional maxAttempts?: number

The hook step’s maxAttempts. Defaults to DEFAULT_STEP_MAX_ATTEMPTS.

run: WorkflowFailureHook<I>

The hook.


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.

I = unknown

Error

WorkflowFailureContext<I>

Promise<void> | void


WorkflowInputOf<D> = D extends object ? I : D extends StandardSchemaV1<unknown, infer O> ? O : D extends object ? I : never

A workflow’s INPUT type — what its declared schema parses to, which is exactly what the body’s parameter should be.

The reason it exists is that nothing checks a hand-written parameter. WorkflowBody takes its input as a function PARAMETER, so it is contravariant: a body declaring a WIDER shape than the schema produces is assignable, and a body declaring the same shape with a field’s optionality or a default’s type subtly different is assignable too. Both compile. A z.number().default(5) against a body that writes input.limit ?? 3 is the sharp version — the schema guarantees limit is present, the ?? is dead, and the two numbers disagree with nothing to report it.

It reads the parameter WorkflowDef.run declares, which IS the schema’s output (InferSchemaOutput<P>), by matching run’s shape — see WorkflowOutputOf for why a reading matches a shape.

Two details a restated shape gets wrong by hand, both of which this gets right for free. A zod .optional() infers a property that may be PRESENT AND undefined, which under exactOptionalPropertyTypes is ?: T | undefined and not ?: T — two templates carry the same four-line comment explaining that, which is a comment z.infer makes unnecessary. And a .default() makes the OUTPUT property required while the input stays optional, so a body reading it needs no fallback at all.

Like WorkflowOutputOf, it needs no build step: import type is erased, so a body in workflows/ naming WorkflowInputOf<typeof theDef> through a type-only import of ../agent.ts drags no runtime cycle behind it.

D

agent.ts
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 named z.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 def there is fixed by the declaration alone (see WorkflowDeclaration), so a body annotated WorkflowInputOf<typeof def> resolves; and the declaration BEFORE .body reads here too, as what its body will 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<D> = D extends object ? Awaited<unknown extends O ? R : O> : never

A workflow’s OUTPUT type, for a page that polls its runs.

This is the end-to-end typing a static page would otherwise be missing. useWorkflowRun<R> makes run.status === "completed" narrow to a typed run.output, and without this the page has to name R by hand — restating a shape the agent module already declares, with nothing checking the two agree.

It needs no build step and no generated .d.ts, because the reason a page “cannot import the agent” does not survive contact with import type: a type-only import is ERASED, so it drags no server graph into the browser bundle.

D

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

D

agent.ts
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.ts
import { 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 = NonNullable<AgentDef["workflows"]>

The workflows an agent may declare — AgentDef["workflows"] without the undefined. The constraint every W is inferred under.

const CALL_BRIEF_CHUNK_CHARS: number

Characters of prompt per <Parameter> — under Twilio’s 500 with the name.


const CALL_BRIEF_SECRET_ENV: "CALLS_API_KEY" = "CALLS_API_KEY"

The default agent-env variable a brief is signed with — see stepPlaceCall’s briefSecretEnv.


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


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


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


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

What it contains. Five sections, joined by blank lines, in this order — the last is included only when the session has tools:

  1. (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.
  2. ## PERSONALITY — warm, calm, competent; fully overridable.
  3. ## SPEAKING — two sentences per reply, an eight-word first sentence, no markdown, how to say numbers and identifiers, one question per turn.
  4. ## LISTENING — read through fillers and self-corrections, have a spoken name, email or code spelled and read back before using it, normalize spoken identifiers.
  5. ## 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.


const DELEGATE_TOOL_NAME: "delegate" = "delegate"

The name the model hands a TASK off by.


const HANDOFF_TOOL_NAME: "handoff" = "handoff"

The name the model hands the CALL off by.


const INSTRUCTION_KEY: "$instruction" = "$instruction"

The key withInstruction writes — reserved, and never a tool’s own.


const MAX_CALL_BRIEF_PROMPT_CHARS: number

The longest task prompt a brief carries — nine chunks, well inside 32 parameters.


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


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


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

Turn two of them on

import { agent } from "@alexkroman1/aai";
export default agent({
name: "Pharmacy Line",
voicePresets: ["echoVerification", "natoAlphabet"],
});

const withLock: <T>(lock, key, fn, options?) => Promise<T>

Run fn while holding a keyed lock, releasing it in every outcome.

T

(key, options?) => Promise<() => void>

string

() => Promise<T>

KeyedLockOptions

Promise<T>


const WORKFLOW_APP_REQUIRED_ENV: readonly string[]

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.