The Day Claude Stopped Inventing My Schedule: Injecting Ground Truth into the Vault with the Google Calendar API A developer solved a problem where Claude was misreading date ranges in Obsidian notes as fully booked time blocks by injecting actual Google Calendar data into the daily brief. The pipeline fetches 21 days of events via the Google Calendar API, converts them to Markdown, and prioritizes them over prose notes. The fix uses gcloud ADC for headless authentication and runs unattended via launchd. This is a follow-up to the Vault auto-ingest pipeline I mentioned in my previous post, " Turning conversation logs into skills https://zenn.dev/bokuwalily/articles/conversation-to-skill ." 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 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 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 Markdown vault-auto-ingest.sh — save to calendar-snapshot.md with last-known-good preservation logic claude -p — inject CAL SNAPSHOT NOTE into the promptFor 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. bash /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:00 XX 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. 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." 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 promptis 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. com.shun.vault-auto-ingest.plist fires at four time slots.