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

cap.web.search (abridged)
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/<slug>/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: 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.

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: 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 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.
Upsert a qualifier
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.
Delete a qualifier
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_<invocation>_<attempt> 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:
Capability call arguments
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

List capabilities
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.
List one capability's providers
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.
List recorded decisions
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.

Catalog

The frozen index the bindings are generated from.

Connector tiers

Native, trusted worker, federated, and what each can prove.

EffectSpec

The effect contract every capability and tool carries.

Idempotency

Why a capability call claims its slot as unresolved.