GenerateSaaS

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

kindWhat it connectsHow it becomes tools
apiAn HTTP API the backend calls.One request tool per connection: any HTTP method (GET by default) with an optional JSON body.
mcpA 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.

The user opens /settings/integrations on the web, or the Integrations tab in the desktop app, and picks a catalog entry.
They fill its credential fields. A sign-in entry has none to fill: it connects only through its own OAuth flow, which on desktop opens in the system browser.
They choose the scope, sign-in entries included. The picker lists only the scopes legal for that entry here, narrowest first.
The credentials are validated before anything is saved, so the form confirms the account it reached instead of storing a dead key.
The connection is stored encrypted server-side and never returned to any client. Its card carries the account identity (shown only to people who may manage the connection, since a shared credential's account is its connector's), a health dot, its scope, who connected it, and - for 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.

QueryWhat 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 | desktopWhich 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 answersVerdictThe connect form says
2xx (any non-5xx at the root, for the reachability check)PassedVerified, or reachable
401/403, a redirect to a sign-in page, or any redirect from a declared health pathRefused<Service> refused these credentials. Check them and try again.
5xx, a timeout, a rate limit, or nothing at allUnreachable<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.

On this page