Workflow DSL¶
The workflow DSL is the YAML format Helix loads when you run
helix run app.yaml. It declares routes (called workflows: in
the wire format), the trigger that feeds each route, the steps
that process each exchange, and the engine-level config that wraps
all of them.
This page documents every field of the top-level document. Source of
truth is pkg/dsl/yaml_loader.go (YAML → Go structs) and
pkg/workflow/definition.go (the runtime definitions the reifier
consumes).
Top-level shape¶
imports: # optional file/glob includes — merged into this document
- "./common/error-handlers.yaml"
- "./routes/*.yaml"
helix: # engine configuration (see pkg/config.HelixConfig)
logging:
level: info
format: json
error-handlers: # reusable named error handlers (see below)
- id: api-defaults
on-error: [...]
connectors: # named connector instances (used via connector://name)
slack-prod:
type: slack
config:
token: "${SLACK_TOKEN}"
workflows: # the routes themselves — required for almost every doc
- id: my-route
trigger:
uri: kafka://orders?brokers=localhost:9092
steps:
- target: postgres://orders/insert?dsn=postgres://localhost/db
rest: # REST DSL blocks — see ./rest.md
- path: "/api/v1"
verbs: [...]
agents: # top-level agents — converted into workflows
pipelines: # top-level pipelines — converted into workflows
tools: # tool:// definitions (mandatory I/O schemas)
mcps: # MCP servers — converted into helix.mcp.servers
skills: # reusable skill bundles
tests: # declarative test cases (see testing guide)
Every top-level block is optional except workflows: (or one of the
shortcuts that compiles into it — agents:, pipelines:, tools:).
imports:¶
A list of file paths or globs to merge into the current document. Paths resolve against the current file's directory.
include: and includes: are accepted as aliases. Imports
are resolved recursively with circular-import detection.
You can also inline fragments of a single file with
${file:path} / ${include:path} directives anywhere a string value
appears — used heavily for long system prompts and Starlang scripts.
helix: — engine configuration¶
Decoded into config.HelixConfig. All fields optional; defaults at the
right-hand column reflect the runtime defaults applied when the field
is absent.
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | "helix" |
Engine instance name (shown in logs/metrics). |
logging.level |
string | info |
debug / info / warn / error. |
logging.format |
string | text |
text or json. |
logging.file |
string | unset | Duplicate logs to this file with rotation. |
logging.components |
map | empty | Per-component log level override. |
management.port |
int | 9090 |
REST management API. |
management.enabled |
bool | true |
|
health.port |
int | 8081 |
Health-check HTTP server. |
health.enabled |
bool | true |
|
metrics.prometheus.enabled |
bool | false |
Scrapeable /metrics. |
metrics.prometheus.port |
int | 9092 |
|
metrics.otlp.enabled |
bool | false |
OTLP/HTTP push exporter. |
metrics.sla.enabled |
bool | false |
Per-route p50/p95/p99 latency gauges. |
tracing.opentelemetry.enabled |
bool | false |
Export OTEL traces. |
tracing.opentelemetry.endpoint |
string | unset | Collector URL. |
shutdown.drain_timeout |
duration | 30s |
Graceful-stop window. |
cluster.enabled |
bool | false |
Multi-node leader election. |
cluster.node_id |
string | hostname | |
cluster.bind_addr |
string | 0.0.0.0 |
|
cluster.bind_port |
int | 7946 |
|
cluster.seeds |
[]string | empty | ["node2:7946", ...] |
tenant.enabled |
bool | false |
Multi-tenancy interceptor. |
tenant.header_name |
string | X-Tenant-ID |
|
audit.enabled |
bool | false |
Audit-log interceptor. |
audit.store |
string | memory |
memory, log, file. |
mcp.servers[] |
list | empty | MCP-server endpoints (see component reference). |
a2a.servers[] |
list | empty | A2A-server endpoints. |
agui.servers[] |
list | empty | AG-UI servers. |
a2ui.servers[] |
list | empty | A2UI servers. |
session.enabled |
bool | false |
Persist session state. |
ocr.backend |
string | unset | Default OCR backend (docling, tesseract, vision-llm, …). |
Sub-blocks like mcp.servers[] map to the same fields documented on
each component's reference page.
workflows: — the route list¶
Every entry maps to a workflow.Definition. The loader recognises the older alias routes: for back-compat, but new code should
use workflows:.
| Field | Type | Default | Description |
|---|---|---|---|
id |
string | required | Unique within the document. Used for logs, metrics, and direct://<id> resolution. |
description |
string | empty | Free-form. Shown in management API / MCP tool descriptions. |
input-schema |
map | empty | JSON Schema for inbound payload (used by MCP, agents). |
trigger |
object | required | Where messages come from. See Trigger. |
steps |
list | empty | The processing pipeline. See Steps. |
error-handler |
object | empty | Inline error handler. See Error handling. |
error-handler-ref |
string | empty | Name of a top-level error-handlers: entry. Mutually exclusive with error-handler:. |
auth |
object | empty | Route-level auth (apikey / jwt / basic / oauth2). |
auto-startup |
bool | true |
If false, the route is registered but its consumer doesn't start. |
singleton |
bool | false |
Run only on one cluster node. Auto-set to true for cron:// / timer:// triggers. |
trigger:¶
The trigger names the URI of the component endpoint that feeds the
route. The first processor in steps: receives the message that
component produces.
| Field | Type | Default | Description |
|---|---|---|---|
uri |
string | required | <scheme>://<path>?<params> — see component reference. |
params |
map[string]string | empty | Additional URI params merged onto the URI before component resolution. Useful when secrets live in env vars. |
params: is the long-form equivalent of writing the query string into
the URI. The two forms below are equivalent:
trigger:
uri: "timer://tick?period=5000&repeatCount=10"
# same as:
trigger:
uri: "timer://tick"
params:
period: "5000"
repeatCount: "10"
steps:¶
Each list item is a single-key map whose key picks the step type and
whose value carries the parameters. The 70+ step types defined in
yamlStep (pkg/dsl/yaml_loader.go) include:
Component / endpoint steps¶
steps:
- target: log://debug?level=info # short scalar form
- target: kafka://orders?brokers=... # any component URI
- wiretap: log://audit # fire-and-forget copy
| Step | Shape | Purpose |
|---|---|---|
target: <uri> |
scalar string | Send to a component endpoint. |
target-dynamic: { starlang: <expr> } |
object | Endpoint is evaluated per-exchange. |
wiretap: <uri> |
scalar string | Fire-and-forget copy (no body merge). |
enrich: { uri, strategy } |
object | Synchronous request/reply enrichment. |
poll-enrich: { uri, timeout, strategy } |
object | Pull a single message from a polling consumer. |
Body / header / property mutators¶
steps:
- log: { message: "received", level: info }
- transform: { starlang: 'payload + " v2"' }
- set-header: { name: "X-Route", constant: "alpha" }
- set-body: { starlang: 'json({ ok: true })' }
- remove-header: "Authorization"
- remove-headers: { pattern: "Helix*", except: ["HelixHttpResponseCode"] }
- set-property: { name: trace_id, starlang: 'uuid()' }
- remove-property: "tmp"
- remove-properties: { pattern: "temp.*", except: ["temp.keep"] }
- convert-body: { type: string, charset: utf-8 }
Inline scripts use Starlang (Helix's default expression engine). See Starlang guide.
Routing EIPs (Enterprise Integration Patterns)¶
steps:
# Content-based router
- choice:
when:
- starlang: 'headers["priority"] == "high"'
steps:
- target: direct://fast-lane
- starlang: 'headers["priority"] == "low"'
target: seda://batch-queue # shorthand: no nested steps
otherwise:
steps:
- target: direct://normal
# Filter — drops exchanges where predicate is false
- filter:
starlang: 'len(payload) > 0'
steps:
- target: direct://keep
# Splitter
- split:
starlang: 'payload.items'
parallel: true
stop-on-exception: false
aggregation-strategy: "max-total"
steps:
- target: direct://process-item
# Aggregator
- aggregate:
correlation: 'headers["customerId"]'
strategy: "concat-strategy"
completion-size: 10
completion-timeout: 30s
steps:
- target: postgres://orders/insert
# Multicast — fan-out to N endpoints
- multicast:
uris: ["direct://a", "direct://b", "direct://c"]
parallel: true
strategy: "first-wins"
- recipient-list: { starlang: 'headers["targets"]', delimiter: ",", parallel: true }
- routing-slip: { starlang: 'headers["pipeline"]', delimiter: "->" }
- dynamic-router: { starlang: 'pickNext(state)' }
- load-balancer:
strategy: weighted # round-robin | random | sticky | failover | weighted
uris: ["http://a", "http://b"]
weights: [3, 1]
sticky-header: "session-id"
Reliability EIPs¶
steps:
- retry:
max-attempts: 3
delay: "1s"
backoff-multiplier: 2.0
steps:
- target: http://flaky-service
- timeout:
duration: 10s
steps:
- target: http://slow-service
fallback:
- set-body: { constant: '{"error":"timeout"}' }
- throttle:
rate: 100
period: "1s"
steps:
- target: http://rate-limited
- delay:
duration: 250ms
steps:
- target: log://debug
- circuit-breaker:
threshold: 5
half-open-after: "30s"
steps:
- target: http://unstable
fallback:
- target: http://backup
Try / catch / finally¶
steps:
- do-try:
steps:
- target: http://primary?timeout=5s
do-catch:
- exceptions: ["HTTP:TOO_MANY_REQUESTS"]
when: 'properties["HelixExceptionCode"] == "HTTP:429"'
on-error-continue: false # rethrow after running
steps:
- log: { message: "rate limited", level: warn }
- exceptions: ["HTTP:*", "HTTP:CONNECTIVITY"]
steps:
- transform:
starlang: '{ error: properties["HelixExceptionMessage"] }'
do-finally:
- log: { message: "tried", level: debug }
exceptions: patterns match against a typed error code (e.g.
HTTP:BAD_REQUEST) or substring of err.Error() for back-compat.
when: adds a Starlang predicate that ANDs with the pattern; the
exchange has HelixExceptionCode, HelixExceptionMessage, and
HelixExceptionNamespace properties pre-populated.
on-error-continue: false rethrows after the catch steps run (Mule's
on-error-propagate). Default is true (swallow).
Loops / saga / idempotent / claim-check / resequence / sort / sample¶
steps:
- loop:
count: 5
max_iterations: 100
steps:
- target: direct://once
- loop:
starlang: 'len(payload.items)'
steps:
- target: direct://each
- saga:
steps:
- target: direct://create-order
- target: direct://charge-card
compensate:
- target: direct://refund
- target: direct://cancel
- idempotent-consumer:
starlang: 'headers["X-Message-Id"]'
repository: redis
redis-url: "redis://localhost:6379/0"
redis-prefix: "myapp:idem:"
ttl: "24h"
- claim-check: { operation: push, key: 'headers["X-Job-Id"]' }
- resequence: { starlang: 'headers.seq', batch-size: 50, timeout: "10s" }
- sort: { starlang: 'a.priority < b.priority' }
- sample: { rate: 10, period: "1s" }
Exception throwing / flow control¶
steps:
- throw-exception: { message: "bad input", type: "ValidationError" }
- rollback: { message: "abort tx" }
- stop: {} # halts the route gracefully
- set-exchange-pattern: "InOnly" # or InOut
Validate / Bean / Step group¶
steps:
- validate: 'sizeOf(payload) > 0 && headers["Content-Type"] != null'
- bean:
ref: "myService"
method: "process"
- step:
id: "validate-and-enrich" # for tracing/metrics
steps:
- validate: 'payload.id != null'
- enrich: { uri: postgres://customers/findById }
- normalize:
conditions:
- starlang: 'headers["format"] == "xml"'
steps: [{ transform: { starlang: 'xmlToJson(payload)' } }]
- starlang: 'headers["format"] == "csv"'
steps: [{ transform: { starlang: 'csvToJson(payload)' } }]
- threads:
pool-size: 10
max-queue: 100
steps:
- target: direct://heavy
on-completion: — per-exchange post-hooks¶
Wraps a sub-pipeline and registers callbacks that fire after the wrapped pipeline completes — either successfully or with an exception (or both).
steps:
- on-completion:
on-when: 'headers["notify"] == "true"'
after-consumer: true # fires after consumer reply, not just route
steps:
- target: postgres://orders/insert
on-complete:
- target: kafka://order-events
on-failure:
- target: kafka://order-dlq
Agent / RAG steps¶
Documented separately under agent reference and
RAG reference. Every entry above (e.g. agent:,
rag-ingest:, rag-chunk:, rag-embed:, rag-retrieve:,
rag-synthesize:, rag-graph-build:, …) appears under steps: like
the rest.
Inline component sugar¶
Convenience shortcuts that compile down to a target: <uri> step
plus header pre-population:
http-request:, db-query:, redis:, kafka-publish:, mongo:,
elasticsearch:, nats-publish:, amqp-publish:, s3:,
send-email:, grpc-call:, websocket-send:, llm-call:,
vector-search:, vector-upsert:.
steps:
- http-request:
url: https://api.example.com/users
method: POST
headers:
Content-Type: application/json
body: '{"name":"alice"}'
timeout: 10s
auth: { type: bearer, token: "${API_TOKEN}" }
- db-query:
dsn: "postgres://localhost/db"
operation: query
sql: "SELECT * FROM users WHERE active = $1"
params: [true]
See each component's reference page for the full param list. The
inline form covers the common case; switch to target: <uri> for
anything the inline form doesn't expose.
error-handler: — route-level dead-letter / redelivery¶
workflows:
- id: kafka-to-pg
trigger: { uri: kafka://orders }
steps:
- target: postgres://orders/insert
error-handler:
dead-letter-channel: kafka://order-dlq
max-redeliveries: 3
redelivery-delay: "5s"
backoff-multiplier: 2.0
| Field | Type | Default | Description |
|---|---|---|---|
dead-letter-channel |
URI | empty | Where to send exchanges that exceed max-redeliveries. |
max-redeliveries |
int | 0 |
Retry count before dead-lettering. |
redelivery-delay |
duration | 0s |
Delay between attempts. |
backoff-multiplier |
float | 1.0 |
Exponential multiplier on the delay. |
For richer logic (typed exception arms, predicates, finally blocks),
use do-try: inside steps: or a named error-handlers: entry
attached via error-handler-ref:.
error-handlers: — reusable named handlers¶
Top-level block. Workflows attach to one via error-handler-ref:.
Mule-style global error handling: write once, attach to many flows.
error-handlers:
- id: api-defaults
on-error:
- exceptions: ["HTTP:TOO_MANY_REQUESTS"]
on-error-continue: false
steps:
- log: { message: "rate limited", level: warn }
- exceptions: ["HTTP:*"]
steps:
- transform: { starlang: '{ error: properties["HelixExceptionMessage"] }' }
do-finally:
- log: { message: "request done", level: debug }
workflows:
- id: search
error-handler-ref: api-defaults
trigger: { uri: http-server://0.0.0.0:8080/search }
steps:
- target: http://upstream/search
error-handler-ref: and inline error-handler: are mutually
exclusive — the reifier wraps steps: in a synthetic do-try: whose
catch arms come from the named handler.
auth: — route-level auth¶
workflows:
- id: secure-api
trigger: { uri: http-server://0.0.0.0:8080/api }
auth:
type: jwt
jwt:
secret: "${JWT_SECRET}"
issuer: "https://auth.example.com"
audience: "myapp"
steps:
- target: direct://handler
type: picks the sub-block to consult:
| Type | Sub-block | Notes |
|---|---|---|
apikey |
apikey: { header: "X-API-Key", keys: ["${API_KEY}"] } |
Header defaults to X-API-Key. |
jwt |
jwt: { secret, public-key, issuer, audience, claims } |
One of secret / public-key. |
basic |
basic: { users: { alice: "<bcrypt hash>" } } |
Bcrypt-hashed passwords. |
oauth2 |
oauth2: { token-url, client-id, client-secret, scopes } |
RFC 7662 introspection. |
Variables: ${VAR} and ${VAR:-default}¶
Every string field is expanded before parsing. Examples:
helix:
logging:
level: "${LOG_LEVEL:-info}"
workflows:
- id: kafka-in
trigger:
uri: "kafka://orders?brokers=${KAFKA_BROKERS}"
steps:
- target: "postgres://orders/insert?dsn=${PG_DSN}"
${NAME}substitutes the env var. Empty if unset.${NAME:-default}falls back todefaultwhen unset or empty.- Bare
$NAMEis not expanded (avoids corrupting bcrypt hashes and Starlang$operators). ${file:path}/${include:path}inline a file's contents — used for large system prompts and Starlang scripts.${vault:path/key}resolves from a Vault server configured via the Vault client (seepkg/config/vault_client.go).
Composition example — multicast inside try/catch with on-completion¶
The whole point of the DSL is composition. Real-world routes look like this:
helix:
logging: { level: info, format: json }
error-handlers:
- id: outbound-defaults
on-error:
- exceptions: ["HTTP:*", "HTTP:CONNECTIVITY"]
steps:
- log: { message: "upstream error", level: error }
- target: kafka://order-dlq
workflows:
- id: order-fanout
description: "Fan out an order to three downstream systems, dead-letter on any failure."
error-handler-ref: outbound-defaults
trigger:
uri: kafka://orders?brokers=${KAFKA_BROKERS}&group=fanout
steps:
- validate: 'payload.id != null && payload.total > 0'
- on-completion:
on-complete:
- target: kafka://order-events?topic=order.fanned-out
on-failure:
- target: kafka://order-events?topic=order.fanout-failed
steps:
- do-try:
steps:
- multicast:
uris:
- "http://shipping/api/orders"
- "http://billing/api/charges"
- "http://crm/api/orders"
parallel: true
strategy: "first-wins"
do-catch:
- exceptions: ["HTTP:5*"]
steps:
- retry:
max-attempts: 3
delay: "2s"
backoff-multiplier: 2.0
steps:
- target: kafka://order-retry-queue
do-finally:
- log: { message: "fanout cycle done", level: debug }
See also¶
- JSON Schema for this file —
helix/schemas/dsl/workflow.json - REST DSL — the
rest:block - Route templates — parameterised snippets
- Component reference — every URI scheme
- Processor reference — every step type's runtime semantics
- Starlang guide — the expression engine
Source¶
pkg/dsl/yaml_loader.go— YAML decoding and conversion toworkflow.Definitionpkg/dsl/reifier.go— definition → in-memory pipelinepkg/dsl/builder.go— the Go fluent builder that mirrors this shapepkg/workflow/definition.go— the runtime definition structspkg/config/config.go— thehelix:blockhelix/schemas/dsl/workflow.json— generated JSON Schema