[Experimental] Upcoming Google Calendar events in-session — /cal toggles a band above the prompt with the next 7 days, /cal N for N days (Claude Mod; needs…

Shows upcoming Google Calendar events inside a Claude Code session, as one Claude Mod (a function-hooks module, hooks/register.ts). Nothing it shows enters the conversation.
/cal: toggles a band above the prompt listing the next 7 days of events./cal N: lists the next N days (1–31), turns the band on, and remembers N.Needs Claude Code 2.1.287 or later, where Claude Mods are on by default. Built and tested against Claude Code 2.1.287.
Every fetch goes through the claude.ai Google Calendar connector with $.mcp.call: one list_calendars, then one list_events per calendar for the window from now to N days ahead. Every calendar you can see is listed except those named in the plugin option exclude_calendars (names or ids). Claude Code asks for it when the plugin is enabled; a list option has no /config row, so change it later under pluginConfigs["calendar@<marketplace>"].options in ~/.claude/settings.json.
Events are fetched when /cal turns the band on, at session start when the band was left on, and every 15 minutes while it is on. Nothing is fetched while the band is off. A fetch never blocks a prompt or a tool call. The claude.ai connectors are still connecting when a session starts, so a failed fetch with nothing yet on screen is retried every 30 s, at most 10 times.
$.mcp.call asks no permission. Any installed Claude Mod can read your mail and calendar through a connected claude.ai connector this way. Install third-party mods with that in mind.
One line per event, soonest first, in the machine's time zone (TZ when set):
今天 14:00–15:00 Dentist @Clinic 還有 4h [ 日曆 ]
明天 全天 中秋節 [ 日曆 ]
09/27 日 22:00–09/28 01:30 Trip [ 日曆 ] [ 連結 ]
09/28 一 18:00–21:00 Dinner @Home [ 日曆 ] [ 連結 ]
今天, 明天, or MM/DD <weekday>, in blue.全天; a timed one its start and end, with the end's date when it ends on another day.還有 3h 45m, or 進行中 once started; the band redraws every minute so it keeps moving.@<location> follows the title, dimmed, when the event has one and it is not a link.[ 日曆 ] opens the event in Google Calendar, or its day when the event has no link. [ 連結 ] opens the event's meeting (Google Meet or another conference's video link), or its location when the location is an https link. On the terminal a button runs open (xdg-open where there is no open); the desktop app draws them as links.Fetching calendar events, Unavailable: <reason> before any fetch succeeded, Stale: <reason>; showing data from <age> ago after a failed one, or No events in the next N days.… N more. Every line is cut at the band's width.The band shares its space with any band another mod draws there (/quota, for one) instead of hiding it. The band's on/off state and N are kept in the plugin's store and read at session start.
claude plugin test calendar
The fixtures in tests/fixtures/world.ts are synthetic; no real calendar data belongs in this repo.
hooks/register.ts 182 lines1import type { On } from 'claude-code'
2import { CALENDAR_BUTTON, calModelOf, URL_BUTTON } from './cal-model.ts'
3import type { CalView } from './cal-model.ts'
4import { calendarsOf, eventsOf, payloadOf, wantedOf } from './events.ts'
5import type { CalEvent } from './events.ts'
6
7const SERVER = 'claude.ai Google Calendar'
8const DEFAULT_DAYS = 7
9const MAX_DAYS = 31
10const DAY_MS = 86_400_000
11const POLL_MS = 900_000
12const RETRY_MS = 30_000
13const MAX_RETRIES = 10
14const BAND_KEY = 'band'
15const DAYS_KEY = 'days'
16
17type Reading = { ok: true; events: CalEvent[] } | { ok: false; reason: string }
18
19let view: CalView = { events: null, failure: null, lastGoodAt: null, days: DEFAULT_DAYS }
20let bandOn = false
21let excluded: string[] = []
22let poll: { cancel(): void } | null = null
23let tick: { cancel(): void } | null = null
24let fetchesStarted = 0
25let lastPublished = 0
26let retries = 0
27
28async function call($: any, tool: string, args: Record<string, unknown>): Promise<unknown> {
29 try {
30 return payloadOf(await $.mcp.call(SERVER, tool, args))
31 } catch {
32 return undefined
33 }
34}
35
36async function fetchEvents($: any, days: number): Promise<Reading> {
37 const calendars = calendarsOf(await call($, 'list_calendars', {}))
38 if (!calendars) return { ok: false, reason: 'Google Calendar connector did not answer' }
39 const now = await $.clock.now()
40 const window = { startTime: new Date(now).toISOString(), endTime: new Date(now + days * DAY_MS).toISOString() }
41 const wanted = wantedOf(calendars, excluded)
42 const lists = await Promise.all(
43 wanted.map(async (c) =>
44 eventsOf(await call($, 'list_events', { calendarId: c.id, ...window, orderBy: 'startTime', pageSize: 100 })),
45 ),
46 )
47 const failed = wanted.filter((_, i) => lists[i] === null).map((c) => c.name)
48 if (failed.length) return { ok: false, reason: `could not read ${failed.join(', ')}` }
49 return { ok: true, events: lists.flatMap((l) => l ?? []) }
50}
51
52// A fetch that started before one already published would put older data back on screen.
53async function fetchAndPublish($: any) {
54 const seq = ++fetchesStarted
55 const reading = await fetchEvents($, view.days)
56 const now = await $.clock.now()
57 if (seq < lastPublished) return
58 lastPublished = seq
59 view = reading.ok
60 ? { ...view, events: reading.events, failure: null, lastGoodAt: now }
61 : { ...view, failure: reading.reason }
62 $.ui.invalidate('ui.render')
63 // At session start the claude.ai connectors are still connecting, so a band left on
64 // would otherwise read "Unavailable" until the 15-minute poll.
65 if (reading.ok) retries = 0
66 else if (!view.events && bandOn && retries < MAX_RETRIES) {
67 retries += 1
68 $.clock.after(RETRY_MS, () => {
69 void fetchAndPublish($).catch(() => {})
70 })
71 }
72}
73
74async function remember($: any, key: string, value: unknown) {
75 try {
76 await $.store.set(key, value)
77 } catch {
78 // The band still follows the command for this session; only the next one will not remember it.
79 }
80}
81
82async function readStore($: any) {
83 try {
84 bandOn = (await $.store.get(BAND_KEY)) === true
85 const days = await $.store.get(DAYS_KEY)
86 if (Number.isInteger(days) && days >= 1 && days <= MAX_DAYS) view = { ...view, days }
87 } catch {
88 bandOn = false
89 }
90}
91
92async function showBand($: any, on: boolean) {
93 bandOn = on
94 await remember($, BAND_KEY, on)
95 $.ui.invalidate('ui.render')
96 if (on) void fetchAndPublish($).catch(() => {})
97 return {}
98}
99
100const DAY_COLOR = '#5b9cf5'
101const NEXT_COLOR = '#46a758'
102
103// The engine has no call that opens a URL, and a Link the terminal cannot open prints its URL.
104function openUrl($: any, url: string) {
105 return $.process.run(['open', url]).catch(() => $.process.run(['xdg-open', url]))
106}
107
108async function renderBand($: any, e: any, beneath: unknown) {
109 const { Box, Text, Link, Button } = await $.ui.resolve(e)
110 // Only the terminal can run a command: `process` is CLI only.
111 const button = (key: string, label: string, href: string) =>
112 e.surface === 'terminal'
113 ? Button({ key, label, onPress: () => void openUrl($, href).catch(() => {}) })
114 : Text({ children: [Link({ href, children: [`[ ${label} ]`] })] })
115 const tz = (await $.env.get('TZ')) || Intl.DateTimeFormat().resolvedOptions().timeZone
116 const model = calModelOf(view, await $.clock.now(), tz)
117 const room = Math.max(1, (e.props.maxRows ?? 10) - (model.notice ? 1 : 0))
118 const hidden = model.rows.length > room ? model.rows.length - room + 1 : 0
119 const lines = []
120 if (model.notice) lines.push(Text({ dimColor: true, wrap: 'truncate-end', children: [model.notice] }))
121 for (const [i, row] of (hidden ? model.rows.slice(0, room - 1) : model.rows).entries()) {
122 const children = [
123 Text({ color: DAY_COLOR, children: [row.day] }),
124 ` ${row.span} `,
125 row.isNext ? Text({ color: NEXT_COLOR, bold: true, children: [row.title] }) : row.title,
126 ]
127 if (row.location) children.push(Text({ dimColor: true, children: [` @${row.location}`] }))
128 if (row.note) children.push(Text({ color: NEXT_COLOR, children: [` ${row.note}`] }))
129 const buttons = [button(`calendar-${i}`, CALENDAR_BUTTON, row.calendarHref)]
130 if (row.urlHref) buttons.push(button(`url-${i}`, URL_BUTTON, row.urlHref))
131 lines.push(Box({ flexDirection: 'row', gap: 2, children: [Text({ wrap: 'truncate-end', children }), ...buttons] }))
132 }
133 if (hidden) lines.push(Text({ dimColor: true, children: [`… ${hidden} more`] }))
134 if (beneath) lines.push(beneath)
135 return Box({ flexDirection: 'column', children: lines })
136}
137
138export function register(on: On, options: { exclude_calendars?: string[] } = {}) {
139 excluded = Array.isArray(options.exclude_calendars) ? options.exclude_calendars : []
140
141 on('session.start', async ($, e, next) => {
142 try {
143 await $.command.register({
144 name: 'cal',
145 description: 'Show upcoming Google Calendar events above the prompt',
146 argumentHint: '[days]',
147 immediate: true,
148 })
149 } catch {
150 // A refused /cal leaves nothing else to run.
151 }
152 await readStore($)
153 if (bandOn) void fetchAndPublish($).catch(() => {})
154 // A reload re-fires session.start on this instance; a second timer would double the cadence.
155 poll?.cancel()
156 poll = $.clock.every(POLL_MS, () => {
157 if (bandOn) void fetchAndPublish($).catch(() => {})
158 })
159 tick?.cancel()
160 tick = $.clock.every(60_000, () => {
161 if (bandOn) $.ui.invalidate('ui.render')
162 })
163 return next(e)
164 })
165
166 on('command.run', { command: 'cal' }, async ($, e) => {
167 const args = (e.args ?? '').trim()
168 if (args === '') return showBand($, !bandOn)
169 const days = Number(args)
170 if (!/^\d+$/.test(args) || days < 1 || days > MAX_DAYS) return { text: `usage: /cal [days], days 1–${MAX_DAYS}` }
171 view = { ...view, days }
172 await remember($, DAYS_KEY, days)
173 return showBand($, true)
174 })
175
176 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
177 if (!bandOn || e.props.hasSurvey) return next(e)
178 // The band is one instance: a tree that drops next(e) hides every other plugin's band.
179 return renderBand($, e, await next(e))
180 })
181}
182hooks/cal-model.ts 156 lines1import type { CalEvent, CalTime } from './events.ts'
2
3export type CalView = { events: CalEvent[] | null; failure: string | null; lastGoodAt: number | null; days: number }
4
5// `day` and `span` arrive padded to their column; `note` is today's countdown.
6export type CalRow = {
7 day: string
8 span: string
9 title: string
10 location: string
11 note: string
12 isNext: boolean
13 calendarHref: string
14 urlHref: string | null
15}
16
17export type CalModel = { notice: string | null; rows: CalRow[] }
18
19const MINUTE = 60_000
20const HOUR = 60 * MINUTE
21const DAY_MS = 24 * HOUR
22const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
23const ALL_DAY = '全天'
24export const CALENDAR_BUTTON = '日曆'
25export const URL_BUTTON = '連結'
26
27type Local = { date: string; time: string }
28
29function localOf(ms: number, tz: string): Local {
30 const parts = new Intl.DateTimeFormat('en-CA', {
31 timeZone: tz,
32 year: 'numeric',
33 month: '2-digit',
34 day: '2-digit',
35 hour: '2-digit',
36 minute: '2-digit',
37 hourCycle: 'h23',
38 }).formatToParts(ms)
39 const part = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
40 return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
41}
42
43function monthDayOf(date: string): string {
44 return `${date.slice(5, 7)}/${date.slice(8, 10)}`
45}
46
47function dayLabelOf(date: string, today: string, tomorrow: string): string {
48 if (date === today) return '今天'
49 if (date === tomorrow) return '明天'
50 const [y, m, d] = date.split('-').map(Number)
51 return `${monthDayOf(date)} ${WEEKDAYS[new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay()]}`
52}
53
54function startOf(time: CalTime, tz: string): Local {
55 return time.kind === 'date' ? { date: time.date, time: '' } : localOf(time.ms, tz)
56}
57
58function spanOf(event: CalEvent, start: Local, tz: string): string {
59 if (event.start.kind === 'date') return ALL_DAY
60 if (!event.end || event.end.kind === 'date') return start.time
61 const end = localOf(event.end.ms, tz)
62 return end.date === start.date ? `${start.time}–${end.time}` : `${start.time}–${monthDayOf(end.date)} ${end.time}`
63}
64
65// Terminal cells: a CJK character takes two.
66export function cellsOf(text: string): number {
67 let cells = 0
68 for (const ch of text) cells += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
69 return cells
70}
71
72function padCells(text: string, width: number): string {
73 return text + ' '.repeat(Math.max(0, width - cellsOf(text)))
74}
75
76// A Link refuses anything but an https URL spelled as the URL parser spells it.
77function httpsHref(text: string): string | null {
78 try {
79 const url = new URL(text)
80 return url.protocol === 'https:' ? url.href : null
81 } catch {
82 return null
83 }
84}
85
86function dayHrefOf(date: string): string {
87 const [y, m, d] = date.split('-').map(Number)
88 return `https://calendar.google.com/calendar/r/day/${y}/${m}/${d}`
89}
90
91function durationOf(ms: number): string {
92 const h = Math.floor(ms / HOUR)
93 const m = Math.floor((ms % HOUR) / MINUTE)
94 return h && m ? `${h}h ${m}m` : h ? `${h}h` : `${m}m`
95}
96
97function noteOf(event: CalEvent, start: Local, today: string, nowMs: number): string {
98 if (event.start.kind !== 'time' || start.date !== today) return ''
99 return event.start.ms <= nowMs ? '進行中' : `還有 ${durationOf(event.start.ms - nowMs)}`
100}
101
102export function lineOf(row: CalRow): string {
103 const buttons = [CALENDAR_BUTTON, row.urlHref && URL_BUTTON].filter(Boolean).map((label) => `[ ${label} ]`)
104 return [row.day, row.span, row.title, row.location && `@${row.location}`, row.note, ...buttons].filter(Boolean).join(' ')
105}
106
107function rowsOf(events: CalEvent[], nowMs: number, tz: string): CalRow[] {
108 const today = localOf(nowMs, tz).date
109 const tomorrow = localOf(nowMs + DAY_MS, tz).date
110 const seen = new Set<string>()
111 const lines = events
112 .map((event) => ({ event, start: startOf(event.start, tz) }))
113 .filter(({ event, start }) => {
114 const key = `${event.id}|${start.date}|${start.time}`
115 if (seen.has(key)) return false
116 seen.add(key)
117 return true
118 })
119 .sort((a, b) => `${a.start.date} ${a.start.time}`.localeCompare(`${b.start.date} ${b.start.time}`))
120 .map(({ event, start }) => ({
121 date: start.date,
122 day: dayLabelOf(start.date, today, tomorrow),
123 span: spanOf(event, start, tz),
124 event,
125 note: noteOf(event, start, today, nowMs),
126 }))
127 const dayWidth = Math.max(0, ...lines.map((l) => cellsOf(l.day)))
128 const spanWidth = Math.max(0, ...lines.map((l) => cellsOf(l.span)))
129 const next = lines.findIndex((l) => l.event.start.kind === 'time')
130 return lines.map(({ date, day, span, event, note }, i) => {
131 const urlHref = event.url ? httpsHref(event.url) : null
132 return {
133 day: padCells(day, dayWidth),
134 span: padCells(span, spanWidth),
135 title: event.title,
136 location: event.location === event.url && urlHref ? '' : event.location,
137 note,
138 isNext: i === next,
139 calendarHref: (event.link && httpsHref(event.link)) || dayHrefOf(date),
140 urlHref,
141 }
142 })
143}
144
145export function calModelOf(view: CalView, nowMs: number, tz: string): CalModel {
146 if (!view.events) {
147 return { notice: view.failure ? `Unavailable: ${view.failure}` : 'Fetching calendar events', rows: [] }
148 }
149 const stale = view.failure
150 ? `Stale: ${view.failure}; showing data from ${durationOf(nowMs - (view.lastGoodAt ?? nowMs))} ago`
151 : null
152 const rows = rowsOf(view.events, nowMs, tz)
153 const empty = rows.length ? null : `No events in the next ${view.days} days`
154 return { notice: stale ?? empty, rows }
155}
156hooks/events.ts 82 lines1export type CalTime = { kind: 'date'; date: string } | { kind: 'time'; ms: number }
2
3export type CalEvent = { id: string; title: string; location: string; link: string; url: string; start: CalTime; end: CalTime | null }
4
5export type Calendar = { id: string; name: string }
6
7function field(value: unknown, key: string): unknown {
8 return value !== null && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined
9}
10
11function stringOf(value: unknown): string {
12 return typeof value === 'string' ? value : ''
13}
14
15// An MCP tool answers its JSON as the text of its content blocks.
16export function payloadOf(result: unknown): unknown {
17 if (field(result, 'isError') === true) return undefined
18 const blocks = field(result, 'content')
19 if (!Array.isArray(blocks)) return undefined
20 const text = blocks.map((b) => (field(b, 'type') === 'text' ? stringOf(field(b, 'text')) : '')).join('')
21 try {
22 return JSON.parse(text)
23 } catch {
24 return undefined
25 }
26}
27
28function timeOf(raw: unknown): CalTime | null {
29 const dateTime = stringOf(field(raw, 'dateTime'))
30 if (dateTime) {
31 const ms = Date.parse(dateTime)
32 return Number.isFinite(ms) ? { kind: 'time', ms } : null
33 }
34 // The connector spells an all-day date as midnight UTC (`2026-10-09T00:00:00Z`); the
35 // calendar day is the date part, which parsing it as a time would shift west of UTC.
36 const date = stringOf(field(raw, 'date'))
37 return /^\d{4}-\d{2}-\d{2}(T|$)/.test(date) ? { kind: 'date', date: date.slice(0, 10) } : null
38}
39
40// A meeting's join link, or a location that is itself a link.
41function urlOf(raw: unknown): string {
42 const entryPoints = field(field(raw, 'conferenceData'), 'entryPoints')
43 const video = Array.isArray(entryPoints) ? entryPoints.find((p) => field(p, 'entryPointType') === 'video') : undefined
44 const location = stringOf(field(raw, 'location'))
45 return stringOf(field(raw, 'hangoutLink')) || stringOf(field(video, 'uri')) || (/^https:\/\//.test(location) ? location : '')
46}
47
48export function calendarsOf(payload: unknown): Calendar[] | null {
49 const list = field(payload, 'calendars')
50 if (!Array.isArray(list)) return null
51 return list.flatMap((c) => {
52 const id = stringOf(field(c, 'id'))
53 return id ? [{ id, name: stringOf(field(c, 'summary')) || id }] : []
54 })
55}
56
57// A calendar with no events in the window answers no `events` key at all.
58export function eventsOf(payload: unknown): CalEvent[] | null {
59 if (payload === null || typeof payload !== 'object') return null
60 const list = field(payload, 'events') ?? []
61 if (!Array.isArray(list)) return null
62 return list.flatMap((raw) => {
63 const start = timeOf(field(raw, 'start'))
64 if (!start || field(raw, 'status') === 'cancelled') return []
65 return [
66 {
67 id: stringOf(field(raw, 'id')),
68 title: stringOf(field(raw, 'summary')) || '(no title)',
69 location: stringOf(field(raw, 'location')),
70 link: stringOf(field(raw, 'htmlLink')),
71 url: urlOf(raw),
72 start,
73 end: timeOf(field(raw, 'end')),
74 },
75 ]
76 })
77}
78
79export function wantedOf(calendars: Calendar[], excluded: string[]): Calendar[] {
80 return calendars.filter((c) => !excluded.includes(c.name) && !excluded.includes(c.id))
81}
82