# Routing rules & strategies (/docs/guides/routing-rules-strategies)



This guide is the **routed** stage, hands-on: how a <Term name="Route" /> binds
<Term name="Work" /> to a <Term name="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 [#option-a--build-it-through-the-api]

### 1. Create pools [#1-create-pools]

```bash
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](#pick-a-strategy)). 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 [#2-add-recipients]

```bash
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 [#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:

```bash
# 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:

```bash
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](/docs/management/rules).

## Option B — declare the topology as a bundle [#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:

```yaml
# 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:

```yaml
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
```

<Callout title="Field name differs by surface">
  The rule's CEL string is named &#x2A;*`cel_expression`*&#x2A; over the API and
  &#x2A;*`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`).
</Callout>

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).

<Callout title="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.
</Callout>

## Pick a strategy [#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](/docs/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](/docs/management/route-authoring).

## How a decision flows [#how-a-decision-flows]

A routable moves through the [Routing Pipeline](/docs/concepts/routing-pipeline),
carried by a mutable `RoutingContext`:

```mermaid
flowchart LR
    V[Validate] --> E[Enrich] --> F[Filter] --> S[Select] --> A[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 [#next-steps]

<Cards>
  <Card title="Routing pipeline" href="/docs/concepts/routing-pipeline">
    The stages every routable passes through.
  </Card>

  <Card title="Strategies" href="/docs/strategies">
    The full catalog of built-in selection algorithms.
  </Card>

  <Card title="Rules" href="/docs/management/rules">
    The rule object, lifecycle verbs, and hot-reload mechanics.
  </Card>

  <Card title="Route authoring" href="/docs/management/route-authoring">
    Route DSL v2 for multi-step selection graphs.
  </Card>

  <Card title="Configuration reference" href="/docs/reference/configuration">
    All `routing.*` and `router.*` tuning knobs.
  </Card>
</Cards>
