Integrations
The unit is a connection at a fully qualified path:
/integrations/<provider>/<connection> — /integrations/slack/main-slack,
/integrations/google/jonas. One integration can hold many connections.
Call the family's get() with no argument for the first connected account,
which is the normal case; pass a connection slug when account identity matters.
The path is simultaneously the durable address, the journal home for that
connection's facts and routed events, and the convention root for its secrets
(/secrets/integrations/slack/main-slack/bot-token). This is the platform's
"an address is a stream coordinate" rule applied without exceptions.
The collection#
itx.integrations is a collection, like itx.secrets and itx.streams:
await itx.integrations.slack.get().chat.postMessage({ channel, thread_ts, text });
await itx.integrations.gmail.get().request({ path: "/users/me/messages" });
await itx.integrations.github.get().octokit.rest.repos.get({ owner, repo });
await itx.integrations.github.get("install-123").octokit.graphql(query, variables);
await itx.integrations.list(); // every connection, built-in and providedTwo kinds of member, one address space — every connection call is
{slug}.get(connection?).{...method}:
- Built-in public families are dispatch branches (
slack,gmail,github,telegram,waitrose) in the collection'sinvokeCapability— plain imperative branches whose code ships with the OS deployment. Gmail's public name maps to thegoogleOAuth provider and durable connection path. Data calls andlist()usegmail; connection-management verbs such asstartOAuthFlowintentionally still takeprovider: "google".list()reportsintegration: "gmail"while retaining the internal/integrations/google/...journal path.BUILTIN_INTEGRATION_SLUGSnames internal providers for lifecycle and mount-collision checks. - Everything else resolves through the ordinary itx capability table under
the
integrationsprefix. A project adds its own integration withprovideCapability({ path: ["integrations", ...] })— data, not deployment. No registry, no provider files, no new dispatch machinery: the itx processor's longest-prefix mount resolution does the rest. Mounting UNDER a built-in slug (["integrations", "slack", ...]) is rejected loudly at provide time — the dispatch would shadow it, making the mount durable, journaled, and silently unreachable.
The old top-level itx.slack / itx.gmail builtins are deleted; connection
families now live under itx.integrations.
Three dimensions, three properties#
The design questions integrations raise map onto properties of different things — none of them needs a framework:
- Receives webhooks or not — a property of the slug's ingress code.
Slack's webhook route (signature verification, ACK-200 doctrine, the team
directory fold) stays deployment code in the app worker; its job is to land
routed events on the right
/integrations/slack/<connection>journal. Integrations without first-party ingress (google, waitrose) simply have none. - Secrets from deployment appconfig vs the project secret system — a property of the connection, visible in where its material lives: Slack's OAuth app credentials are deployment appconfig (Iterate's app registration); each connection's bot token is a project secret at the connection's path. A customer-owned Slack app would flip the first without changing shape.
- Code shipped with the deployment vs data — exactly the getter/table split above. Promotion path for a provided integration: reimplement it as a named getter (a PR to OS); its addresses don't change.
Built-in connections (Slack and Gmail/Google)#
A Slack connection is born at OAuth completion: the callback derives the
connection name from the workspace domain (deterministic — reconnecting the
same workspace updates the same connection), stores the bot token at
/secrets/integrations/slack/<connection>/bot-token, appends
slack/connected (with the connection) to the connection journal, arms the
webhook-router processor on that journal, and claims the team in the
deployment-wide directory as teamId → { projectId, connection }.
Inbound webhooks route by that claim onto the connection journal; the router
processor forwards thread events to
/agents/slack/<connection>/<channel>/ts-<ts> agent streams, so a Slack agent
recovers its connection from its own stream path — which is how its replies
pick the right bot token, and how multiple Slack workspaces in one project
just work. Google connections are named from the account email; tokens live
in the connection secret, refreshed on 401 by the Secret DO's shared
oauth-refresh-token strategy (nothing token-shaped ever lands on a journal).
Gmail REST calls carry that connection's access-token placeholder through
project egress before the Secret DO substitutes it, so the same interceptors
and approval rules apply to reads and sends.
Slack Web API calls normally never hold material: the request carries a
getSecret(path) placeholder for the connection's token and traverses project
egress, which substitutes it inside the Secret Durable Object and records
secret/used audit events. If Slack rejects a live connection's token, trusted
platform code may retry with the deployment Slack app's optional fallback token only
after auth.test proves its team id matches the connection journal. A typo'd
or disconnected connection still errors loudly instead of silently posting
with a deployment-wide credential.
That deployment token is an optional outbound fallback, not connection state. It can be revoked and must never be used to recreate a project association. Only OAuth completion owns the sequence “validated token → secret → router birth/subscription → connected fact → global team claim.” A validly signed webhook that arrives before the final claim is ACKed and ignored; creating a claim beside an unvalidated token therefore produces a false-connected project and silent inbound loss rather than a partial restoration.
Status is a journal tail-fold for every provider — one machine, no
per-provider mechanism: getConnection pages backwards from the journal head
(streamEventsNewestFirst) and stops at the first lifecycle fact
(connected/disconnected). Nothing snapshots a processor: the slack router's
whole state is its channel:thread_ts → streamPath routing table.
list() = journals ∪ credential-defined connections ∪ mounts, deduped by path. Every
/integrations/<slug>/<connection> stream in the project's catalogue is one
entry (source: "builtin" for Slack/Gmail, "provided" otherwise; Gmail is
reported by its public name even though its path contains google), plus every capability-table
mount under integrations (connection: null for an integration-level
mount). Waitrose session-secret paths contribute its credential-defined
connections because it intentionally has no lifecycle journal. A provided
integration whose webhooks journal at the same path as its mount is one entry.
Journals persist after disconnect, so entries carry a status, not existence:
the dashboard counts status.connected.
Management verbs live on the collection: getConnection and disconnect
are connection-scoped; startOAuthFlow({ provider, userId }) and the
provider-blind completeConnect({ provider, code, state, userId }) are not —
the connection name is derived at completion, per above.
Each provider contributes only its exchange half; the storage half (secrets by
argument into Secret DOs, the connected fact, router arming, the directory
claim) is the shared imperative recordConnection(...) helper — a function,
deliberately not an event choreography: connect is synchronous (a browser is
waiting on the callback), and credential material travels by parameter to
exactly one confined home, never onto journals.
Waitrose: the credentials-not-OAuth builtin#
Waitrose is a built-in like the others —
itx.integrations.waitrose.get().<method>(...) dispatches in
deployment code over the vendored client
(domains/integrations/waitrose-api.ts: the reverse-engineered Android app's
operations — its GraphQL gateway rejects hand-slimmed selections, so the
queries are the app's verbatim). What makes it the interesting archetype is
its connect flow: there is none. A connection exists when its secret does —
await itx.secrets.get("/secrets/integrations/waitrose/mum/session").update({
egress: { urls: ["https://www.waitrose.com"] },
material: { username: "mum@example.com", password: "…" },
refresh: {
kind: "waitrose-session",
graphqlUrl: "https://www.waitrose.com/api/graphql-prod/graph/live",
},
});await itx.integrations.waitrose.get("mum").shoppingContext();
await itx.integrations.waitrose.get("mum").searchProducts("milk", { size: 5 });The connection secret holds only { username, password } plus the
waitrose-session refresh strategy — Waitrose has no refresh grant, so the
Secret DO re-runs the login itself: mint on first use, re-mint on 401. The
client's transport rides the project egress door with an accessToken
getSecret placeholder, so no isolate outside the Secret DO ever holds
credentials — the same confinement as every other builtin.
Provided integrations (userspace)#
A project can also implement a whole integration as ordinary code in its own repo — no platform change needed. Two shapes:
-
A worker getter. Add a getter on the default-export class in the seeded
worker.tsthat hands back a vendor SDK client (installed from the project's ownpackage.json), and call it asitx.worker.<getter>.<method>(...). No mount step: the project worker always exists and is late-bound to the repo, so the surface is durable by construction. The platform dispatches every dotteditx.worker.*call as one flattenedinvokeCapability({ path, args })that the worker walks in userland (workerd RPC does not traverse instance fields). Session secrets ride asgetSecretplaceholders in headers of barefetch()calls; dynamic-worker egress routes through the project egress door, so even the project's own integration code never holds its tokens. Exercised inworker-build.e2e.test.ts: the test commits the dep + getter to its own project's repo first, then calls the new surface. -
A collection mount. For providers a project wants addressed like a built-in —
itx.integrations.<slug>.get(<connection>).<method>()—provideCapability({ path: ["integrations", "<slug>"], type: "itx-call", flattenNestedPaths: true })mounts any expression (a standalone dynamic worker, an MCP connection, …) into the collection; the mount is acapability-providedevent on the itx journal — replayable, revocable, enumerable byintegrations.list(). Mount at the project root (itx.capabilityHosts.get("/")) so the collection's dispatch can see it. Exercised ine2e/vitest/integrations-userspace.e2e.test.tswith an "ocado" integration: the echo lane (two connections, substitution proof,__describediscovery, negative controls) and the strategy lane against petshop's GraphQL session-login door (apps/dummy-petshop/src/graphql-login.ts— the same wire shape thewaitrose-sessionstrategy speaks; the session is an ordinary petshop bearer).
The one mechanical accommodation: integrations is a namespace builtin —
rejectBuiltinCollision allows mounts at depth ≥ 2 under it (mounting at
["integrations"] itself, or under a builtin slug like waitrose, is still
a collision).
GitHub: the third builtin#
GitHub connects as a GitHub App installation (deep-link to
github.com/apps/<appSlug>/installations/new → callback with an
installation_id; no code exchange, no user token):
- Connect: the callback claims the installation — connection named
install-<id>-<fence>so delayed cleanup from an older ownership generation cannot brick a later winner, empty material in the connection secret plus thegithub-app-installationrefresh strategy (App id + installation id are public strategy config; the App private key resolves fromAPP_CONFIG_INTEGRATIONS__GITHUBat mint time),github/connectedon the journal,installation_idclaimed in the directory. One exchange half + onerecordConnectioncall — the shape every provider pays. - API calls:
itx.integrations.github.get().octokitis a real wrapped Octokit (rest.*,request(...),graphql(...),paginate(...)). The.octokitnamespace is mandatory: it makes the SDK boundary explicit and a direct.reston the connection is rejected. Its transport carries agetSecretplaceholder through project egress, so interceptors and approval rules run before the Secret DO mints the installation token on first use or re-mints it on 401 — trusted DO code signing the App JWT, no worker, no jail. - Inbound App webhooks land on the door, verify
x-hub-signature-256with plain WebCrypto, and route oninstallation_id. Each delivery is appended once to/integrations/github/<connection>with its complete decoded JSON payload plus small associations typed from Octokit's generated payloads: stable repository coordinates, an optional subject pull request, its content author, and mentioned users. The integration does not create agents or decide what a webhook means to a project. ghin sandboxes works automatically, with no byte handoff: ALL container egress (HTTPS included, MITM'd with the container CA) routes through the project egress door, so a sandbox holds only a placeholderGH_TOKENand substitution + re-mint happen en route. The sandbox DO plantsGH_TOKENper container start when the project has a GitHub connection (the connection secret'saccessTokenas agetSecretplaceholder; lexicographically first connection when several exist), andghreads it from the env natively.gitgets agit http."https://github.com/".extraheaderwith Basic auth (x-access-token+ base64 of the placeholder — GitHub's git smart-HTTP rejects Bearer). Project egress peels Basic Authorization headers before substituting so the placeholder stays findable without putting token bytes in the container.
The provided-lane exhibits remain in the catalogue: github-mcp-connect
(GitHub's MCP server mounted under the github-mcp slug — built-in slugs
cannot be shadowed) and github-webhooks-project-worker (deliveries landing
on the project host's own worker).
A linked repo stream receives only matching push deliveries from its connection
stream so the repo processor can import its default branch. Pull-request
automation is instead ordinary userspace code: the config-repo template shows
a project worker consuming first-hand connection-stream facts and forwarding
them to /agents<repo-path>/pr/<number>. See
GitHub pull-request agents.
Telegram: the fourth builtin#
Telegram has no OAuth — the BotFather token IS the credential — so it gets a
dedicated verb, connectTelegram({ botToken }), instead of a contortion of
the redirect machinery:
- Connect:
getMevalidates the pasted token and yields the bot's numeric id (the stable identity — usernames can change); the id is checked against the deployment-wide directory,setWebhookpoints the bot at/api/integrations/telegram/webhook/<botId>with a secret token derived ashmac(SECRET_ENCRYPTION_KEY, "telegram-webhook:<botId>")(nothing stored, no deployment config — previews work untouched), and the sharedrecordConnectiondoes the rest: token in the connection secret (egress pinned tohttps://api.telegram.org),telegram/connectedon the journal, router armed, bot id claimed. Connection named from the bot username. - API calls:
itx.integrations.telegram.get().<method>(params)— the Bot API is flat, so exactly one method segment with one params object (sendMessage,sendPhoto,getMe, …). The Bot API authenticates in the URL path (/bot<token>/<method>), which is why secret substitution reaches the request URL's path — and only its path — per ADR 0005: the request carries/botgetSecret("/secrets/…")/<method>through project egress and the Secret DO fills the token in. - Inbound updates land on the per-bot door path (Telegram payloads don't
identify the bot), verify the echoed
X-Telegram-Bot-Api-Secret-Tokenagainst the derived value (timing-safe; the one 401), and route on the bot id with idempotency keytelegram-webhook:<botId>:<update_id>— Telegram retries undelivered updates andupdate_idis the delivery identity. - Routing is stateless (no Slack-style route table): an update's
destination is a pure function of its chat —
/agents/telegram/<connection>/chat-<chatId>(/topic-<threadId>appended for forum supergroup topics; ids verbatim, sign included). Thetelegram-agentprocessor transcribes updates into agent context (media gets bracketed hints like[photo], while the raw payload retains thefile_idneeded for a token-safegetFile+ secret-backed egress download), ignores bot-authored updates, and sends thetypingchat action while the agent works; the agent replies viasendMessagewith the chat id from its own path/inputs. - Disconnect: best-effort
deleteWebhook(through the substituting egress path — no material read), then the sharedrecordDisconnection.
Deliberately not built#
- A provider-file registry. An earlier cut of this work modeled built-ins
as data-like
IntegrationDefinitionfiles in a registry array. Rejected: it's a second, static capability table beside the real one, only editable by deployment. Built-ins are just code (dispatch branches); extensions are just capability-table entries (the existing provide system). - A connect event choreography. A proposed follow-up expressed the shared
storage half of connect as a
connect-requestedevent + processor reaction. Rejected on the platform's own doctrine: connect is synchronous,-requestedevents are for asynchronous side effects, sealed credentials on journals would be a second un-shreddable durable home, and an interactive OAuth callback must not block on a cold cross-DO wake chain. The invariance lives inrecordConnection(...), a plain function. - Property-name connection selection.
itx.integrations.slack.mainanditx.integrations.slack["main"]are errors.get()is the only selector: no argument chooses the first connected account; a slug chooses an exact one. - Webhook signature verification in project workers — worker code cannot hold the HMAC secret (substitution is egress-headers/path-only); capability-URL tokens are the workaround. The userspace verification story returns with the jail lane (ADR 0005), not as a compute method on the public secret.
- A generic refresh framework. Refresh is named strategies in the
Secret DO (
oauth-refresh-token,github-app-installation,waitrose-session) — one shared imperative implementation per protocol, parameterized per secret. A provider whose dance fits none of them gets its own small strategy (that is howwaitrose-sessionlanded), not a strategy interpreter.