Hosts and executors
The machine owns agent control flow. A host supplies model request executors.
const result = await runToQuiescence(
createAgentRuntime(machine, {
executors: {
generateText: async (request, { signal }) => {
const response = await mySdk.generate({
model: request.model,
prompt: request.prompt,
signal,
});
return { result: response.text, messages: response.messages };
},
},
}),
{ input },
);Text, stream, and decision executors all receive (request, info); cancellation is info.signal.
Idempotency keys
Execution is at-least-once. A host runs the request and then journals its completion, so a crash between the two re-executes the request on resume. createAgentRuntime(machine, { store, threadId }) makes the log durable before each call, which bounds the duplicate to the one call that was in flight; it does not remove it.
info.callKey makes the duplicate safe to drop. Its format is <executionId>:<requestId>#<n>:
executionIdis the log's lineage id, pinned in the reserved@agent.initentry'smetadataand inherited by every resume.requestIdis the invoke site, andnis the invocation's ordinal there: how many times a fold of the log has started that invoke, a cancelled invocation included. Each iteration of a looped state gets its own key, and so does a call re-entered after a cancel. A run whose log cannot be folded counts the site's completions instead, a decision counting once its chosen event is journaled.- A call inside an invoked child machine names the child's path:
<executionId>:<childId>/<requestId>#<n>, one/segment per nesting level. A%or/inside an id is escaped as%25or%2F. The child's own entries (itsactorPath) are folded too, sonkeeps counting across turns and across invocations of the child. - A decision retry appends its attempt ordinal:
<executionId>:<requestId>#<n>.<attempt>, whereattemptis the number of prior failed attempts. Each retry is a different request, so it gets a different key. - A decision strategy's sub-calls append their role:
<executionId>:<requestId>#<n>.<attempt>.pick,.group, or.fill. - A resumed run re-executing an in-flight request passes the same
callKeyas the original attempt. - A fork mints its own lineage id (see Fork), so none of its calls shares a key with its parent's.
- It is
undefinedoff therunToQuiescencepath (a bareprovideExecutorsbind) and on a run with no event log. - Only model calls get one. A plain actor (
createAsyncLogic, a tool) in flight at a crash runs again on resume with no key. Derive an idempotency key from the domain (an order id, a booking key) and dedupe inside the effect.
The key names the call site, not the request. A version bridge keeps the lineage id, so a migrated machine that asks something else at the same invoke site produces the same key for a different request. Cache on callKey and a fingerprint of the request, and reuse the cached result only when the request also matches. Pass callKey to a provider as its dedupe key for that identical request:
const executors = {
generateText: async (request, info) => {
const fingerprint = JSON.stringify([request.model, request.prompt, request.messages]);
const cached = info.callKey ? results.get(info.callKey) : undefined;
if (cached?.fingerprint === fingerprint) return cached.result;
const result = await callProvider(request, { idempotencyKey: info.callKey });
if (info.callKey) results.set(info.callKey, { fingerprint, result });
return result;
},
};See The event log.
AI SDK adapter
import { createAiSdkExecutors } from "@statelyai/agent/ai-sdk";
const models = { fast: openai("gpt-6-luna") };
const agent = setupAgent({ models /* schemas and requests */ });
await runToQuiescence(
createAgentRuntime(machine, { executors: createAiSdkExecutors({ models }) }),
{ input },
);Passing the same models map to setupAgent({ models }) types the machine's model refs, so a request naming a model the host does not have is a compile error. Executors are always passed explicitly. Core does not import or require the AI SDK at runtime.
decideMode sets how a language model answers a decision: 'object' for one structured-output object, or 'tool' for one tool per candidate event. The default is object mode when every payload can be represented as object variants, otherwise tool mode. In tool mode, parallel tool calls are turned off with providerOptions (openai.parallelToolCalls: false, anthropic.disableParallelToolUse: true), merged under the host's own settings. Decision models are unaffected. See Coercion.
OpenAI SDK adapter
@statelyai/agent/openai maps the same three executors onto the raw openai package, with no AI SDK in between.
- By default, calls that carry function tools use the Responses API:
generateTextwith tools, anddecideunderdecideMode: 'tool'. Reasoning models such asgpt-6-lunareject function tools on Chat Completions. - Tool-free calls use Chat Completions:
generateTextwithout tools,streamText, anddecidein object mode. apipins every call to one API. See the table below.
pnpm add openaiimport OpenAI from "openai";
import { createOpenAiExecutors } from "@statelyai/agent/openai";
const executors = createOpenAiExecutors({
client: new OpenAI(),
resolveModel: (modelRef) => (modelRef === "deep" ? "gpt-6.1-sol" : "gpt-6-luna"),
settings: {
deep: { reasoning_effort: "high" },
},
});
await runToQuiescence(createAgentRuntime(machine, { executors }), { input });client is injected, so the API key, base URL, and transport stay with the host. openai 5 or newer is required. It is an optional peer dependency with no version range, so an app on openai 4 still installs the core, and it is imported for types only. A client wrapper must forward both responses.create and chat.completions.create unless api pins one of them.
api | Calls with function tools | Tool-free calls and object-mode decide |
|---|---|---|
| unset | responses.create | chat.completions.create |
"chat" | chat.completions.create | chat.completions.create |
"responses" | responses.create | responses.create |
"chat"is for a model or OpenAI-compatible server without/v1/responses.generateTextruns its tool loop on Chat Completions, and a tool-mode decision sendstoolswithtool_choice: 'required'."responses"is for a host whose recorded traffic uses the Responses API.
resolveModel maps a machine's model ref to a real OpenAI model id. It defaults to identity, for machines whose refs are already ids.
settings carries the provider knobs that belong to the host rather than the machine, such as reasoning_effort or service_tier. Key it by model ref to give a ref a persona, or pass a function of the request to vary settings per call. Settings merge under what the request declared, so a request that set maxOutputTokens wins. Settings use Chat Completions names. On Responses calls:
reasoning_effortbecomesreasoning.effort.max_completion_tokensbecomesmax_output_tokens.verbositybecomestext.verbosity.- Settings the Responses API shares by name, such as
temperatureandservice_tier, pass through. - Chat-only settings, such as
seed,stop,logprobs, andprediction, are dropped. This includes a request'sseedandstopSequences. Each dropped key logs oneconsole.warnpercreateOpenAiExecutorsinstance. storedefaults tofalse, matching Chat Completions.- A sampling setting the model rejects, such as
temperatureortop_pon a reasoning model likegpt-6-luna, is dropped for that model after its first 400. The call is retried without it, and one warning is logged. The rejected request still counts against an HTTP-level budget.
Structured output sends the declared schema wrapped as { result, reasoning? }, as response_format: { type: 'json_schema' } on Chat Completions or text.format on the Responses API. The parsed result is what the machine validates.
Decisions follow decideMode:
'object'(default for compatible object payloads): one structured-output call whose schema is the decision object, sent withstrict: false, since the adapter validates the answer itself.'tool': one function tool per candidate event, withtool_choice: 'required'andparallel_tool_calls: false. Function tools are sent withstrict: false, since the Responses API defaults strict mode on and arbitrary JSON Schema may fall outside it.- An answer the adapter cannot read as one event comes back as
rejected, and the runtime retries it. See Coercion.
generateText runs the tool loop host-side. Each step that comes back with function calls runs the request's tools, appends the calls and one function_call_output item per result, and asks again. The replay is stateless: calls go back without their item ids, and reasoning items are left out. An incomplete turn, such as one cut off at max_output_tokens, never runs its calls, and an incomplete decision throws instead of choosing. A failed response throws on both paths, with its error code and message. The request's maxSteps bounds the number of OpenAI calls; the default is one, so a single-step request behaves as before. A tool that throws, a call to a tool the request did not offer, and arguments that are not JSON go back to the model as error results. The loop also ends early when the model calls a client-side tool (one with no execute). The call is handed back with finishReason: 'tool-calls' and the raw response. Every step's usage is summed onto the one result the run aggregates. modelCalls reports the steps, and a loop that throws after a step records them on the error, so the run's budget counts them.
toolChoice maps onto tool_choice: 'auto', 'none', and 'required' pass through, and { type: 'tool', name } becomes { type: 'function', name }. It is sent on the first step only, so a forced choice cannot re-fire every step and burn the step budget.
Messages map part by part. Text and image parts become OpenAI content parts (image_url, or input_image on the Responses API, with bytes and bare base64 wrapped in a data URL). Assistant tool-call parts become tool_calls, or function_call items. Tool results become tool messages carrying their tool_call_id, or function_call_output items. A part the adapter cannot carry, such as a file part, throws rather than being dropped.
streamText is text-only. A request that declares tools or a structured output schema is refused, not silently downgraded; route it to generateText.
Every result reports usage on the flat AgentCallUsage field names, with reasoning_tokens and cached_tokens folded onto reasoningTokens and cachedInputTokens, plus the normalized finishReason and the untouched response on raw. A Responses API response has no finish_reason: an incomplete status maps max_output_tokens to 'length', and a completed response with function calls maps to 'tool-calls'. Streams ask for stream_options: { include_usage: true }, so a stream reports usage too.
TanStack AI adapter
@statelyai/agent/tanstack-ai maps the same three executors onto TanStack AI's chat() activity. Any TanStack AI text adapter works as a model: openaiText, anthropicText, geminiText, ollamaText, and the rest.
pnpm add @tanstack/ai @tanstack/ai-openaiimport { openaiText } from "@tanstack/ai-openai";
import { anthropicText } from "@tanstack/ai-anthropic";
import { createTanStackAiExecutors } from "@statelyai/agent/tanstack-ai";
const executors = createTanStackAiExecutors({
models: {
quick: openaiText("gpt-6-luna"),
deep: {
adapter: anthropicText("claude-sonnet-5"),
settings: { modelOptions: { thinking: { type: "enabled", budget_tokens: 4096 } } },
},
},
});
await runToQuiescence(createAgentRuntime(machine, { executors }), { input });The options mirror createAiSdkExecutors:
modelsmaps model refs to text adapters, or to{ adapter, settings }pairs that give a ref a persona.resolveModelresolves refs dynamically, and wins overmodelswhen both are set.settingscarrieschat()options that belong to the host, such asmodelOptions,middleware, ormetadata. Pass an object for every call, or a function of the request.decideModesets how a decision is answered:'object'(the compatible-payload default) or'tool'. See Coercion.
Precedence is global settings, then the model entry's settings, then the request's own generation settings. modelOptions merge key by key.
TanStack AI has no portable sampling or tool-choice options; each provider adapter names its own modelOptions. The adapter maps a request's temperature, topP, topK, maxOutputTokens, seed, stopSequences, and toolChoice by provider:
openai:temperature,top_p,max_output_tokens, andtool_choice. The Responses API has notop_k,seed, orstop, so those are dropped. TanStack AI's OpenAI adapter also dropstemperatureandtop_pfor reasoning models.anthropic:temperature,top_p,top_k,max_tokens,stop_sequences, andtool_choice.seedis dropped.gemini:temperature,topP,topK,maxOutputTokens,seed,stopSequences, andtoolConfig.- Any other provider gets none of them. Map them in a
settingsfunction, which receives the request.
The rest of the contract matches the other adapters:
- Structured output passes the declared schema, wrapped as
{ result, reasoning? }, aschat()'soutputSchema. A structured run cut off by the token limit is anAgentTruncatedError. - Tools run in
chat()'s own loop, ingenerateTextandstreamTextalike. The request'smaxStepsbounds the model turns; the default is one. A tool with noexecutestops the run at its call, withfinishReason: 'tool-calls'. - A forced
toolChoiceapplies to the first model turn only, so the model can answer after the tool runs. generateTextandstreamTextreturn the run's responsemessages: each turn's assistant text and tool calls, and the tool results fed back. Append them to a conversation and they replay into the next request.- Decisions with compatible object payloads use one structured-output object by default; incompatible payloads default to tool mode. Under
decideMode: 'tool'they offer one tool per candidate event, with noexecute, so the run stops at the model's first call. The tool choice is forced where the provider supports it, and an answer with more than one call is rejected. - System messages and the request's
systembecomesystemPrompts, since TanStack AI messages have nosystemrole. Image parts becomeurlor base64datasources; other part types throw. - Every result reports
usagesummed over the run's model turns, the normalizedfinishReason, and every chunk the run produced onraw. A provider error arrives as aRUN_ERRORchunk and is thrown with itscode.
@tanstack/ai is an optional peer dependency. To stream a run to a useChat client over TanStack AI's wire protocol, see tanstack-ai-stream.
Finish reasons and truncation
An executor result may report finishReason, normalized to 'stop', 'length', 'tool-calls', 'content-filter', or 'other'. Map the provider's own vocabulary onto those five and leave the raw value on raw. runToQuiescence lifts the normalized reason onto the request.end trace event, beside usage.
A 'length' finish is the host's to interpret, because only the host sees it:
- Text request: return the text the model did produce, with
finishReason: 'length'. Do not throw. - Structured request: there is no usable output, so throw
AgentTruncatedErrorwith thecauseand, when the model produced something,partialOutput.
import { AgentTruncatedError } from "@statelyai/agent";
import type { AgentRequestExecutorInfo, AgentTextRequest } from "@statelyai/agent";
function onTruncated(
request: AgentTextRequest,
info: AgentRequestExecutorInfo | undefined,
cause: unknown,
partialText?: string,
): never {
// `request.name` is optional, and `requestName` is required. `partialOutput`
// is set only when the provider returned some text, so the error shape
// stays the same whether or not there was a partial answer.
const requestName = request.name ?? "(unnamed)";
throw new AgentTruncatedError(`Request '${requestName}' hit the output token limit.`, {
requestName,
requestId: info?.requestId,
...(partialText !== undefined ? { partialOutput: partialText } : {}),
cause,
});
}The error's code is 'truncated', which is what a machine's onError branches on. Core never throws it.
Executor-owned structured retries
Retry policy belongs to the SDK or host executor. For example, a host may retry a tool-free AI SDK structured-output failure once while preserving the runner's abort signal:
import { NoObjectGeneratedError } from "ai";
const aiSdk = createAiSdkExecutors({ models });
const executors = {
...aiSdk,
generateText: async (request, info) => {
try {
return await aiSdk.generateText(request, info);
} catch (error) {
const hasTools = Object.keys(request.tools ?? {}).length > 0;
if (hasTools || !NoObjectGeneratedError.isInstance(error)) throw error;
return aiSdk.generateText(request, info);
}
},
};Tool-bearing calls are not retried here because tools may have side effects. Use the framework's own interruption and retry facilities when available; Stately Agent forwards messages and execution to it.
Resume events off the wire
A host that resumes a run from an HTTP request or a socket frame parses the payload at the boundary, then hands the parsed event to runToQuiescence:
let event;
try {
event = parseAgentEvent(machine, await request.json());
} catch (error) {
return Response.json({ error: String(error) }, { status: 400 });
}
const result = await runToQuiescence(createAgentRuntime(machine, { store, threadId, executors }), {
event,
});
if (result.ignored) {
return Response.json(
{ error: `'${result.ignored.type}' does not apply right now` },
{ status: 409 },
);
}parseAgentEvent(machineOrSnapshot, payload)takesunknownand returns the event typed as the machine's event union. Give it the machine to read the event schemassetupAgentregistered, or any snapshot of it.- It throws
AgentInvalidEventPayloadError(codeinvalid-event-payload) for a payload that is not an object with a stringtype, a reserved@agent.*type, or fields that fail the schema. That is the 400. - It does not ask whether the current state handles the event, and
runToQuiescenceadds no check of its own. - An event the resumed state has no transition for is ignored: the run settles normally and
result.ignoredholds the event. Answer 409 if the client should know nothing happened.
See Persistence.
Write your own host loop
runAgent(machine, options) is createAgentRuntime(machine, options) driven by this loop:
const init = { snapshot, event: { type: "APPROVE" } };
const runtime = createAgentRuntime(machine, { executors });
let [state, effects] = await runtime.start(init);
await runtime.execute(effects);
if (init.event !== undefined && state.status === "active") {
[state, effects] = runtime.transition(state, init.event);
await runtime.execute(effects);
}
for (let event; (event = await runtime.nextEvent());) {
[state, effects] = runtime.transition(state, event);
await runtime.execute(effects);
}
const result = await runtime.finish();Write the loop yourself when runAgent cannot express the host:
- Non-blocking durable host. A Durable Object woken by alarms runs one leg per wake-up and cannot await a whole run.
- Events pushed mid-run with a custom loop. A socket delivers user events while requests are in flight; hand each one to
runtime.send(event). Without a custom loop,runAgent'sinboxdoes the same. - Custom stop or checkpoint policy. Persist after selected transitions, or stop before
nextEvent()returnsundefined.
Each operation is described in The runtime loop.
Host events mid-run
runtime.send(event) puts a host event in the run's mailbox, behind whatever already arrived. A nextEvent() parked on a pending request resolves with it; the loop applies it with transition like any other event.
const runtime = createAgentRuntime(machine, { executors });
socket.on("message", (event) => runtime.send(event));
cancelButton.onclick = () => runtime.send({ type: "CANCEL" });
let [state, effects] = await runtime.start({ input });
await runtime.execute(effects);
for (let event; (event = await runtime.nextEvent());) {
[state, effects] = runtime.transition(state, event);
await runtime.execute(effects);
}
const result = await runtime.finish();- Ordering. One FIFO with request completions, child messages, and timers. A send during a pending invoke is handed out by the next
nextEvent(); the invoke keeps running unless the transition leaves its state, which aborts itssignal. - Journal. A sent event is a log entry, the same as
runAgent({ event }). Replayingresult.eventslands on the same state. - Unhandled. A sent event the state has no transition for is reported as
result.ignored. - Before
start. Queued; the firstnextEvent()hands it out. Returnstrue. - After the run stopped or the machine left
active. Dropped. Returnsfalse. - After
finish. ThrowsAgentErrorruntime-finished. - Not read. A send after
nextEvent()returnedundefinedwaits for anothernextEvent();finishdrops it.
send is asynchronous: the machine moves when the loop's next transition applies the event. For a cancel that must take effect before the caller returns, call transition from outside the loop on the loop's own state:
let state, effects;
[state, effects] = await runtime.start({ input });
await runtime.execute(effects);
async function cancelAll() {
if (state.status !== "active") return;
[state, effects] = runtime.transition(state, { type: "CANCEL" });
await runtime.execute(effects);
}
for (let event; (event = await runtime.nextEvent());) {
[state, effects] = runtime.transition(state, event);
await runtime.execute(effects);
}Invariants for a direct transition:
- One
state. The loop and the out-of-band caller read and write the same variable; a stalestateforks the run. - Effects executed. Pass every returned
effectstoexecute. - Active only. No
transitiononcestate.statusis notactive. - Loop exits on its own. A parked
nextEvent()returnsundefinedonce the cancel stops the in-flight work.
Abort through the machine
options.signal alone is a hard stop: the run settles { status: "error", cause: "aborted" } and the machine sees nothing. Add abortWith to let the machine handle the abort, for a cancelled final state, cleanup invokes, or an afterRun hook:
const result = await runAgent(machine, {
executors,
input,
signal: request.signal,
abortWith: { event: { type: "CANCEL" }, timeout: 5_000 },
});
// result.status === "done", result.snapshot.value === "cancelled"- On abort.
eventis sent into the run (runtime.send); the run continues until the machine settles. - Abort first. Until the machine has applied
event, the host's directtransitionevents are dropped, includingrunAgent's ownevent. A signal already aborted at start therefore applieseventbefore anything else. - Fallback.
cause: "aborted"as withoutabortWithwhen:- the state has no transition for
event; - the loop never applies it;
- the machine handled it but settles still
active(an idle wait nothing will answer), immediately and without waiting fortimeout; timeoutms pass while the machine is still active and working.
- the state has no transition for
- Journal. The abort event is a log entry; replay reaches the same final state.
runAgent inbox
runAgent returns a promise, so a host holds no runtime to send into. Pass an inbox from createAgentInbox instead:
const inbox = createAgentInbox<EventOf<typeof machine>>();
const run = runAgent(machine, { executors, input, inbox });
cancelButton.onclick = () => inbox.send({ type: "CANCEL" });
const result = await run;- Same as
runtime.send. Eachinbox.sendgoes to the run's mailbox and returns the same boolean. See Host events mid-run. - Before start. Queued; delivered after the init event and after
runAgent's ownevent. Returnstrue. - While idle. An inbox event resumes the run, ahead of a pending
onIdleanswer; that answer is dropped. WithoutonIdle,runAgentreturns at the first idle. - Ignored. An inbox event the state does not handle is reported as
result.ignored. Unlike an ignoredonIdleanswer, it does not end theonIdleloop. - After
runAgentreturns. Returnsfalse. - One call. An inbox serves one
runAgentcall; reusing it throwsAgentErrorinbox-in-use.
Uncontrolled XState actor
provideExecutors(machine, executors) binds the executors onto the machine so a plain createActor runs it, for an application that owns the actor or embeds the agent machine in a larger XState system. See Advanced.
Framework-owned concerns (storage, durable execution, retries, queues, and tool-loop interruption recovery) remain with the framework.