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 requiredEffectSpec, 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.
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)
How tools map to capabilities
A binding’starget 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 reviewedEffectSpecand — for areadbackorwebhookstage — only if the plan is executable by this runtime. A readback stage is admitted only on anativetier bundle withobolcustody and anopen_apitarget, and its probe origin must be inside the bundle’s own egress policy. - If there is no pack,
capabilities.tools_for_slugsynthesizes a tool for every binding whose target names that slug, using the capability spec’s schemas and effect. A binding withstatus: quarantinedis 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.- Native
- Federated
- Trusted worker
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 scores0.5ifbundled_in_broker,0.35otherwise — never a fabricated zero. fitnessis the fraction ofprefer-level requirements the binding covers, or, when the caller asked for nothing, the fraction of the spec’s whole feature vocabulary it covers.qualityis 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 inroute_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
workspace.read; upsert and delete need routing.publish. Both mutations stage a snapshot publication inside the transaction and publish after commit.
Merging
Qualifiers merge inqualifier_id order and then the per-call request merges on top, and the direction is one-way: a request may only narrow.
onlyintersects. An empty intersection is a contradiction and the call is refused, not treated as unconstrained.ignoreunions.pinmust agree; two different pins are a refusal, and a request pin that conflicts with a policy pin is a refusal.min_qualitytakes the maximum,max_costthe 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
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
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
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/routeon the gateway takes an optionaltaskstring (512 characters) and returns up to 10 capability cards: capability id, display name, description, input schema, effect kind,eligible_providersas 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_capabilityis the same resolver offered as a local MCP tool in full tools mode.
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
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.