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; the Assign stage reserves capacity and finalizes the decision.

Next steps