Skip to main content

Workflows & Tasks

A workflow is a state machine (states, transitions, guards, per-state automation); a task is a durable item moving through it over time, backward included.

Overview​

The workflow is the versioned definition; the task is a durable instance that does not terminate on its own and can revisit states. An orchestration is a forward-only DAG that runs and ends; a workflow is a state graph a task lives in (statuses, guarded transitions, a kanban board, backward moves). They compose: a state may dispatch an orchestration, an agent or a single tool call. Choosing an Automation Model compares them, starting with whether the work needs a graph at all; a workflow is the graph layer, built last.

  • states — board columns. Exactly one is initial; any number are terminal (entering one closes the task). A kind: human state never dispatches: the task parks until a principal fires a transition. A state may declare on_enter automation (Per-state automation).
  • transitions — named, directional moves from listed states to one destination. Backward moves are ordinary transitions.

Creating a task places it in the initial state (or a named state, Alternate entry points) and fires its on_enter. Every later move (human, API, agent via MCP, automation outcome) goes through the single transition operation, so guards and the audit trail cannot be bypassed; state is never directly writable. GET /tasks?workflow_id=…&state=… is one board column.

See the Permissions Reference for the workflows: action strings and #tasks for the tasks: action strings.

Data Model​

Workflow​

FieldTypeDescription
idstringPublic identifier (wfl_…)
project_idstringOwning project (hard security boundary)
namestringHuman-readable name, unique per project
descriptionstring | nullOptional description
versionintegerIncremented on every write that changes the state machine; prior versions are archived (see Versioning)
statesarrayState definitions (see below)
transitionsarrayAllowed moves (see below)
payload_schemaobject | nullOptional JSON Schema validated against task payloads
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

State​

FieldTypeDescription
namestringUnique within the workflow
initialbooleanExactly one state must be true — where new tasks start
terminalbooleanEntering a terminal state closes the task (status: closed)
kindstring | nullhuman marks a parking state that never dispatches
on_enterobject | nullAutomation fired when a task enters this state (see below)
stalled_afterinteger | nullSeconds a task may sit in this state before a tasks.stalled event fires (positive integer, or null to never stall). See Stall detection.

Transition​

FieldTypeDescription
namestringUnique within the workflow; the name a caller fires
fromstring[]Source states this transition is valid from
tostringThe single destination state
guardobject | nullJSON Logic over {task, transition, principal}; a false result rejects the move with TASK_GUARD_REJECTED
requires_approvalbooleanGate the move behind a human approval. Firing it parks a pending approval instead of transitioning. See Approval-gated transitions.

Only defined transitions fire; an any-state transition lists every state in from.

Task​

FieldTypeDescription
idstringPublic identifier (task_…)
project_idstringOwning project (hard security boundary)
workflow_idstringThe workflow definition this task is bound to
workflow_versioninteger | nullThe workflow version this task runs on, fixed when the task was created and changed only by a migration. null for tasks created before pinning existed, which run on the live definition
titlestringHuman-readable label
statestringCurrent state name. Read-only — moved only via a transition
statusopen | closedclosed once the task enters a terminal state
payloadobjectCaller-owned task data; input to guards and dispatch input_mappings. The engine never writes into it except declared payload_writes
metadataobjectCaller-owned annotations supplied at creation; invisible to guards and to payload_writes (see Task metadata and Tags and metadata)
last_resultany | nullServer-owned, read-only: the result of the current state's last completed dispatch, overwritten on every dispatch. Guards read it as task.last_result
assigneestring | nullInformational in v1 (a user or actor public ID; not interpreted by the engine)
active_dispatchobject | null{ kind, id, status } of the current state's dispatch, if any — plus attempt while a retry policy is in effect. kind is generation, orchestration_run or tool_call; a tool_call always carries a null id, since a direct tool call leaves no addressable record
automation_statusstring | nullrunning | completed | failed | unrouted for the current state's dispatch, or paused when an operator pause suppressed it before it ran (see Pausing a task)
pause_requested_atstring | nullISO 8601 instant an operator paused this task's automation, or null when no pause is in force (see Pausing a task)
pause_reasonstring | nullThe reason supplied with the pause, when one was
automation_chain_depthintegerServer-owned, read-only: how many machine-driven transitions have run back-to-back with no outside intervention. Reset to 0 by any move a person, a plain API key, or an approval resolution makes. See The automation chain budget
pending_transitionstring | nullName of a requires_approval transition parked awaiting a human decision; null otherwise
tool_contextobjectWrite-only. Caller context for the task's automation dispatches, accepted on create-task and transition-task and never returned by a read. See Dispatch tool context
entered_state_atstringWhen the task entered its current state
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Workflow migration​

FieldTypeDescription
idstringPublic identifier (wfl_mig_…)
workflow_idstringThe workflow whose tasks it moves
project_idstringOwning project
to_versionintegerThe version the tasks move onto
state_mapobject | nullThe state_map applied to every task
statusstringqueued, running or completed
task_countintegerTasks the run covers
migrated_countintegerTasks now on to_version
refused_countintegerTasks left where they were
tasksarray{ task_id, status, error } per task, status one of pending, migrated, refused; error is the refusal as a StoredError
created_by_principal_typestring | nullWho started it
created_by_principal_idstring | nullPublic id of who started it
created_atstringISO 8601 creation timestamp
finished_atstring | nullWhen the last task settled

Transition history​

Every move appends one TaskTransition record; GET /tasks/{id}/history returns them oldest-first, paged in the list envelope.

FieldTypeDescription
idstringPublic identifier (task_tr_…)
task_idstringOwning task
from_statestring | nullSource state (null on the initial placement)
to_statestringDestination state
transitionstring | nullTransition name fired (null for the initial placement)
principal_typestringuser | api_key | automation | approval
principal_idstring | nullThe principal that made the move. For api_key auth this is the API key's own id (key_…), distinguishing which key acted. null for automation, which has no principal — read generation_id / orchestration_run_id / tool_id for the cause
generation_idstring | nullThe agent generation that caused the move (set for both on_complete routing and on_failure, linking the failed generation)
orchestration_run_idstring | nullThe orchestration run that caused the move, when automation-driven
tool_idstring | nullThe tool a tool dispatch called, when that dispatch caused the move. A tool call produces no record of its own, so the tool is the cause
notestring | nullOptional reason supplied by the caller
from_workflow_versioninteger | nullOn a migration row, the version the task left (null when it had none); null on every other row
to_workflow_versioninteger | nullOn a migration row, the version the task moved onto; null on every other row
created_atstringISO 8601 timestamp

Key Concepts​

  • Single transition path. The transition must exist, be valid from the current state, and its guard must pass, whoever fires it.
  • Atomicity & conflicts. The state change runs under a row lock; concurrent transitions on one task serialize. A transition no longer valid from the committed state, or on a closed task, is TASK_TRANSITION_CONFLICT (409). The post-dispatch write (active_dispatch, automation_status, last_result, payload_writes) re-validates under the same lock; a stale write is discarded.
  • Delete is guarded. A workflow with open tasks cannot be deleted (WORKFLOW_HAS_OPEN_TASKS); once every task is closed, deleting it removes those tasks and their history.
  • Payload is working data. PATCH /tasks/{id} updates payload, title or assignee. payload is shallow-merged (omitted keys kept) and validated against payload_schema. No patch reaches read-only last_result, so a guard on task.last_result is satisfied only by a value an automation wrote. Payload writes are not versioned.

Listing a task's transitions​

GET /tasks/{task_id}/transitions lists every transition of the task's pinned version with name, to, requires_approval, allowed, and error_code: the code firing it now as the caller would answer (TASK_TRANSITION_CONFLICT for a closed task, a pending gate or a state outside from; TASK_GUARD_REJECTED for a failing guard), null when allowed. The guard runs with the caller as principal. A requires_approval transition is allowed before its guard passes, since the guard runs when the approval resolves. One check decides both this list and the fire, so it is the dry run; it is advisory, and firing re-validates under the lock. Needs tasks:GetTask.

Writing the payload with a move​

payload on POST /tasks/{task_id}/transitions is merged over the task's payload under the transition's lock, before the guard reads it, exactly as PATCH /tasks/{task_id} merges it, and validated against the pinned payload_schema (TASK_PAYLOAD_INVALID, 400). No other write can land between the payload and the guard that reads it, and a refused move (guard, conflict, invalid payload) writes none of it. On a requires_approval transition the payload stays with the gate and is merged when the approval fires the move, so the guard re-run at resolution reads what the requester sent, and a rejected or expired gate writes nothing. The idempotency key compares payload like the rest of the body.

Retrying a create​

POST /tasks accepts an Idempotency-Key header or idempotency_key body field, unique within the project. The same key with the same body answers 200 with the task the first request created, as it is now, and creates nothing; a retry racing the original replays it too. The same key with a different body (any of workflow_id, title, payload, assignee, state, tool_context, metadata) is IDEMPOTENCY_KEY_REUSED (409). A create that fails claims no key. The key stays claimed for as long as the task exists, so deleting the task releases it.

Retrying a transition​

POST /tasks/{task_id}/transitions accepts an Idempotency-Key header or idempotency_key body field, unique within the task. The same key with the same body answers 200 with the task as it is now and moves nothing, whether the first request applied the move or parked an approval, so a retry after a timeout is safe even on a self-loop. A retry racing the original waits on the task's lock and replays. The same key with a different body is IDEMPOTENCY_KEY_REUSED (409). The key stays claimed for as long as the task exists.

Per-state automation (on_enter)​

on_enter dispatches at most one agent generation, orchestration run or tool call on entry and routes the outcome into a transition:

{
"name": "drafting",
"initial": true,
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "agent_x1",
"input_mapping": {
"prompt": { "cat": ["Write about ", { "var": "task.payload.topic" }] }
},
"payload_writes": {
"draft_id": { "var": "result.object.document_id" }
}
},
"retry": { "max_attempts": 3, "backoff_seconds": 5, "backoff_multiplier": 2 },
"on_complete": [
{ "when": { "==": [{ "var": "result.category" }, "simple"] }, "transition": "to_review" },
{ "when": true, "transition": "to_review" }
],
"on_failure": null
}
}
  • dispatch — kind: agent (agent_id), kind: orchestration (orchestration_id) or kind: tool (tool_id, optional operation_id for a multi-operation tool). input_mapping is JSON Logic over {task} resolving the dispatch input (a tool dispatch's arguments). payload_writes (optional) is JSON Logic over {task, result}, written into named task.payload keys atomically with last_result on completion, so a value outlives the one hop last_result covers. Each write overwrites its key; in a loop an earlier pass's value lingers until the state dispatches again.
  • on_complete — rules evaluated in order against {task, result}; the first match fires its transition as the automation principal (same guards). {result} is the generation output plus the ai_provider_id that served its model (agent), the final run state (orchestration) or the return value (tool), also written to task.last_result. No match → automation_status: completed and a tasks.automation_unrouted event. A matched transition rejected (guard fails for automation, or a concurrent move invalidated it) → automation_status: unrouted and a tasks.automation_rejected event carrying the matched transition and the rejection error_code.
  • retry (optional) — covers execution failures, never on_complete routing. max_attempts counts the first attempt (1–10); the delay before attempt n is backoff_seconds * backoff_multiplier^(n - 2) (defaults: 0, 1). on_failure, or the parked automation_status: failed, fires only after the last attempt; leaving the state between attempts abandons the rest. Each attempt is active_dispatch.attempt; every retried failure emits tasks.automation_retrying (attempt, max_attempts, the recorded failure in error, the failed generation_id/orchestration_run_id).
  • on_failure — transition fired on terminal dispatch failure. Omitted → the task stays with automation_status: failed for a human.

A state, a transition and everything under on_enter take the fields named here and nothing else: any other key is 400 VALIDATION_FAILED on create and update, by path (states.0.on_enter.dispatch.agent). input_mapping, payload_writes, a guard and an on_complete when are JSON Logic bodies, so their contents are carried as written.

Entering a state cancels any dispatch still running from the state left, an in-flight orchestration run included: task state is the source of truth.

Tool dispatch​

A single tool call dispatches directly:

{
"name": "notifying",
"on_enter": {
"dispatch": {
"kind": "tool",
"tool_id": "tool_...",
"input_mapping": { "channel": { "var": "task.payload.channel" } }
},
"on_complete": [{ "when": true, "transition": "to_notified" }]
}
}

input_mapping resolves the arguments; the return value becomes {result} and task.last_result. The call passes the same guardrails as the identical call from an orchestration tool node and lands in the activity feed the same way. It settles within the dispatch: active_dispatch.kind: tool_call with a null id, and the resulting transition carries tool_id as provenance.

Two cases fail the dispatch with TOOL_DISPATCH_FAILED and belong behind an orchestration dispatch:

  • a guardrail routes the tool to human approval (class C): a task dispatch has no run to park and resume;
  • a guardrail blocks the call (class D, or a class-B tripwire): it never ran, so it is a dispatch failure routable through on_failure.

Waiting, polling, and multi-step work​

on_enter dispatches one thing. To wait, poll or run several steps, dispatch an orchestration and use its node types (delay, poll, …). A task dispatch starts the run in durable mode, so the background scheduler owns the wait:

{
"name": "awaiting_settlement",
"on_enter": {
"dispatch": { "kind": "orchestration", "orchestration_id": "orc_..." },
"on_complete": [{ "when": true, "transition": "to_settled" }]
}
}

The run parks sleeping and resumes on its own; the task sits with automation_status: running until the run settles, then routes through on_complete / on_failure. There is no kind: delay or kind: poll; a one-node orchestration expresses it.

Recovery after a restart​

The run behind a dispatch is durable, but the wait for its outcome lives in the process that started it. If the server restarts with a dispatch outstanding (typically a run sleeping through a long delay or poll), the run still finishes on the scheduler and a background reconciler routes the task when it does.

The reconciler considers only a dispatch that has read running longer than TASKS_DISPATCH_RECONCILE_GRACE_MS (default 60000), so a healthy in-process hand-off is never raced. The recovered outcome routes like a live one: same on_complete / on_failure rules, same automation principal, the run as cause.

kind: agent dispatches are not reconciled: a generation parked in requires_action awaiting client tool outputs is legitimately outstanding.

Pausing a task​

POST /api/v1/tasks/{task_id}/pause suppresses every state's on_enter dispatch and every retry chain behind one (the task-level counterpart of pausing an orchestration run); POST /api/v1/tasks/{task_id}/resume lifts it.

A paused task still transitions. Entering a state whose dispatch is suppressed records automation_status: paused; resume reads it to know the on_enter still owes its work, and never re-spends a completed dispatch.

The resumed dispatch runs as whoever resumed, not whoever last moved the task.

A dispatch in flight finishes, and its outcome still routes (into a state whose own dispatch is then suppressed). A task-dispatched orchestration run is not paused with its task; pause it through its own route.

Pausing is idempotent; a closed task answers 409 TASK_NOT_PAUSABLE, and resuming with no pause in force answers 409 TASK_NOT_PAUSED.

A project pause pauses every open task the same way. The project's resume lifts exactly those and runs their suppressed dispatches; resuming one on its own while the project is paused answers 409 PROJECT_PAUSED.

Finding the tasks whose automation is running​

GET /api/v1/tasks filters on automation_status beside status, state, workflow_id and assignee; status=open narrows to the cards in play. Both status filters repeat; values are ORed:

GET /api/v1/tasks?status=open&automation_status=running&automation_status=paused

none selects a null automation_status (never entered a state with an automation); omitted means every task. It is none, not null, because the CLI reads the token null as JSON null. A value outside running / completed / failed / unrouted / paused / none, empty string included, is 400 VALIDATION_FAILED.

Versioning​

The state machine is versioned by the same append-only archive as agent versions, guardrail versions and orchestration versions. Version 1 is written on create; every write that changes the definition increments version and archives a WorkflowVersion. Versioned surface: states, transitions, payload_schema.

A write may name the version it is changing (expected_version, or an If-Match header) and is refused with 409 VERSION_CONFLICT when the resource has moved on — see Concurrent Writes.

A task runs on the version it entered on. POST /tasks stamps version onto the task as workflow_version; validating a transition, parking an approval gate and validating a payload patch all read that version. Editing never re-shapes a task in flight; the live definition is a draft for tasks created from now on, and a migration moves an open task onto it.

Archive nothing: a metadata-only edit (name, description); re-writing the definition already held (compared structurally); restoring the live version. version_label on a create or update tags the version that write creates and is never itself a change.

OperationEndpoint
List versions, newest firstGET /api/v1/workflows/{workflow_id}/versions
Fetch one versionGET /api/v1/workflows/{workflow_id}/versions/{version}
Roll back to a versionPOST /api/v1/workflows/{workflow_id}/versions/{version}/restore

Restore appends, it does not rewind. Restoring v1 at v2 writes v1's definition back as v3; a task pinned to v2 still runs on it. Only the definition rolls back; name and description are untouched. A restored definition passes the same validation, including resolving every on_enter dispatch target (agent, tool or orchestration) in the workflow's project, so a version whose target was deleted fails with WORKFLOW_VALIDATION_FAILED (400).

Migrating a task to a newer version​

POST /tasks/{task_id}/migrate { to_version, state_map? } moves an open task onto a newer version of its workflow. Needs tasks:MigrateTask.

  • A state the target version declares maps to itself; state_map maps one it lacks (an entry for a shared state overrides the identity).
  • The payload is re-validated against the target payload_schema.
  • The mapped state's on_enter is not fired; transition the task to run the target's automation. Changing state clears active_dispatch and automation_status.
  • Recorded in history with transition: null, from_workflow_version, to_workflow_version and the caller as principal; emits tasks.migrated.
RefusalCode
to_version not newer than workflow_versionVALIDATION_FAILED (400)
The workflow has no such versionWORKFLOW_VERSION_NOT_FOUND (400)
The current state is unmappedTASK_MIGRATION_STATE_UNMAPPED (400)
A state_map target is undeclaredTASK_STATE_NOT_FOUND (400)
A mapped state is terminalVALIDATION_FAILED (400)
The payload fails the target schemaTASK_PAYLOAD_INVALID (400)
The task is closed, has a transition pending approval, or a dispatch in flightTASK_MIGRATION_CONFLICT (409)

Migrating every open task​

POST /workflows/{workflow_id}/migrations { to_version, state_map?, wait? } moves every open task on an older version, or on none, onto to_version. Needs workflows:MigrateWorkflowTasks on the workflow and tasks:MigrateTask on each task.

  • Each task is migrated as migrate-task migrates one; a task it would refuse is recorded refused with the error, and the run goes on. A task the caller may not migrate is refused FORBIDDEN.
  • The tasks and the caller's permission on each are fixed when the run is recorded; a task opened later is not included.
  • A state_map target the version lacks (TASK_STATE_NOT_FOUND) or a terminal one (VALIDATION_FAILED) refuses the request before any task moves, as does an unknown version (WORKFLOW_VERSION_NOT_FOUND).
  • The run is the wait handle: 201 with status: queued by default, completed with wait: true. Poll GET /workflows/{workflow_id}/migrations/{migration_id} (workflows:GetWorkflowMigration).
  • A run whose process stops is resumed by the task scheduler once its lease expires; a task it already moved counts as migrated.

Alternate entry points​

POST /tasks accepts an optional state to create the task in instead of initial. Entry behaves like a transition (entered_state_at set, on_enter fires, the stall clock arms) with one history entry (from_state: null, transition: null). An unknown state is TASK_STATE_NOT_FOUND (400).

Approval-gated transitions​

Firing a requires_approval: true transition (user, API key or automation outcome) does not move the task: it parks a pending ApprovalItem (origin: task_transition, carrying task_id and task_transition) and returns the task with pending_transition set. No other transition fires while the gate is open (TASK_TRANSITION_CONFLICT, 409); one gate per task. Resolve it through the approvals endpoints:

  • Approve → the transition fires as the approval principal through the single transition path, guard re-evaluated at resolution time; if no longer valid, the gate clears and tasks.approval_failed fires with transition and error_code.
  • Reject → the gate clears; history gets a note (principal_type: approval, transition: null). The task never moved.
  • Expire → the approvals expiry sweeper clears the gate and appends an expiry note.

Task metadata​

create-task accepts a metadata bag: caller-owned annotations (tenant, originating ticket, import batch) stored on the task and returned verbatim by every read, the list included.

  • payload is part of the machine: guards read task.payload, dispatch input_mappings read it, payload_writes overwrite keys in it.
  • metadata is inert: no guard, mapping or engine write touches it. Everything the engine decides (state, status, workflow_version, last_result, active_dispatch, the automation fields) is a field of its own.

Unlike write-only tool_context, metadata is readable, and it survives every transition (a transition supplies none). A non-object metadata is 400 VALIDATION_FAILED; no task is created.

soat create-task \
--workflow-id "$WORKFLOW_ID" \
--title 'Refund request #8123' \
--metadata '{"tenant_account_id":"42","source":"zendesk"}'

GET /api/v1/tasks narrows by the bag with ?metadata=, the grammar of Documents — Metadata filters: exact equality, in, and ordering by the operand's type. A client that keeps its own record id in metadata finds the task with it, no id map of its own:

soat list-tasks \
--workflow-id "$WORKFLOW_ID" \
--metadata '{"source":"zendesk","external_id":"8123"}'

The filter ANDs with the other list filters (state, status, assignee, …) and narrows what the caller may already see.

Dispatch tool context​

A task carries a tool_context: a flat Record<string, string> forwarded as context headers on every http, mcp and builtin tool call its dispatches make: an agent dispatch's generation, a tool dispatch's call (also resolving the tool's {{context:}} headers and pinned parameters), and an orchestration dispatch's run, on to every node and child run (Run Tool Context).

It attaches per move; creation is the first move:

RequestEffect on the stored bag
create-task --tool-context '{…}'Sets it. This is what the entry state's on_enter runs with
transition-task --tool-context '{…}'Replaces it wholesale
transition-task with no tool_contextKeeps the current one
transition-task --tool-context '{}'Clears it, without closing the task
Any transition into a terminal stateCleared — a closed task holds no credential

A dispatch runs with the credential of whoever last moved the task. Moves supplying no bag preserve it: automated hops (on_complete / on_failure), retry attempts, approval resolutions (the gated move's bag is stored when the gate parks and used when it resolves). A stall is an event, not a move.

Reserved identity keys are stripped and re-derived server-side; an invalid key is INVALID_TOOL_CONTEXT_KEY (400) (Validation). The bag is write-only: no read returns it. Confine a key to the tools that need it with context_keys.

Stall detection​

A state may declare stalled_after (seconds). A sweeper emits a tasks.stalled webhook event when an open task has sat in the state longer. An event, not a transition: the task does not move; route on it via a webhook or trigger. Fires once per stall episode, re-armed by the next transition.

The automation chain budget​

A cycle turning on its own (dispatch, outcome routes back, dispatch again) is bounded by automation_chain_depth; each transition increments or resets it:

The moveEffect
A dispatch outcome routed through on_complete / on_failure (the automation principal)increments
A transition-task call from a dispatched run or agent, made with its run-as tokenincrements
A person, a plain API key, or an approval resolutionresets to 0

When the depth would exceed TASK_AUTOMATION_CHAIN_LIMIT (default 50), the transition is refused with TASK_AUTOMATION_CHAIN_LIMIT before the state change, so the next on_enter never fires; the task parks automation_status: unrouted and tasks.automation_rejected fires. A dispatched run or agent is recognized by its run-as token, not its principal. Any human touch restarts the budget.

Deploying as a formation​

Formation resource type workflow; properties mirror the REST body (name, description, states, transitions, payload_schema), and an on_enter dispatch's agent_id / orchestration_id accept { "ref": "LogicalId" }.

Configuration​

VariableRequiredDescription
TASK_AUTOMATION_CHAIN_LIMITNoHow many machine-driven transitions a task may run back-to-back with no outside intervention before the next one is refused (default 50). See The automation chain budget.

Error Codes​

CodeStatusWhen
WORKFLOW_NOT_FOUND404The workflow does not exist or is not accessible
WORKFLOW_VALIDATION_FAILED400The workflow definition is invalid; meta.problems lists every problem as a validation problem
WORKFLOW_HAS_OPEN_TASKS409The workflow has open tasks and cannot be deleted
TASK_NOT_FOUND404The task does not exist or is not accessible
TASK_PAYLOAD_INVALID400The payload violates the workflow's payload_schema
TASK_STATE_NOT_FOUND400POST /tasks state, or a migration's state_map target, does not name a declared state of the workflow version
TASK_MIGRATION_STATE_UNMAPPED400The target version lacks the task's state and state_map does not map it. See Migrating a task
TASK_MIGRATION_CONFLICT409The task is closed, has a transition pending approval, or has a dispatch in flight
WORKFLOW_VERSION_NOT_FOUND400A migration's to_version names no version of the workflow
WORKFLOW_MIGRATION_NOT_FOUND404The workflow migration does not exist, or belongs to another workflow
TASK_TRANSITION_NOT_FOUND400The named transition does not exist in the workflow
TASK_GUARD_REJECTED400The transition guard evaluated to false
TASK_TRANSITION_CONFLICT409The transition is not valid from the current state, or the task is closed
TASK_AUTOMATION_PROVENANCE_MISSING500An automation transition would be persisted with principal_id, generation_id, orchestration_run_id, and tool_id all null — rejected as a writer bug rather than silently recorded
TOOL_DISPATCH_FAILED422A tool dispatch's call was settled before it ran — blocked by a guardrail, or routed to human approval, which a task dispatch cannot park on
INVALID_TOOL_CONTEXT_KEY400A tool_context key on create-task / transition-task is not a valid header name, or two keys collide on one header. See Dispatch tool context
TASK_AUTOMATION_CHAIN_LIMIT409The task has run TASK_AUTOMATION_CHAIN_LIMIT machine-driven transitions with no outside intervention; the next one is refused. See The automation chain budget
TASK_NOT_PAUSABLE409The task is closed, so it has no automation left to pause. See Pausing a task
TASK_NOT_PAUSED409The task carries no operator pause to lift; advance an idle task by firing a transition instead. See Pausing a task

Webhook events​

Every event's data is the task as GET /api/v1/tasks/{task_id} answers it; the keys listed beside an event ride next to the task's own.

EventWhenBeside the task
tasks.createdA task is created and placed in its initial state
tasks.transitionedA task moves between statestransition, from_state
tasks.closedA task enters a terminal state
tasks.migratedA task moves onto a newer workflow versionfrom_state, from_workflow_version
tasks.automation_unroutedA dispatch completed but no on_complete rule matchedresult
tasks.automation_rejectedA matched on_complete transition was rejected (guard or conflict)result, transition, error_code
tasks.automation_retryingA dispatch attempt failed and a retry attempt remainsattempt, max_attempts, error ({ code, message, meta? }), generation_id, orchestration_run_id
tasks.stalledAn open task sat in a state past its stalled_after (once per episode)
tasks.approval_failedAn approved gated transition could no longer apply at resolution time (guard or conflict)transition, error_code
tasks.pausedAn operator paused a task's automation
tasks.resumedAn operator lifted a task's pause

Examples​

Create a workflow​

soat create-workflow \
--project-id "$PROJECT_ID" \
--name "Content Pipeline" \
--states '[{"name":"draft","initial":true},{"name":"review","kind":"human"},{"name":"published","terminal":true}]' \
--transitions '[{"name":"to_review","from":["draft"],"to":"review"},{"name":"revise","from":["review"],"to":"draft"},{"name":"publish","from":["review"],"to":"published"}]'

Create a task and fire a transition​

TASK_ID=$(soat create-task \
--project-id "$PROJECT_ID" \
--workflow-id "$WORKFLOW_ID" \
--title "Blog post: launch recap" \
--payload '{"topic":"launch recap"}' | jq -r '.id')

soat transition-task --task-id "$TASK_ID" --transition to_review --note "ready for review"

Fire an approval-gated transition​

See Approval-gated transitions.

# Parks instead of moving: the task now shows pending_transition.
soat transition-task --task-id "$TASK_ID" --transition publish

APPROVAL_ID=$(soat list-approvals --project-id "$PROJECT_ID" --status pending \
| jq -r --arg t "$TASK_ID" '.[] | select(.task_id == $t) | .id' | head -n1)

# Approving fires the gated transition as the `approval` principal.
soat approve-approval --approval-id "$APPROVAL_ID"

Pause and resume a task's automation​

See Pausing a task.

soat pause-task --task-id "$TASK_ID" --reason "credit balance went negative"

# Lifts the pause and dispatches the current state's on_enter if it was suppressed.
soat resume-task --task-id "$TASK_ID"

Migrate every open task​

See Migrating every open task.

soat create-workflow-migration --workflow-id "$WORKFLOW_ID" \
--to-version 3 --state-map '{"triage":"intake"}' --wait true