{"slug": "the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-the", "title": "The Day Claude Stopped Inventing My Schedule: Injecting Ground Truth into the Vault with the Google Calendar API", "summary": "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.", "body_md": "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.\n\nInside 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実習)対策`\n\n— \"countermeasure for the 2026-06-11 Breach practicum\" — in a comment in `vault-auto-ingest.sh`\n\n.)\n\nThe 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.\"**\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nThe 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.\"\n\nThe pipeline has three stages.\n\n`gcal-snapshot.py`\n\n— fetch events via the Google Calendar API and convert to Markdown`vault-auto-ingest.sh`\n\n— save to `_calendar-snapshot.md`\n\nwith last-known-good preservation logic`claude -p`\n\n— inject `CAL_SNAPSHOT_NOTE`\n\ninto the promptFor authentication I use **gcloud ADC (Application Default Credentials)**. Instead of carrying around a `credentials.json`\n\nand token file for an OAuth Desktop App, you just run `gcloud auth application-default login --scopes=https://www.googleapis.com/auth/calendar.readonly`\n\nonce, and `google.auth.default()`\n\nworks as-is. It doesn't hit TCC (macOS privacy protection) either, and being able to run it unattended from launchd was the deciding factor.\n\nThe overall skeleton of the script looks like this.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"\nGoogle カレンダー実体スナップショット生成（headless / launchd 可）。\n- 認証: gcloud ADC（calendar.readonly スコープ）。MCP/TCC 不要 → 無人実行可。\n- アカウント配下の全カレンダーを自動列挙（新規追加も自動で拾う）。\n- ノイズ除外: SNS投稿管理カレンダー・日本の祝日。\n- 重複dedup: (タイトル+開始時刻) が同じイベントは1件に畳む。\n- 出力: stdout に Markdown 本文（イベント1件以上で正常終了0、0件/失敗は非ゼロ）。\n\"\"\"\nimport sys\nfrom datetime import datetime, timedelta, timezone\n\nJST = timezone(timedelta(hours=9))\nHORIZON_DAYS = 21\n\n# カレンダーID（部分一致）でのブロックリスト\nBLOCK_CAL_SUBSTR = (\n    \"9ea30ef811a373ec8fe120f0633630c8685127f65fee2cb122c7b625ffdbd954\",  # SNS投稿管理(日次)\n    \"24d111d02338025b6a82ef07f6971ce30b3e0624c6620ee73d9f88c123adc47f\",  # SNS投稿管理(管理用)\n    \"holiday@group.v.calendar.google.com\",                                # 日本の祝日\n)\n\ndef get_service():\n    import google.auth\n    import googleapiclient.discovery as disc\n    creds, _ = google.auth.default(\n        scopes=[\"https://www.googleapis.com/auth/calendar.readonly\"]\n    )\n    return disc.build(\"calendar\", \"v3\", credentials=creds, cache_discovery=False)\n```\n\nIt automatically enumerates all calendars with `calendarList().list()`\n\n, then skips IDs that partially match the block list. Even if you add a new calendar, it's automatically included — management cost is zero.\n\nEvent fetching and dedup work like this.\n\n```\ntimed, allday, seen = [], [], set()\nfor c in cals:\n    cid = c.get(\"id\", \"\")\n    if any(s in cid for s in BLOCK_CAL_SUBSTR):\n        continue\n    try:\n        events = svc.events().list(\n            calendarId=cid, timeMin=tmin, timeMax=tmax,\n            singleEvents=True, orderBy=\"startTime\", maxResults=250,\n        ).execute().get(\"items\", [])\n    except Exception:\n        continue  # 個別カレンダー失敗は無視（他で補う）\n    for ev in events:\n        if ev.get(\"status\") == \"cancelled\":\n            continue\n        title = (ev.get(\"summary\") or \"(無題)\").strip()\n        st, en = ev.get(\"start\", {}), ev.get(\"end\", {})\n        if \"dateTime\" in st:  # 時間ブロック予定\n            s = datetime.fromisoformat(st[\"dateTime\"]).astimezone(JST)\n            key = (title, s.isoformat())\n            if key in seen:\n                continue          # ← dedup: (タイトル+開始時刻) で重複排除\n            seen.add(key)\n            # ...行フォーマット\n        elif \"date\" in st:        # 終日（締切・期間）\n            s = datetime.fromisoformat(st[\"date\"])\n            e = datetime.fromisoformat(en[\"date\"])\n            span = e - s\n            if span.days <= 1:\n                label = f\"{s.month}/{s.day}\"\n            else:\n                last = e - timedelta(days=1)  # 終日endは翌日0時=排他\n                label = f\"{s.month}/{s.day}-{last.month}/{last.day}\"\n```\n\n**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`\n\nset keyed on `(title, start_time)`\n\ncollapses identical events into one.\n\nThe output is Markdown in the format `• 7/15(火) 10:00-12:00 XX practicum @XX University`\n\n. **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.\"\n\nThe script is designed so that \"zero events or failure → non-zero exit,\" letting the caller determine success or failure.\n\nThis is the bash on the caller side that invokes `gcal-snapshot.py`\n\n(step 2.45 of `vault-auto-ingest.sh`\n\n).\n\n```\n# 2.45 カレンダー実体スナップショット（ground truth）\nCAL_SNAPSHOT=\"$VAULT/wiki/_calendar-snapshot.md\"\nCAL_LASTGOOD=\"$CAL_SNAPSHOT.lastgood\"\nCAL_TMP=\"$(mktemp)\"\nCAL_SRC=\"\"\n\n# (1) 第一候補: Google Calendar API\nrun_to 90 \"$PY3\" \"~/.claude/scripts/gcal-snapshot.py\" > \"$CAL_TMP\" 2>/dev/null \\\n  && /usr/bin/grep -q \"•\" \"$CAL_TMP\" 2>/dev/null && CAL_SRC=\"gcal-api\"\n\n# (2) フォールバック: icalBuddy（ローカル Apple Calendar）\nif [ -z \"$CAL_SRC\" ]; then\n  ICALBUDDY=\"\"\n  for c in /opt/homebrew/bin/icalBuddy /usr/local/bin/icalBuddy; do\n    [ -x \"$c\" ] && ICALBUDDY=\"$c\" && break\n  done\n  if [ -n \"$ICALBUDDY\" ]; then\n    {\n      echo \"# カレンダー実体スナップショット（今後21日・自動生成）\"\n      echo \"_generated $(date '+%F %T') by vault-auto-ingest.sh / icalBuddy(fallback)_\"\n      run_to 60 \"$ICALBUDDY\" -n -nc -iep \"title,datetime,location\" \\\n        -po \"datetime,title\" eventsToday+21\n    } > \"$CAL_TMP\" 2>/dev/null\n    /usr/bin/grep -q \"•\" \"$CAL_TMP\" 2>/dev/null && CAL_SRC=\"icalbuddy\"\n  fi\nfi\n\nif [ -n \"$CAL_SRC\" ]; then\n  cp \"$CAL_TMP\" \"$CAL_SNAPSHOT\"\n  cp \"$CAL_SNAPSHOT\" \"$CAL_LASTGOOD\"           # ← 成功したら last-good を更新\nelif [ -s \"$CAL_LASTGOOD\" ]; then\n  # 取得失敗: 前回goodを温存し stale 印で上書き\n  {\n    echo \"# カレンダー実体スナップショット（今後21日・自動生成）\"\n    echo \"_generated $(date '+%F %T') by vault-auto-ingest.sh_\"\n    echo \"⚠️ 本日カレンダー取得失敗。以下は前回取得成功時点の値(stale)。\"\n    tail -n +4 \"$CAL_LASTGOOD\"\n  } > \"$CAL_SNAPSHOT\"\nelse\n  { echo \"# カレンダー実体スナップショット（今後21日・自動生成）\"\n    echo \"(取得失敗・前回値なし)\"; } > \"$CAL_SNAPSHOT\"\nfi\n```\n\n**Design intent of the 3-tier fallback:**\n\n| Priority | Source | Condition |\n|---|---|---|\n| ① | Google Calendar API (gcal-snapshot.py / ADC) | Always attempted |\n| ② | icalBuddy (local Apple Calendar) | On API failure. Permission exists only in interactive runs |\n| ③ | last-known-good (previous success preserved with a stale marker) | When both fail |\n\nThe 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`\n\nas \"this is old information.\"\n\nAfter fetching the calendar snapshot, I assemble the following string as `CAL_SNAPSHOT_NOTE`\n\n.\n\n```\nCAL_SNAPSHOT_NOTE=\"予定・締切は ${CAL_SNAPSHOT}（カレンダー実体・時刻付き）を唯一の正典スケジュールとして読み、ノートの散文より優先しろ。冒頭に⚠️staleとあれば取得日時点の値である点を断れ。\"\n```\n\nThis gets embedded directly into the `claude -p`\n\nprompt for daily brief generation (excerpt).\n\n```\ncd \"$VAULT\" && run_to 900 \"$CLAUDE\" -p \\\n\"...【厳守】ノートに無い情報を推測で足すな。特に予定の拘束時間・所要日数・\n『日中が消える』等の時間コストは、実際の予定記述が無い限り書くな。\nノート中の『M/D〜M/D』は開始と終了の点であって全期間拘束ではない——\n拘束日数や所要時間を捏造するな。日付・時間に関する主張は確定情報と推測を\n明示的に分けろ。${CAL_SNAPSHOT_NOTE}\n出力は $VAULT/wiki/today-brief.md に毎回上書き...\" \\\n  --dangerously-skip-permissions >> \"$LOG\" 2>&1\n```\n\nThe 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.\n\nExplicitly 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.\n\n`com.shun.vault-auto-ingest.plist`\n\nfires at four time slots.\n\n```\n<key>StartCalendarInterval</key>\n<array>\n  <dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>55</integer></dict>\n  <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer></dict>\n  <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>45</integer></dict>\n  <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>15</integer></dict>\n</array>\n```\n\nIf 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`\n\nand is written to \"exit immediately (exit 0) if today's run already succeeded,\" even if all four slots fire, it actually runs only once.\n\n\"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.\n\n**I first tried an OAuth Desktop App, but it wouldn't run from launchd** → the paths to `credentials.json`\n\n+ `token.json`\n\ndon't resolve in launchd's minimal environment. Switching to gcloud ADC (`gcloud auth application-default login`\n\n) solved it immediately. Note that ADC's `calendar.readonly`\n\nscope must be specified explicitly with the `--scopes`\n\nflag.\n\n**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\n\n`grep -q \"•\"`\n\nbefore adopting it is essential. Overwriting the snapshot with an empty file makes the calendar look like \"no appointments.\"**An all-day event's end.date is exclusive (midnight of the next day)** → this is a Google Calendar API spec. A one-day event is\n\n`start.date: 2026-07-15`\n\n, `end.date: 2026-07-16`\n\n. For the display label, apply `end - timedelta(days=1)`\n\nto 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 `seen`\n\nset keyed on `(title, start_time)`\n\n, the same class was output as 2–3 lines.\n\n**ADC tokens may need refreshing every 90 days** → `gcloud auth application-default login`\n\nrequires re-authentication after a long period. If the script emits `AUTH_ERROR`\n\nto stderr, that's the sign to re-authenticate. Thanks to last-good preservation the service doesn't stop, but if `⚠️ stale`\n\nkeeps appearing in `_calendar-snapshot.md`\n\n, be suspicious.\n\n`gcal-snapshot.py`\n\nis headless and launchd-ready via `google.auth.default()`\n\nNext 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**.\n\n**Lily** ([@bokuwalily](https://x.com/bokuwalily)) — indie developer. I build automation infrastructure with Claude Code while mass-producing iOS apps and web services.\n\nYour ❤️ and shares keep me going!\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-the", "canonical_source": "https://dev.to/bokuwalily/the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-vault-with-the-google-39kg", "published_at": "2026-07-10 17:28:08+00:00", "updated_at": "2026-07-10 17:44:41.953111+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools"], "entities": ["Claude", "Google Calendar API", "Obsidian", "gcloud ADC"], "alternates": {"html": "https://wpnews.pro/news/the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-the", "markdown": "https://wpnews.pro/news/the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-the.md", "text": "https://wpnews.pro/news/the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-the.txt", "jsonld": "https://wpnews.pro/news/the-day-claude-stopped-inventing-my-schedule-injecting-ground-truth-into-the-the.jsonld"}}