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.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 - 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 siteEntry.0.siteUrl to index an array) - and an entry without one still gets a reachability check. Either way the verdict is three-way: reachable, key refused (401/403), or unreachable, so a wrong key never reads as an outage.
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.
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.