# 🤖 Integrations — a suite of connected services, from one worked example

Status: **built on main** (branch `integrations-github`). Generalises the
DNSimple proof (`docs/connected-services.md`) into a catalog anyone can
extend: GitHub, Stripe, HubSpot and friends as installable integrations, with
read/write permissions scoped per grant — and therefore per bot — and every
operation available to bots as a tool behind the same consent chips the rest
of the platform uses. GitHub is the first registry-native service, with its
own companion app (`web/static/apps/github.js`).

## The shape: manifests are the palette

Everything hangs off one idea, borrowed from bot recipes: **an integration is
data, not code**. A service ships as a JSON manifest
(`integrations/registry/`) declaring its auth, the ONE API host its
operations may touch, and a closed list of operations — each `read` or
`write`, writes each carrying the confirmation sentence a person taps before
it runs. The generic executor (`integrations/exec.go`) can only do what a
manifest spells; per-service Go code (the old `dnsimple.go`) stops being the
growth path.

**Responses are as reviewable as requests.** Each op may declare a `pick`
projection — a root envelope to descend plus the exact fields that may flow
back. The executor drops everything else before a result ever leaves the
sidecar, which is both a privacy bound (the manifest review covers what data
can enter a thread) and a practical one: an op result rides inside ONE
end-to-end-encrypted message (64 KB blob cap), while a raw GitHub
`/user/repos` page is ~400 KB of mostly-unwanted fields. Oversized results
refuse honestly; nothing is ever silently truncated.

What each party holds is unchanged from the DNSimple design:

| Party | Holds |
|---|---|
| Messenger server | opaque sealed credential blobs, as ever — nothing new |
| Connect broker | OAuth client secrets (one per OAuth-app service) + the public registry (`GET /registry`) |
| Member's client | the credential in the clear only with the member present; seals scoped grants to bots |
| Bot sidecar | a grant in the clear during its window; the manifest-pinned executor |

## Read/write, per bot

A grant sealed to a bot carries `access: "read" | "write"` alongside the
token and expiry (an access-less grant is legacy = read-only). Because a
grant is sealed to exactly one bot's public key, **access is per-bot by
construction**: seal Eleven a write grant for DNSimple and your reporting bot
a read-only one, and the executor enforces the difference — a read grant
cannot run a write op, whatever the model asks for. Least privilege stacks in
four layers:

1. **The token itself** — mint scoped PATs (GitHub fine-grained, Stripe
   restricted keys, DNSimple domain-scoped); the manifest's `pat_help` points
   at each service's scoping page, and the catalog verifies a pasted token
   immediately through the manifest's `viewer` op when one exists.
2. **The connection cap** — chosen by the member AT CONNECT TIME (read-only
   is the default; read & write is the explicit choice), sealed inside the
   credential blob so the server never learns it. The member's client refuses
   to mint any grant beyond the cap (`capAllows`, `ui.js`) — a read-only
   connection cannot produce a write grant whatever a chip or proposal asks —
   and for OAuth services whose manifests declare `scopes` / `scopes_write`,
   the cap also picks the provider-side scope set on the authorize URL, so a
   read-only connection's token is born read-only at the provider too. The
   cap is changeable later in Integrations (re-sealing the blob; the member's
   presence is the consent). Connections stored before caps existed carry no
   cap and behave as before.
3. **The grant** — read/write + expiry, sealed per bot, minted per operation
   (the apps and chips use one-shot ~2-minute grants).
4. **The tap** — every write op renders its `confirm` line as a consent chip
   ("Register blip.com? This buys the domain and costs real money.");
   nothing irreversible runs from model output alone.

## Bots use connections as tools

Interactive: *"@eleven go and register blip.com"* → the brain's `service_op`
tool (its description is GENERATED from the registry — the closed list the
manifests declare is the closed list the model can propose) → a whitelisted
chip whose label, for writes, is the manifest's rendered confirm line → the
tap seals a one-shot access-scoped grant and posts the `connect.op` → the
sidecar executor runs it → the outcome lands as a private notice, never a
data dump. Reads work the same minus the drama. `TestServiceOpEndToEnd`
drives the whole flow, including the negative (a read grant refused before
the registrar is touched).

**Research sessions** widen the interactive flow one notch: a question that
needs *reading* one or more connected services — especially cross-referencing
two ("which of my DNSimple domains point at GitHub Pages?") — gets a single
`service_research` consent chip naming the services. The tap seals a
~10-minute READ grant per service to the bot; the sidecar then runs a bounded
tool loop (`internal/bot/research.go`) whose palette is exactly the granted
manifests' read ops — write ops are never in the model's world here, whatever
the grants say — and posts one answer in the thread that asked. Results ride
fenced in `tool_result` blocks (data, never instructions), each is
pick-projected by the same pinned executor, and a provider pair rotated
mid-loop is handed back sealed to the member exactly like a single op's.

**What the model sees — and never sees.** The op *results* (pick-projected
data) enter the model's context — that is the point of research — and so, like
any conversation a bot takes part in, they transit the Anthropic API from the
sidecar. The *credentials* never do: tokens, grants, and refresh secrets live
only in the sidecar handler's locals and are never interpolated into a prompt,
a tool description, or a tool_result (`TestResearchEndToEnd` pins this — no
request to the model may contain the provider token). Say it to members the
honest way round: "the bot can read what you let it read, and nobody — not
the server, not the model — is ever handed your keys."

Standing (designed, deliberately not built): a member-bot charter gains
`connections: [{service, access}]` — shown on the creation consent card like
sources are — and at approval the client seals a standing grant to the new
bot, renewed by presence like the DNSimple autonomous-window design
(`docs/connected-services.md § token custody`). Task runs then expose the
granted services' READ ops as model tools; writes stay interactive-only until
there's a reviewed answer for unattended spending. This is the natural meet
point with member bots (issue #432).

## Connected-service apps ride the same rail

A service's companion app (DNSimple's domain table, GitHub's repos/issues) is
a normal mini-app gated by `requiresService` and fed by
`ctx.host.connected.requestOp(service, op, args, access)` — the app renders,
the bot executes under the grant, the `pick`-bounded result folds into the
app's state as an ordinary typed event. That last step is what plugs
integrations into the app **sharing model**: folded events are the thread's
log, so the data syncs across the member's devices via snapshots exactly like
a to-do list, and a future `groupCapable` service app would share one fetched
view with a whole group through the same log — no new machinery.

## The external registry repo

The registry directory is destined to be its own public repository
(`elevenmessenger/integrations`) — the audit surface for what integrations
can do, growable by PR without touching this codebase. The pipeline:

1. **Author** a manifest (PR to the registry repo; review = reading the ops —
   including `pick`, which is the review of what data can flow back).
   `integrations.Parse` is exported precisely so the registry repo's CI can
   validate a PR'd manifest with the same fail-closed rules the binary uses.
2. **OAuth services**: register the OAuth app once, secret into the connect
   broker's config — the same one-place-holds-the-secret move as the push
   relay's `.p8`. PAT-only services (GitHub, Stripe, HubSpot) need nothing
   beyond the manifest.
3. **Serve**: the broker embeds the registry and serves `GET /registry`
   (CORS-open) to every instance's web client; each instance also serves its
   own embedded copy at `GET /api/integrations` (same-origin, what the
   catalog uses today); sidecars embed a snapshot at build time
   (single-binary ethos) — the broker copy lets the *catalog UI* grow between
   deploys, while *execution* trusts only the reviewed, embedded snapshot it
   shipped with. Repo split + a broker sync-from-repo job are the ship-time
   steps (creating the GitHub repo is deliberately a human act).

## What's built (this branch)

1. `integrations/registry/` — schema + README + five manifests (DNSimple,
   GitHub, Stripe, HubSpot, Linear; reads with `pick` projections, confirmed
   writes). Linear is the first GraphQL service and needs no executor
   special-casing: each op is a POST to `/graphql` whose `body_json` carries a
   static query string plus member input in `variables`, `pick`'s dot-path
   root descends the `data.…` envelope, and its personal API keys ride
   `"authorization": "none"` (Linear rejects a Bearer prefix on them — the
   header is the bare key). Linear is also the first **mixed-scheme** service —
   its OAuth tokens DO want Bearer — which is why the sealed credential now
   records its `kind` ("pat" | "oauth") at connect time: grants carry it, and
   the executor (`ExecuteAs`) sends an OAuth token as Bearer per RFC 6750
   whatever the manifest's pasted-key scheme says (empty kind = legacy
   credential = manifest scheme, unchanged). Its 24-hour OAuth tokens ride the
   expiring-pair rotation below unchanged; `scopes`/`scopes_write` map the
   connection cap to Linear's `read` vs `read issues:create`. One authoring
   rule keeps GraphQL text out of the
   placeholder machinery: every brace in query text keeps a space inside
   (`{ nodes ` never `{nodes`), so nothing can parse as a `{arg}` placeholder —
   the fail-closed loader rejects any slip.
2. `integrations` Go package — fail-closed loader + the pinned executor
   (arg validation, path/query escaping, JSON/form bodies, host pinning,
   response caps, `pick` projection), embedded into the binary.
3. Sidecar `connect.op` v2 — any manifest op, grant-scope enforcement
   (read/write, expiry, service match); the legacy DNSimple `list` path is
   untouched.
4. The interactive flow — the brain's `service_op` tool → whitelisted consent
   chip (labels sized for a whole consent sentence) → the client seals the
   scoped one-shot grant and round-trips the op → outcome as a private notice.
5. The catalog — `GET /api/integrations` + broker `GET /registry`; the
   Integrations app lists every service with paste-a-scoped-PAT (verified on
   save via `viewer`) and a "Connect with X" button for any service the
   broker has an OAuth app for.
6. The GitHub app — repos, per-repo open issues/PRs, open-an-issue (the first
   write a member performs from inside an app, consented by the form itself).
7. **The OAuth relay is service-generic.** The golden path is: OAuth on the
   provider's own site whenever the broker has an app (the catalog probes
   `/api/oauth/config?service=` and only shows the button when the flow can
   actually start); paste-a-scoped-token is the fallback. The broker holds one
   confidential client per service — DNSimple on its legacy `LCHAT_DNSIMPLE_*`
   envs, everything else on `LCHAT_OAUTH_<ID>_CLIENT_ID`/`_CLIENT_SECRET`
   (provider endpoints come from the manifest's `auth.oauth`; the redirect
   defaults to the broker's own `/oauth/<id>/callback`). Enabling GitHub OAuth
   is therefore: create the GitHub App (callback = the broker's
   `/oauth/github/callback`), set the two envs, restart the broker — no
   instance or client deploy. Prefer a **GitHub App** over a classic OAuth
   app: members pick which repositories at consent time (least privilege as a
   consent screen), and its exchange rides the same confidential broker flow.

8. **Expiring tokens rotate themselves.** Providers with short-lived user
   tokens (GitHub Apps: 8-hour access + single-use rotating refresh tokens)
   work first-class: the stored credential is the whole pair
   (`{secret, refresh, expires_at}`, still sealed to the member's own key),
   the pair rides inside the op grant, and the SIDECAR is the only refresher —
   it rotates a stale pair before the call (or on a surprise 401, retrying
   once), serialized per credential so two near-simultaneous ops share one
   rotation instead of burning the single-use token. The fresh pair rides
   back **on the op result**, sealed to the member, and the requesting device
   re-stores it. Rotation is refused outright when the fresh pair couldn't be
   handed back (no resolvable member key) — a failed op beats a silently
   severed connection. Broker side: `POST /api/refresh`, same auth and
   nothing-persisted shape as the exchange. This is the temporal-custody
   model from `connected-services.md` made native: a stolen blob ages out in
   hours. Failure mode is honest: a dead refresh token (revoked, >6 months
   idle) errors with "reconnect it in Integrations".

Out of scope, called out honestly: standing charter grants (above),
registering the OAuth apps themselves (a human act per provider — the Stripe
and HubSpot manifests are OAuth-ready, scopes mapped to the connection cap
[HubSpot: CRM read scopes always, contacts.write only on a read & write
connection. Stripe: `read_write` for BOTH caps — Stripe refuses the
`read_only` OAuth scope unless support grants the platform read-only
connections, so until that's requested the connection cap alone enforces
read-only there, and the provider token is broader than the cap; ask Stripe
support for read-only connections before go-live if that residue matters];
the broker offers each Connect button
the moment `LCHAT_OAUTH_<ID>_CLIENT_ID`/`_CLIENT_SECRET` land in its env,
and until then the catalog offers paste-a-token alone. NB Stripe's OAuth
`client_secret` is the platform's own secret key — treat its env value with
that weight. HubSpot's 30-minute access tokens ride the expiring-pair
rotation above unchanged),
Disconnect-revokes-provider-side, native catalog UI (connected services
remain web-only), and the Node reference sidecar (`agent/`) neither executes
registry ops nor proposes `service_op` — the Go sidecar is the production
path.
