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.

Overview

A workflow is the versioned definition; a task is a durable instance bound to it that does not terminate on its own and can revisit states. Where 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. The two compose: a state may dispatch an orchestration, an agent, or a single tool call to do its work. See Choosing an Automation Model for the full comparison and composition patterns — starting with whether the work needs a graph at all, since a workflow is the graph layer and the graph is the layer to build last.

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 Per-state automation).
  • transitions — the named, directional moves between states, each valid from listed states to a single destination. Backward moves are just transitions — cycles are the point, not an error.

Creating a task places it in the workflow's initial state (or a named state — see Alternate entry points) and fires that state's on_enter. 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. The board is the point: GET /tasks?workflow_id=…&state=… is one column, with zero application-side state.

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.

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
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 and returned verbatim; invisible to guards and to payload_writes (see Task 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 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)
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. 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). The post-dispatch write (active_dispatch, automation_status, last_result, payload_writes) re-validates under the same lock; a stale write is discarded instead of clobbering the new state.
  • 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 (keys the request omits are kept) and validated against payload_schema. The payload is 100% caller-owned; the automation result lives in the read-only last_result field, which no patch can reach — a guard on task.last_result is only ever satisfied by a value an automation wrote. Transitions are the audited contract; payload writes are not versioned.

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" }] }
},
"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 — one agent (kind: agent, agent_id), orchestration (kind: orchestration, orchestration_id) or tool call (kind: tool, tool_id, optional operation_id to select an operation on a multi-operation tool). input_mapping is JSON Logic over {task} resolving the dispatch input from the task payload — for a tool dispatch it resolves the tool's arguments. payload_writes (optional) is JSON Logic over {task, result}, written into named task.payload keys atomically with last_result when the dispatch completes — a named, deterministic channel that survives past the one hop last_result lives. Each write is a raw overwrite of its key, so in a loop a value from an earlier pass lingers until the state dispatches again.
  • on_complete — labeled rules evaluated in order against {task, result}; the first match fires its transition as the automation principal (subject to the same guards). An agent dispatch exposes its generation output under {result}; an orchestration dispatch exposes its final run state; a tool dispatch exposes the tool's own return value. The result is also written to the server-owned task.last_result. No rule matches → the task stays put with automation_status: completed and a tasks.automation_unrouted event fires. A matched rule whose transition is rejected (guard fails for automation, 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) — never silently stuck.
  • retry (optional) — a retry policy for the dispatch's execution failures, never for 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. If the task leaves the state between attempts, the remaining ones are abandoned. Each attempt is recorded as active_dispatch.attempt, and every retried failure emits a tasks.automation_retrying event (carrying attempt, max_attempts, the error, and the failed generation_id/orchestration_run_id).
  • 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 — including a genuinely in-flight orchestration run — because task state is the source of truth.

Tool dispatch

A state whose work is a single tool call dispatches it directly, with no orchestration in between:

{
"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 tool's arguments from the task context, and the tool's return value becomes {result} and task.last_result. The call is adjudicated by the same guardrails as the identical call made from an orchestration tool node — a workflow dispatch is not a way around them — and is recorded in the activity feed the same way.

Because a tool call settles within the dispatch, there is nothing to poll: the move is recorded with active_dispatch.kind: tool_call and a null id (a tool call leaves no addressable record), and the transition it causes carries tool_id as its provenance.

Two cases belong behind an orchestration dispatch instead, and fail a tool dispatch with TOOL_DISPATCH_FAILED rather than pretending to work:

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

Waiting, polling, and multi-step work

on_enter dispatches one thing. When a state needs to wait a fixed duration, repeat a call until a condition holds, or run several steps, dispatch an orchestration and put the work in its graph — delay, poll, and the rest of the node types are already there, and a task dispatch deliberately starts the run in durable mode so those waits are owned by the background scheduler rather than held open in a request:

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

The run parks as sleeping for the length of the wait and resumes on its own; the task sits in the state with automation_status: running until the run settles, then routes through on_complete / on_failure as usual. There is no kind: delay or kind: poll — a one-node orchestration is the supported way to express it.

Recovery after a restart

The run behind a dispatch is durable, but the wait for its outcome is not: it is held in the process that started it. If the server restarts while a dispatch is outstanding — most plausibly while an orchestration run is sleeping through a long delay or poll interval — the run still finishes on the scheduler, and a background reconciler routes the task when it does.

The reconciler only considers a dispatch that has read running for longer than a grace window (TASKS_DISPATCH_RECONCILE_GRACE_MS, default 60000), so a healthy in-process hand-off is never raced. The recovered outcome is indistinguishable from a live one: the same on_complete / on_failure rules fire, as the same automation principal, with the run recorded as the move's cause.

Dispatches of kind: agent are not reconciled — a generation parked in requires_action awaiting client tool outputs is legitimately outstanding and must not be routed as if it had settled.

Pausing a task

A workflow has no run object — its instance is the task — so the stop an orchestration run gets lands there instead. POST /api/v1/tasks/{task_id}/pause suppresses every state's on_enter dispatch and every retry chain behind one, which is the only work a task drives on its own; POST /api/v1/tasks/{task_id}/resume lifts it.

A paused task still transitions. A move costs nothing while every dispatch it would start is suppressed, so a board stays usable under a pause rather than freezing. Entering a state whose dispatch is suppressed records automation_status: paused — that is what a resume reads to know the state's on_enter still owes its work, and what keeps a resume from re-spending a dispatch that had already completed.

The dispatch that resumes runs as whoever resumed, not as whoever last moved the task: the resume is the decision to spend, and the move that scheduled the work may be weeks old. Mirrors the rule that a human or API-key move names itself.

A dispatch already in flight is left to finish, and its outcome still routes — entering a state whose own dispatch is then suppressed. Only what would start after it is stopped, the same bound an orchestration pause accepts for the round in flight. A task-dispatched orchestration run is not paused with its task; pause that run through its own route when the run itself needs to stop.

Pausing is idempotent — a second pause answers with the task unchanged — a closed task answers 409 TASK_NOT_PAUSABLE, and resuming a task that carries no pause answers 409 TASK_NOT_PAUSED.

Finding the tasks whose automation is running

GET /api/v1/tasks filters on automation_status beside status, state, workflow_id and assignee. The two answer different questions: status=open narrows a board to the cards still in play, while automation_status says which of those has a dispatch of its own under way — the set a consumer that pauses spend has to find without paging the whole board.

The parameter repeats, and the values are ORed:

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

none selects the cards whose automation_status is null — the ones that never entered a state with an automation. That absence is a value a task really holds, so it is a value of the filter too; omitting the parameter already means "every task". It is spelled none rather than null because the CLI reads the token null as JSON null for every nullable field it has, and one spelling has to work in all three clients.

A value outside running / completed / failed / unrouted / paused / none — empty string included — is a 400 VALIDATION_FAILED rather than a silently unfiltered listing.

Versioning

A workflow's state machine is versioned by the same append-only archive that backs agent versions, guardrail versions and orchestration versions. Version 1 is written on create, and every subsequent write that changes the definition increments version and archives it as a WorkflowVersion. The versioned surface is states, transitions and payload_schema.

A task runs on the version it entered on. POST /tasks stamps the workflow's current version onto the task as workflow_version, and every later read of the definition — validating a transition, parking an approval gate, validating a payload patch — resolves it from that version. Editing a workflow never re-shapes a task already in flight; the live columns are a draft for tasks created from now on.

Three writes archive nothing: a metadata-only edit (name, description); re-writing the definition the workflow already holds (compared structurally); restoring the version that is already live. version_label on a create or update annotates the version that write archives; labelling a change 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 of a workflow at v2 writes v1's definition back as v3; a task pinned to v2 still runs on the machine it entered on. Only the definition rolls back — name and description are untouched. A restored definition goes through the same validation as an authored one, including resolving every on_enter dispatch target, so restoring a version whose agent or orchestration has since been deleted fails with WORKFLOW_VALIDATION_FAILED (400).

Alternate entry points

POST /tasks accepts an optional state, naming a declared state to create the task in directly instead of the initial state. Entering the named state behaves exactly like arriving via a transition — entered_state_at is set, on_enter fires, the stall clock arms — and history records the placement as a single entry (from_state: null, transition: null). This lets a caller that already knows which state and payload a task belongs at start it there deterministically. An unknown state name is rejected with TASK_STATE_NOT_FOUND (400).

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. 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 fires as the approval principal through the same single transition path. Its guard is re-evaluated at resolution time; if the move is no longer valid, the gate is cleared and a tasks.approval_failed event fires carrying the transition and errorCode.
  • Reject → the gate is cleared and a note is appended to history (principal_kind: approval, transition: null). The task never moved.
  • Expire → the approvals module's expiry sweeper clears the gate and appends an expiry note to history.

Task metadata

create-task accepts a metadata bag — caller-owned key/value annotations, stored on the task and returned verbatim by every read of it, the list included. Use it for anything that is not task data: which of your own tenants the task belongs to, the ticket that raised it, the import batch that created it.

It is deliberately not payload, and the difference is not cosmetic:

  • payload is part of the machine. Every guard reads it as task.payload, dispatch input_mappings read it, and the workflow's declared payload_writes can overwrite keys in it. A label parked there is visible to the state machine and not safe from it.
  • metadata is inert. No guard sees it, no mapping reads it, and the engine never writes into it. Everything the engine decides about a task — state, status, workflow_version, last_result, active_dispatch, the automation fields — is a field of its own, so no key here can reach it.

It also differs from tool_context in the other direction: tool_context is write-only, because it carries a credential and a task is long-lived and multi-actor. A label is not a credential, so metadata is readable — and it survives every transition, since a transition supplies no metadata of its own.

A non-object metadata is rejected with 400 VALIDATION_FAILED and 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 — fetch and filter client-side.

Dispatch tool context

A task's automations are a generation entry point like any other, so they can carry a tool_context — a flat Record<string, string> forwarded as context headers on every http, mcp and builtin tool call the task's dispatches make. It reaches all three dispatch kinds: an agent dispatch's generation, a tool dispatch's call (where it also resolves the tool's own {{context:}} headers and pinned parameters), and an orchestration dispatch's run — which carries it to every node and child run, see 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

The credential a dispatch runs with belongs to whoever last moved the task. Moves that supply no bag preserve it: automated hops (on_complete / on_failure routing), retry attempts (identical across attempts), and approval resolutions (the bag the gated move supplied is stored when the gate parks and used when it resolves). A stall is an event, not a move, and leaves it untouched.

The reserved identity keys are stripped and re-derived server-side, and an invalid key is rejected with INVALID_TOOL_CONTEXT_KEY (400) — see Validation. The bag is write-only: a task never returns its tool_context, since a task is long-lived and read by everyone who can see the board. Confine a key to the tools that need it with context_keys.

Stall detection

A state may declare stalled_after (seconds). A background sweeper 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.

The automation chain budget

Cycles are healthy; a cycle that turns entirely on its own — a state dispatches, the outcome routes the task back in, it dispatches again, nobody in the loop — is not. The task engine bounds the chain: every task carries an automation_chain_depth, and a transition either increments it or resets it to zero:

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

Once the depth would exceed the limit (TASK_AUTOMATION_CHAIN_LIMIT, default 50), the transition is refused with TASK_AUTOMATION_CHAIN_LIMITbefore the state change, so the next on_enter never fires. The task parks with automation_status: unrouted and a tasks.automation_rejected event fires. A dispatched run or agent is recognized by its run-as token, not its principal. Any human touch starts the budget over, so a task that revisits states for months is bounded only by how far it can travel untouched.

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.

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

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

Pause and resume a task's automation

Suppresses every state dispatch until resumed — 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"