Integrations
An integration is a third-party account the user connects so the model can act on data that lives
outside Thunderbolt. Three live in src/integrations/: Google (Gmail +
Calendar), Microsoft (Outlook mail + OneDrive), and Thunderbolt Pro (the backend-hosted web
search / fetch_content pair, which needs no OAuth account of its own).
Each provider directory exports a createConfigs factory returning ToolConfig[].
getAvailableTools (src/lib/tools.ts:39) is the consumer that matters at
send time, and it decides per-provider whether those configs join the turn. The gating
matrix and the connected-vs-enabled distinction are documented once, in
Prompt and Tools — this page covers
the part that is specific to integrations: what a connection grants, where the credentials live, and
how the OAuth flow differs per platform.
What connecting grants
Connecting is not a scoped, per-request permission. The consent screen hands the app a bearer token for the user’s mailbox, and every tool below runs against it without further prompting. State that plainly to users: enabling the Google integration lets the model read and search the user’s entire Gmail account and write drafts into it.
| Provider | Scopes requested | Tools |
|---|---|---|
gmail.readonly, gmail.compose, calendar.readonly, plus openid/email/profile |
google_check_inbox, google_search_emails, google_get_email, google_draft_email, google_check_calendar |
|
| Microsoft | https://graph.microsoft.com/mail.read, User.Read, offline_access |
microsoft_list_messages, microsoft_get_message, microsoft_search_onedrive, microsoft_get_onedrive_file_content |
Scopes are declared in src/integrations/google/auth.ts:37
and src/integrations/microsoft/auth.ts:37; the tool
tables are at src/integrations/google/tools.ts:546 and
src/integrations/microsoft/tools.ts:335.
Three asymmetries in that table are deliberate or load-bearing:
- Nothing sends mail. The only write is
google_draft_email, a POST to/gmail/v1/users/me/drafts(src/integrations/google/tools.ts:410), so the worst a model can do unattended is leave a draft. Thegmail.composescope the token carries would also permit sending; no tool calls a send endpoint. - The Gmail reads are not
cacheable, the Microsoft ones are. Gmail has a write tool that can mutate state inside a single turn, so a deduped read could go stale; Microsoft ships no mail- or drive-mutating tool today. Adding one must revisit those flags — the reasoning is recorded inline atsrc/integrations/microsoft/tools.ts:336.google_check_calendariscacheable: nothing in the turn can change a calendar. - The Microsoft consent request covers mail, not files. The requested scopes include no Files
permission, yet
searchOneDriveandgetOneDriveFileContent(src/integrations/microsoft/tools.ts:213,:251) call/me/drive— so they fail unless the user’s tenant supplies the permission another way.getOneDriveFileContentcatches the 403 and reportsfailure_reason: 'access_denied'to the model (src/integrations/microsoft/tools.ts:301);searchOneDrivehas no such catch, so there the error propagates.
Gmail payloads are truncated before they reach the model: message bodies at truncateText’s
4,000-character default (src/lib/utils.ts:265) and calendar event
descriptions at 200. The Microsoft tools return Graph’s payload as it arrives, untruncated, with one
exception: getOneDriveFileContent cuts text at llmContentCharLimit (16,000 —
src/lib/utils.ts:260). It only reads text/*; every other MIME type
returns metadata with extraction_failed: true, failure_reason: 'unsupported_type' and a
file_category, so the model can explain the limit instead of inventing content.
Where the credentials live
OAuth tokens are written to integrations_secrets, one row per provider
(src/db/tables.ts:150). The primary key is the provider name — the
Drizzle field is provider, but the column it maps to is called id, which is what you need in raw
SQL. The row holds a JSON blob (access_token, refresh_token, expires_at, and a profile used
only to show which account is connected) plus an enabled flag.
The table is registered localOnly: true
(src/db/powersync/schema.ts:34). Three consequences follow:
- It never syncs. Connecting Google on the laptop does not connect it on the phone; each device
runs its own consent flow. This is also why the table has no entry in
encryptedColumnsMap(src/db/encryption/config.ts:30) — that map governs what is encrypted on the way out to the server, and these rows have no way out. See E2E Encryption. - It is excluded from export.
excludedFromExport(src/dal/export.ts:25) drops it, and import treats anyintegrations_secretskey in the file as unknown, recording it inignoredTableNames(src/dal/import.ts:229); the importing user re-authenticates. Rationale in Export Format. - Writes go through select-then-insert, not upsert. PowerSync exposes local-only tables as SQLite
views, which reject
ON CONFLICT— hence the shape ofsaveIntegrationCredentials(src/dal/integrations.ts:55).
Disconnecting calls deleteIntegrationCredentials
(src/dal/integrations.ts:110), a single local DELETE. No
revocation request is sent to the provider, so the grant survives in the user’s Google or Microsoft
account until they revoke it there. Toggling the switch instead of disconnecting keeps the tokens and
only clears enabled, which withdraws the tools from the model
(src/dal/integrations.ts:98).
The connect flow
All three platforms run authorization-code-with-PKCE and differ only in how the redirect gets back
into the app. useOAuthConnect (src/hooks/use-oauth-connect.ts:186)
branches on platform:
| Platform | Redirect target | How the code comes back |
|---|---|---|
| Web | ${origin}/oauth/callback |
Full-page redirect; the callback route re-navigates in-app with state.oauth |
| Desktop (Tauri) | http://localhost:<port> |
Rust loopback listener on 17421–17423 emits an oauth-callback Tauri event |
| Mobile (Tauri) | https://app.thunderbolt.io/oauth/callback |
App Link / Universal Link, handled by the deep-link listener |
Web and mobile take their redirect URI from getOAuthRedirectUri
(src/lib/oauth-redirect.ts:16). Desktop ignores that value and
passes the loopback URL it has just bound instead
(src/lib/oauth-loopback.ts:45); the port list is in
src-tauri/src/commands.rs:100. Desktop and mobile open the
system browser rather than a webview, so the user authenticates in a browser that already has
their session and can show the real origin.
The three desktop ports are fixed because they are pre-registered as redirect URIs in the provider
consoles: bind_to_port tries each in turn and returns AddrInUse when all are taken
(src-tauri/src/oauth_server.rs:30) rather than falling back
to an OS-assigned port the provider would reject. The listener accepts exactly one connection and
then shuts itself down, so the frontend has nothing to clean up.
Two details bite if you touch this code:
buildAuthUrlandexchangeCodeForTokensmust receive the sameredirect_uri. The loopback port is only known at runtime, so it is threaded through both calls; providers reject the exchange when the two differ.- Flow state lives in
localStorage, notsessionStorage. The desktop loopback flow holds itsstateand verifier in the calling closure, but the web and mobile flows have to persist them across a navigation. On Tauri mobile the OS can kill the app while the user is in the system browser, andsessionStoragewould be gone on relaunch — taking the PKCE verifier and thestatenonce with it. The rationale and the absence of a client-side TTL are recorded atsrc/lib/oauth-state.ts:11.
Callbacks are routed by returnContext, because MCP server authorization shares the same callback
URL; an MCP callback is claimed by handshake ownership first
(src/components/oauth-callback.tsx:69) so a concurrent
integration flow cannot steal it. See MCP Connections.
A successful flow stores the tokens with enabled: true in one step
(src/hooks/use-oauth-connect.ts:155), so connecting implies
enabling.
Two surfaces start the flow besides Settings → Connections: the onboarding auth step, which passes
setPreferredName: true and seeds the preferred_name setting from the OAuth profile
(src/components/onboarding/onboarding-auth-step.tsx:51),
and the connect-integration widget, which the model emits when the user asks for something mail- or
calendar-shaped and the tools are absent
(src/widgets/connect-integration/instructions.ts).
After the widget’s flow completes,
use-handle-integration-completion.ts
replays the original user message so the request the user actually made goes through.
Token refresh
Tools never read the stored access_token directly. They call ensureValidOAuthToken
(src/integrations/oauth-credentials.ts:40), which
reuses the token while expires_at is more than 60 seconds away, refreshes through the backend
otherwise, and writes the new token back to the row. A row with no refresh_token throws rather than
silently degrading — the user has to reconnect. Google’s authorization URL therefore sets
access_type=offline and prompt=consent
(src/integrations/google/auth.ts:67): Google returns a
refresh token only on a consent grant, so forcing the consent screen keeps a re-connect from
producing an access token that cannot be refreshed once it expires.
The backend’s role
The client never holds a client secret. /v1/auth/google/* and /v1/auth/microsoft/*
(backend/src/auth/google.ts,
backend/src/auth/microsoft.ts) are a confidential-client
proxy with three session-authenticated routes:
| Route | Purpose |
|---|---|
/config |
Returns the public client_id and a configured boolean; cached client-side once true |
/exchange |
Adds the client secret to the authorization-code exchange |
/refresh |
Same, for refresh-token grants |
/exchange validates the submitted redirect_uri against isOAuthRedirectUriAllowed
(backend/src/config/settings.ts:313) — the CORS origin list
plus https://app.thunderbolt.io, plus any http://localhost/127.0.0.1 port for the desktop
loopback. /refresh has no redirect_uri to check. The Microsoft routes also send a scope
parameter on both grants, so backend/src/auth/microsoft.ts:14
holds a second copy of the frontend’s scope string that must stay in sync with it — the code
says as much in a comment, which is the only thing keeping the two aligned.
Configuring a deployment
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET and MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET
(backend/src/config/settings.ts:214, documented in
Configuration) are read by these integration
routes and nowhere else — no Better Auth social provider consumes them, so they are not sign-in
credentials despite their placement in the auth section. Sign-in is email OTP (the emailed link
carries the same code), OIDC, or SAML; see
Sign-in and waitlist.
Omitting either half of a pair disables that provider: /config reports configured: false, and
buildAuthUrl throws MisconfiguredOAuthError, whose message names the exact env vars the operator
is missing (src/lib/auth.ts:43). The provider still appears in Settings →
Connections — the UI lists all three unconditionally
(src/settings/connections/use-integrations-controller.tsx:43)
— so on an unconfigured deployment the user sees a Connect button that reports the misconfiguration
when pressed, rather than no entry at all.
The registered redirect URIs on the provider side must cover every platform you ship: the web
origin’s /oauth/callback, https://app.thunderbolt.io/oauth/callback for mobile, and
http://localhost:17421–17423 for desktop.