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 | Influence advisory selection at capacity; persisted hard limits still apply — 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) —
resets committed assignment and daily counters for every recipient in the
pool and returns how many were affected. Active workflow reservations are
separate and remain held. Resetting counters cannot release a waiting workflow;
use supported workflow completion or cancellation and wait for its descendants
to finish.
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
capacity.current reports committed assignments. Selecting workflows hold
additional durable reservations, so this counter alone does not show total
admission occupancy. A zero counter does not prove a recipient has room for
another workflow. daily_count reports committed daily assignments; current-day
reservations also count toward workflow admission.
Capacity limits and over-capacity
Candidate filtering uses capacity and availability to form an eligible set.
allow_over_capacity and over_capacity_budget can influence advisory candidate
selection, but cannot raise a persisted hard limit. Assignment increments,
batch assignments, and confirmation all enforce the hard limit before committing
work. A candidate appearing in a selection preview is not an admission grant.
Capacity admission for selecting workflows
A selecting workflow durably reserves its entire selected or ranked slate before exposing selection output or starting a selected body. This applies to both strategy selection and the routing pipeline. Concurrent admission counts active reservations plus committed assignments. Daily admission counts reservations made in the current day plus committed daily assignments.
The effective concurrency ceiling uses the smaller positive value from the workflow's pinned configuration and the current live recipient configuration. An unlimited value cannot override a positive limit in the other configuration. Tightening a live concurrency limit therefore constrains an already published workflow's next admission.
Daily admission uses the recipient's current live daily_limit and the UTC
calendar day. Reservations admitted that day still count after they are
released, alongside committed daily assignments. Completion or cancellation
does not refund that day's admission budget.
recipient_capacity_exhausted means admission was refused for capacity.
capacity_authority_unavailable means the required capacity authority could not
be used. Neither means there were simply no eligible recipients. Inspect the run
snapshot and step error; a failed or stale observation cannot establish an empty
backlog. A refused admission exposes no partial slate and starts no selected
body.
Reservations remain held until the selecting workflow and all persisted descendants and continuations are terminal. An approval wait, a nested body, a server restart, or elapsed time does not release them. Cancellation acceptance alone is not release: wait for child work to drain. Reservation correctness does not depend on a time-to-live.
Continue-as-new keeps earlier reservations held throughout the continuation lifecycle. Only the exact same complete selection can reuse its reservation without another daily admission charge. A changed selection needs separate admission; parent and sibling selections cannot borrow the reservation.
Each deployment must apply the capacity migrations and run matching runtime, coordinator, and recovery components. Verify concurrent admission, descendant cancellation and drain, and restart recovery in that deployment before relying on its capacity guarantees.
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 successful assignment, committed capacity counters increment under the persisted hard gate. Selecting workflows also reserve their full slate before exposing output or starting selected bodies, as described above.
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.