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 (see Versioning). 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
metadataobject | nullCaller-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

Transition history​

Every move appends one TaskTransition record; GET /tasks/{id}/history returns them oldest-first, paged in the { data, total, limit, offset } 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_kindstringuser | 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
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.

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 errorCode.
  • 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 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. The parameter repeats; 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.

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 annotates the archived version 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).

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 errorCode.
  • Reject → the gate clears; history gets a note (principal_kind: 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"}'

Filtering tasks by a metadata key is not supported; filter client-side.

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
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 does not name a declared state of the 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​

EventWhen
tasks.createdA task is created and placed in its initial state
tasks.transitionedA task moves between states
tasks.closedA task enters a terminal state
tasks.automation_unroutedA dispatch completed but no on_complete rule matched
tasks.automation_rejectedA matched on_complete transition was rejected (guard or conflict)
tasks.automation_retryingA dispatch attempt failed and a retry attempt remains (carries attempt, max_attempts, the error, and the failed generation/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)
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"