Plank help · updated 2026-09-17

Connecting Google (Gmail, Calendar, Drive)

Connect a Google account so your assistant can read and send email, and work with Calendar, Drive, Docs, and Sheets — with the exact one-time setup that works on the first try.

Agents: fetch the raw markdown of this page at /en/help/connecting-google.md

Connecting Google

Your assistant can work with your Google account — read and send Gmail, manage Calendar, and work with Drive, Docs, and Sheets. Once it's connected, you just ask ("any new emails from this client?", "send them this reply", "add the order to the tracker sheet") and it does it.

Google sign-in uses OAuth — you approve access once in your own browser, and the assistant keeps a token so it doesn't have to ask again. This page walks through the one-time setup. It's written for both you and the assistant: the assistant fetches it at https://plank.md/help/connecting-google.md before wiring Google, so the connection goes through cleanly the first time instead of failing partway.

Why the careful setup? Google's sign-in has a few moving parts that have to line up exactly — the right kind of OAuth client, the right permissions, and a token that includes an offline "refresh" so it keeps working. Get one wrong and the connection succeeds once then breaks, or never completes. The steps below are the combination that works.

Step 0 — Try Plank's shared connection first (assistant)

Before sending anyone to the Google Cloud Console, check whether Plank can hand over a ready-made OAuth client. This is the fast path — most connections should end here, at Step 0, and never reach Step 1 at all.

Install the toolkit (skip if scripts/google/ already exists):

BASE=https://plank.md/help/kits
for KIT in $(curl -fsS "$BASE/google-base/manifest.json" | jq -r '.installOrder[]'); do
  curl -fsS "$BASE/$KIT/manifest.json" | jq -r '.files[].path' | while read -r p; do
    mkdir -p "$(dirname "$p")"
    curl -fsS "$BASE/$KIT/files/$p" -o "$p"
  done
done

Ask for the shared client. $PLANK_API_URL and $PLANK_WORKSPACE_ID are already in the environment.

mkdir -p scripts/google
attempt=1
while [ "$attempt" -le 3 ]; do
  code=$(curl -s -o /tmp/gclient.json -w '%{http_code}' \
    "$PLANK_API_URL/internal/google/oauth-client?workspaceId=$PLANK_WORKSPACE_ID")
  case "$code" in
    503) [ "$attempt" -lt 3 ] && sleep 3 ;;
    429) [ "$attempt" -lt 3 ] && sleep 15 ;;
    *) break ;;
  esac
  attempt=$((attempt + 1))
done
echo "http $code"

Write the response to a file, never to stdout — it holds a client secret and must not be printed into the conversation. Use -w, not -f: -f throws away the error body, and you need to read reason to branch correctly.

There are four outcomes, and they are not interchangeable:

  • 200 — a client is available. Move it into place and skip straight to Step 5 — Authorize below; none of Steps 1-4 are needed.

    mv /tmp/gclient.json scripts/google/credentials.json
    
  • 403 — a definitive refusal. The body is {"reason": "cap" | "disabled", "setupUrl": "..."}: either Google's shared-project cap is essentially used up, or the platform switch is off. Either way, Plank's shared connection genuinely isn't available right now — continue with Step 1 below and build the user's own Google Cloud project. This is the one case the manual walkthrough exists for.

  • 503 — the endpoint could not determine the answer (a database read failed transiently), not a refusal. The body is {"reason": "unknown"} — deliberately no setupUrl, because the manual walkthrough is the wrong response to "we don't know," not the right one. The loop above already retries a few times before giving up; most 503s clear within seconds. Only if it is still 503 after retrying should you fall through to Step 1 — and even then, tell the user plainly that the shared connection check is temporarily unavailable rather than presenting it as "no shared connection exists."

  • 429 — the endpoint is rate-limited, not refusing. Treat it exactly like 503: retryable, never a reason to fall through to Step 1. This is not hypothetical — the limit is keyed on the calling sandbox host's address, which every container on that host shares, so one other container's burst can throttle yours even though you did nothing wrong. The loop above already waits longer between retries for 429 than for 503 (the throttle window is a full minute, not a one-off blip), and there is no response body worth reading here. Only fall through to Step 1 if it is still 429 after retrying, and tell the user the shared connection is temporarily busy — not that it doesn't exist.

Getting this branch wrong sends a user into a ten-minute Cloud Console detour over what may have been a one-second blip. When in doubt, retry before you fall through.

What you'll need

(Only if Step 0 didn't work — a 403 with reason: "cap" or "disabled", or a persistent 503/429 after retrying.)

A Google account, and about ten minutes in the Google Cloud Console (free). You do this part yourself because it's tied to your own Google account; the assistant can't click through Google's consent screens for you. After it's done once, you never repeat it.

Step 1 — Create a Google Cloud project and turn on the APIs

  1. Open console.cloud.google.com signed in as the Google account you want to connect.
  2. Create a new project (e.g. "Plank Tools").
  3. Go to APIs & Services → Library and Enable these APIs. Turn on all of them by default — that way the assistant can work with any of these tools later without sending you back here:
    • Gmail API — read, organise, and send email
    • Google Calendar API — events and scheduling
    • Google Drive API — files and folders
    • Google Docs API — documents
    • Google Sheets API — spreadsheets
    • Google Slides API — presentations
    • Google Tasks API — to-dos
    • Need another Google service (Contacts, Forms, Apps Script, …)? Enable that API too — just search its name in the Library.

Step 2 — Configure the OAuth consent screen

  1. Go to APIs & Services → OAuth consent screen (in the newer console this is Google Auth Platform → Branding / Audience).
  2. Personal @gmail.com account: choose External. (The Internal option only exists for Google Workspace organization accounts.) Work / Google Workspace account (like name@yourcompany.com): choose Internal — it skips the test-user and verification friction.
  3. Fill in the app name, your support email, and developer contact email.
  4. Add the scopes. By default request the full set below, so the assistant can work across all of Google without making you reconnect when you later ask for something new:
    • https://www.googleapis.com/auth/gmail.modify and .../gmail.send (read, organise, and send mail)
    • https://www.googleapis.com/auth/calendar (Calendar)
    • https://www.googleapis.com/auth/drive (Drive)
    • https://www.googleapis.com/auth/documents (Docs)
    • https://www.googleapis.com/auth/spreadsheets (Sheets)
    • https://www.googleapis.com/auth/presentations (Slides)
    • https://www.googleapis.com/auth/tasks (Tasks)
    • Asked the assistant for another Google service? Add that service's scope here as well.
  5. External account: under Test users, add the Google address you'll connect. (You'll move the app off Testing in the next step, so this just keeps sign-in working in the meantime.)

Step 3 — Publish the app to Production (don't skip this)

This is the step that keeps the connection alive. On the OAuth consent screen (newer console: Google Auth Platform → Audience), set Publishing status to In production — click Publish app, then Confirm.

Why it's required: while the app is left in Testing, Google expires its refresh token about every 7 days — so the assistant gets logged out and you're forced to authorise again and again. In Production the token keeps working and you only sign in once.

You do not need to finish Google's verification for your own personal use. Because the full set includes restricted scopes (Gmail), Google shows a yellow "requires verification" banner here and an "unverified app" screen during sign-in — that's expected for an app only you use; just continue past it. Verification only removes that warning and lifts the 100-user cap; it has no effect on whether the token stays alive.

Step 4 — Create the OAuth client

  1. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID.
  2. Application type: Desktop app. (This matters — a Desktop-app client uses the http://localhost redirect that the paste-back flow below relies on. Don't use a "Web application" client unless you know its redirect URIs.)
  3. Download the JSON and give it to the assistant (paste it in chat, or upload the file). The assistant saves it in the workspace as scripts/google/credentials.json.

Step 5 — Authorize (the part you do in your own browser)

Because your assistant runs on a remote machine with no browser of its own, you approve access in your browser and hand the result back. The assistant drives this — here's what you'll see:

  1. The assistant gives you a sign-in link. Open it in your own browser and approve access.
  2. Google shows "Google hasn't verified this app" — that's expected for your own app. Click Advanced → Go to (your app) and continue.
  3. After you approve, Google redirects you to a http://localhost… address that won't load — that's also expected. Copy the full URL from your browser's address bar (it contains the one-time code).
  4. Paste that whole URL back into the chat. The assistant exchanges it for a token, saves it to scripts/google/token.json, and confirms by reading your account profile.

That's it — Google is connected, and the assistant's Google tools appear in your Integrations sidebar.

Working with Sheets — for the assistant

The toolkit already ships a reader at scripts/google/sheets/read-sheet.py — check scripts/google/sheets/ before writing anything of your own. Reading a spreadsheet is the first thing almost every real task needs — building a dashboard, pulling figures into a report, reconciling against another system — so reach for it directly instead of improvising a one-off reader per task, which used to be the single largest source of wasted turns in Sheets work back when nothing shipped one.

First read Running commands on the machine. Two facts from it decide whether these scripts run at all: the interpreter is python3 (never python), and the Google libraries live in the user site-packages, so PYTHONNOUSERSITE=1 breaks every import here. If a run prints a FutureWarning about the Python version, prefix it with PYTHONWARNINGS=ignore and move on.

read-sheet.py imports the shared helper at scripts/google/lib/google_auth.py (already present alongside credentials.json and token.json), so it inherits token refresh automatically. There is no writer shipped yet — write scripts/google/sheets/update-sheet.py yourself, once, using the pattern below, the first time a task needs to write cells.

scripts/google/sheets/read-sheet.py — already installed

Accepts a full spreadsheet URL or a bare ID. With no range it returns the workbook's structure — every tab's title, gid, and grid size — which is what you want on the first call so you can address tabs precisely afterwards. Flags: --range (A1 notation), --gid (read a tab by its gid instead — takes precedence over --range), --formulas (return formulas instead of calculated values).

scripts/google/sheets/update-sheet.py — not shipped, write it yourself

#!/usr/bin/env python3
# @plank-integration
# provider: google
# service: sheets
# name: Update Google Sheet
# description: Writes values or formulas to a specified Google Sheets range.
# requires:
#   - file: scripts/google/credentials.json
#   - file: scripts/google/token.json

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
from google_auth import google_service  # noqa: E402


def spreadsheet_id(value: str) -> str:
    match = re.search(r"/spreadsheets/d/([^/]+)", value)
    return match.group(1) if match else value


def main() -> None:
    parser = argparse.ArgumentParser(description="Update cells in Google Sheets.")
    parser.add_argument("spreadsheet", help="Spreadsheet URL or ID.")
    parser.add_argument("--range", dest="cell_range", required=True, help="A1 range, e.g. 'Sheet1'!C13:N13.")
    parser.add_argument("--values-json", required=True, help="Two-dimensional JSON array of values.")
    args = parser.parse_args()

    try:
        values = json.loads(args.values_json)
    except json.JSONDecodeError as exc:
        raise SystemExit(f"--values-json is not valid JSON: {exc}")
    if not isinstance(values, list) or any(not isinstance(row, list) for row in values):
        raise SystemExit("--values-json must be a two-dimensional JSON array")

    result = google_service("sheets", "v4").spreadsheets().values().update(
        spreadsheetId=spreadsheet_id(args.spreadsheet),
        range=args.cell_range,
        valueInputOption="USER_ENTERED",
        body={"values": values},
    ).execute()
    print(json.dumps({
        "updatedRange": result.get("updatedRange"),
        "updatedRows": result.get("updatedRows", 0),
        "updatedCells": result.get("updatedCells", 0),
    }, ensure_ascii=False))


if __name__ == "__main__":
    main()

Usage

# 1. Structure first — every tab, its gid and size
PYTHONWARNINGS=ignore python3 scripts/google/sheets/read-sheet.py "<url-or-id>"

# 2. One tab's values (quote tab names containing spaces or non-Latin text)
PYTHONWARNINGS=ignore python3 scripts/google/sheets/read-sheet.py "<id>" --range "'P&L'!A1:O40"

# 3. By gid, when the tab name is awkward to quote
PYTHONWARNINGS=ignore python3 scripts/google/sheets/read-sheet.py "<id>" --gid 1234567890

# 4. Formulas rather than computed values — use when auditing how a model works
PYTHONWARNINGS=ignore python3 scripts/google/sheets/read-sheet.py "<id>" --range "'P&L'!A1:O40" --formulas

# 5. Write back
PYTHONWARNINGS=ignore python3 scripts/google/sheets/update-sheet.py "<id>" \
  --range "'P&L'!C13:N13" --values-json '[[1,2,3]]'

Building a dashboard from a spreadsheet

Don't re-read the sheet each time you adjust the page. Read once, cache, then iterate offline:

  1. Read the structure, then each tab you actually need. Confirm with the user which tabs matter rather than pulling all of them.
  2. Write the values to one JSON file in the workspace, e.g. finances/<name>-data.json. This is the dashboard's data source and the thing you re-generate when figures change.
  3. Write a sync scriptscripts/google/sheets/sync-<name>.py — that regenerates that JSON in one command. Give it a @plank-integration header so the user can re-run it from the sidebar when the sheet changes. This is what turns "rebuild the dashboard" from a conversation into one click.
  4. Build the HTML against the JSON, following Deliverables. Iterate on the page without touching the network at all.

The rule of thumb: one pass over the network, many passes over the local copy. Sheets reads are quick, but each one is a round-trip that costs a turn, and a dashboard usually needs a dozen refinements after the data is in hand.

Keeping it connected

  • The token refreshes itself. The setup requests offline access, so the saved token includes a refresh token and the assistant renews it automatically — you won't have to sign in again for normal use.
  • If you ever get asked to reconnect every few days, the app slipped back to Testing. Re-open Google Auth Platform → Audience and make sure Publishing status is In production (Step 3). That single setting is what stops the weekly logout.
  • But if other token files on the same OAuth client keep refreshing, it is not the Testing expiry. Publishing status applies to the whole client. A workspace can hold an unnamed token.json beside token-<email>.json files, and have only the unnamed one fail with invalid_grant for weeks while the named ones renew daily. Reconnect that one file (or point the script at the named account's token) — don't send the user off to change the publishing status.

Already connected? Don't run this setup again

This page is the first-time setup. If Google has been working in this workspace, none of it needs redoing — and a missing-looking file is not a reason to start over.

Read this before telling the user Google is disconnected:

  • Look for the token where the scripts actually read it. An older workspace may keep it outside scripts/ — commonly /home/coder/.config/google/<workspace>/token.json. The script's own requires: header and docstring name the path it uses; that is the authority, not this page's convention. Missing from scripts/google/ ≠ disconnected.
  • A token in an older location just gets used where it is — same Google account, no re-authorization. Don't move it into scripts/google/ on your own initiative: that directory is shared with every workspace member while /home/coder is private to one user. See Where credentials live.
  • Test before you diagnose. One cheap read-only call (profile, or reading a single row — never a send) tells you the truth. Only a credential-level rejection — invalid_grant, a revoked grant, or a 401 that survives a token refresh — means the connection is genuinely gone, and the usual cause is the Testing-status expiry above — unless other token files on the same client still refresh, in which case only that one file needs reconnecting. A quota or permission 403, an API that isn't enabled, or a network blip is a different problem: say which one it was instead of asking for a reconnect.
  • Never send someone back to Google Cloud Console over a path convention. Creating a project, enabling APIs and issuing a new OAuth client is a long detour for a non-technical user, and it leaves a second set of credentials behind.

Personal vs work accounts, side by side

You can connect a personal Google account in one workspace and a work account (like info@yourcompany.kz) in another — they never mix. Each Plank workspace keeps its own scripts/google/credentials.json and token.json, so your personal inbox and your company inbox stay cleanly separated. To switch the account a workspace uses, delete its token.json and authorize again.

How it keeps you safe

  • Plank never sees your Google password. You sign in on Google's own page; only the approval (a token) comes back.
  • Credentials stay in your workspace and are git-ignored — they aren't shared with other workspaces or committed anywhere.
  • You can revoke anytime from your Google account's security settings, or by deleting the token file.

See also: Integrations for the bigger picture on connecting services, Workspace scripts & the sidebar for how Google tools appear once connected, and Automations to have your assistant check mail on a schedule.