@statelyai/agent — Alpha P0 Design
Status: implemented (branch p0 alpha, 2026 07 02). P0 landed as four commits — messages, decisions, runAgent, sweep + fixtures — and the branch has since carried follow on P1 work: the AI SDK adapter, a setupAgent event
Status: implemented (branch p0-alpha, 2026-07-02). P0 landed as four commits — messages, decisions, runAgent, sweep + fixtures — and the branch has since carried follow-on P1 work: the AI SDK adapter, a setupAgent event-payload narrowing fix, example migration to the blessed authoring path, a docs rewrite, and a co-located-decisions detour (setupAgent({ decisions })) that was added and then reverted in favor of state-local decisions. Verified green at every commit. This document specified the exact type shapes and behavior before code landed; it is now the reference for what shipped. One naming refinement vs the spec: RunAgentOptions.userInput is typed via the named AgentUserInputExecutor alias (identical signature).
Scope (P0 only):
AgentMessageparts model +messagesSchema(breaking)- Decision primitive:
agent.decide+createDecisionLogic+allowedEvents+resolveDecision runAgentas acreateActorwrapper (return uniondone|idle|error, resume,onTransition,maxModelCalls)- Step-helper separation +
normalizeGeneratorResultsimplification
Deferred to P1+ (not in this doc): createAiSdkExecutors, setupAgent surface consolidation, example rewrites, the decide-block sugar, docs.
1. AgentMessage — parts model
1.1 Motivation
Today (src/types.ts):
export type AgentMessage = { role: string; content: string; [key: string]: unknown };content: string can't carry multimodal input or tool-call/tool-result turns, and widening it later is breaking. We spend the break now by mirroring AI SDK v6 ModelMessage structurally, in-library (no import from ai, so ai stays an optional peer).
1.2 New types (src/types.ts)
export type DataContent = string | Uint8Array | ArrayBuffer;
export type ProviderOptions = Record<string, Record<string, unknown>>;
export interface TextPart {
type: 'text';
text: string;
providerOptions?: ProviderOptions;
}
export interface ImagePart {
type: 'image';
image: DataContent | URL;
mediaType?: string;
providerOptions?: ProviderOptions;
}
export interface FilePart {
type: 'file';
data: DataContent | URL;
mediaType: string;
filename?: string;
providerOptions?: ProviderOptions;
}
export interface ToolCallPart {
type: 'tool-call';
toolCallId: string;
toolName: string;
input: unknown;
providerOptions?: ProviderOptions;
}
export type ToolResultOutput =
| { type: 'text'; value: string }
| { type: 'json'; value: unknown }
| { type: 'error-text'; value: string }
| { type: 'error-json'; value: unknown }
| { type: 'content'; value: Array<TextPart | ImagePart> };
export interface ToolResultPart {
type: 'tool-result';
toolCallId: string;
toolName: string;
output: ToolResultOutput;
providerOptions?: ProviderOptions;
}
export type SystemMessage = {
role: 'system';
content: string;
providerOptions?: ProviderOptions;
};
export type UserMessage = {
role: 'user';
content: string | Array<TextPart | ImagePart | FilePart>;
providerOptions?: ProviderOptions;
};
export type AssistantMessage = {
role: 'assistant';
content: string | Array<TextPart | FilePart | ToolCallPart | ToolResultPart>;
providerOptions?: ProviderOptions;
};
export type ToolMessage = {
role: 'tool';
content: Array<ToolResultPart>;
providerOptions?: ProviderOptions;
};
export type AgentMessage =
| SystemMessage
| UserMessage
| AssistantMessage
| ToolMessage;Decisions baked in:
- Tool-results live in a
tool-role message (AI SDK convention), not folded into user messages (Anthropic convention). Adapter maps identity. Bufferdropped fromDataContent(it extendsUint8Array; keeps core runtime-agnostic).ReasoningPartand tool-approval parts omitted — non-breaking to add later.- The open
[key: string]: unknownindex signature is removed.providerOptionsis the sanctioned passthrough. → migration break for anyone stuffing arbitrary fields onto messages. - Durable persistence stance (blueprint): binary parts (
Uint8Array/ArrayBuffer) andURLinstances are not JSON-serializable; persisting context is the host's concern. Machines that persist snapshots/event logs should carry URL strings or base64 in parts. Enforced by documentation only — JSDoc onImagePart/FilePartplus the persistence recipe. No serializer in P0.
1.3 Helpers (src/utils.ts)
export function systemMessage(content: string): SystemMessage;
export function userMessage(
content: string | Array<TextPart | ImagePart | FilePart>
): UserMessage;
export function assistantMessage(
content: string | Array<TextPart | FilePart | ToolCallPart | ToolResultPart>
): AssistantMessage;
export function toolMessage(content: Array<ToolResultPart>): ToolMessage; // NEWWidening content params is backward-compatible for existing userMessage('...') call sites.
1.4 messagesSchema
Rewrite the validator (setup-agent.ts) to accept the union: role ∈ {system,user,assistant,tool}, and content either a string (where allowed) or an array of parts each with a known type. Rejects unknown roles and unknown part types.
1.5 Migration impact
AgentTextRequest.messages: AgentMessage[]unchanged in name; element type changes.toModelMessagesin the AI SDK host example currently doesrole as 'user'|'assistant'|'system'and passescontentthrough as a string — becomes an explicit identity map toModelMessage(P1, in/ai-sdk).- Any example reading
message.contentasstringunconditionally must narrow. Grep required.
OPEN Q1.1: keep ToolResultOutput as the 5-variant union above, or start with just { type:'text'|'json'; value } and add error/content later? (Union is non-breaking to extend.)
2. Decision primitive
2.1 Concept
A decision is an async effect that resolves to exactly one currently-legal event and raises it into the machine. No output value. Forces a tool call. It replaces the agentEvents-on-text-logic mechanism, which is deleted.
- Live runtime (
runAgent/createActor): the invokedagent.decideactor completes withoutput = chosenEvent; the state'sonDonedelivers it viaenq.sendTo(self, output)(§2.5) into its ownon:handlers. - Step runtime (durable hosts): the decision surfaces in
step.requestsas anAgentDecisionRequest(kind: 'decision', §2.6); the host callsresolveDecision(...)→chosenEvent, thentransitionAgentStep(...).
Both call the same resolveDecision core. Core does no provider mechanics — the decision path is symmetric with the generateText path: the host's decide executor (§2.6) returns { event, reason? }, and core only validates and retries.
2.2 allowedEvents (canonical name)
Hard-rename from agentEvents (authoring) / eventTypes (request). No alias (pre-release). Lives only on decisions.
export type AllowedEvents<TEvent extends string = string> =
| readonly TEvent[]
| ((args: { input: unknown }) => readonly TEvent[]);Semantics: declared candidates ∩ snapshot-legal events. Omitted ⇒ all legal events. Resolver form allowed (runtime narrowing, e.g. HP-gated moves). Because it's intersected with legal events, a resolver can only ever narrow the real surface.
On the runAgent (live) path, this intersection is not computed once at bind time — candidate events are rebuilt from the live snapshot at decision time (declared allowedEvents ∩ currently-legal events, via getAcceptedEvents, with machine event schemas attached), identical in shape to step discovery's getAgentRequests. This is now fixed: an earlier version trusted a stale/defaulted event list instead of re-deriving it per decision.
2.3 createDecisionLogic
export interface DecisionLogicConfig<
TInputSchema extends StandardSchemaV1 = StandardSchemaV1,
TEvent extends string = string,
> {
schemas?: { input: TInputSchema };
model: ResolveTextLogicValue<string, InferOutput<TInputSchema>>;
system?: ResolveTextLogicValue<string | undefined, InferOutput<TInputSchema>>;
prompt?: ResolveTextLogicValue<string | undefined, InferOutput<TInputSchema>>;
messages?: ResolveTextLogicValue<AgentMessage[] | undefined, InferOutput<TInputSchema>>;
allowedEvents?:
| readonly TEvent[]
| ((args: { input: InferOutput<TInputSchema> }) => readonly TEvent[]);
maxRetries?: number; // default 2
temperature?: ResolveTextLogicValue<number | undefined, InferOutput<TInputSchema>>;
maxOutputTokens?: ResolveTextLogicValue<number | undefined, InferOutput<TInputSchema>>;
// ...same model params as TextLogic
metadata?: ResolveTextLogicValue<Record<string, unknown> | undefined, InferOutput<TInputSchema>>;
}
export interface DecisionLogic<...> extends AsyncActorLogic<
ChosenEvent, // output = the raised event object
InferOutput<TInputSchema>
> {
readonly kind: 'statelyai.decisionLogic';
readonly maxRetries: number;
request(input): AgentDecisionRequest; // parallel to TextLogic.request
withExecutor(execute): DecisionLogic<...>;
}
export type ChosenEvent = { type: string; [key: string]: unknown };- No
outputschema (a decision has no output value; its output is the event). - Decisions are generate-only — there is no streaming decision.
- How the model is coerced into choosing (tool-per-event +
toolChoice: 'required', structured output over an event union, …) is adapter business, not core's (§2.6).toolChoiceis therefore not configurable on decisions. - Standalone
createDecisionLogictypesallowedEventsonly asstring[](it doesn't know the machine's events). The inlineagent.decidebuiltin (§2.4) types it against the machine's event-schema keys instead. - Superseded: this section originally promised a co-located
setupAgent({ decisions: {...} })form (P1) as the answer toallowedEventstyping. That form was built and then removed: decisions are state-local by design (a global decision registry duplicates the invoking state'son:handlers and invites drift). The typed answer is the inlineagent.decideinvoke (§2.4), whoseinputis typed against the machine's event-schema keys directly.createDecisionLogicremains for reusable/exported/standalone-testable decision logic registered underactorSources:.
2.4 Builtin agent.decide (v6-alpha-correct syntax)
Zero-config counterpart, symmetric with agent.generateText/agent.streamText. Invoked with inline input. Side effects go through the enq param of a transition function — there is no { actions: [...] } key and no standalone raise()/action-creator in v6 alpha (verified against xstate@6.0.0-alpha.16: types.v6.d.ts:416-436, types.d.ts:207-226). Delivery is built into the decision actor: when the decision resolves, it sends the chosen event to the invoking actor automatically via an external, observable send (so it lands in the event log; see §4.3), not an internal raise (invisible).
choosingMove: {
invoke: {
src: 'agent.decide',
input: ({ context }) => ({
model: 'openai/gpt-5.4-mini',
system: 'Choose exactly one legal move.',
prompt: `Player ${context.playerHp} / Enemy ${context.enemyHp}`,
allowedEvents: context.playerHp <= 6 ? lowHpMoves : defaultMoves, // optional
maxRetries: 2,
}),
onError: { target: 'fumbled' }, // retries exhausted (§2.6)
},
on: {
ATTACK: ({ context }) => ({ target: 'summarizing', context: { /* … */ } }),
DEFEND: { target: 'summarizing' },
FLEE: { target: 'done' },
},
}allowedEvents omitted ⇒ every legal event of choosingMove is offered. The chosen event is delivered to the state's on: handlers automatically; its transition usually exits choosingMove, cancelling the invoke, so onDone normally never runs. When the transition stays in-state, the invoke completes and an explicit onDone (optional, rare) observes the chosen event as output.
2.5 Automatic delivery (built into the decision actor)
Delivery is not a user-wired helper. When the decision resolves, the decision actor sends the chosen event to the invoking actor itself, via an external sendTo(self, …) (not internal raise) specifically so the decision is recorded in the observable event stream — this is what makes event sourcing (§4.3) work. The P3 decide: state-block sugar is just this invoke + onError.
2.6 resolveDecision — validation + retry core
Core does no provider mechanics. The decision path is symmetric with the generateText path: the host receives an AgentDecisionRequest, and its decide executor returns { event, reason? }. How the model is made to choose — tool-per-event + toolChoice: 'required', structured output over an event union, anything — lives in the adapter. /ai-sdk ships the default decide executor (P1); a structured-output variant is a documented recipe for providers without forced tool choice.
export interface AgentRequestExecutorInfo {
onChunk?: (chunk: string) => void;
signal?: AbortSignal;
}
export type AgentRequestExecutor<TResult = AgentRequestExecutorResult> = (
request: AgentTextRequest & { tools: AgentTools },
info?: AgentRequestExecutorInfo
) => PromiseLike<TResult> | TResult;
/** Third executor slot, symmetric with generateText/streamText. */
export interface AgentRequestExecutors<
TGenerateResult = AgentRequestExecutorResult,
TStreamResult = AgentRequestExecutorResult,
> {
generateText: AgentRequestExecutor<TGenerateResult>;
streamText?: AgentRequestExecutor<TStreamResult>;
decide?: AgentDecisionExecutor;
}
export type AgentDecisionExecutor = (
request: AgentDecisionRequest
) => PromiseLike<{ event: ChosenEvent; reason?: string }>;
export interface AgentDecisionRequest {
kind: 'decision';
/** Durable invoke id. */
id: string;
model: string;
system?: string;
prompt?: string;
messages?: AgentMessage[];
/** Candidate events: declared `allowedEvents` ∩ snapshot-legal events. */
events: AgentEventDescriptor[]; // { type, toolName, inputSchema? }
/**
* Prior failed attempts for THIS decision. Empty on the first attempt.
* Adapters render these into the provider request (e.g. a trailing user
* message: "You chose HEAL; unavailable: no potions. Choose from: …") so
* retries converge. Core never rewrites prompts/messages — attempts are
* data on the request, which keeps recorded requests replay-deterministic.
*/
attempts: DecisionAttempt[];
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
seed?: number;
stopSequences?: string[];
metadata?: Record<string, unknown>;
}
export interface ResolveDecisionOptions {
maxRetries?: number; // default 2 (⇒ up to 3 attempts)
signal?: AbortSignal;
/** Mode-3 guard check. Omit ⇒ mode-3 skipped (modes 1–2 only). */
canTake?: (event: ChosenEvent) => boolean;
}
export interface DecisionAttempt {
event?: ChosenEvent;
failure: 'unknown-event' | 'invalid-payload' | 'rejected-by-guard';
reason: string;
}
export class DecisionExhaustedError extends Error {
attempts: DecisionAttempt[];
}
export async function resolveDecision(
request: AgentDecisionRequest,
executor: AgentDecisionExecutor,
options?: ResolveDecisionOptions
): Promise<ChosenEvent>;AgentRequestExecutorInfo is populated on the live runAgent path only; the step path's executeAgentRequest never passes it. Sync returns (TResult, not just PromiseLike<TResult>) are allowed from an executor.
Mode-3 guard-legality uses snapshot.can(event) (v6's preflight predicate — "whether sending the event will cause a non-forbidden transition"; State.d.ts:63-71), which is cleaner than dry-run-and-compare (there is no .changed flag on snapshots in v6). The core takes a canTake probe so the caller supplies snapshot access.
Per attempt:
- Call
executor({ ...request, attempts }). - Validate the returned event: type ∈
request.events→ elseunknown-event; payload against that event's schema → elseinvalid-payload; mode 3:options.canTake?.(event) === false→rejected-by-guard. (Adapter-internal failures such as "model made no tool call" surface asunknown-eventor an executor throw.) - Any failure ⇒ append a
DecisionAttempt, decrement budget, retry. - Success ⇒ return
chosenEvent. Budget exhausted ⇒ throwDecisionExhaustedError(attempts).
Each executor call counts against runAgent's maxModelCalls (a decision that retries twice = 3 model calls).
Missing executor: if the machine registers any DecisionLogic/agent.decide usage and no decide executor is available, runAgent fails at bind time with an error naming the missing slot — not mid-run (§3.2).
Step discovery: getAgentRequests detects DecisionLogic invokes (kind: 'statelyai.decisionLogic') alongside TextLogic and emits AgentDecisionRequest entries. AgentRequest gains kind: 'text'; AgentStep.requests becomes Array<AgentRequest | AgentDecisionRequest> and hosts switch on kind. executeAgentRequest stays text-only; decisions go through resolveDecision + transitionAgentStep.
Call sites — who supplies canTake (two-tier; the self._parent escape hatch is deleted):
runAgent(live) — supported API, no internals.runAgentcreates the actor, so it provides its ownagent.decidesource that closes over anactorRefholder (assigned right aftercreateActor, before any decision runs):canTake: (e) => actorRef.getSnapshot().can(e). The actor sits in the deciding state while the executor runs, so.can()reflects that state's guards. Full modes 1–3.- Step path (durable host) — supported API. The host has the snapshot:
canTake: (e) => snapshot.can(e), thentransitionAgentStep(...). Full modes 1–3. The shippedtwenty-questionsexample wires this directly off the step object:canTake: (e) => step.snapshot.can(e). - Bare
createActorwithagent.decide— modes 1–2 only (type + payload validation, no guard check). Documented limitation; no untyped-internals traversal in a v1 alpha. A new constraint on this tier: an omittedallowedEventsunder barecreateActoris a fail-fast error — with no snapshot to enumerate legal events against, "all legal events" can't be resolved, so it must be declared explicitly.runAgentand the step path both support omittedallowedEvents(⇒ all legal events) fully, since both have a snapshot to intersect against.
On success the agent.decide actor resolves with the chosen event as output and delivers it to the invoking actor itself via enq.sendTo(self, …); on DecisionExhaustedError it rejects (→ invoke onError, error carries attempts).
2.7 getAcceptedEvents guard note
getAcceptedEvents (renamed from getAvailableEvents, §3.1) filters by event type only (see getAcceptedEvents in setup-agent.ts) — it does not evaluate guards. So the mode-3 snapshot.can() check is load-bearing: it's the only thing catching a type-legal-but-guard-rejected choice. Keep the candidate-type list as-is (over-exposes); resolveDecision closes the gap at apply time.
2.8 Removals
agentEventsfromTextLogicConfigand the workflow-config request path.eventTypesfromAgentTextRequest(folded into the decision request shape).AgentRequestLogicalias (it extendedTextLogicadding nothing) — collapse.getEventToolsand execute-carrying event tools — event exposure is decision-owned;getAcceptedEventsreturns plain descriptors and adapters build provider tools from them.- The
self._parentbest-effort mode-3 hack (old Q6.2) — barecreateActoris modes 1–2, documented.
Risk retired. The earlier "async actor needs parent snapshot" risk is resolved by having runAgent (which owns the actor ref) supply canTake via actor.getSnapshot().can(). No self._parent/system traversal from inside async logic is required — bare createActor simply runs without the guard check (modes 1–2).
3. runAgent — createActor wrapper
3.1 Signature
A non-final machine can always accept events, so it is never "paused." The only distinction is whether it is doing work or idle (settled, nothing in flight). The result is a three-variant union; the accepted-event list is derived from the snapshot (via getAcceptedEvents), not embedded.
export interface RunAgentOptions<TMachine extends AnyStateMachine>
extends AgentRequestExecutors {
input?: InputFrom<TMachine>;
// resume
snapshot?: Snapshot<unknown>;
event?: EventFromLogic<TMachine>;
// implementations — sugar for machine.provide({ actorSources }) before the run
actorSources?: Record<string, AnyActorLogic>;
/**
* Optional human-input handler for `agent.userInput` invokes (CLI prompt,
* web form, Slack, …). Idle-first HITL stays the default: model human input
* as event-waiting states and `runAgent` settles `idle`. Provide this only
* when input should be gathered inline without settling. If the machine
* uses `agent.userInput` and neither this nor an actor source is provided,
* bind-time error (message recommends the idle-state pattern).
*/
userInput?: (input: AgentUserInput) => PromiseLike<unknown>;
// observation — all void; no callback controls the run
onChunk?: (chunk: string, info: { request: AgentRequest }) => void;
onResult?: (
request: AgentRequest | AgentDecisionRequest,
result: { output: unknown; raw: unknown }
) => void; // §4.4
/** Fires on every machine transition (snapshot + causing event). Pure
* observation — progress UIs, logging, tracing. Cannot send events. */
onTransition?: (
snapshot: SnapshotFrom<TMachine>,
event: EventFromLogic<TMachine>
) => void;
// control
maxModelCalls?: number; // default 100
signal?: AbortSignal;
}
export type RunAgentResult<TMachine extends AnyStateMachine> =
| { status: 'done'; output: OutputFrom<TMachine>; snapshot: SnapshotFrom<TMachine> }
| { status: 'idle'; snapshot: SnapshotFrom<TMachine> }
| {
status: 'error';
cause: 'aborted' | 'max-model-calls' | 'decision-exhausted' | 'machine' | 'stopped';
error: unknown;
snapshot: SnapshotFrom<TMachine>;
};
export async function runAgent<TMachine extends AnyStateMachine>(
machine: TMachine,
options: RunAgentOptions<TMachine>
): Promise<RunAgentResult<TMachine>>;
/** Renamed from getAvailableEvents. "Events this state can accept right now." */
export function getAcceptedEvents(snapshot: AnyMachineSnapshot): AgentEventDescriptor[];Typing is load-bearing: input/event/onTransition are machine-typed (InputFrom/EventFromLogic), not unknown/AnyEventObject — the run boundary is where users touch the library, and the typesafety pitch dies there otherwise. onChunk carries { request } because parallel states interleave streams; changing the callback arity later is breaking, so it lands now.
No continuation callback. There is deliberately no onIdle-style hook that returns an event to be sent back — idle always settles and the caller resumes explicitly (§3.4). Rationale: (a) the actor is stopped on every settle and snapshot round-trip is lossless, so an inline hook adds zero capability over the resume loop; (b) "return an event and it gets sent" is implicit control flow in a library whose identity is explicit transitions, and it creates a runaway class (a hook that always returns an event loops forever consuming no model calls); (c) policies people would put there ("idle too long → CANCEL", defaults) belong in the machine (after, guards); (d) callers needing reactive mid-run injection use bare createActor — runAgent is a convenience host, not a second runtime. Observation callbacks (onTransition, onResult, onChunk) are all void.
-
done— a final state reached (snapshot.status === 'done'). -
idle— settled, no in-flight work; caller maygetAcceptedEvents(result.snapshot), send an event via resume, or persist. The canonical interactive loop:let r = await runAgent(machine, { input, ...executors }); while (r.status === 'idle') { const event = await promptUser(getAcceptedEvents(r.snapshot)); r = await runAgent(machine, { snapshot: r.snapshot, event, ...executors }); } -
error— arunAgent-level failure, discriminated bycause:'aborted'(signal fired),'max-model-calls'(budget exceeded),'decision-exhausted'(machine error state whose error is/wraps an unhandledDecisionExhaustedError),'machine'(any other machine error state), or'stopped'(external stop). Programmer errors (bad config, missing executor/actor source) still throw — at bind time (§3.2). (Q3.1 resolved:erroris a variant, not a throw.)
3.2 Behavior
-
Merge implementations. Conceptually
provided = machine.provide({ actorSources: options.actorSources })happens first; binding then walks the machine's effective (post-provide) actor sources. This makesrunAgentwork identically for machines pre-provided by the caller. String-keyed sources only — an invoke whosesrcis a direct logic object cannot be rebound. -
Bind executors once. For each effective
TextLogic/DecisionLogic(and theagent.*builtins), wrap with an executor that:- for
TextLogic: callsgenerateText(orstreamText, forwardingonChunk(chunk, { request })), - for
DecisionLogic/agent.decide: runsresolveDecision(request, decide, { canTake: (e) => actor.getSnapshot().can(e) })(§2.6 —runAgentowns the actor ref ⇒ full modes 1–3), - increments a shared model-call counter; on exceeding
maxModelCalls, settles the run{ status: 'error', cause: 'max-model-calls' }, - invokes
onResult(request, { output, raw })with the raw executor result (§4.4).
Fail fast at bind time (throw, not an
'error'result), naming the offending source: aTextLogicinSTREAMmode with nostreamTextexecutor (the runtime bind-time throw —generateTextitself can never be missing, since it's a required field onRunAgentOptions/AgentRequestExecutorsand its absence is a compile-time TypeScript error), a decision with nodecide,agent.userInputwith neitheroptions.userInputnor a provided actor source (error text recommends the idle-state HITL pattern), any other placeholder actor with no implementation, or a direct-objectsrcthat needs execution but can't be rebound. Detection is by logickind, so machines built with rawsetup()(nosetupAgentregistration) bind identically. - for
-
actor = createActor(provided, { input, snapshot }). -
actor.start(); ifoptions.event,actor.send(event). -
Settle loop. Subscribe; on each snapshot:
status === 'done'⇒{ status:'done', output, snapshot }.status === 'error'⇒{ status:'error', cause: <'decision-exhausted' if snapshot.error is/wraps an unhandled DecisionExhaustedError, else 'machine'>, error: snapshot.error, snapshot }.status === 'stopped'(external stop) ⇒{ status:'error', cause:'stopped', error: new Error('Actor stopped externally.'), snapshot }— without this the settle loop hangs forever.isIdle(snapshot)and not done ⇒{ status:'idle', snapshot }. No callback consulted — idle always settles; the caller resumes (§3.4).- every transition ⇒
onTransition(snapshot, event)(observation only, exceptions in the callback are the caller's).
-
maxModelCalls⇒{ status:'error', cause:'max-model-calls', … }; abort ⇒{ status:'error', cause:'aborted', … }. -
The actor is stopped on every settle path (
done/idle/error). Resume is always by snapshot (§3.4), never by holding a live actor.
3.3 Idle detection (isIdle) — v6-verified
Per the spike, snapshot.status ∈ {'active','done','error','stopped'} and snapshot.children is Record<string, AnyActorRef | undefined>; each child exposes getSnapshot(). There is no public field for pending internal (always/raised/after) events, so "no pending internal work" is only approximable via getNextTransitions:
function isIdle(snapshot: AnyMachineSnapshot): boolean {
if (snapshot.status !== 'active') return false;
const childrenBusy = Object.values(snapshot.children ?? {})
.some((c) => c?.getSnapshot?.().status === 'active'); // in-flight invoked actors
if (childrenBusy) return false;
const hasPendingWork = getNextTransitions(snapshot).some(
(t) => t.eventType === '' // eventless (`always`)
|| t.eventType.startsWith('xstate.after') // pending `after` timer
);
return !hasPendingWork;
}A state with an after delay is working (waiting on its own clock), not idle — settling there would strand the timer, since the actor is stopped on settle (§3.2.7). In the live path timers otherwise just run on the actor's clock; no special handling beyond this check. (Spike S2 verified: delayed transitions surface as xstate.after.<delay>.<stateId> in getNextTransitions.)
Debounce to a macrotask before declaring idle (children spin up across transitions), re-check on each emission. Residual imprecision (already-scheduled raised events aren't snapshot-visible) is acceptable — a stray idle just hands control back to the caller, who can resend.
3.4 Serverless resume recipe
let r = await runAgent(machine, { input, ...executors }); // → idle (awaiting approval)
await store.put(threadId, r.snapshot); // persist snapshot (or event log, §4.3)
// ...later, new process, human approved...
const snapshot = await store.get(threadId);
r = await runAgent(machine, { snapshot, event: { type: 'APPROVE' }, ...executors });3.5 What runAgent is NOT
No per-model-call durable checkpoint — whole-machine snapshots at done/idle/error only. Per-request checkpointing and event-sourced durability are the step-helper path (§4).
4. Step helpers + result normalization
4.1 Positioning
initialAgentStep / transitionAgentStep / resolveAgentStep / executeAgentRequest / getAgentRequests remain first-class, documented as the durable / inspectable path (per-model-call checkpoints for Cloudflare Workflows, Temporal, Inngest, DBOS). API change: AgentStep.requests is now Array<AgentRequest | AgentDecisionRequest>, discriminated by kind (§2.6), plus §4.2. Not deprecated; a peer of runAgent, chosen by use case. transitionAgentStep accepts either a raw snapshot or a previous AgentStep as its second argument, unwrapping .snapshot when given the latter — callers can thread the whole step object through without manually plucking the snapshot out.
4.2 normalizeGeneratorResult simplification
Old normalizer duck-typed toolResults first (the legacy path where a decision event arrived inside toolResults) then object→output→text. With decisions now handled explicitly by resolveDecision (which extracts the chosen event from tool calls itself), the generic normalizer only unwraps generator output:
async function normalizeGeneratorResult(result: unknown): Promise<unknown> {
const r = await result;
if (!r || typeof r !== 'object') return r;
if ('object' in r) return await (r as any).object;
if ('text' in r) return await (r as any).text;
if ('output' in r) return await (r as any).output;
return r;
}The fragile "first toolResults[].output" heuristic is deleted. Decision result extraction is explicit and lives in resolveDecision.
4.3 Event sourcing (the real durable story)
The step helpers are already event-driven: transitionAgentStep applies one event, resolveAgentStep applies one model-result event (xstate.done.actor.<id>), and a decision applies one sent event (§2.5). So durability is event sourcing, not (only) snapshot serialization:
Persist the ordered event log. Replay it through the pure
transition(...)function to reconstruct state. A snapshot is an optional compaction checkpoint, not the source of truth.
This is why decisions deliver via enq.sendTo(self, …) (external, recorded) rather than enq.raise (internal, invisible) — a raised event would not appear in the log and replay would diverge. Concretely, a durable host records, per step: the applied event and the raw executor result (§4.4) that produced it, so replay is deterministic (recorded outputs substitute for live model calls) and auditable (usage/tool-calls preserved).
Not built in P0 (no storage adapter) — but the shape is fixed here so the step API and the recorded-event envelope support it. Storage adapters are P3.
4.4 Raw executor result (Q4.1 resolved)
The raw executor result (tool calls, token usage, finish reason) is retained — required for observability and event-sourced replay/audit. It surfaces in three reachable places, none bloating the common return:
// step path: opt-in verbose return
export function executeAgentRequest(
request: AgentRequest,
executors: AgentRequestExecutors
): Promise<unknown>; // normalized value (unchanged default)
export function executeAgentRequest(
request: AgentRequest,
executors: AgentRequestExecutors,
options: { verbose: true }
): Promise<{ output: unknown; raw: unknown }>; // both- Step path:
executeAgentRequest(req, exec, { verbose: true }) → { output, raw }. - Live path:
runAgent'sonResult(request, { output, raw })callback (§3.1) — also the OTel/tracing seam (P3 exporters plug in here). - Durable path: the recorded step envelope carries
{ event, raw }(§4.3). A decision'sreason(§2.6) is adapter-surfaced explanation; it rides inraw, not in the event.
4.5 Timers in the step path
after delays lower to executable delayed-raise actions in step.actions — the one action family a durable host must execute: schedule the raised event on its own engine (Workflows timer, Temporal timer, queue delay) and apply it via transitionAgentStep when it fires. This is the blueprint stance applied to time: the machine declares the delay; the host owns the clock. In the live path timers run on the actor's clock and isIdle counts a pending after as busy (§3.3), so runAgent never settles under a live timer.
(Spike S1 verified: transition()/initialTransition() emit { type: '@xstate.raise', event, delay, id } executable actions — event + delay + id, everything a host needs to schedule and de-duplicate.)
Timers surface only in step.actions; step.requests never contains them — a durable host scanning for model/decision work can ignore actions entirely, and a host scheduling timers can ignore requests entirely.
5. Breaking-change summary (for the alpha changeset)
AgentMessageis now a discriminated union;contentisstring | Part[]; the open index signature is gone.messagesSchemanow rejects at runtime messages that previously passed (unknown roles, unknown part types). Persisted contexts holding old-shape messages have no migration path in the alpha — documented: wipe or migrate manually.agentEvents(config) andeventTypes(request) removed → decisions +allowedEvents.runAgentreturn type changes fromOutputFrom<TMachine>toRunAgentResult<TMachine>(done | idle | error); it no longer throws on a waiting machine. There is no continuation callback (onPause/onIdledo not exist) — idle settles and the caller resumes by snapshot.onChunkgains{ request };onTransitionadded (observation-only).runAgentnow runs all invokes (was: model-only) — machines with side-effecting actors that previously errored now execute. It stops its actor on every settle; resume is snapshot-only.AgentRequestExecutorsgains optionaldecide; machines using decisions require it (bind-time throw otherwise).AgentRequestgainskind: 'text';AgentStep.requestsbecomes akind-discriminated union includingAgentDecisionRequest.getAvailableEventsrenamedgetAcceptedEvents;getEventToolsremoved (descriptors only — adapters build tools).AgentRequestLogicalias removed.normalizeGeneratorResultno longer inspectstoolResults.
6. Resolved decisions & remaining questions
Resolved this pass:
- Q1.1 —
ToolResultOutput: ship the full 5-variant union now (comprehensive from the start). - Q3.1 —
runAgentfailures are a third{ status:'error' }result variant, not a throw (§3.1). - Q4.1 — raw result retained via verbose
executeAgentRequest,onResulthook, and the recorded event envelope (§4.4). - Q-spike (xstate 6.0.0-alpha.16) — resolved (§2.4–2.6, §3.3): no action-creators (
enq-based transition fns); decision delivery =enq.sendTo(self, …)notenq.raise; mode-3 usessnapshot.can(event)withcanTakesupplied byrunAgent(actor.getSnapshot().can) / step host (snapshot.can); async actors can't read parent snapshot, so barecreateActorgets modes 1–2 only;isIdle= active + no busy children + no pendingalways(approximate).
Resolved:
- Q6.1 — superseded: delivery is built into the decision actor (a
sendTo(self, …), not a raise). No user-facing helper — the standalone delivery export was removed. - Q6.2 —
best-effort mode-3 viasuperseded: the internals hack is deleted. Bareself._parentcreateActoris modes 1–2, documented (§2.6). Blessed paths (runAgent/step) supplycanTakefrom real snapshots. - Q6.3 — P0 fixes only the shapes (recorded envelope carries
{ event, raw }, §4.3/§4.4); the first event-log storage adapter ships in P3. - Q6.4 — decision executor contract is
{ event, reason? }(§2.6): coercion mechanics (forced tool choice, structured output) live in adapters; core validates + retries. Retry feedback travels asrequest.attemptsdata — core never rewrites prompts. - Q6.5 — non-model actors reach
runAgentvia theactorSourcesoption (sugar formachine.provide); binding walks effective post-provide sources by logickind, fails fast at bind time on anything unbound (§3.2). - Q6.6 — binary/
URLmessage parts vs persistence: host concern, documented stance only (§1.2), no serializer in P0.
Sign-offs (resolved 2026-07-02):
- O1 —
AgentStep.requestsis onekind-discriminated union (AgentRequest | AgentDecisionRequest). No separatestep.decisionsarray. - O2 — missing
decideexecutor is a bind-time error only. No core fallback synthesizing decisions fromgenerateText— that would put prompt construction + output parsing (provider mechanics) back in core, exactly what §2.6 removed. If a convenience ever ships, it's an adapter helper (e.g.createStructuredDecide(generateText)in/ai-sdk), not core. - O3 — idle-first HITL, with an optional inline handler:
RunAgentOptions.userInput(§3.1) lets the executor gather human input without settling (CLI prompt, form, Slack). Machine usesagent.userInputwith no handler/source ⇒ bind-time error recommending the idle-state pattern. - O4 — superseded:
onIdleis deleted entirely (2026-07-02 review). Idle always settles; the caller owns the resume loop (§3.1 "No continuation callback"). Replaced by observation-onlyonTransition(snapshot, event). The runaway-idle hazard class this question managed no longer exists. - O5 — decided, pending release mechanics: single changeset covering all §5 breaks; publish under changesets prerelease mode (
changeset pre enter alpha→2.0.0-alpha.x) with thealphanpm dist-tag. Not yet executed —changeset pre enter alphahas not been run (no.changeset/pre.json), andpackage.jsonstill reads2.0.0.
Implementation logistics (resolved):
- Acceptance fixtures gating P0: a twenty-questions-style decision game (new example replacing/extending
game-agentas the decisions showcase — richer decision loop than one combat turn),email-drafter(parts messages), and human-in-the-loop (idle → persist → resume) (example since consolidated intoexamples/human-in-the-loop). Tests for these three are written first, TDD-style; remaining examples migrate in P1. - Old tests encoding old semantics (
setup-agent.test.ts,examples.test.tssections onagentEvents/getEventTools/oldrunAgent) are deleted and rewritten against the new surface, not migrated in place. - Landing: one branch, four commits, in dependency order: (1) message types +
messagesSchema, (2) decision primitive + step discovery, (3)runAgentrewrite, (4) removals/renames sweep.
Spikes against xstate@6.0.0-alpha.16 — all verified empirically 2026-07-02 (scripts in .scratch/spikes/):
| Spike | Verdict | Result |
|---|---|---|
| S1 — step-path timers | ✅ PASS | initialTransition emits { type: '@xstate.raise', event, delay, id } executable actions — hosts have everything to schedule; feeding the after-event back transitions correctly (§4.5 holds). |
S2 — after in getNextTransitions | ✅ PASS | Delayed transitions appear as xstate.after.<delay>.<stateId>; always as eventType === '' — isIdle's startsWith('xstate.after') check is valid (§3.3 holds). |
S3 — onDone no-target + sendTo(self) | ✅ PASS* | Event arrives and takes the transition; completed invoke is not re-run (runCount: 1); the send is externally observable (v6 inspection: @xstate.transition events with sent: [...]). Output is on event.output in the onDone transition fn. |
S4 — double provide | ✅ PASS | Chained provides merge (machine.implementations.actorSources shows both overrides) — §3.2.1 holds. |
S5 — snapshot.can() payload + guards | ✅ PASS | Correct for object-form guards and function transitions (HEAL: ({context,event}) => cond ? {target} : undefined) across all cases — mode-3 holds for the dominant authoring style. |
| S6 — direct-object srcs at bind time | ⚠️ PARTIAL | Detectable — but only via machine.config (raw authored src preserved). The built tree (machine.root.states) normalizes object srcs to synthetic string ids (xstate.invoke.0.(machine).b) and silently loses the distinction. |
| S7 — zod v4 → JSON Schema | ✅ PASS | Both ['~standard'].jsonSchema.input/.output and top-level z.toJSONSchema(schema) work (draft 2020-12). Use z.toJSONSchema where a zod instance is known; the ~standard extension for vendor-agnostic paths. |
Implementation notes from the spikes (binding for the P0 code):
- Transition functions are re-evaluated (S3 measured 8× evaluations for one logical transition; exactly one
GOwas sent —enqdeduplicates). All effects in transition/onDone functions MUST go throughenq; never side-effect directly in the function body. The decision actor's built-in delivery (§2.5) already complies; the purity requirement gets a doc callout and a test. - Bind-time invoke enumeration walks
machine.configrecursively (S6), nevermachine.root/built StateNodes, or object srcs masquerade as registered strings and the §3.2.2 fail-fast silently misses them.