name: datagol-connector
description: The shared pattern for building any 3rd-party OAuth connector inside a DataGOL-generated app — Gmail, Calendar, Outlook, Slack, Salesforce, Drive, etc. Covers the Integration Connectors workbook, the OAuth handoff via the existing DataGOL backend redirect (/idp/api/v1/oauth2/{service}/authorize), persisting the connectorId, the token-broker contract (/connector/api/v1/instance/byId/{id}), the Connections page UI shell, the in-page polling loop, and the generated-app API layer structure. Triggered by "build a connector", "connect ", "sync my <inbox|calendar|drive|messages>", or any 3rd-party integration request. Provider-specific scaffolds live in child skills like datagol-google-connector (Gmail + Calendar). Depends on datagol-app-auth.
datagol-google-connector (Gmail + Calendar). Depends on datagol-app-auth.DataGOL Connector (parent pattern)
Runtime skill — DataGOL access goes through the app's API. Connectors ship and run in the generated app, so they are runtime. Under the single-port model, the connector machinery moves server-side: OAuth start, the token broker (
/connector/api/v1/instance/byId/{id}), theIntegration Connectorsworkbook reads/writes, and provider polling all run in the Express API; the browser only calls the app's/api/*(e.g./api/connectors,/api/connectors/:id/sync). The service token never reaches the client. The OAuth redirect still goes through the browser, but theclientRedirectUrilands on an app route that hands theconnectorIdto the server. Where samples below call DataGOL/the broker/provider from the client, place them inserver/. Seedatagol-app-development§Build-time vs runtime. (This also unblocks always-on/background sync, since the sync logic is no longer trapped in a browser tab.)
This skill defines the shape of every DataGOL connector — what every Gmail, Calendar, Outlook, Slack, or Salesforce integration looks like before you fill in provider-specific details. It pairs with a child skill (e.g. datagol-google-connector) that supplies the provider's URLs, scopes, data schemas, and sync logic.
Read
datagol-app-authfirst. This skill assumes a service token inx-auth-token, theDATAGOL_BASE_URLconfig constant, and thedgFetchhelper. None of that is repeated here.
When to use
- The user asks for a 3rd-party data sync — Gmail, Calendar, Outlook, Drive, Slack, Salesforce, etc.
- The user is wiring a "Connections" or "Integrations" page (like the screenshot with Email / Calendar / Storage groups).
- A child skill exists for the provider (
datagol-google-connector, futuredatagol-microsoft-connector, etc.) — load both. The child supplies provider-specific bits; this parent supplies the shared mechanics. - No child skill exists yet — use this skill alone, work off placeholders for provider-specific parts (OAuth scopes, API URLs, row schemas), and write a new child skill afterwards so the next person doesn't reinvent the same shape.
The connector loop (one-page architecture)
[Connections page]
│
│ user clicks "Connect <Provider>"
▼
GET /idp/api/v1/oauth2/{service}/authorize?clientRedirectUri=<this URL>
│
│ 302 to Google / Microsoft / etc. consent screen
▼
[provider OAuth]
│
│ user approves
▼
[DataGOL backend redirect URI — already built]
│
│ exchanges code with server-held client_secret,
│ persists tokens, mints connectorId
▼
302 to <clientRedirectUri>?connectorId=<id>&success=true&redirectUri=
│ (Note: param is `success=true`, NOT `status=success`)
▼
[Connections page on mount]
1. PERSIST CONNECTOR ROW to `Integration Connectors` workbook (FIRST)
2. history.replaceState — scrub query
3. getTokens(connectorId) → { accessToken, refreshToken, expiresAt, accountEmail, ... }
4. PATCH connector row with account_email
5. hand off to child skill: backfill (last 30 days)
│
▼
[60s poller, while page is open]
reads `Integration Connectors` workbook every cycle
for each connected row → child skill's syncOne(connectorRow):
- getTokens(connectorRow.connector_id)
- call provider API for delta since cursor_json
- dedupe + bulk-insert into the data workbook
- advance cursor_json AFTER successful insert
The child skill's job is to supply: scopes, provider API URLs, data-row schemas, syncOne() implementation, and cursor_json shape. Everything else here applies unchanged.
Pre-flight checklist
Before scaffolding any connector code, in this order:
- Run
datagol-app-authprovisioning — create service account, mint token, drop it into.env.local. The child skill can't bulk-insert workbook rows without it. - Always ask the user which workspace to use before creating any workbooks. Call
datagol_list_workspacesto show the user their available workspaces, then explicitly ask: "Which workspace should I create the workbooks in?" Never silently pick the default workspace returned bydatagol_get_workspace_schema— the user must confirm. If they want a new workspace, create it first withdatagol_create_workspaceand use that ID. - Grant the service account
CREATORon the confirmed workspace — re-run Step C ofdatagol-app-authprovisioning if the workspace differs from the one already granted. - Discover existing workbooks with
datagol_get_workspace_schemaon the confirmed workspace. IfIntegration Connectorsalready exists in this workspace, reuse it — don't create a second one. Same for the provider data workbooks (the child skill's responsibility to check). - Announce the polling caveat to the user upfront, don't bury it: "sync only runs while this page is open. For always-on sync we'd need a backend job — out of scope for now."
The Integration Connectors workbook
One workbook per workspace. One row per (account_email, service_type) pair. Created once and reused across every connector the user adds in that workspace.
| internal name | type | notes |
|---|---|---|
connector_id |
LONG_TEXT | unique; opaque id from backend redirect |
service_type |
LONG_TEXT | gmail | calendar | drive | outlook | slack | ... |
provider |
LONG_TEXT | google | microsoft | slack | ... |
account_email |
LONG_TEXT | |
account_display_name |
LONG_TEXT | |
oauth_status |
LONG_TEXT | connected | disconnected | error |
connected_at |
DATE | ISO 8601 with tz |
disconnected_at |
DATE | nullable |
last_synced_at |
DATE | |
cursor_json |
LONG_TEXT | JSON-encoded; child skill defines per-service shape |
last_error |
LONG_TEXT | nullable |
Use datagol_create_workbook to create it with these columns. The cursor is a JSON blob so each provider can encode whatever it needs — Gmail uses {"history_id":"..."}, Calendar uses {"sync_token":"...","calendar_id":"primary"}, future providers will use whatever fits their API. Don't add a per-provider cursor column to the workbook; it'd make schema migrations brittle.
OAuth handoff — the 5 shared steps
Every child reuses these. Don't reimplement them in child skills; just call into src/api/connectors.ts.
1. Start
The Connect button calls:
GET ${DATAGOL_BASE_URL}/idp/api/v1/oauth2/<servicePath>/authorize
?x-auth-token=<encodeURIComponent(DATAGOL_SERVICE_TOKEN)>
&sourceType=connector
&clientRedirectUri=<encodeURIComponent(window.location.href)>
This is a full-page navigation (not a dgFetch call) — the browser follows the redirect chain to the provider's consent screen, so we can't set request headers. Identity is carried in the ?x-auth-token= query param (same name as the header used elsewhere). <servicePath> is per-provider — the child skill knows which (gmail, gcalendar, slack, outlook, ...).
?x-auth-token=MUST be the service token, never a user JWT. The backend ties each connector to the identity that initiates OAuth. The broker (GET /connector/api/v1/instance/byId/{id}) — which the generated app calls later with thex-auth-tokenheader set to the service token — only returns tokens for connectors owned by that same identity. Mixing a user JWT in OAuth start with a service token in the broker call produceserrorCodes: ["REAUTHENTICATION_REQUIRED"]from the broker, even immediately after a fresh OAuth flow. Both calls must use the same identity. Never scaffold aDATAGOL_USER_JWTconstant in the generated app'sconfig.ts— no flow needs it.
Param name is
x-auth-token, notjwtToken. Older example curls used?jwtToken=; the canonical name today matches the header name. If a provider's OAuth start endpoint redirects toauth_failedor returns an emptyconnectorId, double-check the param name isx-auth-token.
⚠️ Sandbox / iframe constraint — always use window.open(_blank) for OAuth
The generated app is often served inside a sandbox iframe (e.g. the DataGOL codex preview). OAuth providers (Slack, Google, Microsoft, etc.) set X-Frame-Options: sameorigin on their consent screens, which causes two cascading failures when the OAuth URL is navigated to inside an iframe:
- The iframe refuses to display the consent screen → browser shows a chrome-error page.
window.location.href = urlinside an iframe only navigates the iframe, not the top frame.window.open(url, '_top')is blocked by cross-origin iframe policy when the sandbox and the top frame are on different origins.
The correct pattern is always window.open(url, '_blank') — open OAuth in a new tab. After the user approves, the new tab lands on clientRedirectUri?connectorId=..., processes the callback, then uses a localStorage signal to notify the original tab (still in the iframe) that a new connector is ready. The new tab then calls window.close() to clean up.
Complete implementation of startOAuth and the cross-tab callback handshake:
// src/api/connectors.ts
export function startOAuthUrl(servicePath: string): string {
const token = encodeURIComponent(DATAGOL_SERVICE_TOKEN);
const back = encodeURIComponent(window.location.href.split('?')[0]);
return (
`${DATAGOL_BASE_URL}/idp/api/v1/oauth2/${servicePath}/authorize` +
`?x-auth-token=${token}&sourceType=connector&clientRedirectUri=${back}`
);
}
export function startOAuth(servicePath: string): void {
const url = startOAuthUrl(servicePath);
const popup = window.open(url, '_blank');
if (!popup) {
// Popup blocked — fall back to navigating the current frame directly.
// The OAuth flow will still work; the user returns to a standalone app URL.
window.location.href = url;
}
}
In the Connections page, after the OAuth callback is fully processed (connector row persisted + backfill complete), emit a localStorage signal and close the tab:
// At the end of handleOAuthReturn(), after backfill finishes:
localStorage.setItem('oauth_done', Date.now().toString());
await new Promise((r) => setTimeout(r, 300)); // let storage event fire
window.close(); // works because this tab was opened by script
In the same Connections page (which is still alive in the original iframe), add a storage event listener so it refreshes automatically when the new tab signals completion:
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === 'oauth_done') loadConnectors();
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
The full cross-tab flow:
Original tab (in iframe)
└─ click Connect
└─ window.open(oauthUrl, '_blank') ← new tab opens
New tab
└─ navigates to provider consent ← no iframe restriction
└─ user approves
└─ lands on clientRedirectUri?connectorId=xxx&status=success
└─ persists row, backfills data
└─ localStorage.setItem('oauth_done', ...)
└─ window.close()
Original tab (storage event)
└─ loadConnectors() ← list refreshes automatically
If the browser blocks popups,
window.openreturnsnulland the fallback (window.location.href) kicks in — this navigates the iframe to the OAuth URL which will fail with the X-Frame-Options error. Surface a UI hint: "If the Slack window didn't open, allow popups for this site in your browser's address bar and try again."
2. User approves on the provider
Out of our hands.
3. Backend redirect
The DataGOL backend completes OAuth (using its server-held client_secret), persists tokens against a new connectorId, then 302s the browser to:
<clientRedirectUri>?connectorId=<id>&status=success
Or &status=error&error=<msg> on failure. The user lands back on whatever URL they started from — that's why clientRedirectUri = window.location.href at start.
4. PERSIST CONNECTOR ROW (immediately, before anything else)
This is the most important step in the whole flow. On mount, the Connections page reads URLSearchParams:
const params = new URLSearchParams(window.location.search);
const connectorId = params.get('connectorId');
// ⚠️ DataGOL returns ?success=true, NOT ?status=success.
// Always check both for forward-compatibility.
const isSuccess = params.get('success') === 'true' || params.get('status') === 'success';
if (connectorId && isSuccess) {
// STEP 4 — persist FIRST. Before token fetch, before backfill, before anything.
await insertRow('Integration Connectors', {
connector_id: connectorId,
service_type: <service>, // child skill knows this
provider: <provider>, // child skill knows this
oauth_status: 'connected',
connected_at: new Date().toISOString(),
});
// STEP 4.5 — scrub the query string so refreshes don't reprocess
// and so connectorId doesn't leak into history / referrer.
history.replaceState({}, '', window.location.pathname);
// ... continue with steps 5+
}
<service> and <provider> are determined by which Connect button was clicked. The page can encode that in clientRedirectUri (e.g. ?_pendingService=gmail) so it's still in scope when the redirect comes back, or read it from a per-button localStorage flag set right before navigating.
5. Hydrate metadata + tokens
Now that the row is durable, fetch tokens and connector metadata:
const { accessToken, refreshToken, expiresAt, accountEmail, accountDisplayName }
= await getTokens(connectorId);
await updateRow('Integration Connectors', { connector_id: connectorId }, {
account_email: accountEmail,
account_display_name: accountDisplayName,
last_synced_at: new Date().toISOString(),
});
Then call the child skill's backfill function (backfillGmail(connectorId), backfillCalendar(connectorId), etc.) — that's where the provider-specific work begins.
Token broker — getTokens(connectorId)
The single entry point for getting an access token, anywhere in the app. Lives in src/api/connectors.ts.
// src/api/connectors.ts (generated)
import { dgFetch } from './datagol';
interface Tokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // unix ms
accountEmail: string;
accountDisplayName: string;
serviceType: string;
}
const tokenCache = new Map<string, Tokens>();
export async function getTokens(connectorId: string): Promise<Tokens> {
const cached = tokenCache.get(connectorId);
// 60-second safety margin — refresh just before the broker would say "expired"
if (cached && cached.expiresAt > Date.now() + 60_000) return cached;
const raw = await dgFetch<any>(`/connector/api/v1/instance/byId/${connectorId}`);
// Actual broker response shape (confirmed):
// {
// success: true,
// data: {
// id: 7246387,
// name: 'oauth2Connector',
// connectorType: 'SLACK',
// credentialType: 'OAUTH2',
// userId: 1012,
// config: [{
// type: 'header',
// data: {
// accessToken: 'xoxb-...',
// tokenType: 'bot',
// expiresAt: null,
// refreshToken: null,
// scope: 'channels:read,...'
// }
// }]
// }
// }
const configData = raw.data?.config?.[0]?.data ?? {};
const tokens: Tokens = {
accessToken: configData.accessToken ?? raw.accessToken ?? raw.access_token,
refreshToken: configData.refreshToken ?? raw.refreshToken ?? raw.refresh_token,
expiresAt: configData.expiresAt ?? raw.expiresAt ?? Date.now() + 50 * 60_000,
accountEmail: raw.data?.accountEmail ?? raw.accountEmail ?? raw.account_email ?? raw.email ?? '',
accountDisplayName: raw.data?.name ?? raw.accountDisplayName ?? '',
serviceType: raw.data?.connectorType ?? raw.serviceType ?? raw.service_type,
};
tokenCache.set(connectorId, tokens);
return tokens;
}
The cache is module-level memory only. Never persist tokens — not to a workbook, not to localStorage, not to logs. On page reload, the cache is empty and the broker is re-called for every connector; that's fine.
When a Google API returns 401, bypass the cache once (force-refresh) and retry; if it still 401s, mark the connector oauth_status: 'error' and surface a "Reconnect" CTA in the UI.
The polling loop
setInterval(syncAll, 60_000) on Connections page mount, clearInterval on unmount. Lives next to the Connections page component (or in a useEffect if the framework is React).
async function syncAll() {
const connectors = await listRows('Integration Connectors', {
whereClause: "`oauth_status` = 'connected'",
});
for (const row of connectors) {
try {
// syncOne is provided by the child skill, dispatched on row.service_type.
await syncOne(row);
} catch (err) {
console.warn(`[sync] ${row.service_type}/${row.account_email} failed:`, err);
// Don't crash the loop — one bad connector shouldn't stop the others.
}
}
}
Why state lives in the workbook and not in memory: page reloads (and tab restores, and browser quits-and-reopens) all need to resume cleanly. The workbook is the single source of truth — every poll cycle re-reads the connector rows. New connectors added by clicking Connect appear in the next cycle automatically.
Generated-app API layer
Components don't call fetch directly. Three files in src/api/:
src/api/datagol.ts — dgFetch (from datagol-app-auth)
src/api/workbooks.ts — listRows, insertRow, bulkInsertRows, updateRow
src/api/connectors.ts — startOAuth, getTokens, disconnectConnector, listConnectors
Plus per-provider client modules added by child skills (src/api/google.ts for Gmail/Calendar, future src/api/microsoft.ts, etc.).
Sketch of connectors.ts:
// src/api/connectors.ts
import { dgFetch } from './datagol';
import { DATAGOL_BASE_URL, DATAGOL_SERVICE_TOKEN } from '../config';
export function startOAuthUrl(servicePath: string): string {
const token = encodeURIComponent(DATAGOL_SERVICE_TOKEN);
const back = encodeURIComponent(window.location.href.split('?')[0]);
return (
`${DATAGOL_BASE_URL}/idp/api/v1/oauth2/${servicePath}/authorize` +
`?x-auth-token=${token}&sourceType=connector&clientRedirectUri=${back}`
);
}
// IMPORTANT: always open in a new tab (_blank), never navigate the current
// frame. OAuth providers set X-Frame-Options: sameorigin and will be blocked
// inside the sandbox iframe. See "Sandbox / iframe constraint" section above.
export function startOAuth(servicePath: string): void {
const url = startOAuthUrl(servicePath);
const popup = window.open(url, '_blank');
if (!popup) {
// Popup blocked — fall back (may show X-Frame-Options error in sandbox).
window.location.href = url;
}
}
export async function getTokens(connectorId: string) { /* see above */ }
export async function disconnectConnector(_connectorId: string): Promise<void> {
// No server-side revocation endpoint exists on DataGOL.
// DELETE /instance/byId/{id} → 405. POST /instance/{id}/disconnect → 404.
// Disconnect is handled entirely by updating the workbook row to
// oauth_status = 'disconnected'. This function is intentionally a no-op.
}
Sketch of workbooks.ts:
// src/api/workbooks.ts
import { dgFetch } from './datagol';
const WS = import.meta.env.VITE_DATAGOL_WORKSPACE_ID as string;
// Resolve workbook id by name. Cache in module memory.
const workbookIdCache = new Map<string, string>();
async function workbookId(name: string): Promise<string> {
if (workbookIdCache.has(name)) return workbookIdCache.get(name)!;
const schema = await dgFetch<any>(`/noCo/api/v2/workspaces/${WS}/schema`);
const wb = (schema.workbooks ?? schema.tables ?? [])
.find((w: any) => w.name === name || w.displayName === name);
if (!wb) throw new Error(`workbook "${name}" not found`);
workbookIdCache.set(name, wb.id);
return wb.id;
}
export async function listRows(
workbook: string,
opts: { whereClause?: string; pageSize?: number } = {},
): Promise<any[]> {
const wbId = await workbookId(workbook);
const body: any = {
requestPageDetails: { pageNumber: 1, pageSize: opts.pageSize ?? 1000 },
};
if (opts.whereClause) body.whereClause = opts.whereClause;
const data = await dgFetch<any>(
`/noCo/api/v2/workspaces/${WS}/tables/${wbId}/cursor`,
{ method: 'POST', body: JSON.stringify(body) },
);
return (data.rows ?? []).map((r: any) => ({ id: r.id, ...r.cellValues }));
}
export async function insertRow(workbook: string, cellValues: Record<string, unknown>): Promise<void> {
const wbId = await workbookId(workbook);
await dgFetch(`/noCo/api/v2/workspaces/${WS}/tables/${wbId}/rows`, {
method: 'POST',
body: JSON.stringify({ cellValues }),
});
}
export async function bulkInsertRows(workbook: string, rows: Record<string, unknown>[]): Promise<void> {
if (rows.length === 0) return;
const wbId = await workbookId(workbook);
await dgFetch(`/noCo/api/v2/workspaces/${WS}/tables/${wbId}/rows/bulk`, {
method: 'POST',
body: JSON.stringify(rows.map((cellValues) => ({ cellValues }))),
});
}
export async function updateRow(
workbook: string,
match: Record<string, unknown>,
cellValues: Record<string, unknown>,
): Promise<void> {
// Find row by match clause, then PATCH.
const matchKey = Object.keys(match)[0];
const matchVal = String(Object.values(match)[0]).replace(/'/g, "''");
const where = `\`${matchKey}\` = '${matchVal}'`;
const rows = await listRows(workbook, { whereClause: where, pageSize: 1 });
if (rows.length === 0) throw new Error(`updateRow: no row matched ${where}`);
const wbId = await workbookId(workbook);
await dgFetch(`/noCo/api/v2/workspaces/${WS}/tables/${wbId}/rows/${rows[0].id}`, {
method: 'PATCH',
body: JSON.stringify({ cellValues }),
});
}
Date columns (connected_at, last_synced_at, etc.) need ISO 8601 with timezone. new Date().toISOString() produces 2026-04-30T15:23:00.000Z which DataGOL accepts; if you build a date manually, see datagol-workbook-operations for the normalizeDate() helper.
Connections page UI shell
Mirror the screenshot the user provided: three sections (Email / Calendar / Storage), each with a subtitle and a row of provider buttons.
Layout:
┌─ Connections ────────────────────────────────────────────┐
│ │
│ Email │
│ Connect Google or Microsoft for sending and full │
│ inbox sync │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ● Connect Google │ │ ■ Connect Microsoft (disabled) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Calendar │
│ Connect Google or Microsoft calendars │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ● Connect Google │ │ ■ Connect Microsoft (disabled) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Storage │
│ One storage account — connect Google Drive or OneDrive │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ● Connect Google │ │ ■ Connect Microsoft (disabled) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
Provider button states:
| State | Display |
|---|---|
| not-connected | Connect <Provider> |
| connected | account@domain — Disconnect plus Last sync 2m ago • 1,247 items |
| coming soon | button rendered disabled with tooltip "coming soon" (Microsoft) |
State per (section × provider) is derived from the Integration Connectors workbook on every render. Keep the UI dumb — just project the workbook into the layout. New connectors appearing mid-session show up automatically at the next render.
Styling follows the host app's convention via datagol-integrate. Don't introduce Tailwind into a non-Tailwind project; don't add styled-components if they're using CSS Modules; etc.
Disconnect flow
When the user clicks Disconnect on a connected row:
- Show an in-page confirmation UI — never
window.confirm(). Render a small inline confirmation state (e.g. the button label changes to "Are you sure? Yes / Cancel", or a compact inline alert replaces the button) directly in the connection card.window.confirm()is a native browser dialog that is blocked in sandboxed iframes, produces inconsistent UX across browsers, and cannot be styled. Always use React state to manage the confirmation step. - There is no server-side revocation endpoint on DataGOL. Do not call
DELETE /connector/api/v1/instance/byId/{id}(405) orPOST /instance/{id}/disconnect(404). ThedisconnectConnector()function insrc/api/connectors.tsshould be a no-op — disconnect is handled entirely by the workbook row update in the next step. - Update the connector row:
oauth_status = 'disconnected',disconnected_at = nowviaPUT /rows(rowidin the body, not the URL). Never usePATCH /rows/:id— it returns 405. Don't delete the row — keeping it gives the user a history of past connections, and re-connecting later can re-use the row byaccount_email. - Call the
onDisconnect()callback to reset UI state. - The next polling cycle's
whereClausefilter (\oauth_status` = 'connected'`) automatically skips disconnected rows.
Example in-page confirmation pattern (React):
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const handleDisconnect = async () => {
setDisconnecting(true);
setConfirmDisconnect(false);
try {
try {
await disconnectConnector(connector.connector_id);
} catch (e) {
console.warn('DELETE connector API failed (continuing local disconnect):', e);
}
await connectors.update(connector.id, {
oauth_status: 'disconnected',
disconnected_at: new Date().toISOString(),
});
onDisconnect();
} catch (e) {
console.error(e);
} finally {
setDisconnecting(false);
}
};
// In JSX — replace the disconnect button with an inline confirm when clicked:
{!confirmDisconnect ? (
<button onClick={() => setConfirmDisconnect(true)}>
<UnlinkIcon /> Disconnect
</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Disconnect?</span>
<button onClick={handleDisconnect} disabled={disconnecting}
style={{ color: '#EA4335', border: '1px solid rgba(234,67,53,0.35)', background: 'transparent' }}>
{disconnecting ? <Spinner /> : 'Yes, disconnect'}
</button>
<button onClick={() => setConfirmDisconnect(false)}
style={{ color: 'var(--text-muted)', border: '1px solid var(--border)', background: 'transparent' }}>
Cancel
</button>
</div>
)}
Hard rules
- Always ask the user which workspace to use before creating any workbooks. Show the workspace list, get explicit confirmation, then create. Never silently use whatever
datagol_get_workspace_schemareturns by default. - All text columns in connector data workbooks must use
LONG_TEXTas theuiDataType. Never useSINGLE_LINE_TEXTfor any field inIntegration Connectorsor any provider data workbook (Gmail Messages, Calendar Events, Slack Messages, etc.). This applies to every field that is not a DATE, NUMBER, BOOLEAN, or ID — including short values likeconnector_id,oauth_status, andservice_type.LONG_TEXTavoids silent truncation of long connector IDs, email addresses, OAuth tokens, and cursor blobs. - Persist
connectorIdtoIntegration Connectorsbefore anything else. It's the sole durable handle to the OAuth grant. If the page reloads (or crashes) before persistence, the user has to re-OAuth — and they will, more often than you think. - Never persist
accessTokenorrefreshTokento a workbook,localStorage,IndexedDB, or any logs. Memory-only via the module-level cache. - The polling loop reads connectors from the workbook on every cycle, not from in-memory state. A page refresh or tab restore must resume cleanly without manual intervention.
- All DataGOL calls go through
dgFetch(which setsx-auth-token). No rawfetchtobe.datagol.ai. - All provider calls use
Authorization: Bearer <accessToken>— that's the standard for Google, Microsoft, etc. Don't tryx-auth-tokenagainst a Google endpoint; it'll fail with a confusing error. - Always advance the cursor after a successful insert batch, never before. If insert fails, the next poll re-tries from the same cursor and the dedupe step keeps it idempotent. If you advance first and insert fails, you've lost data forever.
- Always dedupe by the provider's stable id (Gmail
message_id, Calendarevent_id, etc.) before inserting. Use a smallwhereClausequery against the data workbook before bulk-inserting; only insert ids not already present. - Don't backfill more than 30 days without explicit user confirmation. Provider rate limits are real (Gmail throttles at ~250 quota-units/user/sec) and so is workbook bloat. If the user explicitly asks for "all my mail" or "everything since 2020", confirm out loud, then page through carefully.
- Provider buttons that don't have a child skill yet must render as
disabledwith a "coming soon" tooltip. Don't build a half-working Microsoft button just because the Google one works. - Announce the polling caveat upfront. "Sync only runs while this page is open" is non-obvious to users used to enterprise integrations. Tell them at scaffold time and surface it in the UI (e.g. footer text).
- Always use
window.open(url, '_blank')for OAuth — neverwindow.location.hreforwindow.open(url, '_top'). The generated app runs inside a sandbox iframe; OAuth providers block iframe display withX-Frame-Options: sameorigin._topnavigation is blocked by cross-origin iframe policy. The only working pattern is a new tab (_blank) +localStoragesignal back to the original tab. See the "Sandbox / iframe constraint" section in Step 1 for the complete implementation.
Cross-references
datagol-app-auth— service-token + env-switching foundations. Required reading.datagol-google-connector— Gmail + Calendar implementation. The first child of this parent.datagol-integrate— when grafting the Connections page into an existing user repo. Follows that skill's mounting and styling rules.datagol-workbook-operations— full reference for the workbook read/write APIs theworkbooks.tswrappers call.datagol-workbook-design— when you need to design or extend the data-row workbooks beyond what a child skill specifies.datagol-frontend-design— Connections page styling, button states, dropdowns.datagol-context— DataGOL data model (Workspace → Workbook → Column / Row).