Guides

Routing rules & strategies

Create pools, write CEL routing rules, and pick a selection strategy — via the API or a declarative bundle.

This guide is the routed stage, hands-on: how a Route binds Work to a Worker. Ductor's routing plane decides where a routable lands. It has three moving parts:

  • Pools — named destinations that hold recipients.
  • Rules — priority-ordered CEL expressions that select a pool.
  • Strategies — pluggable algorithms that pick a recipient within the chosen pool.

This guide builds a small topology two ways: imperatively through the API, and declaratively through a routing bundle.

Option A — build it through the API

1. Create pools

curl -s -X POST http://localhost:8080/api/pools \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Primary Pool", "strategy": "weighted", "status": "STATUS_ACTIVE" }'

The strategy field names the selection algorithm the pool uses (see Strategies). A pool with a single recipient degenerates to direct dispatch; give a pool at least two recipients for a strategy to have something to weight.

2. Add recipients

curl -s -X POST http://localhost:8080/api/pools/$POOL_ID/recipients \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "pool_id": "'$POOL_ID'",
    "name": "Jane Smith",
    "type": "agent",
    "status": "STATUS_ACTIVE",
    "state": "RECIPIENT_STATE_AVAILABLE",
    "weight": 1.0,
    "capacity": { "max_concurrent": 10, "daily_limit": 50 },
    "timezone": "America/New_York"
  }'

3. Write routing rules

Rules are CEL expressions evaluated in priority order — lower number wins. Over the API, the expression field is named cel_expression. It evaluates against a small, fixed environment — the incoming routable, the candidate recipient, and now — so you match on routable.attributes.*, routable.priority, and recipient.attributes.* (there is no tenant variable in scope). Create a rule that routes EU traffic to a residency pool:

# EU residency rule (high priority)
curl -s -X POST http://localhost:8080/api/pools/$SECONDARY_POOL_ID/rules \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "route-eu-to-secondary", "cel_expression": "routable.attributes.region == '\''EU'\''", "priority": 100, "enabled": true }'

Validate an expression before you commit it — POST /api/rules/validate compiles it against the same environment and returns type/syntax errors without persisting anything:

curl -s -X POST http://localhost:8080/api/rules/validate \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "cel_expression": "routable.attributes.region == '\''EU'\''" }'

Rules are cached and hot-reloaded across pods via Redis pub/sub. Tune the cache with routing.rule_cache_size (default 1000) and routing.rule_cache_ttl (default 30s); leave rules.hot_reload on (default true) so rule edits propagate without a restart. For the full field set, lifecycle verbs, and the pub/sub invalidation mechanism, see Rules.

Option B — declare the topology as a bundle

For GitOps, declare pools, recipients, rules, and workflow refs in a single YAML bundle and let the reconciler apply it. Point Ductor at the file:

# ductor.yaml
routing:
  bundle_file: ./configs/example-routing.yaml
  bundle_tenant: tenant-1

The bundle itself is strict (unknown keys fail the apply, so typos surface loudly) and dependency-ordered — pools, then recipients, then rules:

pools:
  - name: primary
    display_name: Primary Pool
    categories: [transactional]
    strategy: weighted
  - name: secondary
    display_name: Secondary Pool
    categories: [transactional, eu]
    strategy: weighted

recipients:
  - name: primary-1
    pool_name: primary
    enabled: true
  - name: primary-2
    pool_name: primary
    enabled: true

rules:
  - name: route-eu-to-secondary
    expression: "routable.attributes.region == 'EU'"
    priority: 100
    pool_name: secondary
    enabled: true
  - name: default-to-primary
    expression: "true"        # catch-all — must always evaluate true
    priority: 999
    pool_name: primary
    enabled: true

Field name differs by surface

The rule's CEL string is named cel_expression over the API and expression in a bundle — the two surfaces mirror the same domain/rule field under different YAML/JSON tags. The CEL environment is identical either way (routable, recipient, now).

On startup the reconciler applies the bundle (creates/updates run pools → recipients → rules; deletes run child-first). You can also apply, diff, export, and roll back bundles over the API (POST /api/routing/bundle:apply and friends).

Bundle values are literal

The bundle parser does not template — no ${SECRETS.x}, anchors, or !include. Keep secrets out of the bundle and inject them through connector connections instead.

Pick a strategy

A strategy is the algorithm that selects a recipient within the chosen pool. Set it per-pool via the pool's strategy field. Built-in strategies live in the strategy registry; weighted / weighted_random distribute by recipient weight, respecting each recipient's capacity and availability state.

Strategies implement a small interface — Info() is called first for compatibility checking, and Select() is the hot path. Optional capability interfaces (BatchStrategy, ExplainStrategy, HealthCheckStrategy) let a strategy opt into batch selection, decision explanations, and health probing. Custom strategies register through the strategy registry and, for remote strategies, can be wired as strategy deployments (routing.strategy_deployments.*) with per-call timeouts, circuit breaking, and a fail-open/closed policy.

The full catalog of built-in strategies — allocation, balance, scoring, ranked, geo, eligibility, and the rest — lives in Strategies; start there when picking the algorithm for a pool. To author routing logic beyond a single CEL predicate — multi-step selection graphs with bindings and env scoping — see the Route DSL v2 in Route authoring.

How a decision flows

A routable moves through the Routing Pipeline, carried by a mutable RoutingContext:

Validate Enrich Filter Select Assign

Rules select the pool during the pipeline; the pool's strategy selects the recipient at the Select stage; successful assignment commits capacity counters under a hard gate.

Native selecting workflows additionally reserve the complete selected or ranked slate durably before publishing selection output or starting selected bodies. This admission wraps both strategy selection and the routing pipeline; an advisory candidate or preview is not a capacity grant. Reservations remain held until the workflow and its persisted descendants and continuations finish, including child drain after cancellation. See Capacity admission for live versus pinned limits, refusal evidence, and deployment requirements.

Next steps