Define & publish a workflow
Author a DAG workflow definition in YAML, validate it offline, load it, publish an executable version, then trigger it and watch it run.
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 are authored as YAML, validated, loaded, and then published — publishing freezes an immutable version that the runtime pins runs to. This guide walks the full lifecycle for a small definition.
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?
This guide authors definitions in YAML, but 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 the definition
A definition is a YAML document with a type: workflow_definition envelope and
a definition body. The body declares steps, the edges between them, one or
more triggers, and optional checkpoints. Here is the smallest legal
definition — two noop action steps joined by a success edge:
type: workflow_definition
definition:
family_slug: hello_world
title: Hello World
description: Smallest executable DAG — two steps joined by a success edge.
steps:
- ref: a
type: action
action: noop:noop
args:
key: value
- ref: b
type: action
action: noop:noop
edges:
- source_ref: a
target_ref: b
type: success
triggers:
- id: t1
type: manual
checkpoints:
- name: accepted
step_refs:
- bKey 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 | For action steps, the provider:action (or provider.resource.action) key resolved against the connector Action Registry. |
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.
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 offline
Before loading anything, lint the definition with the CLI. workflow validate
parses the YAML and resolves every reference without touching the database —
it exits non-zero on any problem:
ductor workflow validate ./hello_world.yamlResolution is fully offline. Action keys are checked against the
ActionRegistry ∪ ExecutorRegistry, strategy references against
pkg/strategy.Registry, and hook points against pkg/middleware.AllHookPoints.
That catches unknown action keys, dangling edge refs, unknown trigger types, and
invalid action_config early — before a bad definition reaches the runtime, and
without a running Ductor or Postgres.
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. Load the definition
There are two ways to get a definition into Ductor.
Point workflows.dir at a directory of definition YAML and Ductor loads every
file it finds on boot:
export DUCTOR_WORKFLOWS_DIR=./workflows
ductor serve
# or: ductor serve --workflows-dir ./workflowsCreate and manage definitions over REST / Connect via
WorkflowDefinitionService:
| Action | Verb + path |
|---|---|
| Create | POST /api/workflow-definitions |
| Get | GET /api/workflow-definitions/{id} |
| List | GET /api/workflow-definitions |
| Update draft | PATCH /api/workflow-definitions/{id}/draft |
| Publish | POST /api/workflow-definitions/{id}/publish |
| Duplicate | POST /api/workflow-definitions/{id}/duplicate |
| Archive | POST /api/workflow-definitions/{id}/archive |
| Env overrides | PUT /api/workflow-definitions/{workflow_id}/env-overrides |
4. Publish a version
Environment for the commands below
These commands assume the local stack and seeded tenant from
Getting Started: auth is disabled locally, so
requests need only the X-Tenant-ID header (in production, authenticate per
Auth & Security). $DEF_ID is the definition id the create call
returns (.data.id).
A freshly created or edited definition is a draft. Runs pin to published versions, so publish when you're ready to execute:
curl -s -X POST http://localhost:8080/api/workflow-definitions/$DEF_ID/publish \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID"Publishing is what makes the definition immutable and executable — subsequent
edits go to a new draft, and re-publishing mints the next version under the same
family_slug. In-flight runs continue on the version they started with, which
is how Ductor gives you safe, versioned deploys of workflow logic.
5. Trigger it and watch it run
A published version is executable, so trigger a run and watch it move to completion. These are the same endpoints Getting Started uses:
Trigger a run. Post to the version's :trigger endpoint; the response
carries the new run_id:
RUN_ID=$(curl -s -X POST http://localhost:8080/api/v2/workflow-definitions/$DEF_ID:trigger \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" -H "Content-Type: application/json" \
-d '{"subject_type": "demo", "subject_id": "1"}' | jq -r '.run_id')
echo "run: $RUN_ID"Poll the run status. The run moves pending → running → completed; the
status lives at .data.summary.status:
curl -s http://localhost:8080/api/workflows/runs/$RUN_ID \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jq '.data.summary.status'Inspect the step attempts. List the per-step detail to see each step's attempts and outcome:
curl -s http://localhost:8080/api/workflows/runs/$RUN_ID/steps \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" | jqTo see why this survives a crash — kill the engine mid-run and watch the run pick up exactly where it left off — walk the crash-resume demo in Getting Started.
6. Export a published definition
To capture the exact published YAML (for GitOps, review, or promotion between environments), export by family slug — it writes deterministic YAML to stdout:
ductor workflow export hello_world > hello_world.published.yamlHow 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.