Hosts and executors
Give an agent machine the executor functions that call a model, and choose between the shipped AI SDK adapter or your own.
The executor contract
A host is the code that runs an agent machine and supplies the functions that call a model. The machine decides what to ask; the host executes the ask. The machine never talks to a model directly.
Those functions are the executors, typed as AgentRequestExecutors:
generateText(request)returns{ output }, whereoutputis the text string or the structured object. Optional passthrough fields (usage, tool calls, and so on) are allowed alongsideoutput. Required only when the machine has a generate-mode text request.streamText(request, info)streams chunks throughinfo.onChunkand returns the accumulated{ output }. Required only when the machine has a streaming request.decide(request)returns{ event }, the one event the model chose. Required only when the machine has a decision.
runAgent checks these at bind time, before any actor runs, so a machine that needs decide without one fails immediately rather than mid-run. A machine with only plain actors needs no executors at all. Each executor is a plain async function taking a plain request object, so any SDK or a raw fetch can back it. The machine has no idea which one you used.
The shipped AI SDK adapter
createAiSdkExecutors from @statelyai/agent/ai-sdk is the one adapter this package ships. It builds the { generateText, streamText, decide } set from the Vercel AI SDK, mapping requests onto generateText/streamText and, for decisions, onto a tool-forced generateText call.
import { createAiSdkExecutors, defineModels } from '@statelyai/agent/ai-sdk';
import { openai } from '@ai-sdk/openai';
const models = defineModels({ quick: openai('gpt-5.4-mini') });
const result = await runAgent(machine, {
input: { prompt: 'Why state machines?' },
...createAiSdkExecutors({ models }),
});ai is an optional peer dependency, imported only by this subpath. Core has one runtime peer, xstate. You supply the model resolver, so no provider package becomes a dependency either.
Typed model aliases
Prefer model aliases shared between setupAgent and the adapter: pass one models map to both, and request model: values are typed against its keys.
import { openai } from '@ai-sdk/openai';
import { defineModels } from '@statelyai/agent/ai-sdk';
const models = defineModels({
quick: openai('gpt-5.4-mini'),
careful: openai('gpt-5.4'),
});
const agentSetup = setupAgent({
models,
context: z.object({ prompt: z.string(), answer: z.string().nullable() }),
input: z.object({ prompt: z.string() }),
output: answerSchema,
requests: {
answerQuestion: {
schemas: { input: z.object({ prompt: z.string() }), output: answerSchema },
model: 'quick', // typed as "quick" | "careful"
prompt: ({ input }) => input.prompt,
},
},
});
await runAgent(machine, {
input,
...createAiSdkExecutors({ models }),
});For a fully dynamic or externally configured host — one whose machine must not name concrete models — use resolveModel instead: it takes the raw ref string and returns a model, so refs like "openai/gpt-5.4-mini" resolve without a static map. You can pass both; resolveModel wins. With models alone, an unknown ref throws. This is the max-portability escape hatch — see Which authoring form when.
Model refs are opaque strings, so any string is a legal model: value. A models map is optional: it gives you key autocomplete on request model: fields and a place for the executor to resolve those refs. The AI SDK adapter resolves a ref through its models map, or through resolveModel when the map has no match.
Multi-step tool loops
A text request runs a single model call by default. Set metadata.maxSteps on the request to allow a bounded tool-call loop; the adapter forwards it as stopWhen: stepCountIs(maxSteps). This is adapter behavior, not core behavior: metadata is the host-owned per-call channel.
Writing your own executors
The contract is three plain functions, so a raw fetch is enough:
import type { AgentRequestExecutors } from '@statelyai/agent';
const executors: AgentRequestExecutors = {
generateText: async (request) => {
const res = await fetch('https://api.example.com/v1/generate', {
method: 'POST',
body: JSON.stringify({ model: request.model, prompt: request.prompt }),
});
return { output: await res.text() };
},
};
await runAgent(machine, { input, ...executors });This is backed by real implementations against four runtimes:
- examples/ai-sdk-host/index.ts: the Vercel AI SDK, through the shipped adapter.
- examples/openai-sdk-host/index.ts: the raw
openaipackage (Chat Completions), structured output viaresponse_format, decisions forced withtool_choice: 'required'. - examples/anthropic-sdk-host/index.ts: the raw
@anthropic-ai/sdkpackage (Messages API), structured output via a forced tool call, decisions forced withtool_choice: { type: 'any' }. - examples/cloudflare-agent-host/index.ts and examples/cloudflare-workers-ai-host/index.ts: inside a Cloudflare Durable Object and against the Workers AI binding.
Observation seams
runAgent exposes purely observational callbacks; they return void and cannot control the run:
onTrace(event): one ordered stream of run/request/chunk/transition/emit/end events, withrunId,seq, andtimestamp. This is the eval trace / JSONL / telemetry-adapter slot.onChunk(chunk, info): each streamed chunk of amode: 'stream'request, with theAgentRequestthat produced it (parallel streams stay distinguishable).onResult(request, result): once per resolved text or decision request (decision retries fire per attempt), with the normalizedresult.outputand the raw executor result.result.rawis whatever your executor returned, verbatim: returnusagealongsideoutputandonResultbecomes your token meter. The shipped adapter already does this (raw as AiSdkGenerateResultcarriesusage,finishReason,toolCalls,toolResults).onTransition(snapshot, event): every machine transition, with the new snapshot and the causing event.on: { EVENT: handler, '*': handler }: events the machine emits withenq.emit(...), keyed by emitted event type ('*'catches all).inspect(inspectionEvent): raw xstate inspection passthrough for the whole actor system.onTransitioncovers the root machine only; when a state invokes a child machine (see multi-agent), filterinspectionEvent.type === '@xstate.transition'and readinspectionEvent.actorRefto watch the child's states too, attributed to the child.
await runAgent(machine, {
input,
...executors,
onTrace: (event) => jsonl.write(event),
onChunk: (chunk, info) => process.stdout.write(chunk),
onResult: (request, result) => log(request.id, result.raw),
onTransition: (snapshot, event) => trace(snapshot.value, event.type),
on: { EVALUATED: (e) => console.log(`score ${e.qualityScore}/10`) },
});The split: onTrace is the whole ordered run ledger, useful for evals and exports. onTransition narrates the machine in xstate's vocabulary (state values, events), for targeted tracing and debugging. on narrates in your vocabulary: the machine emits domain progress events at moments the author chose, and the host renders them (a progress UI, an SSE stream, a log line). Declare their schemas in setupAgent and both enq.emit(...) and the on handlers are fully typed:
const agent = setupAgent({
context: z.object({ /* ... */ }),
emitted: {
EVALUATED: z.object({ qualityScore: z.number(), iteration: z.number() }),
},
// ...
});
// In the machine, from any transition or entry function:
onDone: ({ context, output }, enq) => {
enq.emit({ type: 'EVALUATED', qualityScore: output.score, iteration: context.iteration });
return { target: 'checking', context: { evaluation: output } };
},Emitted events are fire-and-forget observation, not control flow: they never target states, and a run behaves identically with no handlers attached.
Note: Tracing and OpenTelemetry are bring-your-own; no exporter ships. Build one on
onTrace, or keep using the narrower seams (onResult,onTransition,on,onChunk) when a host wants separate handlers.
Testing with deterministic executors
Because executors are plain functions, a test can supply scripted ones and never touch the network. Bind them with withExecutor:
const machine = emailDrafter.provide({
actorSources: {
draftEmail: draftEmail.withExecutor(async ({ request }) => {
return { output: { to: 'sam@example.com', subject: 'Hello', body: 'Hi Sam!' } };
}),
},
});examples/email-drafter/index.ts drives a full run this way in its tests: fixed values, deterministic, no model called.
Related
- The step path: the lower-level per-model-call checkpointing loop for durable hosts.
- Quickstart: a host and a machine together end to end.