Managing Resources

Managing Resources

The Ductor management API surface — how to provision and operate tenants, keys, pools, rules, workflows, experiments, connections, and configuration over REST and Connect-RPC.

Everything Ductor routes, runs, and dispatches is a resource you manage through the API. These are the resources that shape the clearing surface: pools are the markets Work enters, recipients are the Workers a decision selects, and rules, workflows, publishers, and connections define how work is priced, routed, and delivered. The dashboard is optional — all of them are created, updated, inspected, and retired over the same REST + Connect-RPC surface your application already calls. This section is the operator's reference for that surface — what each resource is, why and when you manage it, how to do it with real API calls, and where it lives (endpoint, aggregate, storage).

What this section is not

This is the management (control-plane) surface — the CRUD and lifecycle operations that shape your routing and workflow topology. The execution surface (submitting an event to be routed, triggering a run) is covered under Core Concepts. Managing what a decision chooses from lives here; making the decision lives there.

The resource map

Each resource has its own page. Read them in roughly this order — later resources reference earlier ones.

Tenants

The top-level isolation boundary. Provision a tenant, set its quotas, metadata, and feature flags before anything else exists under it.

API Keys

Mint, list, and revoke the tenant-scoped keys that authenticate every other call. Roles, scopes, expiry, and environment constraints.

Pools & Recipients

Routing targets. A pool is a container with a strategy and kill-switch; recipients are the concrete endpoints with capacity and state.

Rules

CEL-based routing rules attached to a pool — priority, enable/disable, and the cross-instance cache invalidation that makes edits take effect live.

Route Authoring

Route DSL v2 — the single public format for authoring routes as one versioned RouteAggregate per tenant that compiles down to the workflow DAG.

Workflows

Workflow definitions (draft → publish → immutable version) and run operations (pause, resume, cancel, signal, continue-as-new).

Experiments

Durable experiments for routing strategies, workflow steps, and agents, with sticky assignments, exposure receipts, and delayed scores.

Connections

Tenant-scoped, encrypted credential bindings to third-party providers that connector steps dispatch through.

Configuration

The runtime-writable config surface — system config snapshots, feature flags, per-tenant overrides, and the routing pipeline mode.

Cases

Create, assign, claim, resolve, and escalate human or agent-assisted operational work.

Environments

Model dev, staging, and production boundaries with protection and readiness policies.

Secrets, Variables & Contexts

Store encrypted secrets, plaintext variables, and tenant-scoped context data.

Publishers

Govern inbound traffic sources with caps, quality thresholds, pricing, and delivery statistics.

Two API generations: /api and /api/v2

Ductor's REST surface is generated from the same protobuf definitions as the Connect-RPC API, so every operation exists in both dialects. As the platform grew, newer services were introduced under a /api/v2 prefix while the established resource CRUD kept its /api paths. Both are current and supported — the prefix simply tells you which generation a service belongs to.

GenerationUsed byPath style
/apiTenants, pools, recipients, rules, experiments, workflow definitions, workflow run reads, system configPlural resource nouns: POST /api/pools, GET /api/tenants/{tenant_id}
/api/v2API keys, workflow run control + signals, connections + connect sessions, runtime configSub-resources and RPC-style verbs: POST /api/v2/workflow-runs/{run_id}:pause

The :verb suffix you'll see on some /api/v2 paths (:trigger, :pause, :approve) is the standard grpc-gateway custom-method syntax. It's a literal part of the URL — POST /api/v2/workflow-runs/{run_id}:cancel is one path, not a path plus a query.

Authentication and tenancy on every call

Every management endpoint is authenticated and tenant-scoped. Two things travel with each request:

  • Authorization: Bearer <token> — a JWT from your OIDC provider, or a Ductor API key as the fallback credential.
  • X-Tenant-ID: <uuid> — the tenant the call operates within.
export DUCTOR_API_KEY="duk_live_..."
export DUCTOR_TENANT="9c8b7a6d-1234-4e5f-8a9b-0c1d2e3f4a5b"

curl -s https://api.ductor.io/api/pools \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"

The tenant comes from the request context, never the body

Where a resource is tenant-scoped, the server takes the tenant identity from the authenticated request — the X-Tenant-ID header and token claims — and ignores any tenant_id in the payload. You cannot create a resource in a tenant you are not authenticated against. This is enforced uniformly; see Tenancy for why isolation is an end-to-end invariant. Full details of the auth model — JWT verification, API-key fallback, RBAC — live in the Authentication section.

Authorization: resource + action + roles

Each RPC declares its own authorization requirement. The interceptor chain evaluates three things against your token:

  • resource_type — what kind of object the call touches (pool, recipient, rule, workflow, workflow_run, workflow_definition, connector, tenant, config, …).
  • actionread, write, delete, or admin.
  • roles — an any-of role gate on sensitive operations. Ductor's built-in roles are viewer, operator, admin, and service. Tenant provisioning and API-key minting, for example, require admin.

Each resource page lists the exact resource_type:action (and any role gate) for its operations so you can scope keys to least privilege.

Conventions shared across every resource

Pagination

List endpoints take a pagination object and return a cursor. Page size caps at 1000, defaults to 50.

# First page
curl -s "https://api.ductor.io/api/pools?pagination.page_size=25" \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"

# Next page — feed back the token you were handed
curl -s "https://api.ductor.io/api/pools?pagination.page_size=25&pagination.page_token=eyJvZmZzZXQiOjI1fQ==" \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"

Every list response carries a pagination block:

{
  "pagination": {
    "next_page_token": "eyJvZmZzZXQiOjUwfQ==",
    "total_count": 250
  }
}

next_page_token is an opaque cursor — treat it as a blob, don't parse it. It's empty on the last page. total_count may be -1 when the total is too expensive to compute.

Response metadata

Every response — success or list — includes a metadata object for observability:

{
  "metadata": {
    "trace_id": "trace-abc123def456",
    "request_id": "req-789ghi012",
    "fetched_at": "2026-07-11T10:30:00Z"
  }
}

trace_id is the OpenTelemetry trace ID — quote it in a support ticket and the whole request is reconstructable end to end.

Errors are Problem JSON

REST errors follow RFC 9457 Problem Details. Domain sentinels map to stable HTTP statuses, and field-level validation failures are itemized:

{
  "type": "https://ductor.io/errors/validation",
  "title": "Invalid argument",
  "status": 400,
  "detail": "name: must not be empty",
  "errors": [
    { "field": "name", "description": "must not be empty", "reason": "FIELD_VIOLATION" }
  ]
}
HTTPMeaningTypical cause
400Invalid argumentMalformed body, failed field validation
401UnauthenticatedMissing/expired token
403Permission deniedToken lacks the required role or scope
404Not foundResource ID doesn't exist in this tenant
409ConflictUniqueness violation, or a state transition that isn't allowed
412Failed preconditionOptimistic-lock version mismatch (see below)
429Quota exceededA tenant quota or rate limit was hit

The Connect-RPC surface returns the equivalent Connect/gRPC codes for the same sentinels.

Optimistic concurrency

Mutable resources that a coordinator or multiple operators can touch carry a version counter (record_version on pools, draft_revision on workflow drafts, db_record_version on workflow runs). Where an operation accepts an expected_version, the server rejects the write with 412 Failed Precondition if the stored version has moved on — you re-read, reconcile, and retry. This is the same optimistic-locking discipline the coordinator uses internally; see Optimistic Locking.

Where to start

If you're standing up a tenant from scratch, the path is linear: