Files
Playa/docs/superpowers/specs/2026-09-26-google-calendar-design.md
EugeneTes 881e7f0be9 fix(google-calendar): address review findings
- log an unknown stored zone once even when the tool description reads it first
- serialise the flow's token write with /calendar_disconnect under the flow gate
- a cancelled or expired pending flow is cleared, not re-sent or shown
- chat keys anchor with \z so a trailing newline is hashed
- a timeMax date includes that whole day (spec updated)
- dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write
- GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal
- test BotSetupService's menu through DI, tokens absent from tool results
- Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name
- docs: per-event timeZone, both config sources for /calendar_*
2026-09-26 20:30:56 +02:00

29 KiB
Raw Blame History

Google Calendar tool and connect flow — design

Date: 2026-09-26 Status: implemented Depends on: 2026-09-26-scheduler-design.md §7–§8 (the chat's time zone, IExtensionContext.TimeZone).

Problem

The agent has no access to the user's calendar. VolcanicSecretary (TypeScript, on pi) already has a working calendar tool with recurring-event support and a Google OAuth device flow driven from Telegram (src/tools/calendar.ts, src/tools/google-token-store.ts, src/telegram/google-device-flow.ts, specs 2026-09-08-recurring-calendar-events-design.md and 2026-09-23-google-calendar-connect-flow-design.md). We port it, reshaped for this harness: an extension on the public API, one Google account per chat, the connect flow through IUserInterface, and automatic polling instead of a /calendar_done command.

Decisions

  • An extension, not host code. New project extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar, referencing only Volcanic.AgenticCore.Abstractions (like Anthropic and ClaudeCode). The tool, the commands and the flow need nothing a host has that the extension API lacks: dialogs go through ctx.Ui, and commands already run outside the agent loop. Any host (Telegram, the headless sample, a future web UI) gets the calendar by registering the extension.
  • One Google account per chat. The extension is loaded in every chat. Each allow-listed chat connects its own Google account with /calendar_connect, and the tool only ever uses that chat's token, so nobody sees another chat's calendar through the agent. The OAuth client (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) stays process-wide: one Google Cloud app for the bot. This is exposure control, not a security boundary between allow-listed users: every one of them runs shell commands as the bot's OS user and could read another chat's token file.
  • The chat key is the session scope, from the public API. IExtensionContext.SessionManager.Scope is already in Abstractions. It's stable for a chat (Telegram passes telegram-<chatId>) and survives /new and resume, unlike SessionId. A headless host gets the default scope derived from the cwd, so one account per workspace. No Abstractions change. Tokens are only needed from tools and commands, which always have a context, so nothing is keyed during activation.
  • Tokens live outside every workspace, one directory per chat: <agentDir>/google-calendar/<chat key>/token.json (agent dir = VOLCANIC_AGENT_DIR or ~/.volcanic/agent), mode 0600, directory 0700. The chat key is the scope when it matches [A-Za-z0-9._-]{1,100} and isn't ./.., else scope-<first 16 hex of SHA-256(scope)>. The reference kept the token in the workspace, which the agent's read/bash tools browse routinely. The root is overridable through GoogleCalendarOptions.TokenDirectory.
  • Scope: https://www.googleapis.com/auth/calendar, the same as the reference. Confirmed working with the device flow in practice, although Google's limited-input scope list doesn't mention it. We ask for one scope and nothing else (no openid/email); the account shown by /calendar_status is the primary calendar's id, which is the account email.
  • Raw HTTP, not Google.Apis.Calendar.v3. Reasons:
    1. Google.Apis.Auth has no device-authorization flow for installed apps; we'd hand-write the OAuth part anyway.
    2. We need seven endpoints (below). The generated client adds Google.Apis, Google.Apis.Core, Google.Apis.Auth and Newtonsoft.Json to an assembly that loads in its own AssemblyLoadContext; none of it is shared with the host.
    3. The existing extensions (Anthropic, OpenAI) take an optional HttpClient and are tested with a FakeHttpHandler. Raw HTTP keeps that pattern: tests assert exact URLs, query strings and bodies.
    4. Serialisation stays on System.Text.Json/VolcanicJson.
  • The device flow polls automatically. There is no /calendar_done. The reference rejected polling because of timers to cancel and a reply colliding with an agent run. Neither applies here: the command handler already runs off the chat's update loop (Telegram's PromptInBackground, and AgentSession.PromptAsync runs commands immediately even while streaming), so the handler itself can poll until done; the result is one ui.Notify, which the Telegram renderer orders with everything else. The only state is one in-flight flow, owned by a single-flight coordinator.
  • The tool is always registered when the client is configured, whether or not an account is connected. Without a token it fails with a message telling the model to ask the user to run /calendar_connect in this chat. The tool list stays stable across a connect (as in the reference). The model never starts the flow: auth isn't an agent task.
  • The OAuth app is published unverified ("In production"). This is the setup the owner already uses. Publishing needs no Google verification for a handful of users, and it avoids testing status's 7-day refresh-token expiry, so a chat connects once instead of weekly. Each user sees Google's "unverified app" warning once, on approval ("Advanced → Go to (unsafe)"); the setup docs describe it in text. Testing status still works (every user added as a test user, otherwise access_denied, which the flow reports as a denial), but then each chat has to reconnect about weekly; the invalid_grant message covers it.
  • Command names use underscores (/calendar_connect, /calendar_status, /calendar_disconnect). Telegram only links and menus commands matching [a-z0-9_]; /claude-code-login is not tappable there.

Google endpoints used

Purpose Request
Start device flow POST https://oauth2.googleapis.com/device/code form client_id, scope
Poll / exchange POST https://oauth2.googleapis.com/token form client_id, client_secret, device_code, grant_type=urn:ietf:params:oauth:grant-type:device_code
Access token POST https://oauth2.googleapis.com/token form client_id, client_secret, refresh_token, grant_type=refresh_token
Revoke POST https://oauth2.googleapis.com/revoke form token
Events GET/POST .../calendar/v3/calendars/{cal}/events, GET/PATCH/DELETE .../events/{id}, GET .../events/{id}/instances
Calendars GET .../calendar/v3/users/me/calendarList, GET .../calendars/primary (account and time zone after connect)

Components

1. Project and options

Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj: project reference to Abstractions (Private="false" as the extension guide says), InternalsVisibleTo the extensions test project, no package references.

public sealed record GoogleCalendarOptions
{
    public string? ClientId { get; init; }          // else GOOGLE_CLIENT_ID
    public string? ClientSecret { get; init; }      // else GOOGLE_CLIENT_SECRET
    public string? TokenDirectory { get; init; }    // else <agentDir>/google-calendar; one <chat key>/token.json per chat
    public Func<string, string?>? GetEnv { get; init; }
    public HttpClient? Http { get; init; }          // tests pass a FakeHttpHandler client
    public TimeProvider? Time { get; init; }        // tests pass a fake clock for polling and token expiry
    public GoogleCalendarConnections? Connections { get; init; } // else GoogleCalendarConnections.Shared
}

GoogleCalendarExtension(GoogleCalendarOptions? options = null), Name = "google-calendar". When the client id or secret is missing it registers nothing and logs one Information line (google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET). If only one of the two is set, it logs a Warning instead.

2. Token store — GoogleTokenStore

  • One store per chat key (GoogleTokenStore(string path)). File shape: {"refreshToken":"1//0e…","scope":"…","account":"user@gmail.com","timeZone":"Europe/Berlin","connectedAt":"2026-09-26T10:00:00Z"}.
  • Read() returns StoredToken?. A missing, empty, unparsable file or one without refreshToken gives null. It never throws, so "unreadable" is the same as "not connected".
  • Write(StoredToken): creates the directory, writes token.json.tmp and moves it over the target (atomic). On Unix the file is created with UnixFileMode.UserRead | UserWrite and the directory with 0700.
  • Delete(): removes the file if present.
  • The refresh token never appears in exceptions, logs, tool results or Details.

3. OAuth client — GoogleOAuthClient

  • StartDeviceFlowAsync(ct) → DeviceAuthorization(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval) (verification_url, or verification_uri when Google sends that; the interval defaults to 5 seconds). A non-2xx response throws GoogleCalendarException with Google's error/error_description, never the client secret.
  • PollAsync(deviceCode, ct) → PollResult, one record with a PollStatus: Success (refresh token, scope, access token and its expiry; the connect flow uses the access token for calendars/primary), Pending, SlowDown, Denied, Expired, Failed(message). The mapping reads the JSON error field whatever the status code. Google documents 428 for authorization_pending and 403 for slow_down/access_denied; the reference only looked at 400. A success without refresh_token is Failed("Google returned no refresh token. Remove the app at https://myaccount.google.com/permissions and run /calendar_connect again."). A 5xx response without an error field throws HttpRequestException and counts as a transport error in the flow (§5). PollResult.ToString() never shows tokens.
  • RefreshAsync(refreshToken, ct) → AccessToken(Token, ExpiresAt). invalid_grant throws GoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat."). Other errors throw GoogleCalendarException("Google token refresh failed: <error>[: <description>]").
  • RevokeAsync(token, ct) → string?: best effort; null when Google revoked the token, else the failure reason (Google's error, HTTP <status> or the transport error). Failures are reported but don't stop a disconnect.

4. Connections — GoogleCalendarConnections (process-wide) and GoogleCalendarConnection (per chat)

GoogleCalendarConnections.Shared is explicit process-wide state, like ClaudeLoginCoordinator.Shared: extensions are per session, but a chat's account outlives its sessions. For(settings, scope) returns that chat's GoogleCalendarConnection (created on first use, kept for the process lifetime) over its own GoogleTokenStore. Connections are keyed by the chat's full token directory (<root>/<chat key>, chat key = ChatKeys.For(scope)), and the first caller's settings create the connection: all extension instances in a process share one configuration in practice, and two token roots never share a connection. Connections share nothing but the OAuth client configuration and the HttpClient (each holds its own stateless GoogleOAuthClient), so chats are independent: separate access-token caches, separate refresh locks, separate pending flows. The tool and the commands call For(settings, ctx.SessionManager.Scope).

Per chat:

  • Task<string> GetAccessTokenAsync(ct): reads the store on every call. It reuses the cached access token while it is valid for more than 60 s and was minted from the same refresh token (a reconnect invalidates it). Refreshes are single-flight behind a SemaphoreSlim. No token → GoogleCalendarException(NotConnected: true, "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.").
  • void Invalidate(): drops the cached access token (after a 401 from the Calendar API).
  • ConnectFlow? Pending: this chat's in-flight device flow, if any (§5). At most one per chat; flows in different chats run concurrently and don't know about each other.
  • ResolveTimeZone(TimeZoneInfo chatZone): the calendar's own zone (the stored timeZone, from calendars/primary), else the chat's zone (ctx.TimeZone, scheduler spec §8). A stored id that doesn't resolve is logged once and falls through to the chat's zone. There's no process-wide override: with per-chat accounts it would be wrong for every chat but one.

5. Connect flow — /calendar_connect

The handler (ConnectCommand) with ctx.Ui:

  1. No UI (!ctx.HasUi): append a CustomMessage saying the command needs an interactive UI (as /claude-code-login does) and stop.
  2. A flow is already pending: re-send its link and code (Notify) with the remaining minutes, and stop. It does not start a second flow: a new device code would invalidate the one the user may be typing. A flow pending in another chat doesn't matter. A flow that is already cancelled or whose code has expired (still parked in its delay) doesn't count: it is cancelled silently, cleared, and a new flow starts; /calendar_status doesn't show it either.
  3. Otherwise StartDeviceFlowAsync, store it as the chat connection's Pending, and send one message: ui.Notify("Connect Google Calendar:\n1. Open <verification_url>\n2. Enter the code <USER-CODE>\n3. Approve access. I'll confirm here when it's done (code valid for <N> min)."). Link and code in one message, for the ordering reason in the re-login spec.
  4. Poll with PollAsync every Interval seconds (Google says 5), delays through TimeProvider:
    • Pending: keep going. SlowDown: add 5 s to the interval permanently (RFC 8628 §3.5).
    • Success: call GET calendars/primary with the new token to learn account (id) and timeZone, write the store (under the connection's flow gate, and only if the flow wasn't cancelled meanwhile), Invalidate() the cache, then Notify("✅ Google Calendar connected as <account> (time zone <tz>)."). If the lookup fails, the token is still saved with those fields empty.
    • Denied: Notify("Google Calendar access was denied.", Warning).
    • Expired, or ExpiresAt passed: Notify("The Google code expired. Run /calendar_connect again.", Warning).
    • Failed or a transport error: up to 3 consecutive transport errors are retried at the normal interval, then Notify("Google Calendar connection failed: <message>", Error).
    • A store write failure: Notify("Got a Google token but could not save it: <message>", Error).
  5. Pending is cleared in a finally, whatever the outcome.

Cancellation: the flow runs on the connection's own CancellationTokenSource, not the command token. /stop doesn't touch it (it only aborts agent runs). /calendar_disconnect cancels it silently. Process shutdown ends it, and the next start has no pending flow; the user runs /calendar_connect again. The flow is bounded by Google's expires_in (1800 s in practice).

Connecting while already connected is allowed (switching accounts). The new token replaces the old one only on success; the old one is not revoked automatically.

6. /calendar_status and /calendar_disconnect

  • /calendar_status: Connected as <account> since <date>, time zone <tz>., Not connected. Run /calendar_connect., or Waiting for approval: open <url> and enter <code> (<N> min left).
  • /calendar_disconnect: under the same flow gate a completing flow writes the store with, cancels a pending flow, revokes the refresh token (best effort), deletes the file, invalidates the cache (so a flow that got its token just before the disconnect can't write it afterwards), then Calendar disconnected. (with Revoking at Google failed: <reason>; remove the app at https://myaccount.google.com/permissions. appended on revoke failure).

The replies go through ctx.Ui.Notify. Without a UI they're appended as a CustomMessage with triggerTurn: false.

7. The calendar tool

CalendarTool : Tool<CalendarArgs>, name calendar. Arguments are camelCase (the VolcanicJson web defaults), which matches Google's own field names.

Field Used by Notes
action all list, search, get, list_instances, create, update, delete, calendars
calendarId all but calendars default primary
timeMin, timeMax list, search, list_instances see time rules. list defaults to now..now+7 d; search to now−30 d..now+365 d
query search (required) Google's q
maxResults list, search, list_instances default 25, cap 250
listMode list occurrences (default, singleEvents=true&orderBy=startTime) or series (singleEvents=false, masters with RRULE)
eventId get, list_instances, update, delete
scope update, delete instance or series; auto-detected when omitted
summary, description, location create, update
start, end create, update see time rules
timeZone create, update IANA id for this event; default the resolved zone (§4)
recurrence create, update (series only) RFC 5545 lines, e.g. ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]; [] on update makes the series a single event

Behaviour follows the reference's recurring-events spec:

  • Scope resolution for update/delete: fetch the event once. recurringEventId set → instance; recurrence set → series; neither → single (an explicit scope is ignored). An explicit scope=instance on a master and scope=series on an occurrence are rejected. recurrence together with an instance is rejected. Recurrence lines must start with RRULE:, EXDATE:, RDATE: or EXRULE: (case-insensitive, trimmed); Google is the authority on the rest. The reference implementation (VolcanicSecretary/src/tools/calendar.ts) is not in this repository, so these are the texts (all thrown as ArgumentException, which the loop turns into an error tool result):
    • missing fields: create needs summary., create needs start., update needs eventId., delete needs eventId.
    • any other scope value: scope must be instance or series.
    • scope=instance on a master: Event <id> is a recurring series. To change one date, call list_instances with eventId=<id>, then pass that occurrence's id with scope=instance.
    • scope=series on an occurrence: Event <id> is one occurrence of the recurring series <series>. To change the whole series, pass eventId=<series> with scope=series.
    • recurrence on an occurrence: recurrence can't be set on a single occurrence; update the series (eventId=<series>) instead.
    • a bad recurrence line: Recurrence line "<line>" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.
    • an empty patch: update needs at least one field to change (summary, description, location, start, end or recurrence).
  • update is a PATCH carrying only the fields given (empty patch → error). delete of an instance cancels one occurrence; of a master deletes the series. The reply texts name which happened, after the header line:
    • create: Created: <line>
    • update: Updated this occurrence only: <line>, Updated the whole series: <line> or Updated: <line>
    • delete: Cancelled one occurrence of "<summary>" (<range>); the rest of the series is unchanged., Deleted the recurring series "<summary>" and all its occurrences. or Deleted "<summary>" (<range>).
  • "This and following" edits, attendees, reminders, colours and conferencing are out of scope, as in the reference.
  • calendars lists calendarList entries (id · summary · accessRole · timeZone, primary marked) so the model can pass calendarId.

Time rules (the reference had none beyond "send dateTime with timeZone"):

  • Zone Z = the event's timeZone argument, else the resolved zone (§4).
  • YYYY-MM-DD is an all-day date ({date}). All-day end is exclusive, as in Google. A missing end on create means one day.
  • A date-time with an offset (2026-09-30T10:00:00+02:00 or Z) keeps its instant and offset and is sent as dateTime, with timeZone: Z. It is normalised to full RFC 3339 (T separator, seconds always present, so 2026-09-30T10:00+02:00 goes out as 2026-09-30T10:00:00+02:00); a canonical value goes out unchanged.
  • A date-time without an offset (2026-09-30T10:00) is local wall time in Z: sent as dateTime without an offset plus timeZone: Z, which Google interprets in that zone. Recurring events are expanded in Z, so a weekly 10:00 stays 10:00 across DST.
  • A missing timed end on create means start + 1 hour. start and end must be the same kind (both dates or both date-times).
  • timeMin/timeMax must be RFC 3339 with an offset for Google. A date or local date-time is converted in Z (TimeZoneInfo) before sending. A timeMin date is that day's midnight; a timeMax date includes that day, so it becomes the next day's midnight (timeMin=timeMax=2026-09-30 covers the whole 30th).
  • Output times are rendered in the resolved zone with the zone in the header line of every result, e.g. Times in Europe/Berlin. When the calendar's zone differs from the chat's (the system prompt shows the chat's), the header says both: Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin)., so the model converts deliberately instead of mixing them up. Lines follow the reference format: <id> · <summary> · Tue 30 Sep 10:00–11:00[ @ <location>][ · recurring (series=<id>)][ · RRULE=…].
  • Description and a PromptGuidelines bullet state the zone and these rules: times without an offset are in <Z>, all-day ends are exclusive, use list_instances to get an occurrence id before editing one date.

Results and errors:

  • Content is the compact text above. Details carries the raw events (id, summary, start, end, recurringEventId, recurrence, htmlLink) for hosts.
  • A Calendar API 401: Invalidate(), refresh and retry once. 404/410: Event <id> not found in <calendarId>. Other errors: Google Calendar <action> failed: <status> <error.message>. No other retries; the model can retry.
  • Not connected / invalid_grant: the tool throws with the §4 message, so the model tells the user to run /calendar_connect.
  • ExecutionMode is Parallel: calls are independent HTTP requests and the token refresh is single-flight.

8. Telegram host

  • Configuration: a GoogleCalendar section (ClientId, ClientSecret) with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET as environment shortcuts, bound into a GoogleCalendarOptions singleton. Empty values count as unset (the Docker compose file passes both through, empty by default). The singleton is registered when either value is set, so a half configuration still reaches the extension, which logs the warning naming the missing value; with neither set nothing is registered. The host adds the client secret to the process environment hook's SecretValues (2026-09-26-process-environment-hook-design.md), and the default deny-list already removes GOOGLE_CLIENT_SECRET from bash, scripts and MCP servers.
  • ChatRegistry and BotSetupService take the options as an optional constructor parameter (DI passes the default, null, when the singleton isn't registered). New host code (section, options, menu, help lines, registration) lives in hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs, so the shared host files get one-line edits.
  • ChatRegistry.CreateAsync adds the extension to AgentSessionOptions.ExtraExtensions for every chat when the options are present. It uses a factory registration (ExtensionRegistration) so each session gets its own instance over the shared GoogleCalendarConnections; the scope telegram-<chatId> picks the chat's account.
  • BotSetupService adds calendar_connect, calendar_status and calendar_disconnect to the global command menu only when both values are set. Every chat can use them.
  • /help mentions the three commands when the extension is active, detected from the session's registered commands (calendar_connect present).
  • Nothing else changes. The flow uses Notify only; the user's replies are never needed, so the pending-input routing is untouched.

Error handling summary

Situation Result
client id/secret unset extension inactive, one info log line
tool used before connecting tool error: not connected, ask the user to run /calendar_connect in this chat
user approves one message in that chat: connected as <account>, zone <tz>
user denies / code expires warning message, no token written
/calendar_connect in two chats at once two independent flows, each chat gets its own link, code and result
/calendar_connect while a flow is pending the same link and code are re-sent
slow_down interval + 5 s, polling continues
transport errors while polling 3 consecutive → flow fails with the message
refresh token revoked (invalid_grant) tool error telling the user to reconnect; file kept until reconnect/disconnect
bot restarts mid-flow flow lost; /calendar_connect again
Calendar API 401 refresh once and retry once
/calendar_disconnect with revoke failure local token deleted; the reply links Google's permissions page

Testing

All in tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/, with no network access. A FakeHttpHandler (same shape as the Anthropic/OpenAI ones: scripted responses per method + path, recorded requests with bodies) and FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing, the package the scheduler and message-context specs add to this test project) so polling and expiry run instantly.

  • OAuth client: device-code request body; poll mapping for authorization_pending at 428 and 400, slow_down at 403, access_denied, expired_token, unknown error, 500, success without refresh_token; refresh invalid_grant.
  • Token store: round trip; null for missing/empty/garbage/keyless; atomic replace; 0600 on Unix (skipped on Windows); delete.
  • Connection: cached token reused, refreshed within 60 s of expiry, dropped when the stored refresh token changes; two concurrent callers cause one refresh; Invalidate.
  • Connect flow (through the extension with a recording IUserInterface): the link and code arrive in one Notify; pending → pending → success writes the store with account and zone and notifies once; slow_down widens the interval (asserted from the fake clock); denied and expired paths; a second /calendar_connect re-sends the same code without a second device/code request; /calendar_disconnect cancels a pending flow; no UI → custom message.
  • Tool: the reference's calendar tests ported one-for-one (recurrence in create body, recurrence validation, recurring marker in list, listMode=series, scope auto-detection both ways, explicit-scope rejections, PATCH carries only given fields, three delete messages, list_instances paging, not connected, invalid_grant). Plus: an offset-less date-time is sent without an offset and with timeZone; timeMin given as a date is converted to RFC 3339 in the zone; all-day create defaults end to the next day; mixed date/date-time is rejected; a 401 from the events endpoint refreshes once and succeeds; search requires query.
  • Per chat: two scopes get two token files under their own directories; a scope with / or .. maps to the hashed key; connecting chat A leaves chat B not connected; concurrent flows in A and B each end with their own store written and their own Notify; /calendar_connect twice in A reuses A's code while B gets a fresh one; /calendar_disconnect in A leaves B's token and cache alone. Resolved zone: the calendar's stored zone, else ctx.TimeZone; the two-zone header when they differ.
  • Telegram (tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs): with ChatHost, every allowed chat lists the calendar tool, and none does without the options. /calendar_connect in a chat, with a fake handler that approves on the second poll, ends with the "connected" message in that chat. Options come from the section, then the environment, empty counting as unset; the client secret lands in SecretValues; the menu and /help list the commands only when the extension is active.

Docs

New docs/google-calendar.md (Google Cloud setup: enable the Calendar API, configure the OAuth consent screen and publish the app without verification, with a text description of the "unverified app" warning users click through, create an OAuth client of type TVs and Limited Input devices, set the env vars, the three commands, where the token lives; testing status as an alternative, with its test-user list and 7-day refresh-token expiry). Update docs/telegram-bot.md (configuration, one account per chat), the extensions list in CLAUDE.md and docs/architecture.md.

Out of scope

  • Several Google accounts in one chat, sharing one account between chats, and other Google APIs.
  • "This and following" edits, attendees and invitations, reminders, free/busy, colours, conferencing.
  • Push notifications or proactive reminders from calendar changes.
  • Automatic reconnect prompts on invalid_grant (the user gets the tool's message through the model).
  • Cleaning up token directories of chats removed from the allow-list (/calendar_disconnect first, or delete the directory).

Open questions

  1. Should calendar.events + calendar.calendarlist.readonly replace the full calendar scope (least privilege), if the device flow allows them? Resolved: kept the full calendar scope; revisit if Google lists narrower scopes for the device flow.