recap-to-notion
Take a session-recap directory and sync it into the Notion database
My Agent Notes / Session Recaps as a single page (one DB row per session). Idempotent
upserts keyed by Session ID. Failures are retried on the next invocation.
This skill assumes:
notion-cliskill (>= 1.1.0) is installed andNOTION_TOKENis configured.- A page titled exactly
My Agent Notesexists in the Notion workspace and is shared with the integration. - The session-recap directory layout is the contract owned by
session-recap.
Cleanup of confirmed-old local recap dirs is not this skill's job — it belongs to
session-recap (the manifest owner). This skill only appends manifest entries on
successful uploads.
Calling notion-cli
notion-cli is not a shell binary — it is a Claude Code skill whose implementation
lives in ~/.notion-cli/lib/notion.ts (a deno script created by the skill's setup.ts).
All invocations in this workflow run that script with scoped deno permissions.
Define the prefix once at the start of the run:
NOTION_PERMS="--allow-env=NOTION_TOKEN,NOTION_API_KEY,NOTION_TOKEN_FILE \
--allow-net=api.notion.com \
--allow-read=$HOME/.notion-cli --allow-write=$HOME/.notion-cli"
# If the user's token source is NOTION_TOKEN_FILE (agenix / sops-nix / 1Password
# CLI mounts), append exactly that one path to the read scope:
[ -n "$NOTION_TOKEN_FILE" ] && NOTION_PERMS="$NOTION_PERMS --allow-read=$NOTION_TOKEN_FILE"
NOTION="deno run $NOTION_PERMS $HOME/.notion-cli/lib/notion.ts"
All later $NOTION <subcommand> [args] lines in this SKILL.md expand to that full
deno invocation. Never assume notion-cli is on $PATH — it is not. If
~/.notion-cli/lib/notion.ts is missing (workspace not yet bootstrapped), tell the
user to run notion-cli's setup.ts once and exit cleanly without modifying any local
state.
Workflow
Feedback Check
If feedback/log.md exists alongside this SKILL.md and has 5 or more entries, read the
last 10. If a pattern is apparent (the same issue in 3+ entries, or average rating
below 3):
- Tell the user (UI language): 「過去のフィードバックで類似パターンを検出: [簡潔に]。
/my-skill-factory improve recap-to-notionで改善できます。」 / English equivalent. - Continue with normal execution.
Phase 0 — Mode dispatch
Determine the mode from the invocation:
- upload mode: an absolute path to a recap directory was passed as
args(this is what session-recap's chain dispatch sends). Run A → B0 → B for that one dir. - retry-only mode: no dir argument, or the user said "recap retry" / "再試行" / similar. Run A → B0.
(Cleanup-only mode lives in session-recap, not here. If the user asks for cleanup
in this skill's invocation, redirect them to /session-recap with the relevant phrase.)
Phase A — Notion target resolution
Read ~/.claude/session-recaps/.notion-config.json:
{
"database_id": "<id>",
"parent_page_title": "My Agent Notes",
"first_configured": "<ISO-8601 with time>"
}
If the file is missing or database_id is empty:
- Locate the parent page:
Pick the result with$NOTION search "My Agent Notes" --filter pages --limit 5 --format jsonobject == "page"and atitleexactly matchingMy Agent Notes. Extract itsid.- If 0 matches → tell the user (UI language): 「Notion 上に
My Agent Notesページを作成し、Internal Integration と共有してください」 / English equivalent. Stop. Do not write.notion-config.json. Local files stay intact for next retry.
- If 0 matches → tell the user (UI language): 「Notion 上に
- Locate or create the database:
Find a$NOTION search "Session Recaps" --filter databases --limit 5 --format jsondatabasewhose parent is the page from step 1 and whose title is exactlySession Recaps.- If found → save its
idto.notion-config.json. - If not found → call:
Save the returnedcat <<'JSON' | $NOTION db create \ --parent-page <parent-page-id> \ --name "Session Recaps" \ --description "Auto-generated recaps from Claude Code sessions" \ --schema-stdin --format json { "properties": { "Title": { "title": {} }, "Date": { "date": {} }, "Session ID": { "rich_text": {} }, "Slug": { "rich_text": {} }, "CWD": { "rich_text": {} }, "Duration": { "rich_text": {} }, "Total Events": { "number": { "format": "number" } }, "Language": { "select": { "options": [{ "name": "ja" }, { "name": "en" }] } } } } JSONidto.notion-config.json. No user confirmation prompt. - If
db createis rejected as an unknown subcommand (older notion-cli that lacks it) → tell the user (UI language): 「Notion 上でMy Agent Notes配下にSession Recapsデータベースを手動作成してください (スキーマは README 参照)。 notion-cli を 1.1.0 以上に上げると自動化されます」 / English equivalent. Stop.
- If found → save its
If .notion-config.json had a value but Phase B/B0 later returns an
object_not_found error from notion-cli, clear the database_id and re-run Phase A
once before failing.
Phase B0 — Retry pending uploads (notion target only)
Goal: catch up any local recap dir that does NOT yet have a target == "notion"
manifest entry.
RECAPS=~/.claude/session-recaps
MANIFEST=$RECAPS/.manifest.jsonl
for dir in "$RECAPS"/[0-9]*_*; do
[ -d "$dir" ] || continue
# Already uploaded to notion?
if [ -f "$MANIFEST" ] && grep -q "\"target\": \"notion\".*\"local_dir\": \"$dir\"" "$MANIFEST"; then
continue
fi
# Otherwise → run Phase B for this dir.
done
For each pending dir, call Phase B. Successes append manifest entries; failures are left for the next invocation (no special handling here).
Other future uploaders (e.g. recap-to-confluence) are expected to filter the manifest
by their own target value the same way. Each uploader is responsible for its own
target's retry queue — independent of others.
Phase B — Upsert one recap dir to Notion
Input: absolute path to a recap dir containing summary.md, note.md, meta.json.
Load metadata from
meta.json:session_id,short_id,slug,cwd,language,started_at,ended_at,duration_seconds,total_events.
Build the Notion page body by merging summary + note:
- Read
summary.mdandnote.md. - In the merged content, rewrite cross-file links: every occurrence of
(./note.md#<anchor>)becomes(#<anchor>)(same-page anchor inside Notion). - Concatenate with a divider:
For English-language recaps the detailed-note heading is just<summary contents> --- # 詳細ノート (Detailed Notes) <note contents># Detailed Notes.
- Read
Convert markdown to Notion blocks: pass the merged markdown through whatever markdown-to-Notion conversion notion-cli accepts via
blocks append. Block JSON should preserve heading levels, lists, code blocks, and inline links (anchor#xlinks → Notion block links to the matching heading).Compute properties (Notion DB row schema):
{ "Title": { "title": [{ "type": "text", "text": { "content": "<YYYY-MM-DD> <slug> <short_id>" } }] }, "Date": { "date": { "start": "<started_at>" } }, "Session ID": { "rich_text": [{ "type": "text", "text": { "content": "<session_id>" } }] }, "Slug": { "rich_text": [{ "type": "text", "text": { "content": "<slug>" } }] }, "CWD": { "rich_text": [{ "type": "text", "text": { "content": "<cwd>" } }] }, "Duration": { "rich_text": [{ "type": "text", "text": { "content": "<formatted from duration_seconds, e.g. 1h 23m>" } }] }, "Total Events": { "number": <total_events> }, "Language": { "select": { "name": "<language>" } } }Look up an existing page keyed by
Session ID:$NOTION db query <database-id> --format json \ --filter '{"property":"Session ID","rich_text":{"equals":"<session_id>"}}'- If
results[]non-empty → takeresults[0].idas the existing page id; status ="updated". - Else → status =
"created".
- If
Apply the upsert:
- created:
$NOTION page create --parent-db <database-id> --title "<title>" --stdinwith stdin JSON{ "properties": { ... }, "children": [<merged blocks>] }. - updated:
$NOTION blocks list <existing-page-id> --format json→ collect every top-level block id.- For each block id,
$NOTION blocks delete <block-id> --yes(Notion's delete is reversible from trash; we do this to clear stale content before re-appending). $NOTION blocks append <existing-page-id>with the merged blocks JSON.$NOTION page update <existing-page-id>with stdin{ "properties": { ... } }to refresh metadata.
- created:
Append a manifest entry on success:
{"target": "notion", "session_id": "<session_id>", "local_dir": "<absolute path>", "target_id": "<page-id>", "target_url": "<page-url>", "uploaded_at": "<now ISO-8601>", "status": "created"|"updated"}Append to
~/.claude/session-recaps/.manifest.jsonl(create file if missing).Report one line in UI language:
[recap-to-notion] uploaded <slug or short_id> → <page-url> (created|updated)
Failure handling
- notion-cli error / network / 401 / 403 / 429 / oversized block / etc.: keep local files intact, do not append to manifest, surface a one-line UI-language error including the cause. The next invocation's Phase B0 will retry. Do not retry inside the same invocation.
object_not_foundfrom notion-cli during Phase B/B0: clear.notion-config.json.database_idand re-run Phase A once. If Phase A succeeds, retry the failed step. If the second attempt also fails, surface the error and stop — manual intervention required..notion-config.jsonparse error: rename to.notion-config.json.bak.<unix-ts>and re-run Phase A.~/.notion-cli/lib/notion.tsmissing (notion-cli workspace not bootstrapped): surface a one-line UI-language message instructing the user to run notion-cli'ssetup.ts(see notion-cli SKILL.md "First-time setup"). Exit without modifying any local state. The user runs setup, then re-invokes recap-to-notion.db createnot available (older notion-cli that pre-dates the subcommand): handled in Phase A (instruct manual DB creation, exit cleanly).- No Notion token resolved (none of
NOTION_TOKEN/NOTION_API_KEY/NOTION_TOKEN_FILEset): notion-cli emits amissing_tokenJSON error. Surface it and exit cleanly without modifying state. User exports a token and re-invokes.
Retrospective
After Phase B (or Phase B0 with no pending) returns:
Consider: were there mid-session corrections? Did Phase A loop multiple times? Did any pending upload fail repeatedly? Were any properties mismapped (e.g. Notion schema drift)?
Ask the user (UI language): 「今回のNotion同期のフィードバック (1-5の評価、気になった点、または何もなければEnter)」 / English equivalent.
If feedback OR corrections occurred: a. Create
feedback/next to this SKILL.md if missing. b. Read or createfeedback/log.mdwith the standard header. c. Prepend a new entry:## <ISO-8601 timestamp> - **Skill Version**: <version from this file's frontmatter> - **Task**: <upload mode | retry mode, dir or "—"> - **Outcome**: success | partial-success | failure | error - **Rating**: <N>/5 (or "—") - **Corrections**: <session corrections, or "none"> - **Issues**: <issues, or "none"> - **User Note**: <verbatim, or "—"> ---Skip recording if the user passes AND no corrections/issues occurred.
Behavior Scenarios
Scenario: Auto-invoked from session-recap chain after generation
Given session-recap has just written summary.md, note.md, meta.json to a recap dir
When session-recap dispatches the chain with that dir as args
Then this skill runs Phase A (resolve target) → B0 (retry pending) → B (upsert
the new dir) and appends a manifest entry on success.
Scenario: Standalone — pass a recap directory path
Given the user invokes /recap-to-notion with an absolute recap dir path
When the skill starts
Then Phase A → B0 → B run for that dir.
Scenario: Standalone — retry-only mode (no dir argument)
Given the user invokes /recap-to-notion with no arguments or says "recap retry"
When the skill starts
Then Phase A → B0 run, attempting to upload any dir without a notion manifest entry.
Scenario: First-run — auto-create Session Recaps DB under My Agent Notes
Given .notion-config.json is missing
And My Agent Notes page exists and is shared with the integration
And Session Recaps DB does not exist yet
When Phase A runs
Then notion-cli db create produces the DB with the documented schema and saves
the new DB id to .notion-config.json — no AskUserQuestion prompt.
Scenario: First-run — discover existing DB and adopt it
Given .notion-config.json is missing
And Session Recaps DB already exists under My Agent Notes
When Phase A runs
Then the existing DB id is saved to .notion-config.json without modification.
Scenario: First-run — My Agent Notes page is missing → notify user, skip sync
Given My Agent Notes page does not exist or is not shared with the integration
When Phase A runs
Then the skill emits a UI-language instruction to create / share the page and exits
without writing .notion-config.json. Local recap files are untouched.
Scenario: notion-cli has no `db create` → notify user, skip sync
Given notion-cli is installed but the version lacks `db create`
And Session Recaps DB does not exist
When Phase A reaches the create step
Then the skill instructs the user to either upgrade notion-cli or create the DB
manually with the documented schema, and exits cleanly.
Scenario: Stale DB ID → object_not_found triggers automatic Phase A re-resolution
Given .notion-config.json points to a database that has been deleted or moved
When Phase B encounters object_not_found
Then the skill clears database_id and re-runs Phase A once before retrying.
Scenario: Idempotent re-upload → updates existing page, no duplicates
Given a recap dir whose Session ID already has a row in the DB
When Phase B runs
Then the existing page is updated (blocks cleared and re-appended, properties
refreshed) and the manifest entry has status: "updated".
Scenario: Notion upsert failure → keep local, manifest not updated
Given Phase B fails (network, 4xx, 5xx, oversized block, etc.)
When the skill handles the error
Then local recap files are kept intact, .manifest.jsonl is not appended to, and
a one-line UI-language error is surfaced.
Scenario: Next invocation Phase B0 picks up previously failed uploads
Given a previous invocation left a recap dir without a notion manifest entry
When this skill runs Phase B0
Then the missing dir is re-attempted and, on success, the manifest entry is appended.
Scenario: Oversized block in markdown → upsert fails with clear error, no manifest entry
Given a code block in note.md exceeds the Notion 2000-char-per-block limit
When notion-cli blocks append rejects the request
Then the skill surfaces the cause in UI language and does not append a manifest
entry. The user is advised to shorten the markdown and re-invoke.
Scenario: notion-cli workspace not bootstrapped → skip sync, leave local intact
Given ~/.notion-cli/lib/notion.ts does not exist (setup.ts has never been run)
When the skill starts
Then it emits a UI-language one-line message pointing the user at notion-cli's
setup.ts and exits without modifying any local state.
Scenario: No Notion token resolved → skip sync, leave local intact
Given none of NOTION_TOKEN / NOTION_API_KEY / NOTION_TOKEN_FILE is set
When notion-cli returns a missing_token JSON error from the first call
Then the skill surfaces the error in UI language and exits without modifying state.
Notes and constraints
- Fixed target: the database title (
Session Recaps) and parent page title (My Agent Notes) are not configurable. Renaming or relocating them in Notion triggers Phase A re-resolution; if either is missing, sync is skipped (local files untouched). - Manifest schema is owned by session-recap. This skill only appends. Format:
{"target": "notion", "session_id": "<uuid>", "local_dir": "<absolute path>", "target_id": "<page id>", "target_url": "<url>", "uploaded_at": "<ISO-8601 with time>", "status": "created"|"updated"} - No retention / cleanup here. That responsibility lives in session-recap. This
skill never deletes anything from
~/.claude/session-recaps/. - No silent overwrite of Notion DB: notion-cli's
db createalready refuses to recreate a same-titled DB under the same parent; this skill never bypasses that. - Page size: Notion practical page limits are very high; the only realistic failure mode is a single block exceeding the per-block character cap (~2000 chars). Such a failure is surfaced verbatim and the user shortens the markdown manually. v1.0.0 does not auto-chunk.
- Dependency:
notion-cli >= 1.1.0, with the workspace bootstrapped at~/.notion-cli/(run notion-cli'ssetup.tsonce). All subcommands are invoked viadeno runagainst~/.notion-cli/lib/notion.ts, never as anotion-clishell binary (which does not exist). - Read access to recap dir: this skill reads
summary.md,note.md,meta.json. It never modifies them.