Skip to main content

Google Workspace Read Tools (Docs / Calendar / Gmail) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Client orgs connect Google once (Nango OAuth, read-only scopes); the Specialist gains three independently-flagged live read tools β€” google_docs_search, google_calendar_read, gmail_search β€” covering "summarize yesterday's sync transcript", "today's meetings", and "check important incoming mail" (issue #5050, product priority flip 2026-07-28).

Architecture: ONE Nango-backed connection (new provider type google_workspace, provider template google, scopes drive.readonly calendar.readonly gmail.readonly) + THREE tools gated by three separate tool_permissions flags (deny-by-default). Execution rides the slack_search path verbatim: agent-api/tool-registry.ts entry (kind: 'nango', integrationTypes: ['google_workspace']) β†’ toolset resolution β†’ humanwork MCP β†’ tool gateway β†’ runtime-tool-executor.service.ts branch β†’ NangoService.httpProxy β†’ Google REST. No Nango custom scripts (unlike Notion β€” Google's REST APIs work through the plain HTTP proxy). Google's granular consent lets a client untick individual scopes; the executor maps 403 insufficientPermissions to a clean "scope not granted β€” reconnect" tool error, so per-product isolation survives the single connection.

Tech Stack: NestJS (api), self-hosted Nango v0.71 (nango-server on Railway dev), Google Drive/Calendar/Gmail v3/v1 REST, Jest.

Explicitly out of scope: write scopes, proactive inbox monitoring, Drive→KB connector sync (dormant P4.8 stub stays untouched), Notion (separate plan), Google app verification (test-user mode for v1).


Prerequisite (user, before Task 6 verification)​

Google Cloud Console β†’ APIs & Services:

  1. Enable APIs: Google Drive API, Google Docs API, Google Calendar API, Gmail API.
  2. OAuth consent screen: External, Testing mode; add the demo Google accounts as test users (≀100).
  3. Credentials β†’ OAuth client ID (Web application). Authorized redirect URI: https://nango-server-dev-4338.up.railway.app/oauth/callback
  4. Hand over client ID + client secret (set on nango-server when creating the integration in Task 1).

Restricted-scope note: drive.readonly + gmail.readonly are restricted; unverified apps show a warning interstitial and are capped at test users. Fine for dev/demo; production requires Google verification (weeks β€” schedule separately).


File Structure​

FileChangeResponsibility
api/src/integrations/credentials/credentials.dto.tsmodifygoogle_workspace in ChannelType + NANGO_CONNECT_PROVIDERS
api/src/integrations/credentials/credentials.service.tsmodifyCHANNEL_REQUIRED_KEYS.google_workspace: [], NANGO_BACKED_TYPES, validateNangoProvider error text
api/src/integrations/credentials/sources.service.tsmodifyany-of-three tool gating + display name
api/src/agent-api/tool-registry.tsmodify3 tool definitions
api/src/runtime-control-plane/runtime-tool-executor.service.tsmodifydispatch + executeGoogleWorkspace + normalizers
api/test/runtime-tool-executor-google.spec.tscreateexecutor unit tests (mirror runtime-tool-executor-slack-search.spec.ts)
api/src/integrations/credentials/__tests__/sources.service.spec.tsmodifygating coverage
frontend/src/lib/api.tsmodifySourceInfo.provider union
frontend/src/app/client/settings/sources/page.tsxmodifytile icon (siGoogle)

Task 1: Nango server integration config (ops, no repo code)​

  • Step 1: In the Nango dashboard on dev (nango-server-dev-4338.up.railway.app), create integration: unique key google_workspace, provider template google, OAuth client ID/secret from the prerequisite, scopes exactly: https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/gmail.readonly
  • Step 2: Verify with a manual Connect session (Nango dashboard "Test connection") against a test Google account; confirm the connection row appears and GET /proxy/drive/v3/about?fields=user succeeds with providerConfigKey=google_workspace.

Task 2: Provider type plumbing (API)​

Files: credentials.dto.ts, credentials.service.ts, sources.service.ts + their specs.

  • Step 1: failing spec β€” extend the lockstep spec (__tests__/credentials.nango-provider-lockstep.spec.ts) provider list with "google_workspace"; extend credentials.nango-kb-providers.spec.ts-style Connect-session test:
it.each(["notion", "google_drive", "google_workspace"])(
"validates %s as a Nango-backed provider and creates a Connect session", ...)

Run: cd api && npx jest credentials.nango-provider-lockstep credentials.nango-kb-providers β†’ FAIL (invalid provider).

  • Step 2: implement β€” in credentials.dto.ts: add 'google_workspace' to the ChannelType union (after 'google_drive') and to NANGO_CONNECT_PROVIDERS. In credentials.service.ts: CHANNEL_REQUIRED_KEYS gets google_workspace: []; add to NANGO_BACKED_TYPES set; update validateNangoProvider's error string and its Extract<...> return type to include "google_workspace". (Pattern: the slack_archive addition β€” same five touch points, no DB migration; integration_type is varchar.)

  • Step 3: sources gating β€” in sources.service.ts replace the single gating tool for this provider with any-of-three:

export const SOURCE_TOOLS_BY_PROVIDER: Record<NangoConnectProvider, string[]> = {
slack_search: ['slack_search'],
slack_archive: ['slack_archive_search'],
quickbooks: ['quickbooks_query'],
xero: ['xero_query'],
notion: ['notion_query'],
google_drive: ['google_drive_query'],
google_workspace: ['google_docs_search', 'google_calendar_read', 'gmail_search'],
};

Tile shows when ANY listed tool is enabled (toolPermissions.isEnabled OR-loop). Display name: google_workspace: 'Google Workspace'. Update sources.service.spec.ts: coverage guard (every provider has β‰₯1 tool) + a test "google_workspace tile appears when only gmail_search is enabled".

  • Step 4: Run both spec files β†’ PASS. Commit: feat(integrations): google_workspace Nango provider type + any-of-three sources gating

Task 3: Frontend tile​

Files: frontend/src/lib/api.ts (SourceInfo provider union), frontend/src/app/client/settings/sources/page.tsx.

  • Step 1: Add "google_workspace" to the SourceInfo["provider"] union in api.ts.
  • Step 2: In sources/page.tsx icon map, add google_workspace: { brandIcon: { path: siGoogle.path, hex: siGoogle.hex } } (import siGoogle from simple-icons, already a dependency).
  • Step 3: cd frontend && npx jest src/app/client/settings (existing tile tests) β†’ PASS. Commit: feat(frontend): Google Workspace source tile

Task 4: Tool registry entries​

Files: api/src/agent-api/tool-registry.ts (append after slack_search).

  • Step 1: Add three definitions, kind: 'nango', integrationTypes: ['google_workspace']:
google_docs_search: {
name: 'google_docs_search', kind: 'nango', handler: 'google_workspace',
description: "Search and read Google Docs in the org's connected Google Workspace (read-only; includes Meet transcripts saved to Drive).",
integrationTypes: ['google_workspace'],
actions: [
{ name: 'search', description: 'Full-text search Google Docs by content/title, newest first.',
parameters: { type: 'object', required: ['query'], properties: {
query: { type: 'string' }, limit: { type: 'integer', default: 10 } } } },
{ name: 'read', description: 'Read one Google Doc as plain text (capped at 50k chars).',
parameters: { type: 'object', required: ['documentId'], properties: {
documentId: { type: 'string' } } } },
],
},
google_calendar_read: {
name: 'google_calendar_read', kind: 'nango', handler: 'google_workspace',
description: "List events from the connected Google account's primary calendar (read-only).",
integrationTypes: ['google_workspace'],
actions: [
{ name: 'listEvents', description: 'List events in a time window (defaults to today, org-local).',
parameters: { type: 'object', properties: {
timeMin: { type: 'string', description: 'ISO 8601; default start of today' },
timeMax: { type: 'string', description: 'ISO 8601; default end of today' },
limit: { type: 'integer', default: 20 } } } },
],
},
gmail_search: {
name: 'gmail_search', kind: 'nango', handler: 'google_workspace',
description: "Search and read the connected Gmail inbox (read-only). Supports Gmail operators (from:, is:unread, newer_than:1d).",
integrationTypes: ['google_workspace'],
actions: [
{ name: 'search', description: 'Search messages; returns sender/subject/date/snippet.',
parameters: { type: 'object', required: ['query'], properties: {
query: { type: 'string' }, limit: { type: 'integer', default: 10 } } } },
{ name: 'read', description: 'Read one message body as plain text (capped).',
parameters: { type: 'object', required: ['messageId'], properties: {
messageId: { type: 'string' } } } },
],
},
  • Step 2: Run registry/toolset specs (npx jest agent-api) β†’ PASS (registry is data; failures = schema drift). Commit: feat(tools): google_docs_search / google_calendar_read / gmail_search registry entries

Task 5: Executor​

Files: runtime-tool-executor.service.ts; create api/test/runtime-tool-executor-google.spec.ts.

  • Step 1: failing tests β€” mirror runtime-tool-executor-slack-search.spec.ts harness (mock NangoService.httpProxy, seeded google_workspace credential). Cases:
it("docs search builds the Drive q filter and normalizes results", ...)
// httpProxy called with provider 'google_workspace', endpoint 'drive/v3/files',
// params.q === "mimeType='application/vnd.google-apps.document' and fullText contains 'sync transcript'"
// β†’ { ok: true, results: [{ documentId, title, modifiedTime, link }] }

it("docs search escapes single quotes in the query", ...) // O'Brien β†’ O\'Brien

it("docs read flattens Docs API JSON body to plain text and caps length", ...)
// endpoint `docs/v1/documents/${id}` β†’ paragraphs/tables flattened, 50_000-char cap

it("calendar listEvents defaults to today and normalizes events", ...)
// endpoint 'calendar/v3/calendars/primary/events', singleEvents=true, orderBy=startTime
// β†’ { ok: true, events: [{ summary, start, end, location, meetLink, attendeeCount }] }

it("gmail search returns id list then hydrates metadata", ...)
// 'gmail/v1/users/me/messages' then per-id 'messages/{id}?format=metadata'
// β†’ { ok: true, results: [{ messageId, from, subject, date, snippet }] } (limit-capped)

it("gmail read decodes the text/plain part (base64url) and caps length", ...)

it("maps Google 403 insufficientPermissions to scope_not_granted with a reconnect hint", ...)
// { ok: false, error: 'scope_not_granted', hint: "Gmail access wasn't granted at consent β€” reconnect and allow it." }

it("maps proxy status:'error' to a generic unavailable error without vendor detail", ...) // #3054

Run: npx jest runtime-tool-executor-google β†’ FAIL (no handler).

  • Step 2: dispatch β€” in execute() next to the slack_search branch:
if (
input.toolName === 'google_docs_search'
|| input.toolName === 'google_calendar_read'
|| input.toolName === 'gmail_search'
) {
return this.executeGoogleWorkspace(input, cred);
}
  • Step 3: implementation β€” executeGoogleWorkspace resolves storedConnectionId exactly like executeSlackWorkspaceSearch (via readCredConfig), then switches on toolName/action:

    • docs.search β†’ GET drive/v3/files, params: { q, orderBy: 'modifiedTime desc', pageSize: bounded(limit,1..25), fields: 'files(id,name,modifiedTime,webViewLink)' } where q = "mimeType='application/vnd.google-apps.document' and fullText contains '<escaped>'" (escape \ and ').
    • docs.read β†’ GET docs/v1/documents/{documentId} (Docs API accepts drive.readonly); flatten body.content[].paragraph.elements[].textRun.content + table cells, join, cap 50k chars. NOTE: verify the Nango google template proxies docs.googleapis.com; if the template pins www.googleapis.com only, fall back to GET drive/v3/files/{id}/export?mimeType=text/plain and confirm NangoClient.proxy tolerates a text (non-JSON) body β€” pick whichever works against the live dev Nango in Task 1's smoke, keep the other path out.
    • calendar.listEvents β†’ GET calendar/v3/calendars/primary/events, params: { timeMin, timeMax, singleEvents: 'true', orderBy: 'startTime', maxResults: bounded(limit,1..50) }; defaults = today UTC day bounds; normalize items[] β†’ { summary, start: start.dateTime ?? start.date, end, location, meetLink: hangoutLink, attendeeCount: attendees?.length ?? 0 }.
    • gmail.search β†’ GET gmail/v1/users/me/messages { q, maxResults: bounded(limit,1..20) }; then per id (≀ limit) GET gmail/v1/users/me/messages/{id} { format: 'metadata', metadataHeaders: 'From,Subject,Date' }; normalize headers + snippet.
    • gmail.read β†’ format: 'full'; walk payload.parts (recursive) for the first mimeType === 'text/plain' part, base64url-decode body.data, cap 20k chars; fall back to snippet.
    • error mapping (shared): proxy status === 'error' + http_status === 403 with body containing insufficientPermissions/ACCESS_TOKEN_SCOPE_INSUFFICIENT β†’ scope_not_granted + per-product hint; other status === 'error' β†’ { ok: false, error: '<tool>_unavailable', detail: 'Temporarily unavailable β€” try again shortly.' } and log server-side only (#3054 β€” no vendor detail to the model).
  • Step 4: npx jest runtime-tool-executor-google runtime-tool-executor-slack-search β†’ PASS. Commit: feat(runtime): Google Workspace read executors (docs/calendar/gmail)

Task 6: Toolset resolution sanity​

  • Step 1: Confirm resolveSpecialistToolsets admits the three tools purely from registry + tool_permissions + an active google_workspace credential (it is registry-driven for slack_search; expect zero code). If any hardcoded allowlist exists, extend it.
  • Step 2: Extend the existing toolset-resolution spec with one case: credential present + only gmail_search enabled β†’ exactly gmail_search advertised (not the other two). Run β†’ PASS. Commit.

Task 7: Dev E2E verification (the three product cases, in priority order)​

  • Enable tool_dispatch_enabled + the google_docs_search flag for Generalist (Demo); connect a test Google account from the Sources page (verify the tile, consent screen shows 3 read-only scopes, connection lands).
  • Case 1 (Docs): put a fake "Weekly Sync β€” transcript" Google Doc in the test account dated yesterday β†’ client thread: "summarize yesterday's sync transcript" β†’ answer quotes real doc content.
  • Case 2 (Calendar): create 2 events today β†’ enable google_calendar_read β†’ "what's on my calendar today?" β†’ both events, correct times.
  • Case 3 (Gmail): enable gmail_search β†’ seed an unread mail β†’ "any important unread mail?" β†’ sender/subject surfaced.
  • Isolation: disable gmail_search only β†’ mail questions refuse cleanly; Docs/Calendar unaffected. Untick Gmail scope at consent (fresh connect) β†’ scope_not_granted hint surfaces.
  • Update issue #5050 with the outcome (GDrive acceptance box) and note Notion is next.

Self-review notes​

  • Spec coverage: 3 cases β†’ Tasks 4/5/7; 3 separate flags β†’ deny-by-default tool_permissions rows per tool (no seeding needed β€” absent row = disabled); single connection β†’ Task 1/2; read-only β†’ scopes list; kill-one-keep-two β†’ Task 7 isolation check.
  • Known decision point deferred to implementation: Docs content path (Docs API JSON vs Drive export) β€” resolved empirically against live Nango in Task 5 Step 3 with both paths specified.
  • No placeholders: every executor endpoint, param, normalization shape, and error mapping is specified above.