---
name: google
description: Live Google Calendar for one account via the Google Calendar API v3 — read plus control. Per-account OAuth authorization-code + PKCE against a Desktop client whose id and secret ship in brand config; the plugin mints the consent URL and exchanges the code in-process, and nothing in platform/ui participates. One account can connect several Google accounts; every operational tool takes an optional account selector (the email address) and refuses ambiguously when two or more are connected and none is named. There is deliberately no Gmail surface — mail runs on the `email` plugin's IMAP/SMTP path, and Gmail's restricted scopes would force Google's annual third-party security assessment, whereas Calendar is only a sensitive scope. Tools — google-account-register / google-account-register-submit / google-account-register-poll: connect a Google Calendar (the result carries one resolved instruction for this install, sign-in auto-opened on the host or paste-the-address-bar-back off it, plus a readback directive; relay the instruction and confirm the connection from google-account-register-poll); google-calendar-list: events in the next rangeDays days, recurring series expanded; google-calendar-event: one event by id; google-calendar-create / google-calendar-update / google-calendar-cancel: control events, attendees notified, update changes only the fields named; google-calendar-respond: accept, decline or tentatively accept an invitation, leaving other attendees' responses untouched; google-calendar-freebusy: busy blocks for this or named calendars; google-account-info: auth state for one connected Google account; google-account-list: every connected Google account with its own status.
tools:
  - name: google-account-register
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: google-account-register-poll
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: google-account-register-submit
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: google-calendar-list
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: google-calendar-event
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: google-calendar-create
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: google-calendar-update
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: google-calendar-cancel
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: google-calendar-respond
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: google-calendar-freebusy
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: google-account-info
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: google-account-list
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/google/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: google
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
    GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
    GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
    CDP_PORT: ${CDP_PORT}
mcp-manifest: auto
---

# Google Calendar

Per-account live Google Calendar. Read the user's real events, and create,
update, cancel, respond to and check availability against them.

## Why this exists

A client sub-account cannot hold a claude.ai connector: connectors are
authorised per client and visible only to the admin/claude.ai session, and the
install holds one house Google identity that cannot represent each client's
separate calendar. Before this plugin the only live-calendar path was that
house-admin-only connector, so a client asking "what is on my calendar?" was
dead-ended. See `references/auth.md`.

## Auth

Authorization-code + PKCE, not device-code. Google's device flow serves a fixed
scope allowlist that excludes Calendar, and the out-of-band paste-a-code flow
has been blocked since 31 January 2023, so consent must land on a real redirect.

The vendor app is a **Desktop (installed app)** client, the one type whose
secret Google sanctions shipping in distributed software. Its id and secret come
from `brand.json` and are stamped by the installer together or not at all.

`google-account-register` mints the PKCE pair, binds a single-shot listener on
`127.0.0.1` at an OS-assigned port, and returns a consent URL carrying that
port. The host is always the IP literal, never `localhost` — Google's
port-agnostic loopback matching applies only to the literal.

When the device's own Chromium is reachable over the Chrome DevTools Protocol
(`CDP_PORT`, the same browser the `browser` plugin drives), register opens the
consent URL in it directly, so the same-host listener completes with nobody
opening a link. The operator is told the sign-in opened on this machine, with
the URL kept as a backup. Where no on-host browser is reachable — a browserless
brand, or a native install with no running Chromium — register returns the URL
exactly as before and the paste path is unchanged; that fallback is a
first-class state, not an error. One `op=consent-open` line records which route
each register took: `surface=cdp` opened it on-host, `surface=url` returned the
link, and `surface=cdp outcome=error` is a reachable browser that refused the
navigation (its CDP body preserved, the URL still returned). The result stamps
the resolved route as `path` (`auto-open` or `paste`) with its single
instruction and a readback directive, and one `op=register-path reg= path=` line
records that route on every register, including a failed bind that never reaches
consent-open. Completion is read back from `google-account-register-poll`, never
asserted from the chat.

Consent on the install's own machine reaches that listener, which verifies
`state`, completes the exchange, and renders a page naming the account. Consent
on another device cannot reach it, so the redirect fails to load and the user
pastes the address-bar URL into `google-account-register-submit`. Both paths
exchange in-process with the stored verifier, resolve the email from the primary
calendar, and write encrypted tokens. `google-account-register-poll` reports the
outcome from local state.

The pending entry stores the redirect it issued, because the port varies per
registration and Google compares the exchange's `redirect_uri` byte for byte
against the one that issued the code. A failed bind falls back to
`http://127.0.0.1:8571`, leaving the paste path exactly as it was.

Token refresh also runs in-process against Google. Nothing in `platform/ui`
mints, exchanges or refreshes a Google credential; its only Google surface is the
standing pending audit below, which reads a file's age and never decrypts.

## Scope

One scope: `https://www.googleapis.com/auth/calendar`. The account's email is
read from `GET /calendars/primary` (whose id is the address) rather than by
requesting `openid`/`email`/`profile`, so consent stays minimal.

## Configuration

| Variable | Source | Effect when unset |
|---|---|---|
| `GOOGLE_CLIENT_ID` | `brand.json#googleClientId` | Plugin inert; register refuses with an actionable message |
| `GOOGLE_CLIENT_SECRET` | `brand.json#googleClientSecret` | Plugin inert; register refuses. Stamped with `GOOGLE_CLIENT_ID` or not at all |
| `CDP_PORT` | brand's device Chromium debug port | Register does not auto-open; it returns the consent URL for the paste path (first-class fallback, not an error) |

Both reach this process by env inheritance from the session-manager unit, which
is why they are declared in the self-named `${FOO}` form (see
`mcp-descriptor-resolve.ts`). Boot reports `clientIdConfigured` and
`clientSecretConfigured`; either reading `false` on a brand that ships both is
the mis-stamp signature.

## Observability

Two failures here emit no event and do not reproduce on demand: a registration
the operator abandons, and a refresh token that expires. Nothing touches either;
they simply stop being true at some future date. Two standing audits report them
without anything polling.

| Audit | Where it runs | Interval | Per-finding line | Heartbeat |
|---|---|---|---|---|
| Stranded pending registration | in-process in `platform/ui` | 5 min | `[google-oauth] op=pending-stranded account=<id> ageSec=<N>` | `[google-oauth] op=pending-audit scanned=<N> stranded=<M>` |
| Refresh-token TTL | spawned `mcp/dist/scripts/account-audit.js` | 1 hour | `[google-account-audit] account=<id> email=<addr> refreshTtlDays=<D> status=<ok\|expiring\|expired>` | `[google-account-audit] op=heartbeat accounts=<N> ok=<A> expiring=<B> expired=<C>` |

They run on different mechanisms deliberately. The pending audit needs no
decrypt — file age alone is conclusive — so it runs in-process and stays
independent of whether any spawn is healthy. The refresh audit must decrypt the
token blob with the per-account key, so it runs where `TokenStore` lives.

**The heartbeat is the point.** Each audit emits its heartbeat on every cycle,
including cycles that find nothing, because an audit that has stopped running is
otherwise indistinguishable from one reporting all-clear. The absence of a
heartbeat over more than one interval is the documented signal that the audit is
down — not silence meaning healthy. A failed pending cycle logs
`op=pending-audit-error`, deliberately a different token from the heartbeat so a
broken cycle can never be counted as a clean one.

`refreshTtlDays` is days left on Google's 180-day **inactivity** revocation
clock, measured from `lastRefresh`. Google publishes no refresh-token expiry and
the stored blob holds none, so this is the only bounded, locally-computable
clock. It does not predict the user revoking access or changing their password,
neither of which is observable from here. A connection holding no refresh token
at all reports `expired` with zero days regardless of how recently it was
written, because it dies when its access token lapses about an hour out.

Diagnostic path:

```
~/<brand>-code/platform/scripts/logs-read.sh --tail server 200 \
  | grep -E '\[google-oauth\]|\[google-account-audit\]'
```

## Out of scope

Gmail (stays on IMAP/SMTP), Contacts, Drive/Docs, push notification channels,
recurring-series per-occurrence editing, and auto-sync with the internal
`scheduling` plugin.
