Skip to content

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.

imports:
  - "./common/error-handlers.yaml"
  - "./routes/*.yaml"

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.

trigger:
  uri: kafka://orders?brokers=localhost:9092&group=processors
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 to default when unset or empty.
  • Bare $NAME is 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 (see pkg/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

Source

  • pkg/dsl/yaml_loader.go — YAML decoding and conversion to workflow.Definition
  • pkg/dsl/reifier.go — definition → in-memory pipeline
  • pkg/dsl/builder.go — the Go fluent builder that mirrors this shape
  • pkg/workflow/definition.go — the runtime definition structs
  • pkg/config/config.go — the helix: block
  • helix/schemas/dsl/workflow.json — generated JSON Schema