Files
VolcanicSecretary/docs/superpowers/specs/2026-09-23-google-calendar-connect-flow-design.md
2026-09-23 09:21:30 +00:00

20 KiB

In-bot Google Calendar connect flow

Goal

Replace the manual "run an OAuth device-flow bootstrap script and paste the resulting refresh token into .env" ceremony with two Telegram commands the owner runs inside the bot:

  1. /connect_calendar — bot starts a Google device-authorization flow and replies with the verification URL + user code.
  2. /calendar_done — after the owner has approved the request on Google's page, this command tells the bot to exchange the device code for a refresh token, save it, and report success.

No background polling on the bot's side, no restart, no .env edit. The static OAuth client (GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET) still lives in env because it belongs to the deployer, not the owner.

Non-goals

  • Multiple Google accounts. The bot has one owner (TELEGRAM_OWNER_ID); one refresh token is enough.
  • Additional Google APIs (Gmail, Drive, Contacts). Scope stays at https://www.googleapis.com/auth/calendar.
  • Automatic reconnect on invalid_grant. The existing "re-run the bootstrap" error message already tells the owner what to do; the "bootstrap" is now /connect_calendar instead of a script.
  • /disconnect_calendar. Not asked for; deleting workspace/.secretary/google-token.json by hand is a fine escape hatch until it comes up.
  • Background polling of Google's token endpoint. Explicitly rejected in favour of an owner-triggered /calendar_done. Simpler control flow, no timers to cancel on shutdown, no chance of a polling reply colliding with an active agent run.
  • Hot-swapping the tool in and out of the agent's tool list. The calendar tool is registered whenever GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set. If there's no refresh token, the tool self-reports "not connected" on invocation. The tool list stays stable across a connect.

Architecture

Three pieces:

  1. Token store (new src/tools/google-token-store.ts) — file-backed storage for the refresh token at workspace/.secretary/google-token.json.
  2. Device-flow client (new src/telegram/google-device-flow.ts) — thin wrapper over Google's two device-flow endpoints. Testable in isolation with a mocked fetch.
  3. Bridge commands (edits in src/telegram/bridge.ts) — two new slash commands that use the client and the store, plus a single in-memory pendingDevice variable for the flow state.

Existing calendar tool (src/tools/calendar.ts) is refactored to read the refresh token from the store on each getAccessToken() call rather than closing over a static string.

Token store

// src/tools/google-token-store.ts
export interface GoogleTokenStore {
  read(): string | null;          // returns refresh_token or null
  write(refreshToken: string): void;
}

export function createGoogleTokenStore(workspaceDir: string): GoogleTokenStore;
  • Path: path.join(workspaceDir, ".secretary", "google-token.json").
  • File shape: { "refresh_token": "1//0e..." }. One key today, easy to extend.
  • write() uses fs.writeFileSync with mode: 0o600 and creates the .secretary directory if it doesn't exist (already ensured by ensureWorkspace, but be defensive — this may be called before/without the scheduler).
  • read() returns null if the file doesn't exist, is empty, or fails to parse. Never throws — an unreadable token file is treated the same as an unset one, so the "not connected" branch takes over.
  • Synchronous API is fine: each call happens once per calendar tool invocation and once per /calendar_done, and the file is a few hundred bytes.

Device-flow client

// src/telegram/google-device-flow.ts
export interface DeviceCode {
  deviceCode: string;
  userCode: string;
  verificationUrl: string;
  expiresAt: number;        // ms since epoch, computed from expires_in
  interval: number;         // seconds — informational; we don't poll
}

export interface DeviceFlowClient {
  start(): Promise<DeviceCode>;
  complete(deviceCode: string): Promise<CompleteResult>;
}

export type CompleteResult =
  | { kind: "success"; refreshToken: string }
  | { kind: "pending" }
  | { kind: "denied" }
  | { kind: "expired" }
  | { kind: "slow_down" }
  | { kind: "error"; message: string };

export function createDeviceFlowClient(opts: {
  clientId: string;
  clientSecret: string;
  scope?: string;           // default "https://www.googleapis.com/auth/calendar"
  fetchFn?: typeof fetch;   // injectable for tests
}): DeviceFlowClient;

start() POSTs to https://oauth2.googleapis.com/device/code with client_id and scope, unwraps { device_code, user_code, verification_url, expires_in, interval }, computes expiresAt = Date.now() + expires_in * 1000, returns the DeviceCode.

complete() POSTs to https://oauth2.googleapis.com/token with:

  • client_id, client_secret
  • device_code
  • grant_type=urn:ietf:params:oauth:grant-type:device_code

Google returns HTTP 200 with { access_token, refresh_token, ... } on success, or HTTP 400 with { error: "..." } for the intermediate states. Map errors to CompleteResult kinds:

Google error CompleteResult
authorization_pending { kind: "pending" }
slow_down { kind: "slow_down" } (we don't poll, but surface it so tests can assert the mapping)
access_denied { kind: "denied" }
expired_token { kind: "expired" }
anything else, or a non-2xx/non-400 response { kind: "error"; message: <error + description or status text> }

Success requires refresh_token to be present in the response body. Google only issues it on the first authorization for a given client+account; if the owner has connected this client to their Google account before and the app hasn't been revoked, subsequent completions may return only an access token. If refresh_token is missing on success, return { kind: "error"; message: "Google returned no refresh token. Revoke this app at https://myaccount.google.com/permissions and try again." }.

Bridge commands

State (module-level in bridge.ts, single owner so no map):

interface PendingDevice {
  deviceCode: string;
  userCode: string;
  verificationUrl: string;
  expiresAt: number;
}
let pendingDevice: PendingDevice | null = null;

Deps additions to the bridge init: deviceFlow: DeviceFlowClient | null, tokenStore: GoogleTokenStore | null. Both are null when google config is absent (i.e. GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET unset). In that case, both commands reply "Google Calendar isn't configured on this bot. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment first."

Command bodies:

/connect_calendar

  1. If deviceFlow is null → reply the "not configured" message.

  2. Call deviceFlow.start(). On failure, reply "Couldn't start Google device flow: <message>."

  3. Set pendingDevice to the returned DeviceCode (overwrites any prior pending flow).

  4. Reply:

    Go to <verificationUrl> and enter code <userCode>.

    When you've approved it there, send /calendar_done here and I'll finish the connection.

    Code expires in <N> minutes.

    N is Math.round((expiresAt - now) / 60000).

/calendar_done

  1. If deviceFlow or tokenStore is null → "not configured" message.

  2. If pendingDevice is null → "No pending calendar connection. Run /connect_calendar first."

  3. If pendingDevice.expiresAt <= Date.now() → clear it, reply "That code expired. Run /connect_calendar to start over."

  4. Call deviceFlow.complete(pendingDevice.deviceCode). Dispatch on result:

    Result Reply Effect on pendingDevice
    success "✅ Calendar connected." cleared
    pending "Google says you haven't approved it yet. Approve at <url> (code <userCode>), then send /calendar_done again." kept
    slow_down same as pending (owner-driven, not our concern to throttle) kept
    denied "You denied the request. Run /connect_calendar to try again." cleared
    expired "That code expired. Run /connect_calendar to start over." cleared
    error "Couldn't finish Google connection: <message>." kept (so retry works)
  5. On success, tokenStore.write(refreshToken) before the reply — if the write throws, reply "Got a refresh token but failed to save it: <message>." and leave pendingDevice cleared (the token is lost either way; the owner can just re-run).

Both commands go through the existing owner-only guard (ctx.from?.id !== config.telegramOwnerId → ignore).

Add both to BOT_COMMANDS:

{ command: "connect_calendar", description: "Link your Google Calendar" },
{ command: "calendar_done",    description: "Finish linking Google Calendar" },

Calendar tool refactor

Change createCalendarTool to depend on the store instead of a static token:

// before
export function createCalendarTool(google: GoogleConfig, timezone: string) { ... }
// after
export interface CalendarDeps {
  clientId: string;
  clientSecret: string;
  tokenStore: GoogleTokenStore;
  timezone: string;
}
export function createCalendarTool(deps: CalendarDeps) { ... }

Inside getAccessToken:

  1. Read deps.tokenStore.read(). If null → throw Error("Google Calendar is not connected. Run /connect_calendar in the bot to link your account.") — the tool wrapper turns thrown errors into tool-error text, which the agent surfaces to the owner naturally.
  2. Reuse the cached access token if still valid (unchanged from today).
  3. Otherwise, POST to https://oauth2.googleapis.com/token with the current refresh token. Existing invalid_grant handling stays but its message becomes: "Google refresh token was rejected (invalid_grant). Run /connect_calendar in the bot to relink."

Cache invalidation: if tokenStore.read() returns a different refresh token than we last minted an access token for, drop the cached access token. Cheap: keep cachedForRefresh: string | null alongside cachedToken.

Wiring in main.ts

if (config.google) {
  const tokenStore = createGoogleTokenStore(config.workspaceDir);
  if (config.google.refreshToken && tokenStore.read() === null) {
    tokenStore.write(config.google.refreshToken);
    logger.warn(
      "GOOGLE_REFRESH_TOKEN env var seeded into workspace/.secretary/google-token.json. " +
      "You can remove GOOGLE_REFRESH_TOKEN from .env now — future updates come through /connect_calendar."
    );
  }
  customTools.push(createCalendarTool({
    clientId: config.google.clientId,
    clientSecret: config.google.clientSecret,
    tokenStore,
    timezone: config.timezone,
  }));
  logger.info("calendar tool enabled");
  deviceFlow = createDeviceFlowClient({
    clientId: config.google.clientId,
    clientSecret: config.google.clientSecret,
  });
  tokenStoreForBridge = tokenStore;
} else {
  logger.info("calendar tool disabled (set GOOGLE_CLIENT_ID/SECRET to enable)");
}

Then pass deviceFlow and tokenStore into registerBridge.

Config change

src/config.ts:

  • GoogleConfig.refreshToken becomes string | undefined.
  • The current "all three or none" validation becomes "if any of client_id/client_secret is set, both must be set; refresh_token is optional and, when present, will be migrated to the workspace store on startup".
  • Log a warn at load time if GOOGLE_REFRESH_TOKEN is set: it's deprecated and will be migrated at startup.

Data flow

Happy path:

owner: /connect_calendar
  → bridge.commandHandler("connect_calendar") called
  → deviceFlow.start() → { deviceCode, userCode: "ABCD-EFGH", verificationUrl, expiresAt }
  → pendingDevice = <above>
  → outbound.sendPlain("Go to https://google.com/device and enter code ABCD-EFGH ...")

owner: (opens https://google.com/device, enters ABCD-EFGH, signs in, approves)

owner: /calendar_done
  → bridge.commandHandler("calendar_done")
  → pendingDevice exists, not expired
  → deviceFlow.complete(deviceCode) → { kind: "success", refreshToken: "1//0e..." }
  → tokenStore.write("1//0e...")
  → pendingDevice = null
  → outbound.sendPlain("✅ Calendar connected.")

owner: "what's on my calendar tomorrow?"
  → agent calls calendar tool with action=list
  → getAccessToken() reads store → mints access token → GET events
  → agent replies with the list

Not-yet-approved path:

owner: /calendar_done  (before approving on Google's page)
  → deviceFlow.complete(deviceCode) → { kind: "pending" }
  → pendingDevice stays
  → outbound.sendPlain("Google says you haven't approved it yet ...")

Expired-before-completion path:

owner: /connect_calendar
  → pendingDevice set with expiresAt = now + 15 min
owner: (waits 20 minutes)
owner: /calendar_done
  → pendingDevice.expiresAt < now
  → pendingDevice = null
  → outbound.sendPlain("That code expired. Run /connect_calendar to start over.")

Restart mid-flow:

owner: /connect_calendar → gets code
[bot restarts]
owner: /calendar_done → pendingDevice is null (in-memory only) → "No pending calendar connection..."

Fine: run /connect_calendar again. The device flow is cheap, and this only matters in the rare case that the bot restarts in the ~15-minute window between the two commands.

Testing

New unit test file src/telegram/google-device-flow.test.ts:

  1. start() posts the right body, parses response, computes expiresAt from expires_in.
  2. complete() returns success and extracts refresh_token.
  3. complete() returns pending for HTTP 400 + authorization_pending.
  4. complete() returns slow_down for HTTP 400 + slow_down.
  5. complete() returns denied for access_denied.
  6. complete() returns expired for expired_token.
  7. complete() returns error with message for HTTP 500 / unknown error code.
  8. complete() returns error when success body is missing refresh_token.

New unit test file src/tools/google-token-store.test.ts:

  1. read() returns null when the file doesn't exist.
  2. read() returns null when the file is empty / not JSON / missing the key.
  3. write() creates the .secretary directory if absent.
  4. write() then read() round-trips the token.
  5. write() uses mode 0600 on POSIX (skip on Windows via a runtime check).
  6. write() twice replaces the previous token.

Extensions to src/telegram/bridge.test.ts (or a new test file if bridge is already crowded):

  1. /connect_calendar when deviceFlow is null → "not configured" message.
  2. /connect_calendar success → sends URL + code message, pendingDevice set.
  3. /connect_calendar twice in a row → second call overwrites pendingDevice, previous code is gone.
  4. /calendar_done with null pendingDevice → "no pending" message.
  5. /calendar_done with expired pendingDevice → "expired" message and state cleared.
  6. /calendar_done success → tokenStore.write called with the token, "connected" message, state cleared.
  7. /calendar_done pending → "haven't approved yet" message, state kept.
  8. /calendar_done denied → "you denied" message, state cleared.
  9. /calendar_done error → error message, state kept.
  10. /calendar_done when tokenStore.write throws → "got a token but failed to save" message, state cleared.

Extensions to src/tools/calendar.test.ts:

  1. When tokenStore.read() returns null → tool throws with the "not connected" message (or the wrapper returns it as tool-error text — assert whichever the current test setup already validates).
  2. When the refresh token in the store changes between calls, the cached access token is dropped and a new one is minted.
  3. invalid_grant from Google produces the updated error message that references /connect_calendar.

Config test additions:

  1. Setting only GOOGLE_CLIENT_ID throws (still requires both id and secret).
  2. Setting id + secret without refresh token no longer throws; google.refreshToken is undefined.
  3. Setting id + secret + refresh token still parses; google.refreshToken is populated (for the startup migration path).

README + .env.example updates

.env.example — reword the Google block:

# Google Calendar tool. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from a Google Cloud OAuth
# client of type "TVs and Limited Input devices" (scope: https://www.googleapis.com/auth/calendar).
# Then in Telegram, run /connect_calendar and follow the two-step flow — no refresh token needed here.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# GOOGLE_REFRESH_TOKEN=  (deprecated: still read once at startup and migrated into
#                         workspace/.secretary/google-token.json, then ignored)

README — replace the current "bootstrap the refresh token" mention with:

Google Calendar. In Google Cloud Console, create an OAuth 2.0 client of type TVs and Limited Input devices and enable the Google Calendar API. Put GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env. Then in Telegram, run /connect_calendar — the bot will reply with a https://google.com/device link and a short code. Approve on that page, send /calendar_done back to the bot, and you're linked. The refresh token lives in workspace/.secretary/google-token.json.

Add commands to the /status / commands list line: /connect_calendar, /calendar_done.

Add a manual E2E step:

  1. /connect_calendar from a fresh workspace (no google-token.json) → bot replies with a https://google.com/device URL and a code. Approve on that page, then /calendar_done → bot replies "✅ Calendar connected" and workspace/.secretary/google-token.json now exists. Ask "what's on my calendar tomorrow?" → the calendar tool answers (was previously "not connected"). Delete the token file, ask again → tool replies "not connected", run the flow to relink.

Error handling

  • Google network errors during start() / complete(): surface Google's error message verbatim; the owner can retry.
  • Filesystem errors on write(): catch, message the owner, clear pending state.
  • Filesystem errors on read(): return null (calendar tool self-reports "not connected").
  • Bot restart mid-flow: pending state is lost (in-memory only); /calendar_done reports "no pending connection". Documented as expected.
  • Race between two /connect_calendar calls: the second overwrites the first; the first code becomes unusable. Fine — owner just uses the code from the most recent message.

Rejected alternatives

  • Poll Google's token endpoint in the background after /connect_calendar. Rejected per user request: adds a timer we have to cancel on shutdown, a message-send that can collide with an active agent run, and complicates the mental model ("when will the bot reply?"). Owner-triggered /calendar_done is unambiguous.
  • Expose the flow as an agent tool instead of two slash commands. Rejected: this is auth, not a task for the LLM. Slash commands are direct, unambiguous, and don't consume tokens or risk the model deciding to run the flow at odd moments.
  • Store the refresh token in MEMORY.md or .env at runtime. Rejected: MEMORY.md is model-visible and the token doesn't belong there; rewriting .env at runtime is racy and requires a restart to pick up.
  • Hot-swap the calendar tool in and out of the agent's tool list when the token store changes. Rejected: Pi's tool list is set at backend construction time; changing it mid-run means restarting the agent host. Cheaper for the always-registered tool to self-report "not connected" and instruct the owner what to do.
  • A single /calendar command that starts a flow on first call and completes it on the second. Rejected: it's not obvious from the command name whether it's a start or a completion, and the reply the bot would have to give ("if you meant start over instead of finish, say X") is worse than having two clearly-named commands.
  • Web-server redirect flow (loopback or hosted callback URL). Rejected: requires infrastructure (a public HTTPS endpoint or an ephemeral localhost port), which is a lot of moving parts for the same end-user UX.