Add Google Calendar as a native Pi custom tool

pi-claude-code-provider fences off Claude Code's MCP servers by design,
so managed MCPs like Google Calendar can't reach the bot through that
provider. Implement calendar access as a first-class Pi tool instead —
it lives alongside `schedule` and calls Google Calendar API v3 directly
with a subscription-free refresh-token grant.

Config: three new optional env vars (GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN) — set all three to enable
the tool, leave all three blank to disable. Partial config raises a
ConfigError at startup so mis-set env is caught early.

Tool: list / create / update / delete on any calendar (default
'primary'). Access tokens are cached until expiry and refreshed on
demand from the long-lived refresh token. Invalid_grant surfaces a
clear "re-run device flow" error.
This commit is contained in:
EugeneTes
2026-09-06 20:51:00 +00:00
parent dacebf9329
commit 9fc348f0e2
5 changed files with 293 additions and 2 deletions

View File

@@ -14,3 +14,10 @@ SECRETARY_TIMEZONE=
SECRETARY_SHELL=
LOG_LEVEL=info
# PI_CODING_AGENT_DIR= (Pi's own override for ~/.pi/agent, mainly for Docker)
# Google Calendar tool: all three or none. Bootstrap the refresh token with an
# OAuth device flow against a Google Cloud OAuth client (type: TVs and Limited
# Input devices) — see docs/. Scope needed: https://www.googleapis.com/auth/calendar
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REFRESH_TOKEN=

View File

@@ -14,6 +14,9 @@ services:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- SECRETARY_TIMEZONE=${SECRETARY_TIMEZONE:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_REFRESH_TOKEN=${GOOGLE_REFRESH_TOKEN:-}
volumes:
- /media/disk1/containers/volcanic_secretary/workspace:/workspace
- /media/disk1/containers/volcanic_secretary/pi-agent:/pi-agent

View File

@@ -11,6 +11,13 @@ export interface Config {
timezone: string;
shellPath?: string;
logLevel: LogLevel;
google?: GoogleConfig;
}
export interface GoogleConfig {
clientId: string;
clientSecret: string;
refreshToken: string;
}
export class ConfigError extends Error {}
@@ -37,6 +44,20 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env, cwd: string = p
throw new ConfigError(`SECRETARY_TIMEZONE is not a valid IANA timezone: "${timezone}"`);
}
const googleClientId = blankToUndefined(env.GOOGLE_CLIENT_ID);
const googleClientSecret = blankToUndefined(env.GOOGLE_CLIENT_SECRET);
const googleRefreshToken = blankToUndefined(env.GOOGLE_REFRESH_TOKEN);
const googleParts = [googleClientId, googleClientSecret, googleRefreshToken];
if (googleParts.some(Boolean) && !googleParts.every(Boolean)) {
throw new ConfigError(
"Google Calendar needs all three of GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, or none.",
);
}
const google: GoogleConfig | undefined =
googleClientId && googleClientSecret && googleRefreshToken
? { clientId: googleClientId, clientSecret: googleClientSecret, refreshToken: googleRefreshToken }
: undefined;
return {
telegramBotToken,
telegramOwnerId,
@@ -47,6 +68,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env, cwd: string = p
timezone,
shellPath: blankToUndefined(env.SECRETARY_SHELL),
logLevel,
google,
};
}

View File

@@ -2,7 +2,7 @@ import path from "node:path";
import { autoRetry } from "@grammyjs/auto-retry";
import { Bot } from "grammy";
import { AgentHost } from "./agent/host.js";
import { createPiBackend } from "./agent/pi-backend.js";
import { createPiBackend, type CustomTools } from "./agent/pi-backend.js";
import { ConfigError, loadConfig } from "./config.js";
import { createLogger } from "./log.js";
import { Scheduler } from "./scheduler/scheduler.js";
@@ -10,6 +10,7 @@ import { JobStore } from "./scheduler/store.js";
import { createOpenAiTranscriber } from "./stt/openai.js";
import { BOT_COMMANDS, formatStatus, registerBridge } from "./telegram/bridge.js";
import { Outbound } from "./telegram/outbound.js";
import { createCalendarTool } from "./tools/calendar.js";
import { createScheduleTool } from "./tools/schedule.js";
import { ensureWorkspace } from "./workspace.js";
@@ -73,11 +74,18 @@ async function main(): Promise<void> {
process.exit(0);
};
const customTools: CustomTools = [createScheduleTool(scheduler, config.timezone)];
if (config.google) {
customTools.push(createCalendarTool(config.google, config.timezone));
logger.info("calendar tool enabled");
} else {
logger.info("calendar tool disabled (set GOOGLE_CLIENT_ID/SECRET/REFRESH_TOKEN to enable)");
}
const backend = createPiBackend({
workspaceDir: config.workspaceDir,
piModel: config.piModel,
timezone: config.timezone,
customTools: [createScheduleTool(scheduler, config.timezone)],
customTools,
logger,
onShutdownRequest: () => void shutdown("extension request"),
});

251
src/tools/calendar.ts Normal file
View File

@@ -0,0 +1,251 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { type Static, Type } from "typebox";
import type { GoogleConfig } from "../config.js";
export const calendarParams = Type.Object({
action: Type.Union(
[Type.Literal("list"), Type.Literal("create"), Type.Literal("update"), Type.Literal("delete")],
{ description: "list events, create/update/delete a single event" },
),
calendar_id: Type.Optional(
Type.String({ description: "Calendar id. Default 'primary' (the owner's main calendar)." }),
),
time_min: Type.Optional(
Type.String({
description: "list: ISO 8601 lower bound for events (inclusive), e.g. 2026-09-07T00:00:00+02:00. Default: now.",
}),
),
time_max: Type.Optional(
Type.String({ description: "list: ISO 8601 upper bound (exclusive). Optional." }),
),
q: Type.Optional(Type.String({ description: "list: free-text search over summaries/descriptions." })),
max_results: Type.Optional(
Type.Number({ description: "list: max events returned. Default 25, capped at 250." }),
),
event_id: Type.Optional(Type.String({ description: "Event id. Required for update and delete." })),
summary: Type.Optional(Type.String({ description: "create/update: event title." })),
description: Type.Optional(Type.String({ description: "create/update: event description." })),
location: Type.Optional(Type.String({ description: "create/update: location string." })),
start: Type.Optional(
Type.String({
description:
"create/update: start. ISO 8601 with offset for timed events (2026-09-10T10:00:00+02:00), or YYYY-MM-DD for all-day.",
}),
),
end: Type.Optional(
Type.String({ description: "create/update: end. Same format as start." }),
),
});
export type CalendarParams = Static<typeof calendarParams>;
interface CalendarEvent {
id: string;
summary?: string;
description?: string;
location?: string;
start?: { dateTime?: string; date?: string; timeZone?: string };
end?: { dateTime?: string; date?: string; timeZone?: string };
htmlLink?: string;
}
const CALENDAR_API = "https://www.googleapis.com/calendar/v3";
export function createCalendarTool(google: GoogleConfig, timezone: string) {
let cachedToken: { access: string; expiresAt: number } | null = null;
const getAccessToken = async (): Promise<string> => {
const now = Date.now();
if (cachedToken && cachedToken.expiresAt > now + 60_000) return cachedToken.access;
const resp = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: google.clientId,
client_secret: google.clientSecret,
refresh_token: google.refreshToken,
grant_type: "refresh_token",
}),
});
if (!resp.ok) {
const text = await resp.text();
if (text.includes("invalid_grant")) {
throw new Error(
"Google refresh token was rejected (invalid_grant). Re-run the device-flow bootstrap and update GOOGLE_REFRESH_TOKEN.",
);
}
throw new Error(`Google token refresh failed: ${resp.status} ${text}`);
}
const data = (await resp.json()) as { access_token: string; expires_in: number };
cachedToken = { access: data.access_token, expiresAt: now + data.expires_in * 1000 };
return data.access_token;
};
return defineTool({
name: "calendar",
label: "Calendar",
description:
"Read and modify the owner's Google Calendar: list events, or create, update, or delete an event.",
promptSnippet: "Read and modify the owner's Google Calendar",
parameters: calendarParams,
execute: async (_toolCallId, params) => {
const text = await handleCalendar(params, timezone, getAccessToken);
return { content: [{ type: "text", text }], details: {} };
},
});
}
export async function handleCalendar(
params: CalendarParams,
timezone: string,
getAccessToken: () => Promise<string>,
): Promise<string> {
const calId = params.calendar_id?.trim() || "primary";
switch (params.action) {
case "list":
return listEvents(calId, params, timezone, getAccessToken);
case "create":
return createEvent(calId, params, timezone, getAccessToken);
case "update":
return updateEvent(calId, params, timezone, getAccessToken);
case "delete":
return deleteEvent(calId, params, getAccessToken);
}
}
async function listEvents(
calId: string,
params: CalendarParams,
timezone: string,
getAccessToken: () => Promise<string>,
): Promise<string> {
const url = new URL(`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events`);
url.searchParams.set("timeMin", params.time_min ?? new Date().toISOString());
if (params.time_max) url.searchParams.set("timeMax", params.time_max);
if (params.q) url.searchParams.set("q", params.q);
url.searchParams.set("maxResults", String(Math.min(params.max_results ?? 25, 250)));
url.searchParams.set("singleEvents", "true");
url.searchParams.set("orderBy", "startTime");
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${await getAccessToken()}` },
});
await ensureOk(resp, "list events");
const data = (await resp.json()) as { items?: CalendarEvent[] };
const items = data.items ?? [];
if (!items.length) return "No events.";
return items.map((event) => describeEvent(event, timezone)).join("\n");
}
async function createEvent(
calId: string,
params: CalendarParams,
timezone: string,
getAccessToken: () => Promise<string>,
): Promise<string> {
if (!params.summary) throw new Error("create requires summary");
if (!params.start || !params.end) throw new Error("create requires start and end");
const body = eventBody(params, timezone);
const resp = await fetch(`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
await ensureOk(resp, "create event");
const event = (await resp.json()) as CalendarEvent;
return `Created: ${describeEvent(event, timezone)}`;
}
async function updateEvent(
calId: string,
params: CalendarParams,
timezone: string,
getAccessToken: () => Promise<string>,
): Promise<string> {
if (!params.event_id) throw new Error("update requires event_id");
const body = eventBody(params, timezone);
if (Object.keys(body).length === 0) throw new Error("update requires at least one field to change");
const resp = await fetch(
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
await ensureOk(resp, "update event");
const event = (await resp.json()) as CalendarEvent;
return `Updated: ${describeEvent(event, timezone)}`;
}
async function deleteEvent(
calId: string,
params: CalendarParams,
getAccessToken: () => Promise<string>,
): Promise<string> {
if (!params.event_id) throw new Error("delete requires event_id");
const resp = await fetch(
`${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${await getAccessToken()}` },
},
);
await ensureOk(resp, "delete event");
return `Deleted event ${params.event_id}.`;
}
function eventBody(params: CalendarParams, timezone: string): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (params.summary !== undefined) body.summary = params.summary;
if (params.description !== undefined) body.description = params.description;
if (params.location !== undefined) body.location = params.location;
if (params.start !== undefined) body.start = toTimeField(params.start, timezone);
if (params.end !== undefined) body.end = toTimeField(params.end, timezone);
return body;
}
function toTimeField(input: string, timezone: string): Record<string, string> {
return /^\d{4}-\d{2}-\d{2}$/.test(input.trim())
? { date: input.trim() }
: { dateTime: input, timeZone: timezone };
}
async function ensureOk(resp: Response, action: string): Promise<void> {
if (resp.ok) return;
const text = await resp.text();
throw new Error(`Google Calendar ${action} failed: ${resp.status} ${text}`);
}
function describeEvent(event: CalendarEvent, timezone: string): string {
const summary = event.summary ?? "(no title)";
const when = describeWhen(event, timezone);
const location = event.location ? ` @ ${event.location}` : "";
return `${event.id} · ${summary} · ${when}${location}`;
}
function describeWhen(event: CalendarEvent, timezone: string): string {
const start = event.start ?? {};
const end = event.end ?? {};
if (start.date) {
return start.date === end.date || !end.date
? `all-day ${start.date}`
: `all-day ${start.date} → ${end.date}`;
}
const s = start.dateTime ? formatTime(start.dateTime, timezone) : "?";
const e = end.dateTime ? formatTime(end.dateTime, timezone) : "?";
return `${s} → ${e}`;
}
function formatTime(iso: string, timezone: string): string {
return new Intl.DateTimeFormat("en-GB", {
timeZone: timezone,
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(iso));
}