GenerateSaaS

Integrations

The desktop app's Integrations tab - the same backend-owned connections the web app makes, connected in-app with any provider sign-in opening in the system browser.

The Integrations tab is the desktop screen onto the same backend-owned catalog the web app uses - one catalog, one set of connections, one place they are stored. A user who connects a service in their browser finds it already connected in the app, and the reverse. AI integrations documents the system; this page is what the app adds.

Declared inpackages/config/src/ai/integrations.ts - see Catalog entries
Where users connectSettings > Integrations
What it offersevery catalog entry whose surfaces include desktop
Storedencrypted in your backend database, per user - nothing on the device
Shown whenconfig.ai.enabled, inside the desktop agents envelope

The screen is gated on the AI service, never on how full the catalog is: every input to the settings rail is a build fact, so no entry waits on a network read. An empty catalog renders the empty state rather than hiding the entry - it is where a user connects their first integration, so hiding it until they had would be a door that only opens once you are through it.

What the app adds

  • Sign-in opens the system browser. An oauth entry sends the user to the provider in their real browser, so their existing session and password manager work, and the app never handles the credential.
  • No local credential store. There is no keychain entry and no on-device dialog: the app calls the same /ai/integrations routes the web app does, over its device-granted bearer token.
  • Connections follow the workspace, not the device. Each is placed at a scope - a project, an organization, or the connector alone - from the same picker the web app shows, and a second machine inherits whatever that scope makes visible at sign-in. One connection is a tool on every surface at once: web chat, automated runs, and this app's own agents.

How a sign-in reaches the browser

The app authenticates with a bearer token, and a freshly opened system browser carries no session cookie for your API - so it cannot simply open the session-guarded /start URL. The app asks the backend for a start handoff instead: a short-lived, single-use token minted over the channel that is authenticated, then spent by the browser. Connecting never needs a web sign-in.

The app opens a loopback listener. The main process listens once on 127.0.0.1, on a port the OS picks, behind a fresh nonce (RFC 8252).
The app mints. POST /api/ai/integrations/oauth/{provider}/handoff, authenticated, with the placement it wants and its listener: { "scope": "project" | "organization" | "user", "projectId": "...", "loopback": { "port": 49152, "nonce": "..." } } (all optional; omitted scope means the connector alone).
The backend authorizes the placement - the deployment's scope ladder, the entry's allowlist, the caller's ownership of the named project - and answers { "startUrl": "...", "expiresInSeconds": 60, "pollId": "..." }.
The app opens startUrl in the system browser, verbatim. The URL is built server-side, so the app never assembles a path or a parameter name.
The browser is redirected to the provider, consents, and returns to the usual callback. Nothing is stored yet: the callback parks the provider's answer behind a one-time completion code for the app. A browser signed in as another user is refused; every other browser hands the code back (below), and one signed in as the minter also releases it to the app's poll.
The app picks the code up - whichever comes first: the loopback, the deep link, or its own poll. While the sign-in is open the app calls POST /api/ai/integrations/oauth/{provider}/handoff/poll with { "pollId": "..." } every 2 seconds (for up to 10 minutes, stopping when the dialog closes) and gets { "status": "pending" } or { "status": "ready", "code": "..." }.
The app claims it. POST /api/ai/integrations/oauth/{provider}/handoff/complete with { "code": "..." }, over its own bearer token. Only the session that minted the handoff can claim; the backend re-checks the placement, stores the connection and answers { "outcome": "connected" | "error" | "limit" }.

How the finished sign-in gets back

The callback lands in the system browser, which has no session for the app and no reliable way into it: a browser does not always follow a redirect into a custom scheme. So the callback answers a small page that sends the browser straight on to the app's loopback listener.

What the user seesA moment of Back to {app}, then a page saying the tab can be closed
Where it goeshttp://127.0.0.1:<port>/done?complete=<code>&connect=<entry>&redirect=<path>&nonce=<nonce>
FallbackAn Open {app} button: <protocol>://settings/integrations?complete=..., also tried on load when the app opened no listener
What it carriesThe completion code and the URL's own fields - no session, no token, no account detail

The listener answers one GET /done with its own Host and nonce, 404s everything else, and closes after that hit, after 10 minutes, on a newer sign-in or on quit. It routes the query exactly as a deep link: the Integrations tab claims the code, announces the outcome, reopens the entry after a failure, and continues to redirect when the sign-in worked - see sending a user to connect one thing. A code delivered twice (loopback plus poll or deep link) is claimed once.

A dev launch on macOS (pnpm dev) does not claim the scheme: it runs the stock Electron app, whose bundle id other apps that ship Electron share, so the link could open one of them instead. The loopback does not depend on the scheme, so dev sign-ins finish the same way.

PropertyValue
Lifetime60 seconds
Usesone - a replayed link starts nothing
Bound tothe minting user, that one provider, and the placement resolved at mint time
Can it be traded for a session?No. It begins one authorization and does nothing else.
Completion codeone use, ten minutes; reaches the poll only from a browser signed in as the minter; claimable only by the minting session
Loopback127.0.0.1 only, one hit, port 1024-65535, a url-safe nonce of 16-128 characters
Poll budgetcacheConfig.oauthHandoffPollRateLimit - 60 a minute per user, apart from the AI window

The token stands in for a session in a URL handed to the OS, so it will be written to browser history. That is why it is single-use and expires in a minute. A forwarded link cannot connect someone else's account: a browser signed in as another user is refused, and a sessionless one's sign-in never reaches the sender's poll - its hand-back goes to 127.0.0.1 on the recipient's own machine, where the sender's listener is not. An attacker's own completion code delivered to someone else's app is refused at the claim.

Local MCP servers are a different feature

An mcp integration is a streamable-HTTP server your backend dials. A stdio server runs on the user's own machine, where no backend can reach it: users add those to their own coding CLI, the app leaves them alone, and they never appear on this tab.

On this page