eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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.
  1. 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.