Sort inbox
An AI second brain with an action layer — Notion + Claude.
npx -y skills add Kisslotina/Steward --skill sort-inboxAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Classifies and files raw Inbox records into the EXISTING typed Notion bases, applies completion notes that close existing items, and enqueues outbound work to the Outbox. Use after capturing notes, or whenever the user asks to sort/process the Inbox. Invoked by roll-day after its sweep phase, or run standalone over the Inbox. Reads Inbox rows with Status=New, decides new-vs-completion, routes to a real base, sets Status=Sorted and Target, then moves filed rows to Inbox Archive.
SKILL.md
14.3 KB, as published. Nobody here has run it
Sort Inbox
Processes every Inbox record with Status=New. Invoked by roll-day after its sweep phase,
or run standalone over the Inbox.
Built to run fast on a small model. Classification is a table lookup (routing.md), not
free reasoning; schemas are read once and reused; the live Inbox is kept small so reads stay
cheap. Follow the steps in order.
Notion free-plan constraints (why this skill is shaped this way)
Two facts about this Notion plan dictate the whole design:
- No server-side property filter.
query_data_sources(SQL) needs Enterprise;query_database_viewneeds Business. Never call them — they error. Read vianotion-searchonly. notion-searchreturns id, title, and timestamp only — at most 25 results, no pagination. The Inbox title field ISNote, so the full capture text needed to classify is already in the search result; no per-rownotion-fetchto read or confirm it.
Because of the 25-row cap, one read can't see a backlog larger than 25. So the skill drains in batches: read ≤25 → classify & file → move that batch out to Inbox Archive → re-read the now-smaller live Inbox → repeat. Each filed batch leaves the live Inbox, so the next read surfaces the next ≤25. The loop and its exit condition are defined once, in Step 5.
Step 0 — load the registry, rules, and schemas (no live Notion list query)
- Read the local registry
bases.local.json(base name → data-source ID), written bybootstrap-notion. This is the source of truth for which bases exist and their IDs. If it is missing, stop and tell the user to runbootstrap-notionfirst (a single discovery query only as a last resort, then offer to save the registry). - Read
.claude/rules/routing.md(the deterministic routing table — apply it first, before any reasoning), then.claude/rules/taxonomy.md(types + routing semantics) andconventions.md(names). - Route only to bases present in the registry. Never invent, rename, or abbreviate a base
(no "Reflections DB", "Shopping DB", "Content Ideas"). No hardcoded IDs anywhere — resolve
every downstream ID from the registry:
Inbox,Inbox Archive, and the targetsTasks,Goals,Ideas,Knowledge,Reviews,Not Recognized,Outbox. A type whose destination base is absent → that item goes to Not Recognized (reason "no destination base: <type>"). - Load the write-contract from
schema.cache.json— do NOT fetch base schemas. This cache (written bybootstrap-notion; shape perschema.cache.example.json) holds, per destination base:title(title-field name),dateFields, andselects(allowed select values), plus the top-levelareasmap (Area name → full Notion URL). Read it once and reuse it for every write this run — nevernotion-fetcha base schema or the Areas DB to rediscover field names, date formats, select values, or Area URLs. This is what prevents failed-write retry loops.- The Areas map is permanent (Areas DB is canonical) — reuse the URLs indefinitely.
- Reviews is a plain create-row. A
reviewnote is filed as ONE new Reviews row (Note= the text,Type= the area,Date= today) — no period row, no search, no read-modify-write. Use the cachedReviews.selects.Typevalues forType. - If the cache is missing or a write later disagrees with Notion → self-heal once, per
references/recovery.md§1.
Step 1 — load the work list: every Inbox row with Status=New
The live Inbox holds only un-filed New rows, because filed rows are moved to Inbox Archive
in Step 4. That invariant is what makes this read both cheap and complete.
- Take the hint list, if given. When
roll-dayinvokes this skill it passes thepage_ids it just swept. Seed the work list from those — they need no discovery. - Read one batch (≤25) of the live Inbox.
notion-searchwithdata_source_url: "collection://<Inbox id>",query: "Inbox"(a broad anchor, not a content word),page_size: 25,max_highlight_length: 0. Union the returnedpage_ids with the hint list. There is no cursor — do not try to page past 25; the batch loop (Step 5) reaches the rest. Remember whether this read returned a full 25 — Step 5 uses it. - Classify straight from the search results — do NOT
notion-fetcheach page. The title returned is theNote(everything routing.md needs), and the live-Inbox invariant means every surfaced row is an unfiledNewcapture. Removing the confirmation fetch eliminates the single largest cost per run. Two special cases:- Note blocks (title starts with "🗒 ") → one task, never re-split.
roll-daysweeps a multi-line Daily-notes block as ONE Inbox row whoseNotebegins with "🗒 " (a handle); the full text is in the row body. File it via Step 3's note-block short-circuit as a single item — do not run the per-line routing table over its contents. To carry the full notes into the task,notion-fetchthis one row to read its body (a scoped, allowed exception to the no-fetch rule). The full text is also preserved verbatim when the row is archived (Step 4). - Detect expenses by cue, not by a pre-set
Type. routing.md row 8 (spent/paid/$/ потратил/оплатил/купил за/руб/грн/…) identifies expense captures from theNotetext. Collect thesepage_ids for Step 4, which moves them out of the live Inbox (with a pending-Finance marker) exactly like filed rows — otherwise they resurface in every search, eat slots in the ≤25 cap, and spin the loop. Count them in the report. - Leftover guard: only if
inbox.lastArchiveFailed = true— seereferences/recovery.md§2. Normal case: trust the invariant, do not fetch.
- Note blocks (title starts with "🗒 ") → one task, never re-split.
- Sanity check. If Step 1.2 returned zero rows for an Inbox that should not be empty, report a read failure — never silently treat the Inbox as empty.
Step 2 — completion vs new
A note matching routing.md row 1 (cues: "closed", "finished", "done", "completed", "сделал", "закрыл", "завершил", "выполнил", "готово") marks an EXISTING item done — it is NOT a new record.
Do NOT search the typed bases to auto-close it — that would cost an extra search + fetch per note and risks closing the wrong item on a weak match. Route every completion note to Not Recognized (reason: "completion note — close the matching item manually") and let the user close the real item by hand.
Step 3 — classify & FILE new items (actually write the row)
Read references/notion-call-forms.md once before writing — it holds the exact, verbatim
call shapes (notion-create-pages, dates, Area URL, selects, Reviews create-row).
Note-block short-circuit — do this BEFORE the routing table. If the row's Note starts
with "🗒 " it is a swept multi-line note block. File it as ONE task and skip the routing
table entirely. Strip the "🗒 " marker, then notion-create-pages ONE row in Tasks:
- title = the block's first non-empty line (marker stripped) — a concise handle;
content(task body) = the full block text from the Inbox row body;date:Do date:start= tomorrow (today + 1);Tag = Triage;Priority= the highest-ranked value in the cached TasksPriorityselects(omit if Tasks has noPriorityfield);Type=Workif the text has a clear work cue, elsePersonal. Verify the row, set the Inbox rowType=task,Status=Sorted,Target="Tasks/<handle>", and archive it (Step 4). For every other row (no "🗒 " prefix) continue below.
- Assign a
Typewithrouting.mdfirst — apply the cue table top-to-bottom, first match wins. Only if a note matches no row, fall back totaxonomy.mdjudgement; still unclear →unsure. Distinguishgoal(a committed, measurable outcome with a horizon) fromidea(an unstarted seed); a startup/business/product concept is anidea, never agoal. - Map to a base in the registry. If none → Not Recognized. For
idea→ Ideas: also setIdeas.Type—Content(post/blog),Startup(business/product), orOther. TheDrafted/Postedstatuses apply mainly toType=Content; leave blank otherwise. - Create the actual page/row in that base's data source, with
parent: { type: "data_source_id", data_source_id: "<id-from-registry>" }. Use property names from the cached schema (Step 0). Per-field rules — all REQUIRED, never blank:Type= Work / Personal on everytaskandreminderfiled to Tasks (work cue → Work, else Personal). The Today board is grouped byType; a blank-Typetask lands in an invisible "(No Type)" group — a filing failure, not a cosmetic gap. Must be a cachedselectsvalue; never invent a third.Do dateon everytask/reminder: parse an explicit date/time if present; otherwiseDo date = today+Tag = Triage. Never blank.Areaon bases that have it (Goals, Ideas, Knowledge, Projects): set from the cachedareasmap and the Area cues inrouting.md(full Notion URL, match on name ignoring emoji). No clear match → Other, never empty.review: create ONE new Reviews row —Note(title) = the text,Type= the area from the Review-type cues inrouting.md(cachedReviews.selects.Type; no match →Other),date:Date:start= today. A plainnotion-create-pages, no read-modify-write.
- Verify the new row exists (it returns a URL/ID), THEN update the source Inbox row: set
Typeto what you assigned in 3.1,Status=Sorted,Target="<RealBaseName>/<title>". WritingTypeback is REQUIRED — aSortedrow with blankTypemeans the sort never recorded its decision.Targetis an audit label only, NOT a substitute for creating the row. Never setStatus=Sortedfor an item whose destination row was not created and verified. If the write failed, leave the rowNewand report the error. - External action → enqueue an Outbox row (Handler per taxonomy) instead of acting directly.
On a transient server error during a write, see references/recovery.md §4 (retry once).
Step 4 — archive filed rows (keep the live Inbox small)
Move every successfully filed/closed row out of the live Inbox so the next read stays cheap.
- Collect the
page_ids to move out this run: a. every row setStatus=Sortedby Steps 2–3; b. the expense rows from Step 1.3 — first set eachStatus=SortedandTarget="expense → pending Finance"(Type=expensepreserved), so a future Finance consumer can find them. If the registry later gains a dedicated holding base, move them there instead — destination is registry-driven; c. any already-Sortedleftovers surfaced by the recovery reconciliation — archive them now, never re-file. notion-move-pagesthem all in one batched call (≤100 ids) into Inbox Archive (same schema as Inbox, soNote/Status/Type/Targetare preserved). Record the move outcome for the next run's leftover guard — seereferences/recovery.md§3.- This is a move, not a delete — the rows live on in Inbox Archive as the permanent intake record. Never delete an Inbox row.
Step 5 — re-read loop (drain until empty)
The 25-row / no-pagination cap means one pass never guarantees the whole backlog was seen. So the run is a loop:
loop:
read a batch (Step 1.2) → classify & file (Steps 2–3) → archive the batch (Step 4)
re-read the live Inbox (Step 1.2)
if the fresh read finds zero Status=New rows → exit
else → repeat
- After every Step 4, unconditionally re-read (Step 1.2) before declaring done. Do not branch on "was the batch full?" — just re-read. A batch of exactly 25 means the cap was hit and there is almost certainly more behind it; a batch under 25 is also not, by itself, a stop signal.
- The only exit is a fresh read with zero
Status=Newrows. (Expense rows are moved out in Step 4, so an all-expense remainder also reaches zero.) - Emit no report between passes — intermediate or partial summaries are forbidden. The run produces exactly one report, after the loop fully drains.
- Safeguard: cap at 10 passes (≈250 rows). If the live
Newcount is not strictly shrinking pass over pass, stop and report a likely failed archive move — never spin.
Rules
- Resolve every base ID from
bases.local.json. No invented names, no hardcoded IDs. - Classify with
routing.mdfirst; only deliberate on notes that match no rule. - Read field names, formats, select values, and Area URLs from
schema.cache.json— nevernotion-fetcha schema or the Areas DB to rediscover them. Self-heal once if stale (references/recovery.md§1). - Never call
query_data_sourcesorquery_database_view(paid). Read vianotion-searchand classify from the returnedNotetitle — no per-page confirmation fetch. - When marking an Inbox row
Sorted, always write its decidedTypeback — never blank. - Every Tasks row gets a
Type(Work/Personal); everytask/remindergets aDo date. - On any base with an Area relation, always set
Area; fall back to Other. - Don't split partially recognized: file whole OR Not Recognized with a reason.
- reference ≠ action. Doubt → Not Recognized.
- Never delete records — only change Status and move filed rows to Inbox Archive.
Output (run report)
Per item: input → Type → REAL base written to (with confirmation) OR Not Recognized (reason). Totals: filed, closed, enqueued to Outbox, Not Recognized, moved to Inbox Archive. Flag any failed write or failed archive move, and report how many passes ran and the final live-Inbox size.