Skip to main content

Workflows & Tasks

Define a state machine — named states, transitions, guards, and per-state automation (a workflow) — and run durable tasks through it that move between states over time, including backward.

  • A workflow is the versioned definition (like an orchestration definition): states, transitions, guards, and automation.
  • A task is a durable instance bound to a workflow (like an orchestration run) — except it does not terminate on its own and can revisit states.

Workflow or orchestration?

An orchestration is a pipeline that ends — a directed acyclic graph that runs forward and terminates. A workflow is a state graph a task lives in — a long-lived entity that moves between named states over days or weeks, and can revisit them.

You want…Use
Statuses, transitions, guards, a kanban board, or an entity that revisits statesWorkflows & Tasks (this module)
A deterministic, forward-only sequence of steps that runs and completesOrchestrations

The two compose: when a task enters a state, that state may dispatch an orchestration or an agent to do its work. A workflow never replaces a run — it drives one.

A support ticket that reopens. A lead that goes qualified → negotiating → stalled → negotiating. A kanban card dragged back a column. None of these fit a DAG — a task is the shape they need.

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

Overview

A workflow's two lists are the whole model:

  • states — the named columns of a board. Exactly one is initial; any number are terminal (entering one closes the task). A kind: human state never dispatches; the task parks there until a principal fires a transition. A state may declare on_enter automation (see Automation).
  • transitions — the named, directional moves between states. A transition lists the states it is valid from and the single state it moves to. Backward moves (review → draft) are just transitions — cycles are the point, not an error, which is exactly what an orchestration DAG rejects by design.

You create a task against a workflow; it is placed in that workflow's initial state and that state's on_enter automation fires. From then on, every state change — human, API, agent (via MCP), or automation outcome — routes through the single transition operation, so guards and the audit trail can never be bypassed. A task's state is never directly writable; only a transition moves it.

The board is the whole point: GET /tasks?workflow_id=…&state=… is one column, the workflow's states are the columns, and each task is a card — with zero application-side state.

Data Model

Workflow

FieldTypeDescription
idstringPublic identifier (wfl_…)
project_idstringOwning project (hard security boundary)
namestringHuman-readable name, unique per project
descriptionstring | nullOptional description
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, actor}; 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.

A transition not defined here cannot be fired by anyone — there is no free-move escape hatch. Define an explicit any-state transition (listing every state in from) if a workflow needs one.

Task

FieldTypeDescription
idstringPublic identifier (task_…)
project_idstringOwning project (hard security boundary)
workflow_idstringThe workflow definition this task is bound to
titlestringHuman-readable label
statestringCurrent state name. Read-only — moved only via a transition
statusopen | closedclosed once the task enters a terminal state
payloadobjectMutable task data; input to guards and dispatch input_mappings
assigneestring | nullInformational in v1 (user/actor public ID)
active_dispatchobject | null{ kind, id, status } of the current state's dispatch, if any
automation_statusstring | nullrunning | completed | failed | unrouted for the current state's dispatch
pending_transitionstring | nullName of a requires_approval transition parked awaiting a human decision; null otherwise
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 append-only TaskTransition record — the audited contract for a task. GET /tasks/{id}/history returns them oldest-first.

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)
actor_kindstringuser | api_key | automation | approval
actor_idstring | nullPrincipal or automation provenance. For api_key auth this is the API key's own id (key_…), distinguishing which key acted; for automation it is the causing generation/run id
generation_idstring | nullThe agent generation that caused the move (set for both on_complete routing and on_failure, linking the failed generation)
run_idstring | nullThe orchestration run that caused the move, when automation-driven
notestring | nullOptional reason supplied by the caller
created_atstringISO 8601 timestamp

Per-state automation (on_enter)

A state's on_enter dispatches at most one agent generation or orchestration run when a task enters it, and routes the outcome back 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" }] }
}
},
"on_complete": [
{ "when": { "==": [{ "var": "result.category" }, "simple"] }, "transition": "to_review" },
{ "when": true, "transition": "to_review" }
],
"on_failure": null
}
}
  • dispatch — one agent (kind: agent, agent_id) or orchestration (kind: orchestration, orchestration_id). input_mapping is JSON Logic over {task} that resolves the dispatch input from the task payload — the same expression language orchestrations use.
  • on_complete — labeled rules evaluated in order against {task, result}; the first match fires its transition as the automation actor (subject to the same guards). An agent dispatch exposes its generation output under {result}; an orchestration dispatch exposes its final run state. The result is also written to task.payload.last_result for downstream states. No rule matches → the task stays put with automation_status: completed and a tasks.automation_unrouted event fires (never silently stuck). A rule matches but its transition is rejected (its guard fails for the automation actor, or a concurrent move invalidated it) → the task stays put with automation_status: unrouted and a tasks.automation_rejected event fires (carrying the matched transition and the rejection errorCode) — again, never silently stuck.
  • on_failure — a transition to fire when the dispatch fails terminally. Omitted → the task stays in the state with automation_status: failed for a human to resolve.

Entering a state cancels any dispatch still running from the state the task is leaving — task state is the source of truth (an entity that lives). This applies to a genuinely in-flight orchestration run (one waiting on a delay/poll or a slow node), not only one parked on human input: the run id is recorded on the task the moment the run is created, so a transition out cancels the live run.

Key Concepts

  • Single transition path. Human, API, agent-via-MCP, and automation outcomes all call the same transition operation. A transition must exist in the workflow and be valid from the task's current state; its guard must pass.
  • Atomicity & conflicts. The state change happens under a row lock; concurrent transitions on one task serialize. A transition that is no longer valid from the committed state — or a transition on a closed task — returns TASK_TRANSITION_CONFLICT (409).
  • Automation completion is atomic too. The post-dispatch write (active_dispatch, automation_status, and the payload.last_result merge) re-validates under the same row lock immediately before writing. A concurrent transition that already moved the task — or re-entered the same state — is detected there, and the stale write is discarded instead of clobbering the new state's data.
  • Definition updates re-validate. Structural changes (states/transitions) are validated on PATCH. Existing tasks in a state a new definition removes stay put but can only leave via transitions valid in the new definition — the definition is the sole authority at fire time.
  • Delete is guarded. A workflow with one or more open tasks cannot be deleted (WORKFLOW_HAS_OPEN_TASKS). Once every task is closed (terminal), deleting the workflow also removes those closed tasks and their transition history.
  • Payload is working data. PATCH /tasks/{id} updates payload, title, or assignee. payload is shallow-merged over the current payload (PATCH semantics): keys the request omits are kept, so setting approved never discards a last_result an on_enter automation wrote. The merged payload is validated against payload_schema. Transitions are the audited contract; payload writes are not versioned.

Approval-gated transitions

A transition with requires_approval: true is a human gate. Firing it (by a user, API key, or automation outcome) does not move the task — it parks a pending ApprovalItem (origin: task_transition, carrying the task_id and task_transition) and returns the task with pending_transition set. The task stays in its current state, and no other transition may fire while the gate is open (TASK_TRANSITION_CONFLICT, 409). One gate at a time per task.

Resolve the gate through the standard approvals endpoints:

  • Approve → the transition is fired as the approval actor through the same single transition path. Its guard is re-evaluated at resolution time against the committed state, so a gate can be filed before the payload that satisfies its guard is set. If the move is no longer valid then (its guard now rejects it, or a definition change invalidated it), the gate is cleared and a tasks.approval_failed event fires carrying the transition and errorCode — surfaced, never silently dropped.
  • Reject → the gate is cleared and a note is appended to the task's history (actor_kind: approval, transition: null). The task never moved.
  • Expire → the approvals module's server-side expiry sweeper flips the item to expired; the gate is cleared and an expiry note is appended to history.

The gated move, when it applies, is recorded in history as the approval actor.

Stall detection

A state may declare stalled_after (seconds). A background sweeper (the same cadence as the orchestration scheduler) emits a tasks.stalled webhook event when an open task has sat in that state longer than the threshold. It is an event, not a transition — the task does not move; routing on a stall stays the author's choice via a webhook or trigger. The event fires once per stall episode and is re-armed on the next transition: entering any state (with or without a stalled_after) resets the timer for the new state.

Deploying as a formation

A workflow is a formation resource type (workflow), so it deploys declaratively alongside the agents and orchestrations its states dispatch. The resource properties mirror the REST body — name, description, states, transitions, payload_schema — and an on_enter dispatch's agent_id / orchestration_id accept { "ref": "LogicalId" } expressions, so a workflow plus the agents that service it can deploy as one stack.

resources:
Reviewer:
type: agent
properties:
ai_provider_id: { ref: Provider }
name: reviewer
ReviewFlow:
type: workflow
properties:
name: review-flow
states:
- { name: draft, initial: true, kind: human }
- name: reviewing
on_enter:
dispatch: { kind: agent, agent_id: { ref: Reviewer } }
on_complete:
- { when: { var: 'result.approved' }, transition: approve }
- { name: approved, terminal: true }
transitions:
- { name: submit, from: [draft], to: reviewing }
- { name: approve, from: [reviewing], to: approved }

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_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

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.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)

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

Firing a requires_approval transition parks a pending approval; approving it applies the move. See Approvals for the resolution endpoints.

# 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` actor.
soat approve-approval --approval-id "$APPROVAL_ID"