Advanced: bare XState actors and hand-written executors
Most hosts call runAgent, or drive the step API themselves. This page covers the two things underneath those: binding executors onto a machine so it runs as a plain XState actor, and writing an executor without an adapter.
provideExecutors: the machine as a plain actor
provideExecutors(machine, executors) returns a machine.provide(...)-ed copy whose agent.generateText, agent.streamText, and agent.decide sources call your executors. What comes back is an ordinary XState machine, so createActor runs it with no run loop around it.
import { createActor } from "xstate";
import { provideExecutors } from "@statelyai/agent";
const actor = createActor(provideExecutors(machine, executors), { input });
actor.start();
actor.send({ type: "USER_REPLIED", text: "Continue" });Reach for this when:
- Your application already owns the actor. A framework's XState integration (
useActor, an actor registry, a durable actor runtime) wants a machine, not a promise of a result. - The agent machine is a child in a larger XState system. Invoke or spawn the bound machine from a parent machine like any other actor logic; its decisions and requests run inside the parent's actor system.
- The actor is long-lived. A chat session, a game loop, or a device controller keeps one actor running across many human turns instead of resuming from a snapshot per turn.
What you give up compared with runAgent: there is no idle settling, no maxModelCalls cap, no event log or snapshot/event resume options, and no run.start/run.end trace boundary. The actor runs until you stop it. Usage events (@agent.usage) are still delivered to the machine, and onTrace still receives the request-level trace.
const bound = provideExecutors(machine, executors, {
onChunk: (chunk) => stream.write(chunk),
onTrace: (event) => log.push(event),
});actorsmerges extra actor sources before binding, the same shape asmachine.provide({ actors }).onChunkreceives every chunk of everymode: 'stream'request.onTracereceivesrequest.start,request.end,request.error, andstream.chunkevents with the same enveloperunAgentemits, minted per root actor. Pair it withtraceTransitionson the actor'sinspectoption to foldmachine.transitionevents into the same stream.
provideExecutors does not descend into invoked child machines. A child with its own agent requests needs its own provideExecutors(...) call, or bindRequestExecutor on its request logic (below). runAgent rebinds children for you.
Writing an executor by hand
An executor is a plain function. generateText and streamText take the lowered request and return the same shape the machine receives in the invoke's onDone:
const executors = {
generateText: async (request, info) => {
const response = await mySdk.generate({
model: request.model,
system: request.system,
messages: request.messages,
signal: info?.signal,
});
return {
result: response.text,
messages: response.messages,
usage: { inputTokens: response.usage.input, outputTokens: response.usage.output },
};
},
};resultis the value: the text, or the structured object the request's output schema describes. Core validates it against that schema.messagesare the provider's response messages for this call. They arrive onoutput.messagesinonDone, so the machine decides what to keep.usageis this call's token usage on the flatinputTokens/outputTokens/totalTokensnames.runAgentfolds it into the run's aggregate and the@agent.usageevent.- Anything else (
finishReason,toolCalls,raw) rides along toonResult(request, { raw })and therequest.endtrace.
streamText returns the same shape once the stream finishes and calls info.onChunk for each chunk on the way. decide returns { event }, the machine event the model chose from request.events; see Decisions.
Structured output
A structured request carries request.outputSchema. Ask the provider for the schema wrapped in a root object, which every provider accepts, and return the parsed result:
import { getJsonSchema, parseProviderOutput, providerOutputSchema } from "@statelyai/agent";
if (request.outputSchema) {
const schema = providerOutputSchema(request.outputSchema, { reasoning: request.includeReasoning });
const jsonSchema = await getJsonSchema(schema);
const raw = await mySdk.generateJson({ ...call, schema: jsonSchema });
return parseProviderOutput(request, raw); // { result, reasoning? }
}providerOutputSchema builds the { result, reasoning? } schema to send. parseProviderOutput validates the provider's JSON against it and returns { result, reasoning? }, which is already a valid executor result. The machine still validates result against the schema it declared.
Sharing one executor with a child machine
bindRequestExecutor(logic, executor) binds a request's TextLogic to a generateText-shaped function, so a parent and its child machines can share one executor without a second provideExecutors call:
childMachine.provide({
actors: {
researchTopic: bindRequestExecutor(childSetup.requests.researchTopic, executors.generateText),
},
});Helpers for the raw result
getCallUsage(raw)reads a call's normalized usage off any executor result.getCallFinishReason(raw)reads its normalized finish reason.parseOutput(schema, value)validates a bare value against a request's declared schema, for hosts that unwrap provider output themselves.
The adapters in @statelyai/agent/ai-sdk and @statelyai/agent/openai are built from exactly these pieces. Read them when a provider needs something this page does not cover.