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-1The 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: trueField 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:
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
Routing pipeline
The stages every routable passes through.
Strategies
The full catalog of built-in selection algorithms.
Rules
The rule object, lifecycle verbs, and hot-reload mechanics.
Route authoring
Route DSL v2 for multi-step selection graphs.
Configuration reference
All routing.* and router.* tuning knobs.