--- title: "Routing" description: "Route qualifiers, the capability browse surface with its provider bindings, and the routing decisions the gateway already took." --- Three kinds of surface live here. Route qualifiers are the operator's written routing rules, published into the workspace snapshot the gateway loads. Capabilities and bindings are reads over the frozen capability packs, marked with what this workspace can actually reach. Route decisions are the decisions the gateway recorded on receipts. Control never runs the selector. Nothing on this page scores a candidate or says what *would* be chosen — `obol-route::select` in the gateway is the only thing that selects, and a second implementation in the dashboard would drift against it. ## Authentication All routes require `Authorization: Bearer `, verified as [/api-reference/overview](/api-reference/overview) describes. Reads need the `workspace.read` action (viewer and above); qualifier writes need `routing.publish` (admin or owner). A non-member gets `404 workspace not found`; a member without the action gets `403 action not permitted`; a frozen workspace answers `409 workspace is frozen` on writes. ## List route qualifiers `GET /api/v1/workspaces/{workspace_id}/route-qualifiers` Every qualifier in the workspace, ordered by `qualifier_id`. Workspace to read. `operator_route_qualifier` documents. Each carries the workspace's current publication state as its own copy. ```bash Request curl https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/route-qualifiers \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "qualifiers": [ { "schema_version": 1, "workspace_id": "ws_acme_prod", "qualifier_id": "oauth-mcp-search", "revision": 1, "capability_id": "cap.web.search", "constraint": { "kind": "only", "targets": ["exa", "tavily"] }, "prefer_auth": ["oauth2"], "prefer_surface": ["mcp"], "prefer_sources": [], "min_quality": 6000, "allow_fallbacks": false, "deleted": false, "publication": { "state": "published", "requested_version": 7, "published_version": 7 } } ] } ``` A stored row that somehow carries more than one active constraint cannot be projected honestly, so the list answers `500` with `{"code": "route_qualifier_payload_invalid"}` and, when it is a safe slug, the offending `qualifier_id`. ## Create or update a route qualifier `POST /api/v1/workspaces/{workspace_id}/route-qualifiers` One endpoint for both create and update, keyed by `qualifier_id`. The request model forbids unknown fields — a body with a misspelled key is a `422`, not a silently dropped rule. 1–128 characters from `[a-z0-9_-]`. Anything else is `400 qualifier_id must be [a-z0-9_-]`. The capability this rule applies to, for example `cap.web.search`. Must be `cap.` followed by a non-empty tool segment, at most 256 code points, and must contain no line terminators. Anything else is `400 capability_id must use the cap prefix`. Omit to apply the qualifier without a capability. A tagged constraint. One of: - `{"kind": "none"}` - `{"kind": "only", "targets": ["exa", "tavily"]}` - `{"kind": "ignore", "targets": ["exa"]}` - `{"kind": "pin", "target": "exa"}` `targets` holds at most 32 entries of 1–256 characters each. Up to 8 of `oauth2`, `api_key`. Up to 8 of `mcp`, `rest`. Up to 8 of `native`, `nango`, `composio`. 0–10000 basis points. A hand-authored prior, not a measurement. 0 to 2^63 − 1. Stored as `max_cost` in the published payload. Reserved. Control accepts, stores, and publishes it; nothing in the gateway acts on it, and no promise is made about it. ADR-0027 makes a fallback a new authorization with its own Cedar decision and idempotency key, so the behavior will arrive with its own contract rather than by this flag changing meaning. Optimistic concurrency. When present it must equal the stored revision, and the qualifier must already exist — claiming a revision of an absent row is `409 route_qualifier_revision_conflict`. The field is body-only; a query parameter of the same name is ignored. ### Response `200` with the same `operator_route_qualifier` document the list returns, carrying the new `revision` and the publication state after the write. ```bash Request curl -X POST https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/route-qualifiers \ -H "Authorization: Bearer $OBOL_SESSION" \ -H "Content-Type: application/json" \ -d '{ "qualifier_id": "oauth-mcp-search", "capability_id": "cap.web.search", "constraint": { "kind": "only", "targets": ["exa", "tavily"] }, "prefer_auth": ["oauth2"], "prefer_surface": ["mcp"], "min_quality": 6000 }' ``` ```json Response { "schema_version": 1, "workspace_id": "ws_acme_prod", "qualifier_id": "oauth-mcp-search", "revision": 1, "capability_id": "cap.web.search", "constraint": { "kind": "only", "targets": ["exa", "tavily"] }, "prefer_auth": ["oauth2"], "prefer_surface": ["mcp"], "prefer_sources": [], "min_quality": 6000, "allow_fallbacks": false, "deleted": false, "publication": { "state": "published", "requested_version": 7, "published_version": 7 } } ``` ### Errors | Status | Detail | Cause | | --- | --- | --- | | `400` | `qualifier_id must be [a-z0-9_-]` | Slug rule, mirrored from the gateway's `RouteQualifier::validate` | | `400` | `capability_id must use the cap prefix` | Fails `ToolName::parse` or is not under `cap` | | `409` | `route_qualifier_revision_conflict` | `expected_revision` mismatch, or a create claiming a revision | | `409` | `route_qualifier_limit_reached` | The workspace already holds 32 qualifiers. A 33rd would make the whole workspace snapshot unloadable at the gateway, so it is refused here | | `422` | field errors | Unknown body key, or a value outside a declared bound | | `500` | `{"code": "workspace publication state is unavailable"}` | Publication state could not be projected | ## Delete a route qualifier `DELETE /api/v1/workspaces/{workspace_id}/route-qualifiers/{qualifier_id}` The qualifier to remove. Minimum `1`. When present it must equal the stored revision. Answers `200` with a tombstone: the same document shape, `deleted: true`, and the revision the row held when it was removed. The tombstone is a response only — the row is gone before the commit, so no `deleted` flag reaches the published route policy. `404 qualifier not found` when the qualifier does not exist in the workspace; `409 route_qualifier_revision_conflict` on a revision mismatch. ## List capabilities `GET /api/v1/workspaces/{workspace_id}/capabilities` Every `cap.*` capability in the frozen packs, sorted by capability id, with the counts that depend on this workspace. The workspace environment. The evidence floor is derived from it. Capability projections. For example `cap.account.create`. From the pack's `EffectSpec` — `read`, `create`, `update`, `delete`, or `external_side_effect`. Defaults to `read` when the pack declares none. `effect_kind != "read"`. The same derivation the synthesized tool uses, so the screen and the approval gate cannot disagree. The minimum evidence class a route must be able to reach. Outside production this is always `untrusted`. In production: `gateway_observed` for `delete` and `external_side_effect`, `broker_attested` for `create` and `update`, and `untrusted` for everything else. How many bindings the packs declare for this capability. How many of those are both non-quarantined and backed by a connection this workspace holds — the set the snapshot actually publishes. Also returned per capability: `display_name`, `description`, `feature_vocabulary`, and `status`. ```bash Request curl https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/capabilities \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "env": "prod", "capabilities": [ { "capability_id": "cap.account.create", "display_name": "Create account", "description": "Create account. Interchangeable across vendors that create a account. Do not name a provider.", "effect_kind": "create", "destructive": true, "evidence_floor": "broker_attested", "status": "active", "feature_vocabulary": ["account.create"], "catalog_bindings": 6, "active_bindings": 1 } ] } ``` ## List a capability's bindings `GET /api/v1/workspaces/{workspace_id}/capabilities/{capability_id}/bindings` The providers that can serve one capability, sorted by `binding_id`. A `cap.`-prefixed capability id. A malformed value is `400 capability_id must use the cap prefix`; a well-formed id no pack declares is `404 capability not found`. The same capability projection the list returns. The concrete tool, for example `salesforce.create_account`. `connector_slug` is its first segment. `native`, `trusted_worker`, or `federated`. The strongest evidence this route can produce. Federated routes cap at `broker_attested` or `untrusted` and can never reach `verified` — see [/connectors/federated](/connectors/federated). Whether the ceiling ranks at or above the capability's derived floor, over the ranking `untrusted` < `broker_attested` < `connector_attested` < `vendor_signed` < `gateway_observed`. An unrecognized class ranks below every known one. `base_micros_usd` is `null` when the pack publishes no base cost — bundled in a broker's plan, or simply unpriced. It is never fabricated as `0`. `bundled_in_broker` is a boolean. A hand-authored prior in basis points. There is no latency or success-rate signal behind it, and any surface rendering it must say so. Whether this workspace holds a connection that reaches the binding. Also returned per binding: `binding_id`, `capability_id`, `custody`, `surface`, `auth_modes`, `catalog_source`, `coverage` (a list of `{feature, claim, fidelity}`), `shared_quota`, `idempotency_propagation`, and `status`. ## List route decisions `GET /api/v1/workspaces/{workspace_id}/route-decisions` Routing decisions the gateway took, newest first. Membership is exactly "the receipt carries a route" — a receipt with no `route_capability` was never routed. 1–200. Opaque receipt cursor from a previous `next_before`. A legacy RFC3339 timestamp is still accepted as a bound; newly issued cursors are `v1.` followed by unpadded base64url of `[timestamp, receipt_id]` so ties inside a second can be traversed (ADR-0042). A malformed cursor is `400 invalid receipt cursor`, never silently replaced with the current time. Filter to one `cap.`-prefixed capability. A malformed value is refused with `400 capability_id must use the cap prefix` rather than ignored. One row per routed receipt. The target the selector chose, with `binding_id` and `reason` alongside it. `reason` comes from a closed vocabulary: `best_score`, `only_candidate`, `pinned_by_policy`, `pinned_by_request`, `fallback_after_failure`. What the chosen route could have proven, from the decision envelope. What the call actually achieved. Both are returned so the gap between them is visible. How many candidates were scored. `excluded_count` counts the rest, and `excluded` lists up to 8 of them as `{target, binding_id, reason}` — the selector bounds both lists at 8, so nothing is paginated inside a row. The exclusion reason is also closed: `coverage_missing`, `qualifier_only`, `qualifier_ignore`, `qualifier_pin`, `auth_mismatch`, `surface_mismatch`, `evidence_floor`, `cost_ceiling`, `quality_floor`, `inactive`, `target_missing`, `destructive_federated`, `quarantined`, `candidate_cap`, `request_widens_policy`, `circuit_open`, `success_floor`, `not_authorized`. Cursor for the next page, or `null` on the last page. Also returned per decision: `receipt_id`, `occurred_at`, `capability`, `surface`, `catalog_source`, `evidence_floor`, `state`, `decision`, `algorithm_version`, and `snapshot_version`. Per-candidate scores and weights stay in the receipt envelope — read one decision at a time through [/api-reference/operator](/api-reference/operator#get-a-receipt). ```bash Request curl "https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/route-decisions?limit=25&capability=cap.web.search" \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "decisions": [ { "receipt_id": "rcp_01J8Z0", "occurred_at": "2026-09-04T10:15:02Z", "capability": "cap.web.search", "selected": "exa.search", "binding_id": "exa-web-search", "reason": "best_score", "surface": "mcp", "catalog_source": "native", "evidence_floor": "untrusted", "evidence_ceiling": "gateway_observed", "evidence_trust": "gateway_observed", "state": "completed", "decision": "allow", "eligible_count": 2, "excluded_count": 1, "excluded": [ { "target": "tavily.search", "binding_id": "tavily-web-search", "reason": "coverage_missing" } ], "algorithm_version": 1, "snapshot_version": 7 } ], "next_before": null } ``` Related reading: [/routing/capabilities](/routing/capabilities) for the capability model and [/routing/catalog](/routing/catalog) for how bindings reach a snapshot.