- 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_*
29 KiB
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 onlyVolcanic.AgenticCore.Abstractions(likeAnthropicandClaudeCode). The tool, the commands and the flow need nothing a host has that the extension API lacks: dialogs go throughctx.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.Scopeis already in Abstractions. It's stable for a chat (Telegram passestelegram-<chatId>) and survives/newand resume, unlikeSessionId. 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_DIRor~/.volcanic/agent), mode 0600, directory 0700. The chat key is the scope when it matches[A-Za-z0-9._-]{1,100}and isn't./.., elsescope-<first 16 hex of SHA-256(scope)>. The reference kept the token in the workspace, which the agent'sread/bashtools browse routinely. The root is overridable throughGoogleCalendarOptions.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 (noopenid/email); the account shown by/calendar_statusis the primary calendar's id, which is the account email. - Raw HTTP, not
Google.Apis.Calendar.v3. Reasons:Google.Apis.Authhas no device-authorization flow for installed apps; we'd hand-write the OAuth part anyway.- We need seven endpoints (below). The generated client adds
Google.Apis,Google.Apis.Core,Google.Apis.Authand Newtonsoft.Json to an assembly that loads in its ownAssemblyLoadContext; none of it is shared with the host. - The existing extensions (
Anthropic,OpenAI) take an optionalHttpClientand are tested with aFakeHttpHandler. Raw HTTP keeps that pattern: tests assert exact URLs, query strings and bodies. - 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'sPromptInBackground, andAgentSession.PromptAsyncruns commands immediately even while streaming), so the handler itself can poll until done; the result is oneui.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_connectin 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; theinvalid_grantmessage covers it. - Command names use underscores (
/calendar_connect,/calendar_status,/calendar_disconnect). Telegram only links and menus commands matching[a-z0-9_];/claude-code-loginis 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()returnsStoredToken?. A missing, empty, unparsable file or one withoutrefreshTokengives null. It never throws, so "unreadable" is the same as "not connected".Write(StoredToken): creates the directory, writestoken.json.tmpand moves it over the target (atomic). On Unix the file is created withUnixFileMode.UserRead | UserWriteand the directory with0700.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, orverification_uriwhen Google sends that; the interval defaults to 5 seconds). A non-2xx response throwsGoogleCalendarExceptionwith Google'serror/error_description, never the client secret.PollAsync(deviceCode, ct) → PollResult, one record with aPollStatus:Success(refresh token, scope, access token and its expiry; the connect flow uses the access token forcalendars/primary),Pending,SlowDown,Denied,Expired,Failed(message). The mapping reads the JSONerrorfield whatever the status code. Google documents 428 forauthorization_pendingand 403 forslow_down/access_denied; the reference only looked at 400. A success withoutrefresh_tokenisFailed("Google returned no refresh token. Remove the app at https://myaccount.google.com/permissions and run /calendar_connect again."). A 5xx response without anerrorfield throwsHttpRequestExceptionand counts as a transport error in the flow (§5).PollResult.ToString()never shows tokens.RefreshAsync(refreshToken, ct) → AccessToken(Token, ExpiresAt).invalid_grantthrowsGoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat."). Other errors throwGoogleCalendarException("Google token refresh failed: <error>[: <description>]").RevokeAsync(token, ct) → string?: best effort;nullwhen 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 aSemaphoreSlim. 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 storedtimeZone, fromcalendars/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:
- No UI (
!ctx.HasUi): append aCustomMessagesaying the command needs an interactive UI (as/claude-code-logindoes) and stop. - 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_statusdoesn't show it either. - Otherwise
StartDeviceFlowAsync, store it as the chat connection'sPending, 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. - Poll with
PollAsynceveryIntervalseconds (Google says 5), delays throughTimeProvider:Pending: keep going.SlowDown: add 5 s to the interval permanently (RFC 8628 §3.5).Success: callGET calendars/primarywith the new token to learnaccount(id) andtimeZone, write the store (under the connection's flow gate, and only if the flow wasn't cancelled meanwhile),Invalidate()the cache, thenNotify("✅ 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, orExpiresAtpassed:Notify("The Google code expired. Run /calendar_connect again.", Warning).Failedor a transport error: up to 3 consecutive transport errors are retried at the normal interval, thenNotify("Google Calendar connection failed: <message>", Error).- A store write failure:
Notify("Got a Google token but could not save it: <message>", Error).
Pendingis cleared in afinally, 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., orWaiting 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), thenCalendar disconnected.(withRevoking 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.
recurringEventIdset →instance;recurrenceset →series; neither → single (an explicitscopeis ignored). An explicitscope=instanceon a master andscope=serieson an occurrence are rejected.recurrencetogether with an instance is rejected. Recurrence lines must start withRRULE:,EXDATE:,RDATE:orEXRULE:(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 asArgumentException, 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
scopevalue:scope must be instance or series. scope=instanceon 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=serieson an occurrence:Event <id> is one occurrence of the recurring series <series>. To change the whole series, pass eventId=<series> with scope=series.recurrenceon 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).
- missing fields:
- update is a
PATCHcarrying 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>orUpdated: <line> - delete:
Cancelled one occurrence of "<summary>" (<range>); the rest of the series is unchanged.,Deleted the recurring series "<summary>" and all its occurrences.orDeleted "<summary>" (<range>).
- create:
- "This and following" edits, attendees, reminders, colours and conferencing are out of scope, as in the reference.
calendarslistscalendarListentries (id · summary · accessRole · timeZone, primary marked) so the model can passcalendarId.
Time rules (the reference had none beyond "send dateTime with timeZone"):
- Zone
Z= the event'stimeZoneargument, else the resolved zone (§4). YYYY-MM-DDis an all-day date ({date}). All-dayendis exclusive, as in Google. A missingendon create means one day.- A date-time with an offset (
2026-09-30T10:00:00+02:00orZ) keeps its instant and offset and is sent asdateTime, withtimeZone: Z. It is normalised to full RFC 3339 (Tseparator, seconds always present, so2026-09-30T10:00+02:00goes out as2026-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 inZ: sent asdateTimewithout an offset plustimeZone: Z, which Google interprets in that zone. Recurring events are expanded inZ, so a weekly 10:00 stays 10:00 across DST. - A missing timed
endon create means start + 1 hour.startandendmust be the same kind (both dates or both date-times). timeMin/timeMaxmust be RFC 3339 with an offset for Google. A date or local date-time is converted inZ(TimeZoneInfo) before sending. AtimeMindate is that day's midnight; atimeMaxdate includes that day, so it becomes the next day's midnight (timeMin=timeMax=2026-09-30covers 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=…]. Descriptionand aPromptGuidelinesbullet state the zone and these rules: times without an offset are in<Z>, all-day ends are exclusive, uselist_instancesto get an occurrence id before editing one date.
Results and errors:
Contentis the compact text above.Detailscarries 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. ExecutionModeisParallel: calls are independent HTTP requests and the token refresh is single-flight.
8. Telegram host
- Configuration: a
GoogleCalendarsection (ClientId,ClientSecret) withGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETas environment shortcuts, bound into aGoogleCalendarOptionssingleton. 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'sSecretValues(2026-09-26-process-environment-hook-design.md), and the default deny-list already removesGOOGLE_CLIENT_SECRETfrombash, scripts and MCP servers. ChatRegistryandBotSetupServicetake 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 inhosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs, so the shared host files get one-line edits.ChatRegistry.CreateAsyncadds the extension toAgentSessionOptions.ExtraExtensionsfor every chat when the options are present. It uses a factory registration (ExtensionRegistration) so each session gets its own instance over the sharedGoogleCalendarConnections; the scopetelegram-<chatId>picks the chat's account.BotSetupServiceaddscalendar_connect,calendar_statusandcalendar_disconnectto the global command menu only when both values are set. Every chat can use them./helpmentions the three commands when the extension is active, detected from the session's registered commands (calendar_connectpresent).- Nothing else changes. The flow uses
Notifyonly; 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_pendingat 428 and 400,slow_downat 403,access_denied,expired_token, unknown error, 500, success withoutrefresh_token; refreshinvalid_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 oneNotify; pending → pending → success writes the store with account and zone and notifies once;slow_downwidens the interval (asserted from the fake clock); denied and expired paths; a second/calendar_connectre-sends the same code without a seconddevice/coderequest;/calendar_disconnectcancels 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_instancespaging, not connected,invalid_grant). Plus: an offset-less date-time is sent without an offset and withtimeZone;timeMingiven as a date is converted to RFC 3339 in the zone; all-day create defaultsendto the next day; mixed date/date-time is rejected; a 401 from the events endpoint refreshes once and succeeds;searchrequiresquery. - 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 ownNotify;/calendar_connecttwice in A reuses A's code while B gets a fresh one;/calendar_disconnectin A leaves B's token and cache alone. Resolved zone: the calendar's stored zone, elsectx.TimeZone; the two-zone header when they differ. - Telegram (
tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs): withChatHost, every allowed chat lists thecalendartool, and none does without the options./calendar_connectin 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 inSecretValues; the menu and/helplist 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_disconnectfirst, or delete the directory).
Open questions
ShouldResolved: kept the fullcalendar.events+calendar.calendarlist.readonlyreplace the fullcalendarscope (least privilege), if the device flow allows them?calendarscope; revisit if Google lists narrower scopes for the device flow.