--- title: "Catalog" description: "The frozen Nango and Composio connector index, how a catalog row becomes a connection and then a routable capability binding, and how catalog broker connections are established." --- The catalog is a frozen, content-reviewed index of connectors that Obol will list, shipped with the image at `packages/connectors/catalog/index.json`. It is discovery data, not live authority. Catalog brokers are **vendors, never authority** (invariant 9). A broker never mints a receipt, assigns evidence trust, enforces a policy decision, or supplies idempotency. Idempotency is always Obol's, and broker-side retries are disabled on every call — neither Nango nor Composio offers an idempotency primitive, and their retry knobs generate duplicate writes. ## What is in the freeze | Fact | Value | |---|---| | `schema_version` | `1` | | `generated_at` | `2026-08-29T00:00:00Z` | | Connectors | 2,147 | | Rows with a `native` source | 103 | | Rows with a `nango` source | 980 | | Rows with a `composio` source | 1,380 | | `default_tier: native` | 103 | | `default_tier: federated` | 2,044 | Lane counts overlap and do not sum to the catalog size: many vendors are carried by both brokers. The browse read publishes `lane_overlap` so a rail cannot be drawn as a partition it is not. Each row is a slug, a display name, a description, categories, a default tier, and one entry per source: ```json One catalog row { "slug": "linear", "display_name": "Linear", "description": "Linear.", "categories": ["ticketing"], "default_tier": "federated", "sources": [ { "provider": "composio", "auth_modes": ["oauth2"], "mcp": true, "allowed_mcp_origins": ["https://mcp.composio.dev"], "upstream_id": "linear" } ] } ``` `allowed_mcp_origins` is the reviewed origin allowlist a federated completion is checked against. It is control's input, never a browser's, and the browse projection drops it along with `upstream_id` and `mcp`. Slugs are validated on load: lowercase alphanumerics, `_`, and `-`, no dots, and unique. Uniqueness is what makes the browse ordering a total order — two rows sharing a slug would tie on every key, and a tie in a paged read is how a row gets dropped from one page and repeated on the next. ## How it is generated `packages/connectors/catalog/generate.py` builds `index.json` from two inputs: - **Vendored public broker catalogs** in `sources/` — Nango's `api-catalog.txt` and Composio's `toolkits.md`. Rows whose auth mode does not map onto Obol's closed `oauth2 | api_key` pair are dropped, as are `NONE` and `NO_AUTH` rows. - **Obol's own reviewed native packs.** `native_packs()` walks `packages/connectors/*/bundle.json` and adds a `native` source for every pack whose tier is `native` and custody is `obol`, keyed by the pack directory name — which is the slug control resolves a bundle by. Auth mode comes from the pack's reviewed `help.json`. Regeneration and drift checking: ```bash Regenerate the freeze python3 packages/connectors/catalog/generate.py python3 packages/connectors/catalog/generate.py --check python3 packages/connectors/catalog/generate_capabilities.py python3 packages/connectors/catalog/generate_capabilities.py --check ``` Detected drift **quarantines** the index. It is never a hot swap from a live broker API. That matters more here than elsewhere: Composio's catalog is machine-generated from scraped OpenAPI specs, its slugs have been renamed outside its own version system, and pinning a toolkit version does not pin the vendor API behind it. Connector brand marks for the dashboard gallery are a separate reviewed artifact (`logos.json`), and ship with the app rather than being fetched from a broker at render time. ## Reading the catalog `GET /api/v1/workspaces/{id}/catalog` answers in two shapes, discriminated by the presence of `page`. With no query parameter at all it returns every row verbatim with the workspace's connections attached — the shape the connect dialog reads. Supply any of `q`, `category`, `source`, `limit`, `offset`, or `cursor` and it returns a page of gallery cards plus facets: ```bash Browse the catalog curl "https://control.tryobol.dev/api/v1/workspaces/ws_123/catalog?q=linear&source=nango&limit=24" \ -H "Authorization: Bearer $OPERATOR_TOKEN" ``` | Parameter | Bound | |---|---| | `q` | 64 characters | | `category` | 64 characters | | `source` | `native`, `nango`, or `composio`. Anything else is a `422` | | `limit` | 1–100, default 24 | | `offset` | 0–100,000 | | `cursor` | 128 characters, and mutually exclusive with `offset` | Every bound is refused rather than clamped: a silently narrowed page or truncated query is a different answer to a different question wearing a `200`. The one exception is a category nobody uses, which is an empty page — "nothing is filed under that" is a real answer. A source is not that exception, because the lanes are a closed enum. Ranking is exact slug, then slug prefix, then name prefix, then substring in slug, then substring in name; ties break on slug, which is unique, so the same offset names the same row on every call. Both facet lists are computed over the **unfiltered** catalog so neither rail reshuffles while an operator types. A cursor is an offset plus a digest of the schema version, the freeze timestamp, and every filter field. Replaying a cursor issued for one query against another is refused rather than followed. It is not a signature and confers no authority — every offset it can name is one the caller could pass directly. `GET /api/v1/workspaces/{id}/catalog/{slug}` returns one row whole, or `404` for an unknown slug. Both reads require `workspace.read`. ## From a catalog row to a routable capability `POST /api/v1/workspaces/{id}/catalog-providers` with `{"provider": "nango" | "composio", "api_key": "…"}` creates one broker connection per workspace. The broker's own API key is sealed by the gateway and the connection goes active. A second registration for the same provider is refused. `POST /api/v1/workspaces/{id}/connections` with `{"slug", "source"}` resolves the row and its source, takes the workspace lock, and either reuses an abandoned credential-less `pending` row or creates one. What comes back in `next` depends on the source's auth mode: `restricted_key`, `redirect` (native OAuth), or `catalog_redirect` (federated). For a native API-key source, `POST .../restricted-key`. For native OAuth, follow the returned authorize URL to the callback. For a federated source, follow the broker's session URL and then call `POST .../federated-complete` — which **always fails today** with `409 broker_verification_unavailable`, so no federated connection can be finished. See the warning below. Every mutation stages a snapshot publication inside its transaction and publishes after commit. Nothing is routable until the published version covers the workspace's current snapshot version. Once a native or federated connection is active, `tools_from_graph` publishes its concrete tools. `bindings_for_routes` includes active capability bindings whose target resolves to exactly one tool and whose source, custody, tier, surface, and evidence ceiling match the connection. ADR-0063 makes all structurally valid catalog bindings eligible without native-only or curation gates. Inferred broker target names still need a matching concrete tool contract; eligibility does not certify their mapping. See [Capabilities](/routing/capabilities). A workspace holds at most one active connection per connector. A second is refused with a conflict rather than added, because two active connections on one connector publish two tool snapshots with the same name and the gateway resolves whichever sorts first — leaving the other credential silently unreachable. ## Federated connect sessions Starting a federated connection creates a `CatalogConnectSession` (`cxs_…`) that is: - Bound to the workspace, the connection, the initiating operator, and the provider. - Frozen with the reviewed `allowed_mcp_origins` for that source at handoff time, so a later catalog or settings change cannot widen what completion will accept. - Valid for 10 minutes, single use. Control then asks the gateway to mint the broker session under a capability token and returns the broker's redirect URL along with the `cxs_` id. Because the broker has no return leg to Obol, the connections and catalog reads hand a **resumable** handle back — but only to a caller who holds `connection.manage`, only to the operator who initiated it, only while unconsumed and unexpired, only while the session's provider still matches the connection's, and only once the snapshot is published. Those are exactly the grounds completion itself refuses on, so a surface never offers a handle that completion would reject. ### Completion `POST /api/v1/workspaces/{id}/connections/{connection_id}/federated-complete` takes the `cxs_` id, the broker's upstream connection id, and the MCP URL. It runs in two short transactions with no transaction open across the network: 1. **Phase A** locks `Membership → Workspace → Connection → Connector → CatalogConnectSession → SnapshotPublication`, validates eligibility and the upstream id, captures the frozen origins, and commits — releasing every lock before a packet leaves. 2. **The probe** normalizes and validates the URL against those origins with no lock held. See [Capabilities](/routing/capabilities#native-and-federated-targets) for what that validator does and does not guarantee. 3. **Phase B** retakes the same locks in the same order, repeats every check on fresh rows, and mutates. The upstream connection id is broker-supplied and lands verbatim in an audit row, so it is refused if it is empty, padded, over 256 bytes, carries control characters, or is URL-shaped. Only the bare reviewed origin reaches the audit detail — never the path or the full URL. Federated completion currently always fails. `complete_federated_session` in `apps/control/app/services/connections.py` raises `broker_verification_unavailable` (`409`) at the mutation boundary, leaving the connection pending and the handoff unconsumed. An unauthenticated `HEAD` probe proves a reviewed, reachable origin — not who owns the connected account, whether authorization is active, or whose OAuth client it uses, and an operator-supplied identifier cannot be authority for a credentialed route. No authenticated broker-account verifier exists yet. ## Managing a connection | Route | Effect | |---|---| | `POST .../reconnect` | Supply a new client secret or API key for a pending connection and re-issue the handoff. Supersedes and deletes unconsumed sessions | | `POST .../disable` | Keep the row and the sealed credential, stop publishing its tools. Reversible by reconnecting | | `POST .../purge-credential` | Destroy the vaulted credential, keep the row. Irreversible; status becomes `disabled`, `credential_revision` bumps, and `last4` moves into the audit detail as it is cleared | | `DELETE .../{connection_id}` | The only deletion this plane offers, and only for a row that never held a credential — an unfinished setup, not a connection. `connection.discard` is audited | A connection row is otherwise kept forever, because it is the audit trail of a credential this workspace held. ## Revocation is two-step Disabling or purging at Obol covers the Obol hop only. The broker still holds the vendor credential, and a broker-held token can act outside Obol entirely — through the customer's own scripts or another connected agent. Obol's audit log is a record of Obol-mediated traffic, not of everything the credential did. Composio cannot revoke API-key-scheme connections at all, leaving revocation to the end user at the vendor. {/* TODO: ADR-0030 requires control to call the broker's disconnect API and the dashboard to render "revoked at Obol, still live at broker". No broker disconnect call exists in `apps/control` today. */} ## Broker asymmetry The two brokers are adopted in different roles, and the difference is enforced in the snapshot rather than in a runbook. The credential substrate. Its catalog is configuration data across roughly 980 providers, its raw proxy preserves the vendor's numeric HTTP status, and it permits extracting the token — which is the precondition for hybrid custody, and therefore the only path by which a federated write could ever reach `verified`. Obol must use the raw proxy rather than the normalized action endpoint, and the `proxy-forward-all-response-headers` account flag is a contractual precondition: without it the vendor's request id never reaches a receipt. Rate limits are per Nango account, not per Obol workspace. A read-biased long-tail source, barred from the destructive path. Tokens are redacted on every tier, so hybrid custody is structurally unavailable. Its normal execution path returns a computed `successful` boolean with no vendor status and no typed vendor object id to check it against. Its execution allowlist is documented as bypassable and its schema-modifier hooks do not run over MCP, so **no Cedar decision may ever be delegated to broker-side filtering**. Quota is shared across all users of a managed app, and every authenticated endpoint draws from one org budget. That is what `shared_quota` on a binding records: a federated candidate's effective capacity degrades on strangers' traffic. Official and high-risk connectors stay native or trusted-worker regardless. `github` has `default_tier: native` and `stripe` has `default_tier: trusted_worker`; federation is listed as an additional source, not the default. ## Broker content is data, never instruction Any surface a broker controls can carry text aimed at a model — Composio's own pricing page ships a block addressed to AI agents instructing them how to sign up. Catalog-sourced bundles are therefore frozen, content-addressed, workspace-qualified, and conformance-tested, and broker runtime metadata is never authority. ## Current state All structurally valid catalog capabilities and bindings are eligible across native and federated sources (ADR-0063). Missing conformance, curation, or additional providers does not exclude a candidate. Workspace access and concrete operation contracts still constrain publication and execution. Federated completion still refuses with `409 broker_verification_unavailable` until authenticated broker-account verification exists; eligibility does not complete a connection. The historical review and source ledger live at `docs/reviews/2026-09-04-connectors-federated.md`. ## Related pages What the federated tier is and what it can prove. The gate that decides native, trusted worker, or federated. Gateway-owned exchange and refresh for native connections. Full request and response shapes.