Define & publish a workflow
Create, inspect, publish, run, and reuse a tenant workflow through the API, with optional offline YAML validation.
This guide is the executed stage of the clearing lifecycle, hands-on: a workflow definition is a DAG of steps that Ductor's coordinator executes durably once Work is routed to a Worker. Definitions can be authored as YAML or through the tenant API. Publishing freezes an immutable version that the runtime pins runs to. This guide uses an ordinary tenant API key to create, inspect, publish, trigger, and reuse a small workflow. The optional YAML check runs locally before any server mutation.
Prerequisite: the runtime must be enabled
The DAG coordinator is gated behind workflow_runtime.enabled (default true). If you disabled
it, set DUCTOR_WORKFLOW_RUNTIME_ENABLED=true before running definitions.
Prefer code over YAML?
The same workflows can be built in Go, TypeScript, or Python with the SDKs — the SDK compiles to the identical definition the runtime executes here. YAML is the portable, GitOps form; the SDKs give you types, IDE completion, and inline tests.
1. Author a connector-free definition
This example projects a literal greeting and then waits two seconds. It needs
no connector credentials or paid provider. Save this optional YAML form as
hello_world.yaml if you want to run the offline validator:
type: workflow_definition
definition:
environment: default
family_slug: hello_world
title: Hello World
steps:
- ref: greet
type: dataflow
title: Build greeting
args:
operation: project
input_expr: TRIGGER
redaction_policy: none
project:
message: '"hello"'
- ref: pause
type: wait
title: Short delay
args:
duration: 2s
edges:
- source_ref: greet
target_ref: pause
type: successinput_expr selects the run input. redaction_policy: none exposes this
synthetic greeting for inspection; use the default hashed samples for sensitive
data.
The YAML envelope contains a definition body. Optional triggers and checkpoints
can be added for event entry points and named progress markers. The manual
trigger endpoint below starts this example directly.
Key fields:
| Field | Meaning |
|---|---|
family_slug | Stable identifier for this workflow family. Publishing creates versions (v1, v2, …) under the slug. |
steps[].ref | Unique handle for a step within the definition; edges and expressions reference it. |
steps[].type | Step kind. action is the workhorse; there are 15 step types in total (see below). |
steps[].action | The executor key. Connector actions use the Action Registry; an approval step requires exactly approval. |
edges[].type | Transition condition — success, and error/compensation variants. |
triggers[] | How runs start — manual, event (with match patterns), cron schedule, etc. |
checkpoints[] | Named progress markers over a set of step refs, used for acceptance and visibility. |
The 15 step types
action is one of 15 authorable step types. The rest cover control flow,
fan-out, human-in-the-loop, AI, and cross-runtime calls:
| Type | Purpose |
|---|---|
action | Call a connector action or a routing-finalization adapter. |
wait | Pause until a timeout or external signal. |
approval | Pause until a human approves or rejects. |
scatter | Fan out over a collection. |
for_each | Process a frozen collection sequentially through one child workflow. |
gather | Collect results from a preceding scatter. |
dataflow | Transform/filter/partition/reduce/project payloads via bounded CEL. |
race | Resolve on the first qualifying predecessor. |
subworkflow | Trigger a child workflow run. |
human_loop | Pause until a case is resolved by a human analyst. |
ai_action | A single LLM inference call through the AI inference proxy. |
ai_agent | An agentic tool-calling loop with execution bounds. |
bridge | Invoke a remote SDK step over the bridge protocol. |
digest | Collapse N upstream events sharing a key into one downstream tick. |
event_set | Park until a correlated set of external events completes. |
See the DAG workflow model for the full
semantics of each. The ai_action / ai_agent steps are covered in
AI; bridge steps in SDKs.
Add a human approval gate
A native approval step requires "type": "STEP_TYPE_APPROVAL" and the
top-level "action": "approval". Put its authorization audience and deadline
in args; args.action is invalid. This separate example uses the same create,
inspect, and publish API lifecycle below. Choose a unique family slug, then
inspect its review and done nodes and send an approval decision as described
below; the greeting-specific output checks apply only to the greeting example:
{
"environment": "default",
"family_slug": "review_before_work",
"title": "Review before work",
"steps": [
{"ref": "review", "type": "STEP_TYPE_APPROVAL", "action": "approval",
"args": {"audience": {"match_mode": "any", "roles": ["operator"]},
"timeout_ms": 600000, "on_timeout": "fail"}},
{"ref": "done", "type": "STEP_TYPE_DATAFLOW",
"args": {"operation": "project", "input_expr": "TRIGGER",
"redaction_policy": "none", "project": {"reviewed": "true"}}}
],
"edges": [{"source_ref": "review", "target_ref": "done", "type": "EDGE_TYPE_APPROVED"}]
}In YAML, use type: approval, action: approval, and edge types approved,
rejected, or done. The API equivalents are EDGE_TYPE_APPROVED,
EDGE_TYPE_REJECTED, and EDGE_TYPE_DONE; ordinary success and error edges
are invalid for this step type.
audience is required. approver_role is only a display/audit label and grants
no authority. A reviewer needs current workflow_run:write permission and must
match the frozen audience. Inspect the run snapshot for review.status: waiting
and a pending done node, then use the supported
approve or reject operation.
A refused decision must leave the gate waiting and downstream work unstarted.
timeout_ms accepts integers from 0 through 9223372036854. Zero, or omission,
waits indefinitely; set an explicit deadline when unattended waiting is not
acceptable. With a positive timeout, on_timeout accepts fail (the default),
approve, or skip. Use approve only when automatically allowing work after
the deadline is intentional. on_timeout without a positive timeout and
a nonempty expires_at_cel are rejected. Publication also refuses a missing or different
action with APPROVAL_ACTION_INVALID, or invalid typed arguments with
APPROVAL_SPEC_INVALID, before creating a published version. Repair the editable
draft, review it again, and publish a new version.
HTTP refusals return 400 with top-level error_code: "INVALID_ARGUMENT". Each
errors[] item includes the step reference in field, the validation code,
and a corrective description.
Event triggers
An event trigger fires runs when a matching event arrives. Each trigger
carries one or more patterns, and every pattern is either a literal event
name (stripe.invoice.paid) or a suffix wildcard (stripe.invoice.*).
Wildcard dispatch is off by default
Literal-name triggers always work. Suffix-wildcard fan-out is gated behind the default-OFF feature
flag ductor.events.wildcards.enabled — leave it off unless you have explicitly opted into
wildcard event routing.
Steps that call connectors
Action steps can call a third-party system through a connector. Reference a
connection and pass templated arguments (${{ ... }} expressions resolve at
dispatch time against trigger payload, variables, and prior step outputs):
steps:
- ref: create_invoice
type: action
action: stripe.invoices.create
connection_ref: "${{ VARS.stripe_connection_ref }}"
connection_ref_mode: direct
args:
customer_id: "${{ TRIGGER.payload.customer_id }}"
amount: "${{ TRIGGER.payload.amount_cents }}"
currency: "${{ TRIGGER.payload.currency }}"
retry_max_attempts: 3
retry_backoff_ms: 1000
timeout_ms: 10000
on_error: failSteps with external side effects can declare compensation — inverse actions
that run on_failure or on_cancel — so a partially-completed run unwinds
cleanly.
2. Validate YAML offline (optional)
With the Ductor binary installed, lint the saved YAML. workflow validate
parses and validates it without touching the database. It exits nonzero when
the definition is invalid or a reference cannot be proven by the offline catalog:
ductor workflow validate ./hello_world.yamlThe offline catalog checks linked action, strategy, and hook references. It checks graph structure and expressions without a running Ductor or Postgres. Deployment-dependent capabilities can remain unresolved; a successful offline check does not establish live credentials, permissions, budgets, or connector readiness. Publication performs the server-side validation. This command does not upload the YAML or create a tenant definition.
Definition size limits
Structural validation caps how large a single definition can be. These bounds are checked first, before any per-step graph analysis, and a definition over either limit fails validation with a single coded violation:
| Limit | Maximum | Violation code | Message |
|---|---|---|---|
| Steps per definition | 500 | MAX_STEP_COUNT_EXCEEDED | definition has N steps, maximum is 500 |
| Edges per definition | 5000 | MAX_EDGE_COUNT_EXCEEDED | definition has N edges, maximum is 5000 |
The edge cap keeps the per-step graph scans (predecessor lookup, dataflow,
cycle detection) clear of the O(V·E) blow-up a dense graph would cause. At the
500-step ceiling it still allows an average fan-out of ten edges per step — well
above real workflows, which typically run one to four. Real DAGs never approach
either limit; hitting one almost always means a generated definition has looped.
3. Create and inspect a tenant draft
Use a running instance with the workflow runtime enabled, Bash, curl 7.76 or
later, and jq. Have an administrator issue an ordinary tenant key with an
appropriate role and workflow_definition:read, workflow_definition:write,
workflow:read, workflow:write, and workflow_run:read scopes. Set DUCTOR_API_KEY to that key and
DUCTOR_TENANT_ID to its tenant UUID. Administrative setup is covered in
Getting Started and
Issue & use API keys.
Run the remaining commands in one Bash session. The helper fails on HTTP errors;
pipefail and checked identifiers prevent errors from turning into null ids:
set -euo pipefail
: "${DUCTOR_API_KEY:?Set your ordinary tenant API key}"
: "${DUCTOR_TENANT_ID:?Set your tenant UUID}"
BASE=${DUCTOR_API_URL:-http://localhost:8080}
ductor_api() {
curl --fail-with-body --silent --show-error --max-time 30 \
-H "Authorization: Bearer $DUCTOR_API_KEY" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" "$@"
}The API accepts a JSON creation request, not the YAML envelope. Save the same two-step graph in its canonical request form:
environment is required and names an active, mutable environment that allows
direct mutation, does not require promotion, and your key can
write. Use its canonical key (default here), rather than its UUID. Changing a
draft's environment requires current write authority in both environments;
publication checks the saved binding again.
cat > hello_world.json <<'JSON'
{
"environment": "default",
"family_slug": "hello_world",
"title": "Hello World",
"steps": [
{"ref": "greet", "type": "STEP_TYPE_DATAFLOW", "title": "Build greeting",
"args": {"operation": "project", "input_expr": "TRIGGER",
"redaction_policy": "none", "project": {"message": "\"hello\""}}},
{"ref": "pause", "type": "STEP_TYPE_WAIT", "title": "Short delay",
"args": {"duration": "2s"}}
],
"edges": [{"source_ref": "greet", "target_ref": "pause", "type": "EDGE_TYPE_SUCCESS"}]
}
JSON
CREATED=$(ductor_api -X POST "$BASE/api/workflow-definitions" \
-H "Content-Type: application/json" --data-binary @hello_world.json)
DRAFT_ID=$(echo "$CREATED" | jq -er '.data.id | strings | select(length > 0)')
ductor_api "$BASE/api/workflow-definitions/$DRAFT_ID" > hello_world.draft.json
jq -e '.data | select(.status == "DEFINITION_STATUS_DRAFT")' hello_world.draft.json
DRAFT_REVISION=$(jq -r '.data.draft_revision // 0' hello_world.draft.json)If a draft already exists for hello_world, creation returns a conflict. Inspect
that family through GET /api/workflow-definitions or choose a new family_slug;
do not treat a failed creation as an empty workflow. Review the saved draft's
steps and edges before publication.
4. Publish the reviewed revision
A freshly created or edited definition is a draft. Publication creates a new
immutable row and returns its id at published.id; the draft id remains editable.
Publish the exact revision you reviewed and capture the published id:
PUBLISHED=$(ductor_api -X POST "$BASE/api/workflow-definitions/$DRAFT_ID/publish" \
-H "Content-Type: application/json" \
-d "$(jq -nc --argjson revision "$DRAFT_REVISION" '{expected_draft_revision: $revision}')")
PUBLISHED_ID=$(echo "$PUBLISHED" | jq -er '.published.id | strings | select(length > 0)')
ductor_api "$BASE/api/workflow-definitions/$PUBLISHED_ID" > hello_world.published.json
jq -e '.data | select(.status == "DEFINITION_STATUS_PUBLISHED")' hello_world.published.jsonIf the draft changed after review, publication refuses the stale revision. Read and review the current draft before trying again. Other publication refusals require resolving their validation or authority error; no executable version should be assumed from an error response.
Re-publishing later creates another version under the same family_slug.
In-flight runs keep the immutable version they started with.
5. Trigger and inspect the outcome
Use PUBLISHED_ID, not DRAFT_ID, to start a run:
TRIGGERED=$(ductor_api -X POST "$BASE/api/v2/workflow-definitions/$PUBLISHED_ID:trigger" \
-H "Content-Type: application/json" \
-d '{"subject_type": "demo", "subject_id": "1"}')
RUN_ID=$(echo "$TRIGGERED" | jq -er '.run_id | strings | select(length > 0)')
echo "run: $RUN_ID"Read the status; repeat this read until it reaches a terminal state. The expected
successful state is completed. A nonterminal response is not completion, and
an HTTP error is a failed observation:
ductor_api "$BASE/api/workflows/runs/$RUN_ID" > hello_world.run.json
jq -e '.data.summary.status | strings' hello_world.run.jsonInspect the visible step evidence after completion:
ductor_api "$BASE/api/workflows/runs/$RUN_ID/steps" > hello_world.steps.json
jq . hello_world.steps.jsonThe attempt list need not contain in-coordinator dataflow steps. Read the run snapshot for their authoritative node status and output:
ductor_api "$BASE/api/v2/workflow-runs/$RUN_ID/snapshot" > hello_world.snapshot.json
jq -e '.nodes.greet.latest_output_json | fromjson | .result | select(.message == "hello")' \
hello_world.snapshot.jsonThe output should be {"message":"hello"}. A successful run status alone does
not establish that result. For a failed run, retain its status, snapshot, and step
errors, resolve the cause, and use the supported
workflow runtime controls for recovery.
For process recovery with intact persistence, follow the crash-resume example. A process restart is separate from restoring a database backup.
6. Reuse the workflow
Keep hello_world.json as the editable creation payload, and
hello_world.published.json as the API snapshot of the exact published version.
The snapshot includes server-owned fields and is not itself a creation request.
Another authorized person can adapt the creation payload without source changes:
jq '.family_slug = "hello_world_copy" | .title = "Hello World copy"' \
hello_world.json > hello_world_copy.jsonThey configure their own tenant credentials, create the adapted request, review
its returned draft, then publish and trigger its published.id using the steps
above. Share the payload and expected greeting, not an API key. Review connector,
environment, spending, and approval requirements again when adapting it to real
work; an earlier definition does not grant current execution authority.
Administrative YAML operations are separate
workflows.dir is a server bootstrap setting for global system definitions.
ductor workflow export reads the configured database directly in that global
namespace. Neither is the authenticated tenant creation or retrieval route used
here. Use the tenant API to inspect and reuse this workflow; do not expect a
global filesystem scan or export command to locate its tenant-owned version.
How this maps to the runtime
Once published and triggered, a run is owned by the serial
Coordinator, which loads run state,
applies pending attempt results, computes the next tick, persists atomically
with optimistic locking, and then dispatches newly-runnable steps to parallel
Step Workers. Your edges, checkpoints, and compensation blocks are exactly
what the Coordinator evaluates on each tick.