AIエージェントに立てさせたリマインドは、セッションと一緒に消える — .ics を書き出してカレンダーへ渡す

Claude Code に「来週これを確認して」と頼むと、リマインドを立ててくれる。便利だと思って3件ほど登録してもらったあと、返ってきた注意書きで気づいた。

このリマインドはこのセッション限りで、ディスクに保存されません。Claude を終了すると消えます。

そのとおりだった。エージェントのセッション内スケジューラは、プロセスが生きている間だけのものだ。数日後・1か月後を見に行くための予定を、そこに置いてはいけない。

かといって手で4件をカレンダーに打ち込むのも面倒だし、「その日に何を見て、どう判断するか」という中身が抜け落ちるのが嫌だった。そこを含めて残せる方法を用意した。

注意: 以下は macOS での手順。カレンダー連携の外部サービスやAPIキーは使わず、標準ライブラリだけで完結させている。

まず、使える道具が無かった

素直に考えれば Google Calendar API を叩くか、CLI を入れるか、MCP コネクタを繋ぐかだ。手元を調べたらどれも無かった。

gcalcli      未インストール
khal         未インストール
icalBuddy    未インストール

MCP のコネクタも Google カレンダーは繋いでいない。認証情報を新しく発行して管理するほどの用事でもない。4件の予定を入れたいだけだ。

そこで、認証もインストールも要らない2つの経路に絞った。

  1. .ics ファイルを書き出してインポートする(まとめて入れるとき)
  2. Google カレンダーの登録画面URLを組み立てる(1件ずつ入れるとき)

どちらも仕様が公開されていて、こちらから何も預けなくていい。

.ics を自分で書くときの落とし穴3つ

iCalendar(RFC 5545)は素直なテキスト形式だが、日本語を入れると途端に3か所で壊れる

① 1行75オクテット。しかも文字の途中で切ってはいけない

RFC 5545 は「1行75オクテット以内、超えるなら折り返して継続行の先頭に空白1つ」と決めている。文字数ではなくオクテット数なので、日本語は1文字3バイトとして数える。

ここで素直に「73バイトで切る」と書くと、UTF-8 の3バイト文字を真ん中で割ってしまう。壊れたバイト列ができあがり、カレンダーアプリが読めないか文字化けする。

継続バイトは 10xxxxxx なので、切る位置がそれなら1バイトずつ戻せばいい。

def fold(line):
    b, out = line.encode("utf-8"), []
    while len(b) > 73:
        cut = 73
        while cut > 0 and (b[cut] & 0xC0) == 0x80:   # 継続バイトなら戻す
            cut -= 1
        out.append(b[:cut].decode("utf-8"))
        b = b" " + b[cut:]
    out.append(b.decode("utf-8"))
    return out

② 改行は CRLF

LF だけでも読めるアプリはあるが、仕様は CRLF だ。Python で書き出すときは newline="" を指定しないと、環境によって勝手に変換される。

io.open(path, "w", encoding="utf-8", newline="").write(text)

③ エスケープは順序が大事

DESCRIPTION に入れるテキストは、バックスラッシュ・セミコロン・カンマ・改行をエスケープする。バックスラッシュを最初にやらないと、あとで足したエスケープ用のバックスラッシュを二重に変換してしまう。

def esc(s):
    return (s.replace("\\", "\\\\").replace(";", "\\;")
             .replace(",", "\\,").replace("\n", "\\n"))

カンマ, セミコロン; バックスラッシュ\ の混じった文 が、ファイルの中では次のように格納されていれば正しい。

カンマ\, セミコロン\; バックスラッシュ\\ の混じった文

自分の検証スクリプトに引っかかった

書き出したあと確認したら、こう出た。

75オクテット超の行: 1
CRLF 改行: NG

焦って直そうとしたが、壊れていたのは検証の方だった。

s = open("out.ics", encoding="utf-8").read()      # ← テキストモード

Python のテキストモードはユニバーサル改行で、読み込むときに \r\n\n に変換する。だから「CRLF が無い」と出る。さらに \n で分割すると各行の末尾に \r が残り、1バイト分だけ長く見えて「75オクテット超」になる。

バイナリで読み直したら、どちらも問題なかった。

b = open("out.ics", "rb").read()
print("CRLF:", b.count(b"\n") == b.count(b"\r\n"))
print("75超:", sum(1 for l in b.split(b"\r\n") if len(l) > 75))

改行やエンコーディングを検証するときに、変換をかける読み方をしてはいけない。当たり前の話だが、自分の検査結果を疑うより先に生成物を疑ってしまった。

もう一つの経路:登録画面のURLを組み立てる

Google カレンダーには、パラメータを付けて開くと登録画面が内容入りで開くURLがある。

https://calendar.google.com/calendar/render
  ?action=TEMPLATE
  &text=件名
  &dates=20260903T000700Z/20260903T003700Z
  &details=説明
  &ctz=Asia/Tokyo

日時は UTC で YYYYMMDDTHHMMSSZ、開始と終了をスラッシュで繋ぐ。ctz を付けておくと表示のタイムゾーンが揃う。

ただしこの方式には実用上の弱点がある。説明文を日本語で長く書くとURLが2,000文字を超える。実際、私の4件は1本あたり2,000〜2,500文字になった。チャットに貼っても読めないし、クリックもしづらい。

そこで、リンクを並べた小さな HTML を書き出してブラウザで開く形にした。

二重管理を避ける

ここで一つ気をつけたことがある。HTML の中身を別に持たない.ics を読み戻してリンクを組み立てる。

raw = open("reminders.ics", encoding="utf-8").read()
raw = re.sub(r"\r\n ", "", raw)        # 折り返しを元に戻す

同じ予定を2か所に書くと、片方だけ直して食い違う。一方を正として、もう一方はそこから生成する。折り返しを戻す処理が必要なぶん少し手間だが、食い違いが起きない方が大事だ。

カレンダーをエージェントの外部記憶にする

技術的には以上だが、実際に効いたのは中身の方だった。

件名だけ入れても、当日「何を見ればいいんだっけ」と思い出せない。だから説明欄に、その日の作業手順と判断基準まで書いた。

確認すること
1. 日次バッチのログで、処理した件数と落とした件数(理由つき)
2. 落ちた分が一時的な失敗か、条件の設定ミスかを切り分ける

判断基準
- 落ちた件数がゼロの日が3日続いた → 監視を緩めてよい
- 同じ理由で2回以上落ちている → リトライで誤魔化さず設定を疑う

★前回、処理件数は想定どおりなのに対象の選び方が間違っていた。
  数字が正常に見えても、母数の取り方を疑うこと。

こう書いておくと、別のセッションのエージェントに「今日のカレンダーの内容をやって」と言うだけで作業が再開できる。セッションのリマインドが消えても、判断の文脈はカレンダーに残る。

エージェントの記憶は揮発する。揮発しない場所に、判断に必要な文脈ごと置いておく。今回いちばんの収穫はこれだった。

動作環境

項目バージョン
macOS26.6.2 / Apple M4 / arm64
Python3.9.6(Command Line Tools 同梱)
依存なし(標準ライブラリのみ。requests も icalendar も使わない)

icalendar などのライブラリを使えば折り返しもエスケープも任せられる。ただしこの程度の用途で依存を増やしたくなかったのと、仕様のどこが効いているかを自分で把握しておきたかったので手で書いた。

Linux / Windows でも Python さえあれば動く。タイムゾーンはスクリプト冒頭の TZ_OFFSET_HOURSTZID で変える。

スクリプト全文

events.json を用意して渡すと、.ics.html を書き出す。

[
 {"start": "2026-09-03 09:07", "minutes": 30,
  "title": "日次バッチの結果を確認",
  "description": "判断基準\n- 落ちた件数がゼロの日が3日続いた → 監視を緩めてよい\n- 同じ理由で2回以上落ちている → 設定を疑う"}
]
python3 make-reminders events.json reminders
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""予定を書いた JSON から、Google カレンダーに渡せる .ics と、
クリックして1件ずつ登録できる .html を書き出す。

なぜ必要か: AI エージェントのセッション内リマインドはセッションと一緒に消える。
「その日に何を見て、どう判断するか」までカレンダーの説明欄に書いておけば、
別のセッションからでもそこを読んで作業を再開できる。

使い方:
  make-reminders events.json [出力の接頭辞=reminders]

events.json の形(datetime はローカル時刻):
  [{"start": "2026-08-31 10:23", "minutes": 30,
    "title": "...", "description": "複数行でよい"}]
"""
import sys, json, html, datetime, urllib.parse, io

TZ_OFFSET_HOURS = 9          # Asia/Tokyo。他の地域なら変える
TZID = "Asia/Tokyo"
WEEK = "月火水木金土日"


def to_utc(s: str) -> datetime.datetime:
    d = datetime.datetime.strptime(s.strip(), "%Y-%m-%d %H:%M")
    return d - datetime.timedelta(hours=TZ_OFFSET_HOURS)


def stamp(d: datetime.datetime) -> str:
    return d.strftime("%Y%m%dT%H%M%SZ")


def esc(s: str) -> str:
    """RFC 5545 のテキスト値エスケープ。順序が大事(\\ を最初に)。"""
    return (s.replace("\\", "\\\\").replace(";", "\;")
             .replace(",", "\\,").replace("\n", "\\n"))


def fold(line: str) -> list:
    """1行75オクテット以内に折り返す。継続行は先頭に空白1つ。
    ★マルチバイト文字の途中で切らないこと。UTF-8 の継続バイト(10xxxxxx)を避ける。"""
    b, out = line.encode("utf-8"), []
    while len(b) > 73:                       # 73 + 継続用の空白1 + 余裕
        cut = 73
        while cut > 0 and (b[cut] & 0xC0) == 0x80:
            cut -= 1
        out.append(b[:cut].decode("utf-8"))
        b = b" " + b[cut:]
    out.append(b.decode("utf-8"))
    return out


def build_ics(events: list) -> str:
    L = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//make-reminders//JA",
         "CALSCALE:GREGORIAN", "METHOD:PUBLISH"]
    for i, e in enumerate(events, 1):
        s = to_utc(e["start"])
        t = s + datetime.timedelta(minutes=int(e.get("minutes", 30)))
        L += ["BEGIN:VEVENT", f"UID:make-reminders-{i}-{stamp(s)}@localhost",
              f"DTSTAMP:{stamp(s)}", f"DTSTART:{stamp(s)}", f"DTEND:{stamp(t)}",
              f"SUMMARY:{esc(e['title'])}", f"DESCRIPTION:{esc(e.get('description', ''))}",
              "BEGIN:VALARM", "TRIGGER:-PT0M", "ACTION:DISPLAY",
              "DESCRIPTION:Reminder", "END:VALARM", "END:VEVENT"]
    L.append("END:VCALENDAR")
    folded = []
    for line in L:
        folded += fold(line)
    # ★改行は CRLF。LF だけだと読み込めないカレンダーがある。
    return "\r\n".join(folded) + "\r\n"


def gcal_url(e: dict) -> str:
    s = to_utc(e["start"])
    t = s + datetime.timedelta(minutes=int(e.get("minutes", 30)))
    return "https://calendar.google.com/calendar/render?" + urllib.parse.urlencode({
        "action": "TEMPLATE", "text": e["title"],
        "dates": f"{stamp(s)}/{stamp(t)}",
        "details": e.get("description", ""), "ctz": TZID})


def label(e: dict) -> str:
    d = datetime.datetime.strptime(e["start"].strip(), "%Y-%m-%d %H:%M")
    return f"{d.month}/{d.day}({WEEK[d.weekday()]}){d:%H:%M}"


def build_html(events: list) -> str:
    items = "\n".join(f"""  <li>
    <div class="when">{html.escape(label(e))}</div>
    <a class="btn" href="{html.escape(gcal_url(e))}" target="_blank" rel="noopener">{html.escape(e['title'])}</a>
    <details><summary>説明を見る</summary><pre>{html.escape(e.get('description', ''))}</pre></details>
  </li>""" for e in events)
    return f"""<!doctype html>
<html lang="ja"><meta charset="utf-8"><title>カレンダーへ登録</title>
<style>
 body{{font-family:-apple-system,BlinkMacSystemFont,"Hiragino Sans",sans-serif;max-width:760px;margin:40px auto;padding:0 20px;line-height:1.7;color:#1e293b}}
 h1{{font-size:20px}} p.lead{{color:#64748b;font-size:14px}}
 ul{{list-style:none;padding:0}} li{{border:1px solid #e2e8f0;border-radius:12px;padding:16px;margin-bottom:14px}}
 .when{{font-size:13px;color:#64748b;margin-bottom:6px}}
 .btn{{display:inline-block;background:#0284c7;color:#fff;text-decoration:none;padding:9px 16px;border-radius:8px;font-weight:700;font-size:14px}}
 .btn:hover{{background:#0369a1}}
 details{{margin-top:10px}} summary{{cursor:pointer;font-size:13px;color:#0284c7}}
 pre{{white-space:pre-wrap;background:#f8fafc;padding:12px;border-radius:8px;font-size:12.5px;color:#334155;overflow-x:auto}}
</style>
<h1>Google カレンダーへ登録</h1>
<p class="lead">ボタンを押すと登録画面が内容入りで開きます。まとめて入れるなら .ics をインポートしてください。</p>
<ul>
{items}
</ul>
</html>"""


def main():
    if len(sys.argv) < 2:
        print(__doc__.strip(), file=sys.stderr); sys.exit(1)
    events = json.load(io.open(sys.argv[1], encoding="utf-8"))
    prefix = sys.argv[2] if len(sys.argv) > 2 else "reminders"
    io.open(f"{prefix}.ics", "w", encoding="utf-8", newline="").write(build_ics(events))
    io.open(f"{prefix}.html", "w", encoding="utf-8").write(build_html(events))
    print(f"{prefix}.ics / {prefix}.html を書き出しました({len(events)} 件)")
    for e in events:
        print(f"  {label(e)}  {e['title']}")


if __name__ == "__main__":
    main()

限界と注意

登録は手作業が1回だけ残る。 .ics のインポートも、URLの「保存」も、人が押す必要がある。完全自動にするなら Google Calendar API と OAuth が要る。そこまでやる用事かどうかは分けて考えた方がいい。私の場合は4件だったので、ここで止めるのが妥当だった。

インポートは重複を弾かない。 同じファイルを2回インポートすると、UID が同じでもカレンダーによっては2件に増える。試すときは専用のカレンダーを作っておくと安全。

繰り返し予定には対応していない。 RRULE を書けば作れるが、今回は不要だったので入れていない。

タイムゾーンは UTC 固定で書き出している。 VTIMEZONE ブロックを持たせる方が丁寧だが、UTC で書けば解釈のズレは起きないので割り切った。夏時間のある地域で「現地の9時」を厳密に扱いたいなら、そこは作り込む必要がある。

まとめ

  • AIエージェントのセッション内リマインドはセッションと一緒に消える。数日先の予定を置く場所ではない
  • .ics は標準ライブラリだけで書ける。ただし75オクテット折り返し(文字を割らない)・CRLF・エスケープ順序の3つで壊れる
  • 改行やエンコーディングの検証を、変換をかける読み方でやってはいけない。自分の検査結果の方が壊れていた
  • 同じ予定を2か所に持たない。一方を正として、もう一方はそこから生成する
  • いちばん効いたのは説明欄に判断基準まで書いたこと。揮発しない場所に文脈ごと置けば、別のセッションからでも再開できる

コメント

タイトルとURLをコピーしました