Managing Resources

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

CreatePoolPOST /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):

FieldTypePurpose
default_strategystringStrategy key used when the pool's strategy is unset.
default_strategy_pipelinestringNames a strategy_pipelines entry to use as the default (mutually exclusive with default_strategy).
default_recipestringA curated strategy recipe that expands into an effective strategy/pipeline at validation time (mutually exclusive with the two above).
strategy_pipelinesmapNamed composable strategy pipelines; stage options validated against the registry.
strategy_optionsmapPer-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:

FieldTypePurpose
routing_modestringauto (engine assigns) or claim (recipients self-select from a shared queue).
pipeline_modestringExecution path — dag/linear/inherit; empty inherits tenant/global. See Configuration.
default_timeoutdurationPer-assignment timeout.
max_routing_depthintCap on overflow/redirect hops (default 3).
queue_ttl_secondsintTTL for queued items (default 1h).
shardingstructDistribute recipients across sub-pools for scale.
dedupJSONDeduplication config (parsed by the dedup module).
flow_controlJSONRate/concurrency flow-control config.

Fallback, overflow, and capacity headroom:

FieldTypePurpose
fallbackenumWhat to do when no recipient is eligible — see Fallback behavior.
overflow_pool_idstring (uuid)Target for overflow fallback (pool at capacity).
catchall_pool_id / catchall_delaystring (uuid) / durationTarget and delay for catchall fallback.
allow_over_capacity / over_capacity_budgetbool / doublePermit controlled over-subscription past capacity — see Capacity limits.
slaSLAConfigSLA targets and escalation — see SLA / speed-to-lead.

Claim mode and kill-switch:

FieldTypePurpose
claim_queue_idstring (uuid)Required when routing_mode is claim; the shared queue recipients pull from.
kill_switch / kill_switch_reason / kill_switch_at / kill_switch_bybool / string / ts / stringKill-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

UpdatePoolPATCH /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

SetPoolEnabledPOST /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: false engages the kill-switch (routing off); true clears it.
  • expected_version is the last record_version you read. If the stored version has moved on, the call fails with 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.

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

GetPoolStatsGET /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"
  }
}

ResetPoolCapacityPOST /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

DeletePoolDELETE /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:

StateEligible for new work?
RECIPIENT_STATE_AVAILABLEYes.
RECIPIENT_STATE_BUSYCan still receive (at the engine's discretion) but is working.
RECIPIENT_STATE_OFFLINENo — not online.
RECIPIENT_STATE_PAUSEDNo — 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: 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

CreateRecipientPOST /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"

UpdateRecipientPATCH /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 } }'

DeleteRecipientDELETE /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:

fallbackEffect
queueHold the item in a queue for later processing.
dropDiscard the item — it cannot be routed.
overflowRoute to overflow_pool_id.
catchallRoute 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:

FieldPurpose
enabledTurns SLA tracking on.
max_queue_timeMax time an item may wait in queue before it's a breach (IsQueueBreached).
max_response_timeMax time to first response before a breach (IsResponseBreached).
escalation_pool_idWhere to route the item on breach (HasEscalation).
alert_webhook_urlEndpoint 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 is queued_for_claim rather than an assignment, and each recipient's claiming is bounded by a claim.Budget (claims per period). The shark_tank strategy 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:

  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, 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

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