capture — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited capture (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Status. The author has migrated orchestration to an hourly Claude Code cloud routine that polls Telegram and writes to Notion directly — see orchestration/README.md (Pattern A). This skill documents the local / GitHub-Actions-era pattern (Pattern B), which remains fully usable for setups without cloud routines.
This skill resolves placeholders against ~/.claude/state/personal_config.json. See _config/README.md and _config/personal_config.example.json for setup. If the config is missing or a needed field is unset, the skill must surface an error to the user and refuse to proceed rather than guess.
Required config fields:
personal_config.notion.tasks_data_source_idpersonal_config.notion.tasks_parent_page_id (parent page that holds the Tasks DB plus the Brief YYYY-MM-DD child pages)personal_config.paths.claude_state_dirTelegram credentials live in <claude_state_dir>/telegram.json (gitignored), not in personal_config.json. The bot itself is provisioned per docs/telegram-setup.md; bot tokens never go in the repo.
The user sends short replies and ad-hoc captures to their Telegram bot all day. This skill polls those messages, classifies each one, and writes the result into the unified Notion Tasks DB. It is the inbound half of the task orchestration system (the outbound half is /daily-brief).
The system is designed so the user never has to open Notion or even Telegram to file a task — they tap a one-line reply on their phone and Claude does the bookkeeping.
{{personal_config.notion.tasks_data_source_id}}collection://{{personal_config.notion.tasks_data_source_id}}{{personal_config.notion.tasks_parent_page_id}}<claude_state_dir>/telegram.json — parse as JSON. Fields: bot_token, chat_id.https://api.telegram.org/bot<bot_token>/<claude_state_dir>/telegram_offset.json — shape {"last_update_id": <int>}. Create with last_update_id = 0 if missing.Brief YYYY-MM-DD (today's date) as a child of the Tasks parent page. Written by the GitHub Actions cron each morning. Use mcp__notion__notion-search with query="Brief <today>" scoped to the Tasks parent, or list child pages. The brief contains a JSON code block at the bottom with the shape below.<claude_state_dir>/today_brief.json — written only when /daily-brief is invoked manually within Claude Code (not by the cron).{"date": "YYYY-MM-DD", "available_hours": <num>, "tasks": [{"n": 1, "page_id": "...", "title": "...", "priority": "P0", "est_min": ..., "due": ..., "project": ..., "type": ...}, ...]}| Property | Type | Notes |
|---|---|---|
| Task | Title | Required for new rows |
| Status | Status | To-do, In progress, Waiting, Done, Dropped |
| Priority | Select | P0, P1, P2, P3 |
| Due | Date | ISO YYYY-MM-DD |
| Est min | Number | integer minutes |
| Type | Select | Research, Teaching, Admin, Personal, Health, Travel, Social |
| Project | Relation | optional |
| Context | Multi-select | computer, phone-OK, on-the-go, home, deep-focus, shallow |
| Energy | Select | Deep, Shallow |
| Source | Select | set to Telegram for everything this skill creates |
| Notes | Rich text | use for actual-minutes logs and parse residue |
| Created | Created time | auto |
Run these steps in order every time the skill triggers.
$tg = Get-Content "<claude_state_dir>\telegram.json" | ConvertFrom-Json
$offsetPath = "<claude_state_dir>\telegram_offset.json"
$offset = if (Test-Path $offsetPath) { (Get-Content $offsetPath | ConvertFrom-Json).last_update_id } else { 0 }If telegram_offset.json exists but fails to parse, treat last_update_id = 0 and overwrite it after this run. Note the corruption in the final report so the user can investigate if they want.
Use Invoke-RestMethod to GET https://api.telegram.org/bot<token>/getUpdates?offset=<last_update_id + 1>&timeout=0. timeout=0 is short-poll, which is the right choice here — /capture is invoked on demand, not running forever.
$url = "https://api.telegram.org/bot$($tg.bot_token)/getUpdates?offset=$($offset + 1)&timeout=0"
$resp = Invoke-RestMethod -Uri $url -Method GetOnly process messages where message.chat.id == $tg.chat_id — defense in depth in case anyone else ever DMs the bot.
If resp.result is empty, send no Notion writes and reply nothing on Telegram. Tell the user "No new messages." and exit. This is the happy path most of the time.
For each update in resp.result, take update.message.text, trim, lowercase a copy for matching, and route on first match:
^\s*(\d+)\s+done\s*$ — mark task N done.^\s*(\d+)\s+done\s+(\d+)\s*$ — mark done + log actual minutes.^\s*(\d+)\s+push\s+(.+?)\s*$ — bump Due to parsed day.^\s*(\d+)\s+drop\s*$ — set Status=Dropped.^\s*add:\s*(.+)$ — explicit add line.^\s*morning\s+(\d+(?:\.\d+)?)\s*$ — capacity override; call /daily-brief with --hours <N>.^\s*\?\s*$ — reply with today's brief from the Notion Brief YYYY-MM-DD page (or local file fallback). No Notion writes.Case-insensitive matching throughout.
<N> to a Notion pageFor done/push/drop, look up the brief in this order:
Brief <today's date in ISO> under the Tasks page parent.language: json containing the brief JSON.n == <N>.<claude_state_dir>/today_brief.json — only if the Notion brief doesn't exist for today (e.g., the cron didn't run, or /daily-brief was invoked manually instead).If neither source has a matching entry, reply on Telegram with "Task N not in today's brief — run /daily-brief first or use 'add:'." and skip the Notion write for that message. Don't advance offset for that message so it can be re-processed after running the brief.
When the brief is from Notion: use the page_id field from the parsed JSON exactly as written — it's the task row's Notion ID, not the brief-page ID.
Use the official Notion MCP tools. Property names must match the schema above exactly (case-sensitive). Status names are also case-sensitive.
Mark done (mcp__notion__notion-update-page):
page_id: from today_briefStatus = "Done"Mark done with actual minutes:
Notes (rich text): "actual: <N>min (logged <today>)". If Notes already has content, append on a new line; do not overwrite.Push:
Due = parsed date. Day-name parsing (today's date as anchor):tomorrow → today + 1today → todaymon/monday, tue/tuesday, wed/wednesday, thu/thursday, fri/friday, sat/saturday, sun/sunday → next occurrence (strictly future; if today is Wed and the user says "wed", that means next Wed)in N days → today + NYYYY-MM-DD → as-isDrop: Status = "Dropped".
Add (mcp__notion__notion-create-pages):
parent: {"data_source_id": "{{personal_config.notion.tasks_data_source_id}}"}Task (title): the cleaned task textStatus: To-doPriority: parsed (default P2)Est min: parsed (default 30)Type: parsed or inferred (default Admin if nothing else fits)Source: TelegramDue: parsed if a day was specified; otherwise omit (no default)add: and natural-language addsFor add: <body>, extract these from <body>:
\bP[0-3]\b (uppercase or lowercase). Default P2.(\d+)\s*(min|m|h|hr|hours)\b. Convert hours to minutes. Default 30.#(\w+). Map to Type select: research→Research, personal→Personal, admin→Admin, teaching→Teaching, health→Health, travel→Travel, social→Social. Unknown tag → put the raw tag in Notes, infer Type from keywords.by <day>, due <day>, tomorrow, today, ISO date. Use the same day parser as push.For natural-language adds (anything that didn't match a prior pattern), do the same extraction over the whole message text. If the message starts with "remind me to ", "remember to ", "todo: ", "need to ", strip that prefix before using the remainder as the title. If a sensible title cannot be extracted (e.g., a single emoji), reply asking the user to rephrase rather than guessing.
Type inference fallback when no #tag and Type isn't obvious from keywords:
paper, draft, revision, R&R, referee, JMP, analysis, regression, project names from personal_config.projects[].name → Researchemail, expense, form, reimburs, tax, onboarding, office → Admingym, dentist, doctor, appt, appointment, meds → Healthdinner, restaurant, flight, hotel, trip, reservation → Travel (lean) or Personalmom, dad, birthday → Social or PersonalAdminThese heuristics will be wrong sometimes — that's fine. The user can fix Type in Notion or by replying again. Better to file imperfectly than to interrogate them.
After processing all updates, send a single concise reply via POST https://api.telegram.org/bot<token>/sendMessage with chat_id and text. Use one short line per message processed. Examples:
OK 1 done (Submit R&R revision)OK 2 -> Thu May 14 (Email coauthor)OK 3 droppedAdded: Book restaurants for trip — P2, 15min, Personal, due WedCouldn't parse day 'next-ish' — try mon/tue/.../tomorrow/in 3 days.Batch all confirmations into one Telegram message (one per line) to avoid rate limits when the user has sent a flurry of replies.
Set last_update_id to the max update.update_id across processed updates and write back to telegram_offset.json. Do this even if some Notion writes failed — otherwise a single bad message will block the whole queue. For failures, include them in the Telegram confirmation so the user can retry manually.
Use atomic write (tmp + rename) — cloud sync can read a half-written JSON if Set-Content is interrupted, which corrupts the offset and replays processed messages on the next poll.
$path = "<claude_state_dir>\telegram_offset.json"
$tmp = "$path.tmp"
@{ last_update_id = $maxUpdateId } | ConvertTo-Json | Set-Content -Encoding utf8 $tmp
Move-Item -Force $tmp $pathApply the same tmp + Move-Item -Force pattern to any other local JSON state file the skill writes. Telegram and Notion API calls are atomic by remote contract — only local state files need the wrapper.
Example 1 — done with time: Input message: 1 done 145 Action: Look up task 1 in today_brief, update page → Status=Done, append actual: 145min (logged 2026-05-12) to Notes. Telegram reply: OK 1 done in 145min (Submit R&R revision)
Example 2 — natural-language add: Input message: remind me to email a coauthor about figures by Wednesday Action: Create page → Task="Email coauthor about figures", Priority=P2, Est min=30, Type=Admin, Source=Telegram, Due=next Wed. Telegram reply: Added: Email coauthor about figures — P2, 30min, Admin, due Wed May 13
Example 3 — explicit add: Input message: add: prep slides for seminar P1 90min #research Action: Create page → Task="prep slides for seminar", Priority=P1, Est min=90, Type=Research, Source=Telegram. Telegram reply: Added: prep slides for seminar — P1, 90min, Research
Example 4 — capacity override: Input message: morning 4 Action: Invoke /daily-brief --hours 4. Don't send a separate confirmation — the new brief is the confirmation.
"Notion down — retry with /capture later". Advance offset for messages that succeeded.retry_after is usually under 60s. Wait and retry once; if it fails again, leave the offset where it is and report the rate limit in the final summary. Don't spam retries.Brief YYYY-MM-DD page nor local today_brief.json) when <N> action arrives: Telegram-reply with "No brief loaded — run /daily-brief first." and skip those messages. Don't advance offset for them.last_update_id = 0, log the corruption in the final report, continue. Yes, this re-processes some messages — that's safer than dropping them. Notion writes are idempotent enough (done→done, drop→drop) that re-processing rarely causes harm; for add lines this could create duplicates, but the user can dedupe in Notion.<claude_state_dir>/telegram.json. Don't try to reconstruct."Didn't parse: '<text>'. Try '<N> done', 'add: ... P2 30min #type', etc." rather than silently dropping./loop or /schedule for that./notion-log's job.<N> via today_brief.json./daily-brief.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.