# Pools & Recipients (/docs/management/pools-and-recipients)



Pools and recipients are the **targets** a routing decision chooses from — a pool
is the market a unit of <Term name="Work" /> enters, and its recipients are the
candidate <Term name="Worker" />s. A **pool** is a container: it has a selection
strategy, a kill-switch, and configuration, but no capacity of its own. A
**recipient** is a concrete endpoint inside one or more pools — an agent, team,
queue, or downstream system — and it's where capacity and availability actually
live. When the [routing pipeline](/docs/concepts/routing-pipeline) runs, it loads
a pool, filters its recipients down to the eligible ones, and hands them to a
[strategy](/docs/strategies) to pick a winner.

<Callout title="Recipient is the shipping term; Worker is where it's going" type="info">
  <TechnicalName public="Worker" api="Recipient" /> is the primitive the clearing
  vocabulary uses for anyone who can take work — human, team, queue, or AI agent.
  On this page that identity is a **recipient**, and the API, storage, and fields
  below all say `recipient`. The worker registry spans recipients and agent
  definitions as one identity for market participation and bid eligibility — see
  [Workers](/docs/management/workers). Pool membership and capacity are still
  managed as recipients, exactly as documented here.
</Callout>

<Callout title="Where they live">
  Both are served by the management service. Pools: `POST /api/pools` and
  friends, persisted to the `pools` table. Recipients: `POST
    /api/pools/{pool_id}/recipients`, persisted to `recipients` with membership in
  `pool_members` and counters in `recipient_capacity`. Domain aggregates are at
  `domain/pool/` and `domain/recipient/`.
</Callout>

## Pools [#pools]

### The pool object [#the-pool-object]

<TypeTable
  type="{
  id: { description: &#x22;Output-only.&#x22;, type: &#x22;string (uuid)&#x22; },
  name: { description: &#x22;Required. Unique per tenant.&#x22;, type: &#x22;string&#x22; },
  description: { description: &#x22;Optional.&#x22;, type: &#x22;string&#x22; },
  status: { description: &#x22;STATUS_ACTIVE, STATUS_PAUSED, or STATUS_DISABLED.&#x22;, type: &#x22;Status&#x22; },
  strategy: { description: &#x22;Selection strategy key (e.g. smooth_weighted_round_robin).&#x22;, type: &#x22;string&#x22; },
  config: { description: &#x22;Routing mode, fallback, kill-switch, SLA, timeouts — see below.&#x22;, type: &#x22;PoolConfig&#x22; },
  recipient_ids: { description: &#x22;Output-only. Members of this pool.&#x22;, type: &#x22;repeated string&#x22; },
  rule_ids: { description: &#x22;Output-only. Rules attached to this pool.&#x22;, type: &#x22;repeated string&#x22; },
  parent_pool_id: { description: &#x22;Optional. For sub-pool hierarchies.&#x22;, type: &#x22;string&#x22; },
  metadata: { description: &#x22;Your bookkeeping.&#x22;, type: &#x22;map<string,string>&#x22; },
  record_version: { description: &#x22;Output-only. Optimistic-concurrency counter.&#x22;, type: &#x22;int64&#x22; },
}"
/>

A pool routes only when it is **active and its kill-switch is off** — internally
`CanRoute() = IsActive() && !IsKillSwitchActive()`. A paused or disabled pool, or
one with the kill-switch engaged, is skipped.

<Callout type="info">
  A pool has **no capacity field**. "Is there room?" is always answered by the
  recipients — a pool is full only when all its eligible recipients are at
  capacity. This keeps capacity accounting in one place; see
  [recipients](#recipients) below.
</Callout>

### Create a pool [#create-a-pool]

`CreatePool` — `POST /api/pools` (`pool:write`):

```bash
curl -s -X POST https://api.ductor.io/api/pools \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "billing-agents",
    "description": "Tier-2 billing support",
    "strategy": "smooth_weighted_round_robin",
    "status": "STATUS_ACTIVE",
    "config": {
      "routing_mode": "auto",
      "default_timeout": "30s",
      "max_routing_depth": 3
    },
    "metadata": { "team": "billing" }
  }'
```

```json
{
  "data": {
    "id": "3f1e2d3c-...",
    "name": "billing-agents",
    "status": "STATUS_ACTIVE",
    "strategy": "smooth_weighted_round_robin",
    "config": { "routing_mode": "auto", "default_timeout": "30s", "max_routing_depth": 3 },
    "recipient_ids": [],
    "record_version": 0,
    "created_at": "2026-07-11T10:45:00Z"
  }
}
```

### The `PoolConfig` surface [#the-poolconfig-surface]

`PoolConfig` (`domain/pool/config.go`) is a large struct. Grouped by what they
control:

**Strategy selection** (mutually exclusive authoring inputs — pick one):

| Field                       | Type   | Purpose                                                                                                                                                                       |
| --------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `default_strategy`          | string | Strategy key used when the pool's `strategy` is unset.                                                                                                                        |
| `default_strategy_pipeline` | string | Names a `strategy_pipelines` entry to use as the default (mutually exclusive with `default_strategy`).                                                                        |
| `default_recipe`            | string | A curated [strategy recipe](/docs/strategies) that expands into an effective strategy/pipeline at validation time (mutually exclusive with the two above).                    |
| `strategy_pipelines`        | map    | Named composable [strategy pipelines](/docs/strategies/pipelines); stage options validated against the registry.                                                              |
| `strategy_options`          | map    | Per-strategy parameter overrides. Outer key = strategy name (e.g. `shark_tank`), inner = a flat param map; route-level options overlay these, deepest-wins per top-level key. |

**Routing behavior and limits:**

| Field               | Type     | Purpose                                                                                                                                             |
| ------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `routing_mode`      | string   | `auto` (engine assigns) or `claim` (recipients self-select from a shared queue).                                                                    |
| `pipeline_mode`     | string   | Execution path — `dag`/`linear`/`inherit`; empty inherits tenant/global. See [Configuration](/docs/management/configuration#routing-pipeline-mode). |
| `default_timeout`   | duration | Per-assignment timeout.                                                                                                                             |
| `max_routing_depth` | int      | Cap on overflow/redirect hops (default 3).                                                                                                          |
| `queue_ttl_seconds` | int      | TTL for queued items (default 1h).                                                                                                                  |
| `sharding`          | struct   | Distribute recipients across sub-pools for scale.                                                                                                   |
| `dedup`             | JSON     | Deduplication config (parsed by the dedup module).                                                                                                  |
| `flow_control`      | JSON     | Rate/concurrency flow-control config.                                                                                                               |

**Fallback, overflow, and capacity headroom:**

| Field                                          | Type                     | Purpose                                                                                                        |
| ---------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `fallback`                                     | enum                     | What to do when no recipient is eligible — see [Fallback behavior](#fallback-behavior).                        |
| `overflow_pool_id`                             | string (uuid)            | Target for `overflow` fallback (pool at capacity).                                                             |
| `catchall_pool_id` / `catchall_delay`          | string (uuid) / duration | Target and delay for `catchall` fallback.                                                                      |
| `allow_over_capacity` / `over_capacity_budget` | bool / double            | Permit controlled over-subscription past capacity — see [Capacity limits](#capacity-limits-and-over-capacity). |
| `sla`                                          | `SLAConfig`              | SLA targets and escalation — see [SLA / speed-to-lead](#sla--speed-to-lead).                                   |

**Claim mode and kill-switch:**

| Field                                                                      | Type                        | Purpose                                                                         |
| -------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------- |
| `claim_queue_id`                                                           | string (uuid)               | Required when `routing_mode` is `claim`; the shared queue recipients pull from. |
| `kill_switch` / `kill_switch_reason` / `kill_switch_at` / `kill_switch_by` | bool / string / ts / string | Kill-switch state (set via `SetPoolEnabled`, below).                            |

### Read pools [#read-pools]

```bash
# One pool
curl -s https://api.ductor.io/api/pools/$POOL_ID \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"

# List, optionally filtered by status
curl -s "https://api.ductor.io/api/pools?status=STATUS_ACTIVE&pagination.page_size=50" \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"
```

`GetPool` and `ListPools` require `pool:read`. List responses paginate with the
standard `pagination` cursor.

### Update a pool [#update-a-pool]

`UpdatePool` — `PATCH /api/pools/{pool_id}` (`pool:write`) — is a partial update;
only the fields you send change.

```bash
curl -s -X PATCH https://api.ductor.io/api/pools/$POOL_ID \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{ "strategy": "weighted_round_robin", "config": { "default_timeout": "45s" } }'
```

### The kill-switch: `SetPoolEnabled` [#the-kill-switch-setpoolenabled]

`SetPoolEnabled` — `POST /api/pools/{pool_id}:set_enabled` (`pool:write`) —
toggles the pool kill-switch atomically with optimistic concurrency. This is the
operator's emergency stop for a pool: flip `enabled: false` and the pool stops
routing immediately without deleting anything.

```bash
curl -s -X POST "https://api.ductor.io/api/pools/$POOL_ID:set_enabled" \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false, "expected_version": 4, "reason": "provider outage — halting dispatch" }'
```

* `enabled: false` engages the kill-switch (routing off); `true` clears it.
* `expected_version` is the last `record_version&#x60; you read. If the stored
  version has moved on, the call fails with &#x2A;*`412 Failed Precondition`** — re-read
  and retry. Pass `0` to skip the version guard.
* `reason` (max 4096 chars) is stamped on the audit trail and onto
  `config.kill_switch_reason`.

The response returns the pool with its incremented `record_version`.

<Callout title="Why a version guard on the kill-switch">
  The kill-switch is exactly the operation where two operators reacting to the
  same incident could race. The `expected_version` check makes the toggle a
  compare-and-swap: the second writer sees `412` and re-reads the current state
  instead of blindly clobbering it. Same discipline as
  [optimistic locking](/docs/concepts/optimistic-locking) in the coordinator.
</Callout>

### Pool stats and capacity reset [#pool-stats-and-capacity-reset]

`GetPoolStats` — `GET /api/pools/{pool_id}/stats` (`pool:read`) — returns
operational metrics aggregated from the pool's recipients:

```json
{
  "data": {
    "pool_id": "3f1e2d3c-...",
    "total_recipients": 12,
    "active_recipients": 9,
    "total_capacity": 120,
    "used_capacity": 84,
    "utilization": 0.70,
    "queue_depth": 3,
    "updated_at": "2026-07-11T10:50:00Z"
  }
}
```

`ResetPoolCapacity` — `POST /api/pools/{pool_id}/capacity/reset` (`pool:write`) —
clears the in-flight and daily counters for **every** recipient in the pool and
returns how many were affected. Use it to recover from counter drift or to start
a fresh daily window manually.

### Delete a pool [#delete-a-pool]

`DeletePool` — `DELETE /api/pools/{pool_id}` (`pool:delete`) — removes the pool
and disassociates its recipients (the recipients themselves survive; only their
membership in this pool is dropped).

## Recipients [#recipients]

A recipient is the routable endpoint. It has **state** (is it available?),
**capacity** (does it have room?), a **weight** (how much traffic it should get
relative to peers), and **attributes** (the key/value data your rules evaluate).

### The recipient object [#the-recipient-object]

<TypeTable
  type="{
  id: { description: &#x22;Output-only.&#x22;, type: &#x22;string (uuid)&#x22; },
  name: { description: &#x22;Required.&#x22;, type: &#x22;string&#x22; },
  type: { description: &#x22;Free-form: agent, team, queue, …&#x22;, type: &#x22;string&#x22; },
  pool_id: { description: &#x22;Primary pool.&#x22;, type: &#x22;string&#x22; },
  pool_ids: { description: &#x22;All pools this recipient belongs to.&#x22;, type: &#x22;repeated string&#x22; },
  status: { description: &#x22;STATUS_ACTIVE / PAUSED / DISABLED.&#x22;, type: &#x22;Status&#x22; },
  state: { description: &#x22;Live availability — see below.&#x22;, type: &#x22;RecipientState&#x22; },
  weight: { description: &#x22;Relative selection weight (default 1.0).&#x22;, type: &#x22;double&#x22; },
  capacity: { description: &#x22;Concurrency and daily limits — see below.&#x22;, type: &#x22;Capacity&#x22; },
  attributes: { description: &#x22;Evaluated by routing rules.&#x22;, type: &#x22;map<string,Value>&#x22; },
  tags: { description: &#x22;Free-form labels.&#x22;, type: &#x22;repeated string&#x22; },
  schedule: { description: &#x22;Availability windows (IANA tz).&#x22;, type: &#x22;Schedule&#x22; },
  timezone: { description: &#x22;Availability windows (IANA tz).&#x22;, type: &#x22;string&#x22; },
  location: { description: &#x22;For distance-based routing.&#x22;, type: &#x22;Location&#x22; },
  external_id: { description: &#x22;Your system's ID for this endpoint.&#x22;, type: &#x22;string&#x22; },
}"
/>

### Recipient state [#recipient-state]

`RecipientState` is the live availability signal, distinct from the
administrative `status`:

| State                       | Eligible for new work?                                         |
| --------------------------- | -------------------------------------------------------------- |
| `RECIPIENT_STATE_AVAILABLE` | Yes.                                                           |
| `RECIPIENT_STATE_BUSY`      | Can still receive (at the engine's discretion) but is working. |
| `RECIPIENT_STATE_OFFLINE`   | No — not online.                                               |
| `RECIPIENT_STATE_PAUSED`    | No — administratively paused.                                  |

A recipient is **eligible** when `state == AVAILABLE` and it is **not at
capacity**. That's the filter the pipeline applies before a strategy ever sees
the candidate.

### Capacity [#capacity]

<TypeTable
  type="{
  current: { description: &#x22;In-flight assignments right now.&#x22;, type: &#x22;int32&#x22; },
  max_concurrent: { description: &#x22;Ceiling on concurrent assignments (default 10).&#x22;, type: &#x22;int32&#x22; },
  daily_limit: { description: &#x22;Max assignments per day. 0 means unlimited.&#x22;, type: &#x22;int32&#x22; },
  daily_count: { description: &#x22;Output-only. Today's count so far.&#x22;, type: &#x22;int32&#x22; },
  daily_reset_at: { description: &#x22;Output-only. When the daily window rolls over.&#x22;, type: &#x22;timestamp&#x22; },
}"
/>

A recipient is *at capacity* when `current >= max_concurrent`, or when
`daily_limit > 0 && daily_count >= daily_limit` (`Capacity.IsAtCapacity`). The
domain exposes the derived views the engine uses: `AvailableCapacity()` (the
smaller of concurrent and daily headroom), `HasRoom()` (any headroom left), and
`Utilization()` (`current / max_concurrent`, 0–1). The daily counter resets
after a rolling 24 hours (`NeedsReset` / `Reset`), or manually via
[`ResetPoolCapacity`](#pool-stats-and-capacity-reset).

#### Capacity limits and over-capacity [#capacity-limits-and-over-capacity]

By default an at-capacity recipient is **filtered out** of the eligible set — it
simply won't be selected. A pool can opt into controlled over-subscription:

* `allow_over_capacity: true` permits routing to an at-capacity recipient.
* `over_capacity_budget` (a percentage) bounds how far past capacity the pool
  will push before it stops.

Use this when a short, bounded burst is preferable to queuing or dropping — for
example, letting a hot pool absorb a spike rather than overflow it. Without these
flags, capacity is a hard gate.

### Create a recipient [#create-a-recipient]

`CreateRecipient` — `POST /api/pools/{pool_id}/recipients` (`recipient:write`).
The pool comes from the path.

```bash
curl -s -X POST https://api.ductor.io/api/pools/$POOL_ID/recipients \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jordan Reyes",
    "type": "agent",
    "external_id": "agent-4471",
    "state": "RECIPIENT_STATE_AVAILABLE",
    "weight": 2.0,
    "capacity": { "max_concurrent": 5, "daily_limit": 40 },
    "tags": ["billing", "spanish"],
    "attributes": {
      "skill": { "string_value": "billing" },
      "seniority": { "number_value": 3 },
      "vip_certified": { "bool_value": true }
    },
    "timezone": "America/Chicago"
  }'
```

<Callout title="Attributes use the Value wrapper">
  `attributes` is a `map<string, Value>`, and `Value` is a typed oneof — so each
  attribute is wrapped by its kind: `{"string_value": "billing"}`,
  `{"number_value": 3}`, `{"bool_value": true}`, plus `list_value` and
  `object_value` for arrays and nested objects. These are the exact fields your
  [CEL rules](/docs/management/rules) read when filtering and boosting
  recipients.
</Callout>

### Read, update, delete recipients [#read-update-delete-recipients]

```bash
# Get one
curl -s https://api.ductor.io/api/recipients/$RECIPIENT_ID \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"

# List within a pool, filter by status and state
curl -s "https://api.ductor.io/api/pools/$POOL_ID/recipients?state=RECIPIENT_STATE_AVAILABLE" \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"
```

`UpdateRecipient` — `PATCH /api/recipients/{recipient_id}` (`recipient:write`) —
is a partial update. Setting `pool_id` **moves** the recipient's primary pool.

```bash
curl -s -X PATCH https://api.ductor.io/api/recipients/$RECIPIENT_ID \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{ "weight": 1.5, "capacity": { "max_concurrent": 8 } }'
```

`DeleteRecipient` — `DELETE /api/recipients/{recipient_id}` (`recipient:delete`).

### Pause and resume [#pause-and-resume]

Two dedicated verbs flip availability without a full update — the common
operational lever for taking a recipient out of rotation:

```bash
# Take out of rotation → RECIPIENT_STATE_PAUSED
curl -s -X POST https://api.ductor.io/api/recipients/$RECIPIENT_ID/pause \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"

# Put back → RECIPIENT_STATE_AVAILABLE
curl -s -X POST https://api.ductor.io/api/recipients/$RECIPIENT_ID/resume \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"
```

Both require `recipient:write`. A paused recipient stays a member of its pools and
keeps its capacity counters — it's simply skipped by the eligibility filter until
resumed.

## Fallback behavior [#fallback-behavior]

When a routing pass finds **no eligible recipient**, `config.fallback`
(`domain/pool/fallback.go`) decides what happens to the work:

| `fallback` | Effect                                                       |
| ---------- | ------------------------------------------------------------ |
| `queue`    | Hold the item in a queue for later processing.               |
| `drop`     | Discard the item — it cannot be routed.                      |
| `overflow` | Route to `overflow_pool_id`.                                 |
| `catchall` | Route to `catchall_pool_id`, after waiting `catchall_delay`. |

`overflow` and `catchall` **require a target pool** (`RequiresTargetPool`) — set
the matching `*_pool_id`, or config validation rejects the pool. Overflow and
catchall hops count against `max_routing_depth`, so a chain of spill-over pools
can't loop forever.

## SLA / speed-to-lead [#sla--speed-to-lead]

`config.sla` (`SLAConfig`, `domain/pool/sla.go`) puts a clock on a pool — the
"speed-to-lead" guarantee that a routable is picked up and responded to quickly:

| Field                | Purpose                                                                      |
| -------------------- | ---------------------------------------------------------------------------- |
| `enabled`            | Turns SLA tracking on.                                                       |
| `max_queue_time`     | Max time an item may wait in queue before it's a breach (`IsQueueBreached`). |
| `max_response_time`  | Max time to first response before a breach (`IsResponseBreached`).           |
| `escalation_pool_id` | Where to route the item on breach (`HasEscalation`).                         |
| `alert_webhook_url`  | Endpoint notified on breach (`HasAlert`).                                    |

The pool config is the *policy*; two [strategy](/docs/strategies&#x29; building blocks
enforce and rescue against it: the &#x2A;*`sla_deadline`*&#x2A; strategy ranks candidates
by deadline headroom (who can beat the clock with the most room), and the
&#x2A;*`sla_rescue`** recipe pairs `sla_deadline` with an availability fallback so
breaching work still lands somewhere.

## Routing modes: auto vs claim [#routing-modes-auto-vs-claim]

`config.routing_mode` (`domain/pool/routing_mode.go`) chooses *how* an item
reaches a recipient:

* **`auto`** (default) — the engine selects a winner and assigns the item to it.
  This is push routing: the strategy decides.
* **`claim`** — the engine places the item in a shared **claim queue**
  (`claim_queue_id`, required) and recipients **self-select** ("lead ponds"). The
  decision outcome is `queued_for_claim` rather than an assignment, and each
  recipient's claiming is bounded by a `claim.Budget&#x60; (claims per period). The
  &#x2A;*`shark_tank`** strategy is the classic claim pattern: it broadcasts the item
  to the pool and then awaits a claim.

<Callout type="info">
  Claim mode inverts the model — instead of the system assigning work, qualified
  recipients pull it. Set `routing_mode: "claim"` **and** a valid
  `claim_queue_id` together; either without the other is a config validation
  error.
</Callout>

## How routing consumes them [#how-routing-consumes-them]

At decision time the pipeline calls the pool port, which loads the pool together
with its recipients (`LoadPoolData` → pool + recipients, served from a
pub/sub-invalidated cache on the hot path). The pipeline then:

1. Checks the pool can route (`active && !kill_switch`).
2. Filters recipients to the eligible set (`state == AVAILABLE && !at capacity`),
   applying schedules and rule filters.
3. Hands the survivors to the pool's [strategy](/docs/strategies), which returns
   the winner.
4. On assignment, the winner's capacity counters increment.

So the levers you manage here map directly onto routing behavior: `state` and
`capacity` gate eligibility, `weight` and `attributes` shape which eligible
recipient wins, and the pool `strategy` decides how.

## Bulk topology changes [#bulk-topology-changes]

To apply an entire pool/recipient/rule topology atomically — idempotent by name,
all-or-nothing in one transaction — use the routing **bundle** endpoints
(`ApplyRoutingBundle` / `DiffRoutingBundle` under `/api/routing/bundle`). They're
the right tool for declarative, GitOps-style management of your routing graph
rather than imperative per-resource calls.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Rules" href="/docs/management/rules">
    Attach CEL rules to a pool to filter, boost, and redirect.
  </Card>

  <Card title="Strategies" href="/docs/strategies">
    How a strategy turns eligible recipients into a single winner.
  </Card>

  <Card title="The routing pipeline" href="/docs/concepts/routing-pipeline">
    Validate → Enrich → Filter → Select → Assign, end to end.
  </Card>
</Cards>
