Files
VolcanicAgenticCore/docs/google-calendar.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

10 KiB

Google Calendar

extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar gives the agent a Google Calendar. Each chat connects its own Google account from the chat itself, with no browser on the bot's machine. The Telegram bot loads it in every chat once the OAuth client is configured.

What it does

  • A calendar tool with the actions list, search, get, list_instances, create, update, delete and calendars. It's registered whenever the client is configured, connected or not. Before a chat connects, the tool tells the model to ask the user to run /calendar_connect.
  • Three commands: /calendar_connect, /calendar_status and /calendar_disconnect.
  • One Google account per chat. The account is keyed by the session scope, which the Telegram bot sets to telegram-<chatId>. A headless host uses the default scope derived from the working directory, so it gets one account per workspace. The tool only uses the current chat's token, so nobody sees another chat's calendar through the agent.

This controls what the agent shows to whom. It is not a security boundary between allow-listed users: every one of them can run shell commands as the bot's OS user, and so could read another chat's token file.

Google Cloud setup

You need one Google Cloud OAuth client for the bot. All chats share it; each chat still approves its own account.

  1. Create a project at console.cloud.google.com. Then go to APIs & Services → Library → Google Calendar API and click Enable.
  2. Set up the OAuth consent screen (in the newer console: Google Auth Platform → Branding and Audience). Pick user type External and fill in the app name, a support email and the developer contact address. You don't need to add scopes there: the bot requests https://www.googleapis.com/auth/calendar itself.
  3. Publish the app: Audience → Publishing status → Publish app, so the status reads "In production". A handful of users doesn't need Google's verification, and refresh tokens of a published app don't expire after 7 days.
  4. Expect the "unverified app" warning. When a user approves access, Google first shows a page titled "Google hasn't verified this app". It says the app requests access to sensitive info in the Google account and that the user shouldn't use it until the developer verifies it with Google. The big button on it is Back to safety. To go on, click the small Advanced link at the bottom left, then Go to (unsafe). On the consent screen that follows, allow "See, edit, share, and permanently delete all the calendars you can access using Google Calendar" and press Continue. Each Google account sees this once.
  5. Go to Credentials → Create credentials → OAuth client ID and pick the application type TVs and Limited Input devices. The device flow the bot uses only works with that type. Copy the client id and the client secret.

Configure the bot

Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, or GoogleCalendar:ClientId and GoogleCalendar:ClientSecret in appsettings.json (GoogleCalendar__ClientId and GoogleCalendar__ClientSecret in the environment). A value in the section wins over the environment variable.

GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=GOCSPX-... \
TELEGRAM_BOT_TOKEN=123456:ABC... \
TELEGRAM_ALLOWED_USER_IDS=111111111 \
dotnet run --project hosts/Volcanic.AgenticCore.Telegram
  • Empty values count as unset.
  • With neither value set, the extension stays off and logs one information line: google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.
  • With only one of them set, it stays off and logs a warning that names the missing one.
  • With both set, every chat gets the tool and the commands, and the three commands appear in the bot's menu and in /help.

The client secret is added to the process policy's SecretValues, so it shows up as [REDACTED] in tool output. GOOGLE_CLIENT_SECRET is also on the default deny-list (*_SECRET), so bash, scheduled scripts and MCP servers never see it in their environment. See telegram-bot.md.

Connecting a chat

/calendar_connect sends one message with the link and the code:

Connect Google Calendar:
1. Open https://www.google.com/device
2. Enter the code ABCD-EFGH
3. Approve access. I'll confirm here when it's done (code valid for 30 min).

Open the link on any device, sign in with the Google account for this chat, enter the code and approve. The bot polls Google on its own, so there's nothing more to type. When you approve, the chat gets:

✅ Google Calendar connected as you@gmail.com (time zone Europe/Berlin).

The account shown is the primary calendar's id, which is the account's email address.

  • Sending /calendar_connect again while the bot waits re-sends the same link and code with the minutes left. It doesn't start a second flow, because a new code would invalidate the one you may be typing.
  • A denial ("Google Calendar access was denied."), an expired code ("The Google code expired. Run /calendar_connect again.") and a failure ("Google Calendar connection failed: ...") each end the wait with one message.
  • /stop doesn't cancel the wait; it only stops agent runs. /calendar_disconnect does cancel it.
  • A restart of the bot loses a pending flow. Run /calendar_connect again.
  • Connecting again while connected switches the chat to the newly approved account. The old token is replaced once the new one arrives, but it is not revoked.

/calendar_status shows one of three things:

  • Connected as <account> since <date>, time zone <tz>.
  • Not connected. Run /calendar_connect.
  • Waiting for approval: open <url> and enter <code> (<N> min left).

/calendar_disconnect cancels a pending flow, revokes the token at Google (best effort) and deletes the local token. If Google can't be reached or refuses, the token is still deleted locally and the reply says so; remove the app by hand at myaccount.google.com/permissions.

Where the token lives

Each chat's refresh token is stored in <agentDir>/google-calendar/<chat key>/token.json. The agent dir is $VOLCANIC_AGENT_DIR, or ~/.volcanic/agent when that's unset. Hosts can move the root with GoogleCalendarOptions.TokenDirectory.

  • On Unix the file has mode 0600 and its directory 0700.
  • The directory is outside every chat workspace, so the agent doesn't come across it while it reads and searches the workspace.
  • The chat key is the session scope (telegram-<chatId>) when it matches [A-Za-z0-9._-]{1,100}. Any other scope becomes scope-<16 hex digits>, the start of the scope's SHA-256 hash.
  • Removing a chat from the allow-list doesn't delete its directory. Run /calendar_disconnect first, or delete the directory yourself.

The file holds the refresh token, the granted scope, the account, the calendar's time zone and the connection time. The refresh token never appears in logs, tool results or error messages.

Time zones

  • create and update take an optional timeZone (an IANA id such as Europe/Berlin) for the event. Times without an offset are then in that zone.
  • Otherwise times without an offset (2026-09-30T10:00) are in the calendar's own time zone, which the bot reads when the chat connects. If that's unknown, they are in the chat's zone.
  • A time with an offset (2026-09-30T10:00:00+02:00) keeps its offset.
  • Every tool result starts with the zone it uses, such as Times in Europe/Berlin. When the calendar's zone differs from the chat's, the header names both: Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).
  • A date alone (2026-09-30) is an all-day event. All-day ends are exclusive, as in Google: an event on the 30th ends on the 1st.
  • In a list or search window, a timeMax date includes that day: timeMin and timeMax both 2026-09-30 cover the whole 30th.
  • For recurring events, list_instances returns the ids of single occurrences. update and delete detect whether an id is one occurrence or a whole series, so scope (instance or series) only needs to be passed to be explicit. "This and following" edits aren't supported.

Testing status instead of publishing

You can leave the app in Testing status instead of publishing it:

  • Add every user's Google account under Audience → Test users. Anyone else gets access_denied, which the bot reports as "Google Calendar access was denied."
  • Google expires refresh tokens of apps in testing status after 7 days. After that the tool reports that Google rejected the stored calendar token, and the chat has to run /calendar_connect again, about once a week.

Other hosts

The extension only depends on the public API, so any host can load it:

services.AddSingleton(new GoogleCalendarOptions { ClientId = "...", ClientSecret = "..." });
services.AddAgenticCore(o => o.AddExtension<GoogleCalendarExtension>());

AddExtension<T>() creates one instance per session, which the extension needs because it keeps per-session fields. Don't use the overload that takes a shared instance. Without the singleton, the extension reads GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from the environment. To add it for particular sessions only, pass an ExtensionRegistration in AgentSessionOptions.ExtraExtensions, as the Telegram host does (GoogleCalendarSetup.Registration). /calendar_connect needs a UI (ctx.HasUi) to show the link and code; without one it replies that it needs an interactive UI.

Troubleshooting

Message Cause and fix
"Google Calendar is not connected. Ask the user to run /calendar_connect in this chat." This chat never connected, or it disconnected. Run /calendar_connect
"Google rejected the stored calendar token (revoked or expired)." The access was revoked, the account's password changed, or the app is in testing status and 7 days have passed. Run /calendar_connect again
"Google Calendar connection failed: Google refused the device code request: invalid_client ..." Wrong client id or secret, or the OAuth client isn't of type TVs and Limited Input devices
"Google returned no refresh token." Google already had a grant for this app and didn't issue a new token. Remove the app at myaccount.google.com/permissions and run /calendar_connect again
No calendar tool or commands at all The client id or secret is missing. Check the startup log for the google-calendar disabled line