cd /news/large-language-models/the-day-claude-stopped-inventing-my-… · home topics large-language-models article
[ARTICLE · art-54575] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=↑ positive

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.

read8 min views1 publishedJul 10, 2026

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 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 Markdownvault-auto-ingest.sh

— save to _calendar-snapshot.md

with last-known-good preservation logicclaude -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.

#!/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

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

).

CAL_SNAPSHOT="$VAULT/wiki/_calendar-snapshot.md"
CAL_LASTGOOD="$CAL_SNAPSHOT.lastgood"
CAL_TMP="$(mktemp)"
CAL_SRC=""

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"

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
  {
    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.

<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.

I first tried an OAuth Desktop App, but it wouldn't run from launchd → the paths to credentials.json

  • token.json

don't resolve in launchd's minimal environment. Switching to gcloud ADC (gcloud auth application-default login

) solved it immediately. Note that ADC's calendar.readonly

scope must be specified explicitly with the --scopes

flag.

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."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

start.date: 2026-07-15

, end.date: 2026-07-16

. For the display label, apply end - 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 seen

set keyed on (title, start_time)

, the same class was output as 2–3 lines.

ADC tokens may need refreshing every 90 daysgcloud auth application-default login

requires re-authentication after a long period. If the script emits AUTH_ERROR

to stderr, that's the sign to re-authenticate. Thanks to last-good preservation the service doesn't stop, but if ⚠️ stale

keeps appearing in _calendar-snapshot.md

, be suspicious.

gcal-snapshot.py

is headless and launchd-ready via google.auth.default()

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.

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*

── more in #large-language-models 4 stories · sorted by recency
── more on @claude 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-day-claude-stopp…] indexed:0 read:8min 2026-07-10 ·