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 states | Workflows & Tasks (this module) |
| A deterministic, forward-only sequence of steps that runs and completes | Orchestrations |
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 thetasks:action strings.
Overview
A workflow's two lists are the whole model:
states— the named columns of a board. Exactly one isinitial; any number areterminal(entering one closes the task). Akind: humanstate never dispatches; the task parks there until a principal fires a transition. A state may declareon_enterautomation (see Automation).transitions— the named, directional moves between states. A transition lists the states it is validfromand the single state it movesto. 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
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (wfl_…) |
project_id | string | Owning project (hard security boundary) |
name | string | Human-readable name, unique per project |
description | string | null | Optional description |
states | array | State definitions (see below) |
transitions | array | Allowed moves (see below) |
payload_schema | object | null | Optional JSON Schema validated against task payloads |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
State
| Field | Type | Description |
|---|---|---|
name | string | Unique within the workflow |
initial | boolean | Exactly one state must be true — where new tasks start |
terminal | boolean | Entering a terminal state closes the task (status: closed) |
kind | string | null | human marks a parking state that never dispatches |
on_enter | object | null | Automation fired when a task enters this state (see below) |
stalled_after | integer | null | Seconds a task may sit in this state before a tasks.stalled event fires (positive integer, or null to never stall). See Stall detection. |
Transition
| Field | Type | Description |
|---|---|---|
name | string | Unique within the workflow; the name a caller fires |
from | string[] | Source states this transition is valid from |
to | string | The single destination state |
guard | object | null | JSON Logic over {task, transition, actor}; a false result rejects the move with TASK_GUARD_REJECTED |
requires_approval | boolean | Gate 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
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (task_…) |
project_id | string | Owning project (hard security boundary) |
workflow_id | string | The workflow definition this task is bound to |
title | string | Human-readable label |
state | string | Current state name. Read-only — moved only via a transition |
status | open | closed | closed once the task enters a terminal state |
payload | object | Mutable task data; input to guards and dispatch input_mappings |
assignee | string | null | Informational in v1 (user/actor public ID) |
active_dispatch | object | null | { kind, id, status } of the current state's dispatch, if any |
automation_status | string | null | running | completed | failed | unrouted for the current state's dispatch |
pending_transition | string | null | Name of a requires_approval transition parked awaiting a human decision; null otherwise |
entered_state_at | string | When the task entered its current state |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 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.
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (task_tr_…) |
task_id | string | Owning task |
from_state | string | null | Source state (null on the initial placement) |
to_state | string | Destination state |
transition | string | null | Transition name fired (null for the initial placement) |
actor_kind | string | user | api_key | automation | approval |
actor_id | string | null | Principal 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_id | string | null | The agent generation that caused the move (set for both on_complete routing and on_failure, linking the failed generation) |
run_id | string | null | The orchestration run that caused the move, when automation-driven |
note | string | null | Optional reason supplied by the caller |
created_at | string | ISO 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_mappingis 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 theautomationactor (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 totask.payload.last_resultfor downstream states. No rule matches → the task stays put withautomation_status: completedand atasks.automation_unroutedevent fires (never silently stuck). A rule matches but its transition is rejected (its guard fails for theautomationactor, or a concurrent move invalidated it) → the task stays put withautomation_status: unroutedand atasks.automation_rejectedevent fires (carrying the matchedtransitionand the rejectionerrorCode) — again, never silently stuck.on_failure— a transition to fire when the dispatch fails terminally. Omitted → the task stays in the state withautomation_status: failedfor 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
closedtask — returnsTASK_TRANSITION_CONFLICT(409). - Automation completion is atomic too. The post-dispatch write (
active_dispatch,automation_status, and thepayload.last_resultmerge) 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}updatespayload,title, orassignee.payloadis shallow-merged over the current payload (PATCH semantics): keys the request omits are kept, so settingapprovednever discards alast_resultan on_enter automation wrote. The merged payload is validated againstpayload_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
approvalactor 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 atasks.approval_failedevent fires carrying thetransitionanderrorCode— 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
| Code | Status | When |
|---|---|---|
WORKFLOW_NOT_FOUND | 404 | The workflow does not exist or is not accessible |
WORKFLOW_VALIDATION_FAILED | 400 | The workflow definition is invalid |
WORKFLOW_HAS_OPEN_TASKS | 409 | The workflow has open tasks and cannot be deleted |
TASK_NOT_FOUND | 404 | The task does not exist or is not accessible |
TASK_PAYLOAD_INVALID | 400 | The payload violates the workflow's payload_schema |
TASK_TRANSITION_NOT_FOUND | 400 | The named transition does not exist in the workflow |
TASK_GUARD_REJECTED | 400 | The transition guard evaluated to false |
TASK_TRANSITION_CONFLICT | 409 | The transition is not valid from the current state, or the task is closed |
Webhook events
| Event | When |
|---|---|
tasks.created | A task is created and placed in its initial state |
tasks.transitioned | A task moves between states |
tasks.closed | A task enters a terminal state |
tasks.automation_unrouted | A dispatch completed but no on_complete rule matched |
tasks.automation_rejected | A matched on_complete transition was rejected (guard or conflict) |
tasks.stalled | An open task sat in a state past its stalled_after (once per episode) |
tasks.approval_failed | An approved gated transition could no longer apply at resolution time (guard or conflict) |
Related Tutorials
- Write a Sonnet with a Workflow — a task flows through agent-driven states and a human review, with a backward move a DAG would reject.
Examples
Create a workflow
- CLI
- SDK
- curl
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"}]'
const { data: workflow } = await soat.workflows.createWorkflow({
body: {
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' },
],
},
});
curl -s -X POST "$SOAT_URL/api/v1/workflows" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"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
- CLI
- SDK
- curl
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"
const { data: task } = await soat.tasks.createTask({
body: {
project_id: PROJECT_ID,
workflow_id: WORKFLOW_ID,
title: 'Blog post: launch recap',
payload: { topic: 'launch recap' },
},
});
const { data: moved } = await soat.tasks.transitionTask({
path: { task_id: task.id },
body: { transition: 'to_review', note: 'ready for review' },
});
TASK_ID=$(curl -s -X POST "$SOAT_URL/api/v1/tasks" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","workflow_id":"'"$WORKFLOW_ID"'","title":"Blog post: launch recap","payload":{"topic":"launch recap"}}' | jq -r '.id')
curl -s -X POST "$SOAT_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
# 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"
// Parks instead of moving: parked.pending_transition === 'publish'.
const { data: parked } = await soat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'publish' },
});
const { data: pending } = await soat.approvals.listApprovals({
query: { project_id: PROJECT_ID, status: 'pending' },
});
const gate = pending.find((a) => a.task_id === TASK_ID)!;
// Approving fires the gated transition as the `approval` actor.
await soat.approvals.approveApproval({ path: { approval_id: gate.id } });
curl -s -X POST "$SOAT_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"transition":"publish"}'
APPROVAL_ID=$(curl -s "$SOAT_URL/api/v1/approvals?project_id=$PROJECT_ID&status=pending" \
-H "Authorization: Bearer $TOKEN" \
| jq -r --arg t "$TASK_ID" '.[] | select(.task_id == $t) | .id' | head -n1)
curl -s -X POST "$SOAT_URL/api/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'