AI integrations
The backend-owned catalog of third-party services a signed-in user connects once, so chat, automated runs and desktop agents can all act on them as tools.
Integrations are the third-party services the AI acts on for a signed-in user: an HTTP API the backend calls, or a hosted MCP server it dials. The backend owns every part - it validates credentials, stores them encrypted, probes health, and turns each connection into agent tools - so the web app and the desktop app are two screens onto one system.
Every generated app offers two built-in connectors out of the box - API for any HTTP API, MCP Server for any streamable-HTTP server - so a user can connect something on day one. Name your own services by adding catalog entries in packages/config/src/ai/integrations.ts: those lead the picker, one sharing a built-in's id replaces it, and config.ai.integrations.genericConnectors: false offers the curated entries alone.
The two kinds
kind | What it connects | How it becomes tools |
|---|---|---|
api | An HTTP API the backend calls. | One request tool per connection: any HTTP method (GET by default) with an optional JSON body. |
mcp | A streamable-HTTP MCP server the backend dials. | One tool per server tool, advertised in the mcp_ namespace (the model calls mcp_search, not search), from a per-connection cache re-synced every 15 minutes. |
A stdio MCP server is not an integration - it runs on the user's machine, which no backend can dial. Users add those to their own coding CLI, and your backend never sees them.
How a user connects
Integrations ride config.ai.enabled: their routes sit behind the AI router's guard, so they are inert when AI is off.
/settings/integrations on the web, or the Integrations tab in the desktop app, and picks a catalog entry.mcp - the tool count.Send a user to connect one thing
A page that needs a connection it cannot find links to the integrations screen with the errand on the query, and the user is returned to it afterwards.
| Query | What it does |
|---|---|
connect=<entry id> | Opens the Add dialog on that entry, so the user never hunts the catalog. An id this app does not offer is ignored. |
reconnect=<entry id> | Signs that entry's existing connection in again on arrival, keeping the connection and its id - for a page that found a sign-in expired. With no connection yet it opens the Add dialog instead. |
redirect=<in-app path> | Where the user continues once the errand is over: after Save, when they cancel the dialog it opened, or as soon as a sign-in comes back connected. |
surface=web | desktop | Which app to hand a sign-in back to. Each app sends its own, so you never set this by hand. |
push(`${config.routes.settingsIntegrations}?connect=google-search-console&redirect=/reports/seo`)redirect must be a path this app owns - one leading /, no scheme, no host. Anything else is dropped and the user simply stays on the integrations screen. Both values survive the trip through the provider on the server-side authorization record, never on the callback's own query, which carries no authority at all.
A sign-in is filed only for the user who began it. The callback must arrive in a browser signed in as that same user, so a consent link forwarded to someone else connects nothing - and a desktop sign-in is claimed by the app itself, not the browser. A user who left the organization or lost the project while at the provider files nothing either.
Afterwards a connection can be renamed any time, and its credentials replaced as a deliberate opt-in through the same validation gate a first connect passes. A sign-in connection is rename-only - its credentials are a minted token, renewed with Reconnect, which leads the card while its sign-in has expired (the card says Sign-in expired rather than Not working) and sits in its menu otherwise - and scope never changes. The edit controls appear only for someone who may manage that row.
Every connection is testable. An api entry's declared health block verifies the credentials and may name the account - identityPath is a dot-path into the JSON response (user.email, or users.0.email to index an array); a sign-in connection always shows the signed-in account's email instead (openid email scopes) - and an entry without one still gets a reachability check. A wrong key never reads as an outage:
| The service answers | Verdict | The connect form says |
|---|---|---|
| 2xx (any non-5xx at the root, for the reachability check) | Passed | Verified, or reachable |
401/403, a redirect to a sign-in page, or any redirect from a declared health path | Refused | <Service> refused these credentials. Check them and try again. |
| 5xx, a timeout, a rate limit, or nothing at all | Unreachable | <Service> could not be reached. Try again in a moment. |
The raw status goes to the server log only. Both sentences are translation keys (screens.integrations.test_refused / test_unreachable in shared.json) with the entry's name interpolated.
An mcp probe is a successful tools/list, and the server's advertised name becomes its identity. Its verdict is two-way by design: the MCP client reports one opaque failure for a dead host and a refused header alike, and the dialer will not echo upstream error text that can carry the credential. Both re-check on demand from the card.
A failed MCP sync keeps the previously cached tools and records only the health failure, and a live server that answers with an empty list keeps them too - saying so on the card ("reachable but listed no tools, previous tools kept") rather than silently emptying a working toolset. After a failure the automatic re-dial waits 5 minutes; the manual re-check on the card ignores that cool-down.
Where a connection lives
Every connection is placed at one scope - a project, an organization, or the connector alone - which decides who can use it and therefore whose agents get it as a tool. Which scopes a deployment offers follows from its projects and tenancy flags, and an entry can narrow that further.
What the agents get
Every callable connection becomes tools in the one shared toolset assembly, so chat, automated runs and the desktop app's agents see the same set with no per-surface wiring. A sign-in shared with a project or organization is a shared tool too: every member who can see it runs on it (scope).
An MCP tool its server declares destructive (destructiveHint: true, not readOnlyHint: true) waits for the same Approve / Deny card as a capability with needsApproval, showing the exact input; every surface without the card refuses it. A tool that declares no hints runs unprompted, and a catalog re-sync (at most 15 minutes) picks up a server that changes its hints. A tool cached before hints were read, served while its server is unreachable, waits for the card too.
Three invariants hold on every call, enforced in @repo/ai/integrations: the model supplies a request path, never a URL, host-locked to the connection's fixed origin, so a prompt-injected model cannot retarget the request or the credential riding on it; credentials resolve fresh per call and fail closed - a connection that no longer decrypts yields an error string, never a secret; and every outbound request passes the SSRF guard with redirects refused.
AI prompts
Every prompt layer the product injects, which lane receives which, and where to edit each one.
Catalog entries
How to declare one AI integration - the AiIntegrationSpec fields, the auth axis of none, api key, HTTP Basic, OAuth sign-in and MCP headers, plan-gated entries, and a worked Google Search Console entry.