Human in the loop
A human wait is an ordinary XState state with accepted events. Once no child,
request, or in-process timer remains active, runToQuiescence settles as
idle. Use isAgentIdle(snapshot) afterward to distinguish an intentional
human wait from a stuck machine.
A wait with an after timer (an SLA, a reminder) is not idle while that timer
is armed in process: the run keeps waiting for it to fire. A host that answers
the wait later passes timers: { schedule, cancel } so the run settles idle
with the timer handed to the host. See
Host-owned timers.
awaitingApproval: {
meta: {
interaction: {
label: "Approve {draft.subject}?",
events: {
APPROVE: { label: "Approve", style: "primary" },
REJECT: { label: "Reject", style: "danger" }
},
textEvent: "REJECT"
}
},
on: {
APPROVE: { target: "sending" },
REJECT: { target: "revising" }
}
}Type the metadata
setupAgent types every state's meta as AgentInteractionMeta by default, keyed by the machine's declared events. A choice or textEvent that names an event the machine never declared is a compile error:
const agentSetup = setupAgent({
context: contextSchema,
events: { APPROVE: z.object({}), REJECT: z.object({ reason: z.string() }) },
});
agentSetup.createMachine({
// ...
states: {
review: {
meta: {
interaction: {
label: "Approve {subject}?",
events: { APPROVE: "Approve", DECLINE: "Decline" }, // error: DECLINE is not an event
},
},
},
},
});The descriptor is an optional interaction with a label (a string with {context.path} interpolation, or a function of the context), an events map of choices, payload choices (an array, or a context path), and a textEvent. A machine that declares its own meta schema replaces the default; interactionMetaSchema is the runtime schema for the interaction shape when you build a schema pack by hand with createAgentSchemas.
Payload choices
An events entry is one button per event type. When one event type needs several buttons, one per payload, declare choices instead. Each choice is a label and the full event it sends:
voting: {
meta: {
interaction: {
label: "Who is the chameleon?",
choices: [
{ label: "Seat 1", event: { type: "ACCUSE", seat: 1 } },
{ label: "Seat 2", event: { type: "ACCUSE", seat: 2 } },
{ label: "Pass", event: { type: "PASS" }, style: "danger" }
]
}
},
on: { ACCUSE: { target: "revealing" }, PASS: { target: "revealing" } }
}When the choices depend on context, keep them in a context array of the same shape and set choices to its path. The refund desk computes this turn's proposals on entry to deciding:
deciding: {
entry: ({ context }) => ({ context: { choices: deskChoices(context) } }),
meta: {
interaction: {
label: "Turn {turn}: {notice} Choose the next step.",
choices: "choices" // [{ label: "Refund $240", event: { type: "ISSUE_REFUND", amount: 240 } }, ...]
}
}
}getInteraction(snapshot).choices lists the declared choices, inline or read from the context path:
- Labels interpolate
{context.path}like the interaction label. - Each event is checked against the machine's event schema and
snapshot.can(event). Only the choices the machine takes right now are listed. choicesis present only when the state declareschoices. Theeventslist is unchanged.
Convert a pressed button back with its index in choices, or its label. A label that several listed choices share throws; pass the index instead:
const first = eventFromInteraction(paused.snapshot, { choice: 0 });
const refund = eventFromInteraction(paused.snapshot, { choice: "Refund $240" });
// { type: "ISSUE_REFUND", amount: 240 }Free-text field
textEvent: "REJECT" puts the text in a text field. Name another field with { type, field }:
asking: {
meta: {
interaction: {
label: "Please confirm your identity.",
textEvent: { type: "IDENTITY_CLAIM", field: "claim" }
}
},
on: { IDENTITY_CLAIM: { target: "verifying" } }
}getInteraction reports textEvent: "IDENTITY_CLAIM" and textField: "claim", and eventFromInteraction(snapshot, { text }) returns { type: "IDENTITY_CLAIM", claim: text }. textField is absent when the field is text.
Render and validate
const paused = await runToQuiescence(createAgentRuntime(machine, { executors }), { input });
if (paused.status === "idle") {
const interaction = getInteraction(paused.snapshot);
const event = eventFromInteraction(paused.snapshot, { type: "APPROVE" });
await storage.put(id, paused.persist());
return { interaction, event };
}getInteraction interpolates labels, collapses whitespace, and filters choices and textEvent through XState's currently accepted events. eventFromInteraction preserves fixed fields declared in interaction metadata and validates the chosen payload against the machine's event schema.
Both read event schemas from setupAgent, or from a plain XState machine's own setup({ schemas: { events } }).
Whitespace collapses because labels are usually authored as multi-line template literals and interpolated context can carry newlines of its own. Pass { preserveWhitespace: true } when the label is deliberately multi-line, such as a rendered diff:
const interaction = getInteraction(paused.snapshot, { preserveWhitespace: true });Both functions are typed off the snapshot, so eventFromInteraction returns the machine's own event union. No cast is needed to send the result back:
const event = eventFromInteraction(paused.snapshot, { type: "APPROVE" });
await runToQuiescence(createAgentRuntime(machine, { executors }), {
snapshot: paused.persist(),
event,
});When the answer arrives over the wire instead of from your own code, parse it at the boundary with parseAgentEvent(machine, await request.json()) and pass the result as event. A bad payload throws, which is a 400; an event the paused state has no transition for is ignored and comes back as result.ignored. See Persistence.
Drive several turns
let result = await runToQuiescence(createAgentRuntime(machine, { executors }), { input });
while (result.status === "idle") {
const snapshot = result.persist();
await storage.put(id, snapshot);
const event = await promptUser(getInteraction(result.snapshot));
result = await runToQuiescence(createAgentRuntime(machine, { executors }), { snapshot, event });
}For HTTP or queue-based applications, persist result.persist() and resume in
a later request with a fresh runtime and { snapshot, event }. Storage remains
framework-owned.