The Day Claude Stopped Inventing My Schedule: Injecting Ground Truth into the Vault with the Google Calendar API
The Day Claude Stopped Inventing My Schedule: Injecting Ground Truth into the Vault with the Google Calendar API
This is a follow-up to the Vault auto-ingest pipeline I mentioned in my previous post, "Turning conversation logs into skills." This time I want to write about how I killed a problem where Claude was misreading a date range as a "block of committed time" and generating a fictional schedule out of it - by injecting the actual calendar directly via the Google Calendar API.
Inside my daily brief, Claude kept interpreting a note like "Breach practicum starting 6/11" as "that entire week is fully booked," and repeatedly suggesting things like "since you'll be tied up from 6/11 onward, you should move X up." The cause was that Claude was reading a date written in the prose of a note as "occupied by an appointment for that whole period." (You can still see (2026-06-11 Breach実習)対策 - "countermeasure for the 2026-06-11 Breach practicum" - in a comment in vault-auto-ingest.sh.)
The problem: a date range in prose ≠ a commitment on the calendar
The notes accumulated in my Vault (Obsidian) describe periods in prose, like "XX practicum M/D–M/D." Claude reads these to build the daily brief, but it frequently misread "the two dates, a start point and an end point" as "committed for the entire span in between."
The fundamental problem was that the only information Claude could reference about "what happens when" was the prose in the notes. The actual data from the calendar app (title, start time, end time) did not exist in the Vault at all. The inference "there's a date in the note = fully committed for that whole period" is a result of the LLM trying to fill in context from natural language. The more ambiguous the information, the harder the LLM tries to fill in the gaps, and the further that filled-in content drifts from reality. The only fix is to explicitly give it the correct schedule information.
The solution: generate a "canonical schedule" via the Google Calendar API
The answer is simple. Every morning, fetch the next 21 days of events from the Google Calendar API, convert them to Markdown, and explicitly inject that into the daily brief prompt with the instruction "read this file as the single canonical schedule, and prioritize it over the prose in the notes."
The pipeline has three stages:
gcal-snapshot.py- fetch events via the Google Calendar API and convert to Markdownvault-auto-ingest.sh- save to_calendar-snapshot.mdwith last-known-good preservation logic- Daily brief generation via
claude -p- injectCAL_SNAPSHOT_NOTEinto the prompt
gcal-snapshot.py: ADC auth, automatic enumeration of all calendars, dedup
For authentication I use gcloud ADC (Application Default Credentials). Instead of carrying around a credentials.json and token file for an OAuth Desktop App, you just run gcloud auth application-default login --scopes=https://www.googleapis.com/auth/calendar.readonly once, and google.auth.default() works as-is. It doesn't hit TCC (macOS privacy protection) either, and being able to run it unattended from launchd was the deciding factor.
The overall skeleton of the script looks like this:
#!/usr/bin/env python3
"""Google カレンダー実体スナップショット生成(headless / launchd 可)。
- 認証: gcloud ADC(calendar.readonly スコープ)。MCP/TCC 不要 → 無人実行可。
- アカウント配下の全カレンダーを自動列挙(新規追加も自動で拾う)。
- ノイズ除外: SNS投稿管理カレンダー・日本の祝日。
- 重複dedup: (タイトル+開始時刻) が同じイベントは1件に畳む。
- 出力: stdout に Markdown 本文(イベント1件以上で正常終了0、0件/失敗は非ゼロ)。
"""
import sys
from datetime import datetime, timedelta, timezone
JST = timezone(timedelta(hours=9))
HORIZON_DAYS = 21
# カレンダーID(部分一致)でのブロックリスト
BLOCK_CAL_SUBSTR = (
"9ea30ef811a373ec8fe120f0633630c8685127f65fee2cb122c7b625ffdbd954", # SNS投稿管理(日次)
"24d111d02338025b6a82ef07f6971ce30b3e0624c6620ee73d9f88c123adc47f", # SNS投稿管理(管理用)
"holiday@group.v.calendar.google.com", # 日本の祝日
)
def get_service():
import google.auth
import googleapiclient.discovery as disc
creds, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/calendar.readonly"])
return disc.build("calendar", "v3", credentials=creds, cache_discovery=False)
It automatically enumerates all calendars with calendarList().list(), then skips IDs that partially match the block list. Even if you add a new calendar, it's automatically included - management cost is zero.
Event fetching and dedup work like this:
timed, allday, seen = [], [], set()
for c in cals:
cid = c.get("id", "")
if any(s in cid for s in BLOCK_CAL_SUBSTR):
continue
try:
events = svc.events().list(
calendarId=cid,
timeMin=tmin,
timeMax=tmax,
singleEvents=True,
orderBy="startTime",
maxResults=250,
).execute().get("items", [])
except Exception:
continue # 個別カレンダー失敗は無視(他で補う)
for ev in events:
if ev.get("status") == "cancelled":
continue
title = (ev.get("summary") or "(無題)").strip()
st, en = ev.get("start", {}), ev.get("end", {})
if "dateTime" in st:
# 時間ブロック予定
s = datetime.fromisoformat(st["dateTime"]).astimezone(JST)
key = (title, s.isoformat())
if key in seen:
continue # ← dedup: (タイトル+開始時刻) で重複排除
seen.add(key)
# ...行フォーマット
elif "date" in st:
# 終日(締切・期間)
s = datetime.fromisoformat(st["date"])
e = datetime.fromisoformat(en["date"])
span = e - s
if span.days <= 1:
label = f"{s.month}/{s.day}"
else:
last = e - timedelta(days=1) # 終日endは翌日0時=排他
label = f"{s.month}/{s.day} - {last.month}/{last.day}"
Why dedup is necessary: when the same event is registered in multiple calendars (personal, university, shared), the API returns the same event multiple times. A seen set keyed on (title, start_time) collapses identical events into one.
The output is Markdown in the format • 7/15(火) 10:00-12:00XX practicum @XX University. By separating timed blocks from all-day items (deadlines and periods) into different sections, I prevent Claude from confusing "today's committed hours" with "a deadline date." The script is designed so that "zero events or failure → non-zero exit," letting the caller determine success or failure.
vault-auto-ingest.sh: last-known-good preservation logic
This is the bash on the caller side that invokes gcal-snapshot.py (step 2.45 of vault-auto-ingest.sh):
# 2.45 カレンダー実体スナップショット(ground truth)
CAL_SNAPSHOT="$VAULT/wiki/_calendar-snapshot.md"
CAL_LASTGOOD="$CAL_SNAPSHOT.lastgood"
CAL_TMP="$(mktemp)"
CAL_SRC=""
# (1) 第一候補: Google Calendar API
run_to 90 "$PY3" "~/.claude/scripts/gcal-snapshot.py" > "$CAL_TMP" 2>/dev/null \
&& /usr/bin/grep -q "•" "$CAL_TMP" 2>/dev/null && CAL_SRC="gcal-api"
# (2) フォールバック: icalBuddy(ローカル Apple Calendar)
if [ -z "$CAL_SRC" ]; then
ICALBUDDY=""
for c in /opt/homebrew/bin/icalBuddy /usr/local/bin/icalBuddy; do
[ -x "$c" ] && ICALBUDDY="$c" && break
done
if [ -n "$ICALBUDDY" ]; then
{
echo "# カレンダー実体スナップショット(今後21日・自動生成)"
echo "_generated $(date '+%F %T') by vault-auto-ingest.sh / icalBuddy(fallback)_"
run_to 60 "$ICALBUDDY" -n -nc -iep "title,datetime,location" \
-po "datetime,title" eventsToday+21
} > "$CAL_TMP" 2>/dev/null
/usr/bin/grep -q "•" "$CAL_TMP" 2>/dev/null && CAL_SRC="icalbuddy"
fi
fi
if [ -n "$CAL_SRC" ]; then
cp "$CAL_TMP" "$CAL_SNAPSHOT"
cp "$CAL_SNAPSHOT" "$CAL_LASTGOOD" # ← 成功したら last-good を更新
elif [ -s "$CAL_LASTGOOD" ]; then
# 取得失敗: 前回goodを温存し stale 印で上書き
{
echo "# カレンダー実体スナップショット(今後21日・自動生成)"
echo "_generated $(date '+%F %T') by vault-auto-ingest.sh_"
echo "⚠️ 本日カレンダー取得失敗。以下は前回取得成功時点の値(stale)。"
tail -n +4 "$CAL_LASTGOOD"
} > "$CAL_SNAPSHOT"
else
{
echo "# カレンダー実体スナップショット(今後21日・自動生成)"
echo "(取得失敗・前回値なし)"
} > "$CAL_SNAPSHOT"
fi
Design intent of the 3-tier fallback:
| Priority | Source | Condition |
|---|---|---|
| ① | Google Calendar API (gcal-snapshot.py / ADC) |
Always attempted |
| ② | icalBuddy (local Apple Calendar) | On API failure. Permission exists only in interactive runs |
| ③ | last-known-good (previous success preserved with a stale marker) | When both fail |
The important point is that a failure never wipes the snapshot to empty. Even if ADC auth has expired and the API dies, it keeps the last correct snapshot with a stale marker attached. Claude is instructed by prompt to treat anything marked ⚠️ stale as "this is old information."
Injection into the daily brief prompt
After fetching the calendar snapshot, I assemble the following string as CAL_SNAPSHOT_NOTE:
CAL_SNAPSHOT_NOTE="予定・締切は ${CAL_SNAPSHOT}(カレンダー実体・時刻付き)を唯一の正典スケジュールとして読み、ノートの散文より優先しろ。冒頭に⚠️staleとあれば取得日時点の値である点を断れ。"
This gets embedded directly into the claude -p prompt for daily brief generation (excerpt):
cd "$VAULT" && run_to 900 "$CLAUDE" -p \
"...【厳守】ノートに無い情報を推測で足すな。特に予定の拘束時間・所要日数・
『日中が消える』等の時間コストは、実際の予定記述が無い限り書くな。
ノート中の『M/D〜M/D』は開始と終了の点であって全期間拘束ではない--
拘束日数や所要時間を捏造するな。日付・時間に関する主張は確定情報と推測を
明示的に分けろ。${CAL_SNAPSHOT_NOTE}
出力は$VAULT/wiki/today-brief.md に毎回上書き..." \
--dangerously-skip-permissions >> "$LOG" 2>&1
The phrase "prioritize it over the prose in the notes" is what does the work. Before I wrote that, Claude would try to "fill in" the schedule by comprehensively referencing articles across the Vault. By pinning the canon to a single file and explicitly making it take precedence, I was able to suppress the gap-filling. Explicitly writing "the single canonical source" into the prompt is the core of the effect. With "use this as a reference," Claude tries to integrate it with other information sources. By asserting "believe only this," the mistaken inferences from prose stop.
launchd: a 4-slot self-healing design
com.shun.vault-auto-ingest.plist fires at four time slots:
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>55</integer></dict>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer></dict>
<dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>45</integer></dict>
<dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>15</integer></dict>
</array>
If it launches at 4:55 while the machine is asleep, it re-fires at 8:20 when the Mac wakes up. Because the bash side has a DONE_MARKER and is written to "exit immediately (exit 0) if today's run already succeeded," even if all four slots fire, it actually runs only once. "Auto-retry until success, then no-op after success" is the core philosophy: whether it's a temporary ADC auth failure, a freeze from sleep, or no network connection, it self-heals at the next slot.
Pitfalls I hit
- I first tried an OAuth Desktop App, but it wouldn't run from launchd → the paths to
credentials.json+token.jsondon't resolve in launchd's minimal environment. Switching togcloud ADC(gcloud auth application-default login) solved it immediately. Note that ADC'scalendar.readonlyscope must be specified explicitly with the--scopesflag. - The icalBuddy fallback silently returns zero events from launchd without permission → an interactive run has TCC permission for Apple Calendar, but launchd is not granted it. A check that verifies the content with
grep -q "•"before adopting it is essential. - Overwriting the snapshot with an empty file makes the calendar look like "no appointments." → The last-good preservation logic prevents this.
- An all-day event's
end.dateis exclusive (midnight of the next day) → this is a Google Calendar API spec. A one-day event isstart.date: 2026-07-15,end.date: 2026-07-16. For the display label, applyend - timedelta(days=1)to show the final day. If you don't know this, it gets mis-displayed as "the two days 7/15–7/16." - The same event registered in multiple calendars gets duplicated → there were cases where the same class was in both the university's timetable calendar and my personal calendar. Until I deduped with the
seenset keyed on(title, start_time), the same class was output as 2–3 lines. - ADC tokens may need refreshing every 90 days →
gcloud auth application-default loginrequires re-authentication after a long period. If the script emitsAUTH_ERRORto stderr, that's the sign to re-authenticate. Thanks to last-good preservation the service doesn't stop, but if⚠️ stalekeeps appearing in_calendar-snapshot.md, be suspicious.
Summary
- Claude misreads a date range as a "block of committed time" because it lacks accurate schedule information and tries to fill in the gaps from the prose in the notes
gcal-snapshot.pyis headless and launchd-ready viagcloud ADC+google.auth.default(), with automatic enumeration of all calendars, a block list, and dedup included- On fetch failure, it preserves the last-known-good with a stale marker and never wipes the snapshot to empty
- The core of the effect is explicitly injecting "the single canonical schedule, prioritized over the prose in the notes" into the daily brief prompt
- launchd is a 4-slot self-healing design (a done marker prevents double execution, and the next slot retries on failure)
Next time I'll write about a stop hook runaway that spun off from this pipeline and made me want to shut it down - how I pinpointed the cause of a cost spike using the transcript and fixed it.
Lily (@bokuwalily) - indie developer. I build automation infrastructure with Claude Code while mass-producing iOS apps and web services. My work and articles are collected at bokuwalily.com 🖥️ The story of how I built a "runs while I sleep" system with AI and grew it to 1.2M yen/month is in my paid note article 💰 OSS: github.com/bokuwalily 🐙 For the latest updates and inquiries, reach me on X @bokuwalily 🌍 Your ❤️ and shares keep me going!
Written by Lily - I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio · X · GitHub
Comments
No comments yet. Start the discussion.