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:
- Enable APIs: Google Drive API, Google Docs API, Google Calendar API, Gmail API.
- OAuth consent screen: External, Testing mode; add the demo Google accounts as test users (β€100).
- Credentials β OAuth client ID (Web application). Authorized redirect URI:
https://nango-server-dev-4338.up.railway.app/oauth/callback - Hand over client ID + client secret (set on
nango-serverwhen 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β
| File | Change | Responsibility |
|---|---|---|
api/src/integrations/credentials/credentials.dto.ts | modify | google_workspace in ChannelType + NANGO_CONNECT_PROVIDERS |
api/src/integrations/credentials/credentials.service.ts | modify | CHANNEL_REQUIRED_KEYS.google_workspace: [], NANGO_BACKED_TYPES, validateNangoProvider error text |
api/src/integrations/credentials/sources.service.ts | modify | any-of-three tool gating + display name |
api/src/agent-api/tool-registry.ts | modify | 3 tool definitions |
api/src/runtime-control-plane/runtime-tool-executor.service.ts | modify | dispatch + executeGoogleWorkspace + normalizers |
api/test/runtime-tool-executor-google.spec.ts | create | executor unit tests (mirror runtime-tool-executor-slack-search.spec.ts) |
api/src/integrations/credentials/__tests__/sources.service.spec.ts | modify | gating coverage |
frontend/src/lib/api.ts | modify | SourceInfo.provider union |
frontend/src/app/client/settings/sources/page.tsx | modify | tile 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 keygoogle_workspace, provider templategoogle, 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=usersucceeds withproviderConfigKey=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"; extendcredentials.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 theChannelTypeunion (after'google_drive') and toNANGO_CONNECT_PROVIDERS. Incredentials.service.ts:CHANNEL_REQUIRED_KEYSgetsgoogle_workspace: []; add toNANGO_BACKED_TYPESset; updatevalidateNangoProvider's error string and itsExtract<...>return type to include"google_workspace". (Pattern: theslack_archiveaddition β same five touch points, no DB migration;integration_typeis varchar.) -
Step 3: sources gating β in
sources.service.tsreplace 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 theSourceInfo["provider"]union inapi.ts. - Step 2: In
sources/page.tsxicon map, addgoogle_workspace: { brandIcon: { path: siGoogle.path, hex: siGoogle.hex } }(importsiGooglefromsimple-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.tsharness (mockNangoService.httpProxy, seededgoogle_workspacecredential). 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 theslack_searchbranch:
if (
input.toolName === 'google_docs_search'
|| input.toolName === 'google_calendar_read'
|| input.toolName === 'gmail_search'
) {
return this.executeGoogleWorkspace(input, cred);
}
-
Step 3: implementation β
executeGoogleWorkspaceresolvesstoredConnectionIdexactly likeexecuteSlackWorkspaceSearch(viareadCredConfig), then switches ontoolName/action:- docs.search β
GET drive/v3/files,params: { q, orderBy: 'modifiedTime desc', pageSize: bounded(limit,1..25), fields: 'files(id,name,modifiedTime,webViewLink)' }whereq = "mimeType='application/vnd.google-apps.document' and fullText contains '<escaped>'"(escape\and'). - docs.read β
GET docs/v1/documents/{documentId}(Docs API acceptsdrive.readonly); flattenbody.content[].paragraph.elements[].textRun.content+ table cells, join, cap 50k chars. NOTE: verify the Nangogoogletemplate proxiesdocs.googleapis.com; if the template pinswww.googleapis.comonly, fall back toGET drive/v3/files/{id}/export?mimeType=text/plainand confirmNangoClient.proxytolerates 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; normalizeitems[]β{ 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'; walkpayload.parts(recursive) for the firstmimeType === 'text/plain'part, base64url-decodebody.data, cap 20k chars; fall back tosnippet. - error mapping (shared): proxy
status === 'error'+http_status === 403with body containinginsufficientPermissions/ACCESS_TOKEN_SCOPE_INSUFFICIENTβscope_not_granted+ per-product hint; otherstatus === 'error'β{ ok: false, error: '<tool>_unavailable', detail: 'Temporarily unavailable β try again shortly.' }and log server-side only (#3054 β no vendor detail to the model).
- docs.search β
-
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
resolveSpecialistToolsetsadmits the three tools purely from registry +tool_permissions+ an activegoogle_workspacecredential (it is registry-driven forslack_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_searchenabled β exactlygmail_searchadvertised (not the other two). Run β PASS. Commit.
Task 7: Dev E2E verification (the three product cases, in priority order)β
- Enable
tool_dispatch_enabled+ thegoogle_docs_searchflag 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_searchonly β mail questions refuse cleanly; Docs/Calendar unaffected. Untick Gmail scope at consent (fresh connect) βscope_not_grantedhint 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_permissionsrows 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.