---
title: "Capabilities"
description: "What a capability is, how bindings map it onto concrete native and federated tools, how the selector filters and scores them, and the operator surfaces that read it."
---
A **capability** is a job several vendors can do — `cap.issue.create`, `cap.web.search` — expressed as one schema an agent calls instead of a vendor-specific tool. It is a routing and shape layer over the connector fabric, not a fourth connector tier (ADR-0026).
Two artifacts define it:
- A **capability spec** carries the canonical input and output schemas, a closed `feature_vocabulary`, a required `EffectSpec`, a status, and human-readable text.
- A **capability binding** maps one already-existing concrete tool onto that spec, declaring its tier, custody, surface, catalog source, auth modes, coverage claims, cost hint, quality prior, evidence ceiling, shared-quota flag, and idempotency propagation.
Both ship as frozen JSON packs in `packages/connectors/catalog/capabilities/`, one file per capability, each holding `{ "spec": …, "bindings": [ … ] }`. They are loaded once per process and republished verbatim into every workspace snapshot. They are not database rows and there is no per-workspace authoring surface for them.
The reserved connector prefix is `cap`, and `ToolName` splits on the first `.` only. So `cap.issue.create` is connector `cap`, tool `issue.create` — no parser change, no collision with a customer connector named `web`, and one Cedar policy can govern all routed traffic with `resource.connector == "cap"`.
## What a spec looks like
```json cap.web.search (abridged)
{
"spec": {
"schema_version": 1,
"capability_id": "cap.web.search",
"display_name": "Web search",
"description": "Search the public web and return ranked results. Distinct from cap.web.scrape, which fetches one URL. Do not name a provider.",
"feature_vocabulary": ["search.fresh", "search.render"],
"input_schema": {
"type": "object",
"additionalProperties": false,
"properties": { "query": { "type": "string", "minLength": 1 } },
"required": ["query"]
},
"output_schema": { "type": "object", "required": ["results"] },
"effect": { "schema_version": 1, "kind": "read", "verification": { "type": "none" } },
"status": "active"
},
"bindings": [
{
"binding_id": "exa-search",
"capability_id": "cap.web.search",
"target": "exa.search",
"tier": "federated",
"custody": "federated",
"surface": "mcp",
"auth_modes": ["api_key"],
"catalog_source": "nango",
"coverage": { "features": [] },
"cost_hint": { "base": 2000, "bundled_in_broker": true },
"quality": 8000,
"evidence_ceiling": "untrusted",
"shared_quota": true,
"idempotency_propagation": "none",
"status": "quarantined"
}
]
}
```
The description tells the model what the job is and explicitly says not to name a provider. Choosing the vendor is Obol's half of the division of labour; the caller picks the job.
## How tools map to capabilities
A binding's `target` is a concrete namespaced tool name such as `exa.search` or `github.create_issue`. Two things have to line up before that binding is routable in a workspace.
### The tool has to exist in the snapshot
`tools_from_graph` walks every active connection and asks `tools_for_connection` for its tools:
- If the connector slug has a reviewed pack on disk (`packages/connectors//bundle.json`), the pack's tools are published, each carrying the reviewed `EffectSpec` and — for a `readback` or `webhook` stage — only if the plan is executable by this runtime. A readback stage is admitted only on a `native` tier bundle with `obol` custody and an `open_api` target, and its probe origin must be inside the bundle's own egress policy.
- If there is no pack, `capabilities.tools_for_slug` synthesizes a tool for every binding whose target names that slug, using the capability spec's schemas and effect. A binding with `status: quarantined` is skipped here too — otherwise a caller could bypass capability routing and invoke the synthesized name directly.
`destructive` is derived from the effect kind (everything but `read`), never asserted, because that flag is what the Cedar approval gate and the CEL production prefilter read.
### The binding has to match the live connection
`bindings_for_routes` pairs a binding with the connection behind its target and keeps it only if `binding_matches_connection` agrees on every fact:
| Fact | Rule |
|---|---|
| `status` | The connection is `active` |
| `catalog_source` | Equals the connection's `broker_ref.provider`, or `native` when custody is `obol` |
| `custody` | Equals the connection's custody |
| `tier` | `federated` for federated custody, `catalog_remote` kind, or an `mcp_remote` target; `trusted_worker` for a worker target; otherwise `native` |
| `surface` | `rest` for `open_api`, `mcp` for a worker or remote MCP target |
| `evidence_ceiling` | At or below the tier's maximum: `gateway_observed` native, `connector_attested` trusted worker, `broker_attested` federated |
A target that resolves to more than one tool is skipped rather than guessed at — snapshot validation refuses duplicate tools, and an ambiguous target must not be activated before that boundary either.
The gateway repeats the same check at selection time in `CapabilityBinding::connection_mismatch`, so a binding that drifts away from its connection is excluded with `auth_mismatch`, `surface_mismatch`, or `evidence_floor` rather than dispatched.
## Native and federated targets
The tier a binding declares is the tier the connection actually is, and each tier caps what the receipt can ever claim.
Obol vaults the customer credential and the gateway executes the vendor call itself through its generic OpenAPI executor. `openapi_target` in `apps/control/app/services/tools.py` builds the target from the pack's `openapi.json`, and refuses to publish it unless `servers[0].url` sits inside the bundle's reviewed egress policy — scheme, host, and port all present, no userinfo, no query, no fragment, no control characters. A vendor-authored document must never widen where a credentialed request is sent. `allowed_headers` is empty by design.
Evidence ceiling: `gateway_observed`. Surface: `rest`. See [Native connectors](/connectors/native).
A catalog broker holds the vendor credential and the call goes over remote MCP. The broker-supplied MCP URL is untrusted input, so `apps/control/app/services/targets.py` normalizes it, matches its origin byte-for-byte against the origins frozen into the connect session, resolves every DNS answer and requires all of them globally routable, and walks up to three redirects revalidating every hop — HTTPS only, port 443 or none, no query, no fragment, no userinfo, no IP literal, no `localhost` or `.local`. Every rejection raises the same fixed string, so the validator cannot become an oracle for which hostnames are reviewed.
That is **control-plane** hardening. It does not pin the socket the gateway later dials, so it does not by itself defeat final-socket DNS rebinding; gateway egress policy is the last defence. Evidence ceiling: `broker_attested` at best, `untrusted` in practice. Surface: `mcp`. Federated bindings are eligible under `cap.*` on the same basis as native bindings (ADR-0063); an actual matching connected tool and concrete authorization are still required. See [Federated connectors](/connectors/federated).
Reserved design, not a routable tier today: there is no worker deployable, and no connector pack declares `tier: trusted_worker`. Under ADR-0024 a reviewed worker would plan the request and normalize the response while the gateway performed the credentialed HTTP; the shipped executor instead hands the credential to the worker (ADR-0014). Evidence ceiling: `connector_attested`. Surface: `mcp`. See [Trusted workers](/connectors/trusted-workers).
## Selection
`CapabilityRouter::select` (`apps/gateway/crates/obol-route/src/select.rs`) is a pure function of the snapshot, the merged route request, the unauthorized-candidate list, and the clock.
### Exclusions
Every excluded candidate lands on the decision record with a closed reason:
| Reason | Cause |
|---|---|
| `quarantined` | The binding's status is `quarantined` |
| `not_authorized` | Cedar's `route` action refused this key on this concrete tool |
| `target_missing` | The tool or its connection is absent from the snapshot |
| `inactive` | The connection is not active |
| `auth_mismatch` | Catalog source, custody, or tier disagrees with the connection |
| `surface_mismatch` | Declared surface disagrees with the connection's target |
| `destructive_federated` | A `delete` or `external_side_effect` on a federated binding in `prod` |
| `evidence_floor` | The declared ceiling cannot meet the derived floor |
| `qualifier_only` / `qualifier_ignore` / `qualifier_pin` | Excluded by a route constraint |
| `cost_ceiling` | Over `max_cost`, or unpriced and not `bundled_in_broker` |
| `quality_floor` | Under `min_quality` |
| `coverage_missing` | A `require`-level feature the binding does not support |
| `candidate_cap` | Ranked outside the top 8 after scoring |
A `requirements` key that is not in the spec's own `feature_vocabulary` is ignored rather than honoured. `requirements` is lifted from tool-call arguments and is therefore model-authored; an open string map that can exclude candidates is a steering surface with no upper bound on what it can be made to say.
### Scoring
See [Routing overview](/routing/overview#the-score) for the formula and weights. The relevant properties here:
- Price is a log ratio to the cohort's cheapest candidate, floored at 100 micros and capped at `ln 4`. An unpriced candidate scores `0.5` if `bundled_in_broker`, `0.35` otherwise — never a fabricated zero.
- `fitness` is the fraction of `prefer`-level requirements the binding covers, or, when the caller asked for nothing, the fraction of the spec's whole feature vocabulary it covers.
- `quality` is a hand-authored prior in basis points and appears squared. It is a routing heuristic and never evidence; it must not appear in a verification summary.
### Route reason
The record says how the winner was picked: `pinned_by_request`, `pinned_by_policy`, `only_candidate`, or `best_score`. One survivor is not a choice, and a pin is a decision an operator or caller already made — neither is reported as scoring.
## Route qualifiers
A qualifier is a standing workspace rule, stored in Postgres and published in `route_policy.qualifiers`. Operators send and read a tagged document; the gateway reads a flat legacy shape, and the translators in `apps/control/app/api/routing.py` are the only bridge.
```bash Upsert a qualifier
curl -X POST https://control.tryobol.dev/api/v1/workspaces/ws_123/route-qualifiers \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"qualifier_id": "search-eu-only",
"capability_id": "cap.web.search",
"constraint": { "kind": "only", "targets": ["exa"] },
"prefer_surface": ["mcp"],
"min_quality": 7000,
"max_cost_micros_usd": 20000,
"expected_revision": 3
}'
```
| Field | Meaning |
|---|---|
| `qualifier_id` | `[a-z0-9_-]`, at most 128 characters. Unique per workspace |
| `capability_id` | Must use the `cap.` prefix and parse as a tool name. Omit to apply to every capability |
| `constraint` | `{"kind":"none"}`, `{"kind":"only","targets":[…]}`, `{"kind":"ignore","targets":[…]}`, or `{"kind":"pin","target":…}` |
| `prefer_auth` | `oauth2`, `api_key` |
| `prefer_surface` | `mcp`, `rest` |
| `prefer_sources` | `native`, `nango`, `composio` |
| `min_quality` | 0–10000 basis points |
| `max_cost_micros_usd` | Non-negative, bounded by `i64::MAX` |
| `expected_revision` | Optimistic concurrency. A mismatch is `409`, and supplying it for a row that does not exist is also `409` rather than a create |
A constraint target may name a binding id, a concrete tool name, or a connector slug; all three match. Requests are closed (`extra="forbid"`) and every enumeration is a literal, mirroring the Rust side's `deny_unknown_fields` — a control plane that silently dropped `pin` because the caller wrote `pinned` would publish a rule the operator did not write.
At most 32 qualifiers per workspace. The 33rd is a `409` rather than a rule that quietly makes the whole snapshot unloadable at the gateway.
```bash Delete a qualifier
curl -X DELETE "https://control.tryobol.dev/api/v1/workspaces/ws_123/route-qualifiers/search-eu-only?expected_revision=4" \
-H "Authorization: Bearer $OPERATOR_TOKEN"
```
Reads need `workspace.read`; upsert and delete need `routing.publish`. Both mutations stage a snapshot publication inside the transaction and publish after commit.
### Merging
Qualifiers merge in `qualifier_id` order and then the per-call request merges on top, and the direction is one-way: **a request may only narrow**.
- `only` intersects. An empty intersection is a contradiction and the call is refused, not treated as unconstrained.
- `ignore` unions.
- `pin` must agree; two different pins are a refusal, and a request pin that conflicts with a policy pin is a refusal.
- `min_quality` takes the maximum, `max_cost` the minimum.
- `prefer_*` lists concatenate.
`allow_fallbacks` is accepted, stored, and published, and **nothing acts on it**. ADR-0027 makes a fallback a new authorization with its own Cedar `call` decision and its own `idt__` key; it will arrive with that contract or not at all. The dashboard renders no control for it.
## Per-call routing arguments
A caller may pass routing preferences alongside the capability's own arguments. `RouteRequest::take_from` strips them before anything reaches the vendor:
```json Capability call arguments
{
"query": "obol control plane",
"intent": "find pricing pages",
"requirements": { "search.fresh": "prefer" },
"route": { "only": ["exa"], "prefer_surface": ["mcp"] }
}
```
`route` and `requirements` are developer-facing and are omitted from the MCP input schema the model sees. `intent` is reduced to a digest for correlation and is never a scoring input — content-derived routing is a measured cost channel, so a sentence a model wrote must not choose a credentialed vendor.
## The browse surface
```bash List capabilities
curl https://control.tryobol.dev/api/v1/workspaces/ws_123/capabilities \
-H "Authorization: Bearer $OPERATOR_TOKEN"
```
Each row carries `capability_id`, `display_name`, `description`, `effect_kind`, `destructive`, the derived `evidence_floor` for this workspace's environment, `status`, `feature_vocabulary`, `catalog_bindings` (every binding in the pack) and `active_bindings` (those the workspace can actually reach). The second number is deliberately the set `compile_snapshot` would publish — a count including unconnected providers would tell an operator they had a choice they do not have.
```bash List one capability's providers
curl https://control.tryobol.dev/api/v1/workspaces/ws_123/capabilities/cap.web.search/bindings \
-H "Authorization: Bearer $OPERATOR_TOKEN"
```
Each binding projects target, connector slug, tier, custody, surface, auth modes, catalog source, coverage claims, cost hint, quality, evidence ceiling, `meets_evidence_floor`, `shared_quota`, `idempotency_propagation`, status, and `connected`. `cost_hint.base_micros_usd` is `null` when the pack publishes no base cost — a fabricated `0` would be summed as real spend downstream.
This is a **catalog** view, not a candidate set. Nothing on it is a prediction: no row is marked eligible or favoured, because control does not run the selector.
```bash List recorded decisions
curl "https://control.tryobol.dev/api/v1/workspaces/ws_123/route-decisions?capability=cap.web.search&limit=50" \
-H "Authorization: Bearer $OPERATOR_TOKEN"
```
Decisions are receipts whose `route_capability` column is non-null, newest first, with an opaque cursor. Each row names the selected target, the binding, the route reason, the surface, the catalog source, both `evidence_floor` and `evidence_ceiling`, the achieved `evidence_trust`, the eligible and excluded counts, the excluded candidates with their reasons, the algorithm version, and the snapshot version. Both evidence facts are shown so the gap between what the route could have proved and what the call actually achieved is visible. Per-candidate scores and weights stay in the receipt envelope.
A filter that names a value which is not a capability is refused rather than ignored: a filter that quietly does nothing returns a complete-looking list that is not the list the operator asked for.
The dashboard renders these at `/dashboard/routing`, `/dashboard/routing/capabilities/[capabilityId]`, and `/dashboard/routing/decisions`.
## Discovery, off the call path
Two read surfaces let a caller find a capability without executing one:
- `POST /v1/route` on the gateway takes an optional `task` string (512 characters) and returns up to 10 **capability cards**: capability id, display name, description, input schema, effect kind, `eligible_providers` as a bare integer, whether approval is required, the evidence floor, a coarse confidence band, and the terms that matched. It never dispatches, never unwraps a credential, and never names a provider.
- `find_capability` is the same resolver offered as a local MCP tool in full tools mode.
A card is returned only when the key could already reach at least one provider for it. Nothing matching and everything matching being invisible are one indistinguishable `200` with an empty list.
## Visibility and errors
`tools/list` shows a capability only when Cedar's `list` permits the capability tool **and** at least one candidate passes CEL visibility and Cedar `list` for this principal. When every candidate is unauthorized, a call to it renders as `Unknown tool`, byte-identical to an invented name — routing must not become the catalogue oracle `tools/list` refuses to be. A key that can reach some provider but for which nothing survives filtering gets the distinguishable `no eligible provider` error, which is safe precisely because visibility already told it the capability exists.
## Current state
All structurally valid catalog capabilities and bindings are eligible, including
native, Nango, Composio, and single-provider jobs. ADR-0063 supersedes ADR-0059 and
the blanket activation/curation gates in ADR-0026/0048/0055. Generated bindings
are active by default; explicit quarantine remains available for faulty or disabled
bindings. `packages/connectors/catalog/capability-report.json` records the counts.
Eligibility describes the catalog candidate pool. A workspace still needs an
active matching connection and an actual concrete target. Key scope, Cedar
permission, route qualifiers, valid transforms and arguments, required features,
evidence floors, and production destructive-operation controls determine what
can execute. No policy or key grant is implied by activation.
Conformance fixtures and curation records remain descriptive quality evidence.
Their absence does not block eligibility, and an eligible mapping can still fail
request or response validation. Unreviewed feature coverage stays unknown.
Generated broker targets such as `{slug}.read` and `{slug}.mutate` are inferred;
the frozen index does not provide per-vendor operation schemas. They must resolve
to the workspace's concrete tool contract before publication.
`cap.web.search` and `cap.web.scrape` are hand-authored. Other jobs are generated
from the frozen catalog and native packs without a minimum provider count.
Eligible federated bindings retain their recorded evidence ceiling; they do not
become gateway-observed or verified.
## Related pages
The frozen index the bindings are generated from.
Native, trusted worker, federated, and what each can prove.
The effect contract every capability and tool carries.
Why a capability call claims its slot as `unresolved`.