Pools & Recipients
Manage routing targets — pools (containers with a strategy and kill-switch) and recipients (concrete endpoints with capacity and state) — over the management API.
Pools and recipients are the targets a routing decision chooses from — a pool is the market a unit of Work enters, and its recipients are the candidate Workers. 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 runs, it loads a pool, filters its recipients down to the eligible ones, and hands them to a strategy to pick a winner.
Recipient is the shipping term; Worker is where it's going
Worker (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. Pool membership and capacity are still
managed as recipients, exactly as documented here.
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/.
Pools
The pool object
Prop
Type
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.
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 below.
Create a pool
CreatePool — POST /api/pools (pool:write):
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" }
}'{
"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
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 that expands into an effective strategy/pipeline at validation time (mutually exclusive with the two above). |
strategy_pipelines | map | Named composable strategy 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. |
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. |
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. |
sla | SLAConfig | SLA targets and escalation — see 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
# 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
UpdatePool — PATCH /api/pools/{pool_id} (pool:write) — is a partial update;
only the fields you send change.
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
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.
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: falseengages the kill-switch (routing off);trueclears it.expected_versionis the lastrecord_versionyou read. If the stored version has moved on, the call fails with412 Failed Precondition— re-read and retry. Pass0to skip the version guard.reason(max 4096 chars) is stamped on the audit trail and ontoconfig.kill_switch_reason.
The response returns the pool with its incremented record_version.
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 in the coordinator.
Pool stats and capacity reset
GetPoolStats — GET /api/pools/{pool_id}/stats (pool:read) — returns
operational metrics aggregated from the pool's recipients:
{
"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
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
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
Prop
Type
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
Prop
Type
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.
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: truepermits 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
CreateRecipient — POST /api/pools/{pool_id}/recipients (recipient:write).
The pool comes from the path.
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"
}'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 read when filtering and boosting
recipients.
Read, update, delete recipients
# 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.
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
Two dedicated verbs flip availability without a full update — the common operational lever for taking a recipient out of rotation:
# 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
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
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 building blocks
enforce and rescue against it: the sla_deadline strategy ranks candidates
by deadline headroom (who can beat the clock with the most room), and the
sla_rescue recipe pairs sla_deadline with an availability fallback so
breaching work still lands somewhere.
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 isqueued_for_claimrather than an assignment, and each recipient's claiming is bounded by aclaim.Budget(claims per period). Theshark_tankstrategy is the classic claim pattern: it broadcasts the item to the pool and then awaits a claim.
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.
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:
- Checks the pool can route (
active && !kill_switch). - Filters recipients to the eligible set (
state == AVAILABLE && !at capacity), applying schedules and rule filters. - Hands the survivors to the pool's strategy, which returns the winner.
- 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
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
API Keys
Mint, list, and revoke tenant-scoped API keys — roles, per-key scopes, expiry, and environment constraints — the operational workflow.
Workers
The worker registry — one durable identity for anyone who can take Work, human or agent, with ownership, fenced readiness, and bid eligibility built in.