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 isinitial; any number areterminal(entering one closes the task). Akind: humanstate never dispatches: the task parks until a principal fires a transition. A state may declareon_enterautomation (Per-state automation).transitions— named, directional movesfromlisted statestoone 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 thetasks:action strings.
Related Tutorials
- Write a Sonnet with a Workflow — agent-driven states, a human review, a backward move
- Close the Monthly Books - Step 8 (Define the close period as a workflow) — a guarded, approval-gated transition beside an orchestration
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 |
version | integer | Incremented on every write that changes the state machine; prior versions are archived (see Versioning) |
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, principal}; 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. |
Only defined transitions fire; an any-state transition lists every state in from.
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 |
workflow_version | integer | null | The 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 |
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 | Caller-owned task data; input to guards and dispatch input_mappings. The engine never writes into it except declared payload_writes |
metadata | object | Caller-owned annotations supplied at creation; invisible to guards and to payload_writes (see Task metadata and Tags and metadata) |
last_result | any | null | Server-owned, read-only: the result of the current state's last completed dispatch, overwritten on every dispatch. Guards read it as task.last_result |
assignee | string | null | Informational in v1 (a user or actor public ID; not interpreted by the engine) |
active_dispatch | object | 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_status | string | null | running | 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_at | string | null | ISO 8601 instant an operator paused this task's automation, or null when no pause is in force (see Pausing a task) |
pause_reason | string | null | The reason supplied with the pause, when one was |
automation_chain_depth | integer | Server-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_transition | string | null | Name of a requires_approval transition parked awaiting a human decision; null otherwise |
tool_context | object | Write-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_at | string | When the task entered its current state |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Workflow migration
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (wfl_mig_…) |
workflow_id | string | The workflow whose tasks it moves |
project_id | string | Owning project |
to_version | integer | The version the tasks move onto |
state_map | object | null | The state_map applied to every task |
status | string | queued, running or completed |
task_count | integer | Tasks the run covers |
migrated_count | integer | Tasks now on to_version |
refused_count | integer | Tasks left where they were |
tasks | array | { task_id, status, error } per task, status one of pending, migrated, refused; error is the refusal as a StoredError |
created_by_principal_type | string | null | Who started it |
created_by_principal_id | string | null | Public id of who started it |
created_at | string | ISO 8601 creation timestamp |
finished_at | string | null | When 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.
| 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) |
principal_type | string | user | api_key | automation | approval |
principal_id | string | null | The 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_id | string | null | The agent generation that caused the move (set for both on_complete routing and on_failure, linking the failed generation) |
orchestration_run_id | string | null | The orchestration run that caused the move, when automation-driven |
tool_id | string | null | The 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 |
note | string | null | Optional reason supplied by the caller |
from_workflow_version | integer | null | On a migration row, the version the task left (null when it had none); null on every other row |
to_workflow_version | integer | null | On a migration row, the version the task moved onto; null on every other row |
created_at | string | ISO 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
closedtask, isTASK_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}updatespayload,titleorassignee.payloadis shallow-merged (omitted keys kept) and validated againstpayload_schema. No patch reaches read-onlylast_result, so a guard ontask.last_resultis 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) orkind: tool(tool_id, optionaloperation_idfor a multi-operation tool).input_mappingis JSON Logic over{task}resolving the dispatch input (atooldispatch's arguments).payload_writes(optional) is JSON Logic over{task, result}, written into namedtask.payloadkeys atomically withlast_resulton completion, so a value outlives the one hoplast_resultcovers. 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 theautomationprincipal (same guards).{result}is the generation output plus theai_provider_idthat served itsmodel(agent), the final run state (orchestration) or the return value (tool), also written totask.last_result. No match →automation_status: completedand atasks.automation_unroutedevent. A matched transition rejected (guard fails forautomation, or a concurrent move invalidated it) →automation_status: unroutedand atasks.automation_rejectedevent carrying the matchedtransitionand the rejectionerror_code.retry(optional) — covers execution failures, neveron_completerouting.max_attemptscounts the first attempt (1–10); the delay before attemptnisbackoff_seconds * backoff_multiplier^(n - 2)(defaults: 0, 1).on_failure, or the parkedautomation_status: failed, fires only after the last attempt; leaving the state between attempts abandons the rest. Each attempt isactive_dispatch.attempt; every retried failure emitstasks.automation_retrying(attempt,max_attempts, the recorded failure inerror, the failedgeneration_id/orchestration_run_id).on_failure— transition fired on terminal dispatch failure. Omitted → the task stays withautomation_status: failedfor 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.
| Operation | Endpoint |
|---|---|
| List versions, newest first | GET /api/v1/workflows/{workflow_id}/versions |
| Fetch one version | GET /api/v1/workflows/{workflow_id}/versions/{version} |
| Roll back to a version | POST /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_mapmaps 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_enteris not fired; transition the task to run the target's automation. Changing state clearsactive_dispatchandautomation_status. - Recorded in history with
transition: null,from_workflow_version,to_workflow_versionand the caller as principal; emitstasks.migrated.
| Refusal | Code |
|---|---|
to_version not newer than workflow_version | VALIDATION_FAILED (400) |
| The workflow has no such version | WORKFLOW_VERSION_NOT_FOUND (400) |
| The current state is unmapped | TASK_MIGRATION_STATE_UNMAPPED (400) |
A state_map target is undeclared | TASK_STATE_NOT_FOUND (400) |
| A mapped state is terminal | VALIDATION_FAILED (400) |
| The payload fails the target schema | TASK_PAYLOAD_INVALID (400) |
| The task is closed, has a transition pending approval, or a dispatch in flight | TASK_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
refusedwith the error, and the run goes on. A task the caller may not migrate is refusedFORBIDDEN. - 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_maptarget 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
waithandle:201withstatus: queuedby default,completedwithwait: true. PollGET /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
approvalprincipal through the single transition path, guard re-evaluated at resolution time; if no longer valid, the gate clears andtasks.approval_failedfires withtransitionanderror_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.
payloadis part of the machine: guards readtask.payload, dispatchinput_mappings read it,payload_writesoverwrite keys in it.metadatais 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.
- CLI
- SDK
- curl
soat create-task \
--workflow-id "$WORKFLOW_ID" \
--title 'Refund request #8123' \
--metadata '{"tenant_account_id":"42","source":"zendesk"}'
const { data, error } = await soat.tasks.createTask({
body: {
workflow_id: WORKFLOW_ID,
title: 'Refund request #8123',
metadata: { tenant_account_id: '42', source: 'zendesk' },
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/tasks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"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:
- CLI
- SDK
- curl
soat list-tasks \
--workflow-id "$WORKFLOW_ID" \
--metadata '{"source":"zendesk","external_id":"8123"}'
const { data, error } = await soat.tasks.listTasks({
query: {
workflow_id: WORKFLOW_ID,
metadata: JSON.stringify({ source: 'zendesk', external_id: '8123' }),
},
});
if (error) throw new Error(JSON.stringify(error));
curl -G "$SOAT_BASE_URL/api/v1/tasks" \
-H "Authorization: Bearer $SOAT_TOKEN" \
--data-urlencode "workflow_id=$WORKFLOW_ID" \
--data-urlencode '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:
| Request | Effect 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_context | Keeps the current one |
transition-task --tool-context '{}' | Clears it, without closing the task |
Any transition into a terminal state | Cleared — 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 move | Effect |
|---|---|
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 token | increments |
| A person, a plain API key, or an approval resolution | resets 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
| Variable | Required | Description |
|---|---|---|
TASK_AUTOMATION_CHAIN_LIMIT | No | How 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
| 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; meta.problems lists every problem as a validation problem |
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_STATE_NOT_FOUND | 400 | POST /tasks state, or a migration's state_map target, does not name a declared state of the workflow version |
TASK_MIGRATION_STATE_UNMAPPED | 400 | The target version lacks the task's state and state_map does not map it. See Migrating a task |
TASK_MIGRATION_CONFLICT | 409 | The task is closed, has a transition pending approval, or has a dispatch in flight |
WORKFLOW_VERSION_NOT_FOUND | 400 | A migration's to_version names no version of the workflow |
WORKFLOW_MIGRATION_NOT_FOUND | 404 | The workflow migration does not exist, or belongs to another workflow |
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 |
TASK_AUTOMATION_PROVENANCE_MISSING | 500 | An 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_FAILED | 422 | A 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_KEY | 400 | A 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_LIMIT | 409 | The 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_PAUSABLE | 409 | The task is closed, so it has no automation left to pause. See Pausing a task |
TASK_NOT_PAUSED | 409 | The 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.
| Event | When | Beside the task |
|---|---|---|
tasks.created | A task is created and placed in its initial state | |
tasks.transitioned | A task moves between states | transition, from_state |
tasks.closed | A task enters a terminal state | |
tasks.migrated | A task moves onto a newer workflow version | from_state, from_workflow_version |
tasks.automation_unrouted | A dispatch completed but no on_complete rule matched | result |
tasks.automation_rejected | A matched on_complete transition was rejected (guard or conflict) | result, transition, error_code |
tasks.automation_retrying | A dispatch attempt failed and a retry attempt remains | attempt, max_attempts, error ({ code, message, meta? }), generation_id, orchestration_run_id |
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) | transition, error_code |
tasks.paused | An operator paused a task's automation | |
tasks.resumed | An operator lifted a task's pause |
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
See Approval-gated transitions.
- 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` principal.
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` principal.
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 '{}'
Pause and resume a task's automation
See Pausing a task.
- CLI
- SDK
- curl
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"
const { data: paused, error } = await soat.tasks.pauseTask({
path: { task_id: TASK_ID },
body: { reason: 'credit balance went negative' },
});
if (error) throw new Error(JSON.stringify(error));
// paused.automation_status === 'paused' once a suppressed state is entered.
await soat.tasks.resumeTask({ path: { task_id: TASK_ID } });
curl -s -X POST "$SOAT_URL/api/v1/tasks/$TASK_ID/pause" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"reason":"credit balance went negative"}'
curl -s -X POST "$SOAT_URL/api/v1/tasks/$TASK_ID/resume" \
-H "Authorization: Bearer $TOKEN"
Migrate every open task
See Migrating every open task.
- CLI
- SDK
- curl
soat create-workflow-migration --workflow-id "$WORKFLOW_ID" \
--to-version 3 --state-map '{"triage":"intake"}' --wait true
const { data: run, error } = await soat.workflows.createWorkflowMigration({
path: { workflow_id: WORKFLOW_ID },
body: { to_version: 3, state_map: { triage: 'intake' }, wait: true },
});
if (error) throw new Error(JSON.stringify(error));
// run.tasks lists each task as migrated or refused, with the refusal's error.
curl -s -X POST "$SOAT_URL/api/v1/workflows/$WORKFLOW_ID/migrations" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"to_version":3,"state_map":{"triage":"intake"},"wait":true}'