eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Integrations β€” a suite of connected services, from one worked example
2
3Status: **built on main** (branch `integrations-github`). Generalises the
4DNSimple proof (`docs/connected-services.md`) into a catalog anyone can
5extend: GitHub, Stripe, HubSpot and friends as installable integrations, with
6read/write permissions scoped per grant β€” and therefore per bot β€” and every
7operation available to bots as a tool behind the same consent chips the rest
8of the platform uses. GitHub is the first registry-native service, with its
9own companion app (`web/static/apps/github.js`).
10
11## The shape: manifests are the palette
12
13Everything hangs off one idea, borrowed from bot recipes: **an integration is
14data, not code**. A service ships as a JSON manifest
15(`integrations/registry/`) declaring its auth, the ONE API host its
16operations may touch, and a closed list of operations β€” each `read` or
17`write`, writes each carrying the confirmation sentence a person taps before
18it runs. The generic executor (`integrations/exec.go`) can only do what a
19manifest spells; per-service Go code (the old `dnsimple.go`) stops being the
20growth path.
21
22**Responses are as reviewable as requests.** Each op may declare a `pick`
23projection β€” a root envelope to descend plus the exact fields that may flow
24back. The executor drops everything else before a result ever leaves the
25sidecar, which is both a privacy bound (the manifest review covers what data
26can enter a thread) and a practical one: an op result rides inside ONE
27end-to-end-encrypted message (64 KB blob cap), while a raw GitHub
28`/user/repos` page is ~400 KB of mostly-unwanted fields. Oversized results
29refuse honestly; nothing is ever silently truncated.
30
31What each party holds is unchanged from the DNSimple design:
32
33| Party | Holds |
34|---|---|
35| Messenger server | opaque sealed credential blobs, as ever β€” nothing new |
36| Connect broker | OAuth client secrets (one per OAuth-app service) + the public registry (`GET /registry`) |
37| Member's client | the credential in the clear only with the member present; seals scoped grants to bots |
38| Bot sidecar | a grant in the clear during its window; the manifest-pinned executor |
39
40## Read/write, per bot
41
42A grant sealed to a bot carries `access: "read" | "write"` alongside the
43token and expiry (an access-less grant is legacy = read-only). Because a
44grant is sealed to exactly one bot's public key, **access is per-bot by
45construction**: seal Eleven a write grant for DNSimple and your reporting bot
46a read-only one, and the executor enforces the difference β€” a read grant
47cannot run a write op, whatever the model asks for. Least privilege stacks in
48four layers:
49
501. **The token itself** β€” mint scoped PATs (GitHub fine-grained, Stripe
51 restricted keys, DNSimple domain-scoped); the manifest's `pat_help` points
52 at each service's scoping page, and the catalog verifies a pasted token
53 immediately through the manifest's `viewer` op when one exists.
542. **The connection cap** β€” chosen by the member AT CONNECT TIME (read-only
55 is the default; read & write is the explicit choice), sealed inside the
56 credential blob so the server never learns it. The member's client refuses
57 to mint any grant beyond the cap (`capAllows`, `ui.js`) β€” a read-only
58 connection cannot produce a write grant whatever a chip or proposal asks β€”
59 and for OAuth services whose manifests declare `scopes` / `scopes_write`,
60 the cap also picks the provider-side scope set on the authorize URL, so a
61 read-only connection's token is born read-only at the provider too. The
62 cap is changeable later in Integrations (re-sealing the blob; the member's
63 presence is the consent). Connections stored before caps existed carry no
64 cap and behave as before.
653. **The grant** β€” read/write + expiry, sealed per bot, minted per operation
66 (the apps and chips use one-shot ~2-minute grants).
674. **The tap** β€” every write op renders its `confirm` line as a consent chip
68 ("Register blip.com? This buys the domain and costs real money.");
69 nothing irreversible runs from model output alone.
70
71## Bots use connections as tools
72
73Interactive: *"@eleven go and register blip.com"* β†’ the brain's `service_op`
74tool (its description is GENERATED from the registry β€” the closed list the
75manifests declare is the closed list the model can propose) β†’ a whitelisted
76chip whose label, for writes, is the manifest's rendered confirm line β†’ the
77tap seals a one-shot access-scoped grant and posts the `connect.op` β†’ the
78sidecar executor runs it β†’ the outcome lands as a private notice, never a
79data dump. Reads work the same minus the drama. `TestServiceOpEndToEnd`
80drives the whole flow, including the negative (a read grant refused before
81the registrar is touched).
82
83**Research sessions** widen the interactive flow one notch: a question that
84needs *reading* one or more connected services β€” especially cross-referencing
85two ("which of my DNSimple domains point at GitHub Pages?") β€” gets a single
86`service_research` consent chip naming the services. The tap seals a
87~10-minute READ grant per service to the bot; the sidecar then runs a bounded
88tool loop (`internal/bot/research.go`) whose palette is exactly the granted
89manifests' read ops β€” write ops are never in the model's world here, whatever
90the grants say β€” and posts one answer in the thread that asked. Results ride
91fenced in `tool_result` blocks (data, never instructions), each is
92pick-projected by the same pinned executor, and a provider pair rotated
93mid-loop is handed back sealed to the member exactly like a single op's.
94
95**What the model sees β€” and never sees.** The op *results* (pick-projected
96data) enter the model's context β€” that is the point of research β€” and so, like
97any conversation a bot takes part in, they transit the Anthropic API from the
98sidecar. The *credentials* never do: tokens, grants, and refresh secrets live
99only in the sidecar handler's locals and are never interpolated into a prompt,
100a tool description, or a tool_result (`TestResearchEndToEnd` pins this β€” no
101request to the model may contain the provider token). Say it to members the
102honest way round: "the bot can read what you let it read, and nobody β€” not
103the server, not the model β€” is ever handed your keys."
104
105Standing (designed, deliberately not built): a member-bot charter gains
106`connections: [{service, access}]` β€” shown on the creation consent card like
107sources are β€” and at approval the client seals a standing grant to the new
108bot, renewed by presence like the DNSimple autonomous-window design
109(`docs/connected-services.md Β§ token custody`). Task runs then expose the
110granted services' READ ops as model tools; writes stay interactive-only until
111there's a reviewed answer for unattended spending. This is the natural meet
112point with member bots (issue #432).
113
114## Connected-service apps ride the same rail
115
116A service's companion app (DNSimple's domain table, GitHub's repos/issues) is
117a normal mini-app gated by `requiresService` and fed by
118`ctx.host.connected.requestOp(service, op, args, access)` β€” the app renders,
119the bot executes under the grant, the `pick`-bounded result folds into the
120app's state as an ordinary typed event. That last step is what plugs
121integrations into the app **sharing model**: folded events are the thread's
122log, so the data syncs across the member's devices via snapshots exactly like
123a to-do list, and a future `groupCapable` service app would share one fetched
124view with a whole group through the same log β€” no new machinery.
125
126## The external registry repo
127
128The registry directory is destined to be its own public repository
129(`elevenmessenger/integrations`) β€” the audit surface for what integrations
130can do, growable by PR without touching this codebase. The pipeline:
131
1321. **Author** a manifest (PR to the registry repo; review = reading the ops β€”
133 including `pick`, which is the review of what data can flow back).
134 `integrations.Parse` is exported precisely so the registry repo's CI can
135 validate a PR'd manifest with the same fail-closed rules the binary uses.
1362. **OAuth services**: register the OAuth app once, secret into the connect
137 broker's config β€” the same one-place-holds-the-secret move as the push
138 relay's `.p8`. PAT-only services (GitHub, Stripe, HubSpot) need nothing
139 beyond the manifest.
1403. **Serve**: the broker embeds the registry and serves `GET /registry`
141 (CORS-open) to every instance's web client; each instance also serves its
142 own embedded copy at `GET /api/integrations` (same-origin, what the
143 catalog uses today); sidecars embed a snapshot at build time
144 (single-binary ethos) β€” the broker copy lets the *catalog UI* grow between
145 deploys, while *execution* trusts only the reviewed, embedded snapshot it
146 shipped with. Repo split + a broker sync-from-repo job are the ship-time
147 steps (creating the GitHub repo is deliberately a human act).
148
149## What's built (this branch)
150
1511. `integrations/registry/` β€” schema + README + five manifests (DNSimple,
152 GitHub, Stripe, HubSpot, Linear; reads with `pick` projections, confirmed
153 writes). Linear is the first GraphQL service and needs no executor
154 special-casing: each op is a POST to `/graphql` whose `body_json` carries a
155 static query string plus member input in `variables`, `pick`'s dot-path
156 root descends the `data.…` envelope, and its personal API keys ride
157 `"authorization": "none"` (Linear rejects a Bearer prefix on them β€” the
158 header is the bare key). Linear is also the first **mixed-scheme** service β€”
159 its OAuth tokens DO want Bearer β€” which is why the sealed credential now
160 records its `kind` ("pat" | "oauth") at connect time: grants carry it, and
161 the executor (`ExecuteAs`) sends an OAuth token as Bearer per RFC 6750
162 whatever the manifest's pasted-key scheme says (empty kind = legacy
163 credential = manifest scheme, unchanged). Its 24-hour OAuth tokens ride the
164 expiring-pair rotation below unchanged; `scopes`/`scopes_write` map the
165 connection cap to Linear's `read` vs `read issues:create`. One authoring
166 rule keeps GraphQL text out of the
167 placeholder machinery: every brace in query text keeps a space inside
168 (`{ nodes ` never `{nodes`), so nothing can parse as a `{arg}` placeholder β€”
169 the fail-closed loader rejects any slip.
1702. `integrations` Go package β€” fail-closed loader + the pinned executor
171 (arg validation, path/query escaping, JSON/form bodies, host pinning,
172 response caps, `pick` projection), embedded into the binary.
1733. Sidecar `connect.op` v2 β€” any manifest op, grant-scope enforcement
174 (read/write, expiry, service match); the legacy DNSimple `list` path is
175 untouched.
1764. The interactive flow β€” the brain's `service_op` tool β†’ whitelisted consent
177 chip (labels sized for a whole consent sentence) β†’ the client seals the
178 scoped one-shot grant and round-trips the op β†’ outcome as a private notice.
1795. The catalog β€” `GET /api/integrations` + broker `GET /registry`; the
180 Integrations app lists every service with paste-a-scoped-PAT (verified on
181 save via `viewer`) and a "Connect with X" button for any service the
182 broker has an OAuth app for.
1836. The GitHub app β€” repos, per-repo open issues/PRs, open-an-issue (the first
184 write a member performs from inside an app, consented by the form itself).
1857. **The OAuth relay is service-generic.** The golden path is: OAuth on the
186 provider's own site whenever the broker has an app (the catalog probes
187 `/api/oauth/config?service=` and only shows the button when the flow can
188 actually start); paste-a-scoped-token is the fallback. The broker holds one
189 confidential client per service β€” DNSimple on its legacy `LCHAT_DNSIMPLE_*`
190 envs, everything else on `LCHAT_OAUTH_<ID>_CLIENT_ID`/`_CLIENT_SECRET`
191 (provider endpoints come from the manifest's `auth.oauth`; the redirect
192 defaults to the broker's own `/oauth/<id>/callback`). Enabling GitHub OAuth
193 is therefore: create the GitHub App (callback = the broker's
194 `/oauth/github/callback`), set the two envs, restart the broker β€” no
195 instance or client deploy. Prefer a **GitHub App** over a classic OAuth
196 app: members pick which repositories at consent time (least privilege as a
197 consent screen), and its exchange rides the same confidential broker flow.
198
1998. **Expiring tokens rotate themselves.** Providers with short-lived user
200 tokens (GitHub Apps: 8-hour access + single-use rotating refresh tokens)
201 work first-class: the stored credential is the whole pair
202 (`{secret, refresh, expires_at}`, still sealed to the member's own key),
203 the pair rides inside the op grant, and the SIDECAR is the only refresher β€”
204 it rotates a stale pair before the call (or on a surprise 401, retrying
205 once), serialized per credential so two near-simultaneous ops share one
206 rotation instead of burning the single-use token. The fresh pair rides
207 back **on the op result**, sealed to the member, and the requesting device
208 re-stores it. Rotation is refused outright when the fresh pair couldn't be
209 handed back (no resolvable member key) β€” a failed op beats a silently
210 severed connection. Broker side: `POST /api/refresh`, same auth and
211 nothing-persisted shape as the exchange. This is the temporal-custody
212 model from `connected-services.md` made native: a stolen blob ages out in
213 hours. Failure mode is honest: a dead refresh token (revoked, >6 months
214 idle) errors with "reconnect it in Integrations".
215
216Out of scope, called out honestly: standing charter grants (above),
217registering the OAuth apps themselves (a human act per provider β€” the Stripe
218and HubSpot manifests are OAuth-ready, scopes mapped to the connection cap
219[HubSpot: CRM read scopes always, contacts.write only on a read & write
220connection. Stripe: `read_write` for BOTH caps β€” Stripe refuses the
221`read_only` OAuth scope unless support grants the platform read-only
222connections, so until that's requested the connection cap alone enforces
223read-only there, and the provider token is broader than the cap; ask Stripe
224support for read-only connections before go-live if that residue matters];
225the broker offers each Connect button
226the moment `LCHAT_OAUTH_<ID>_CLIENT_ID`/`_CLIENT_SECRET` land in its env,
227and until then the catalog offers paste-a-token alone. NB Stripe's OAuth
228`client_secret` is the platform's own secret key β€” treat its env value with
229that weight. HubSpot's 30-minute access tokens ride the expiring-pair
230rotation above unchanged),
231Disconnect-revokes-provider-side, native catalog UI (connected services
232remain web-only), and the Node reference sidecar (`agent/`) neither executes
233registry ops nor proposes `service_op` β€” the Go sidecar is the production
234path.
235