
Build Note
Extract action items from Notion with Claude
A scan agent reads Notion pages as numbered blocks and files proposals, not tasks — structured output, a fingerprint that makes dismissal stick, and real costs.
Published August 29, 2026
A scan agent reads each Notion page as numbered blocks and returns structured items — but nothing becomes a task on its own. Every item lands in a proposal inbox next to the quote that evidences it, and a fingerprint of page plus title means a rejected suggestion never comes back.
Why should the agent propose instead of create?
Client notebooks are where the loose ends live: a promise made on a call, a question nobody answered, a decision that was made but never executed. Those are exactly the things that fall through, and exactly the things a model is good at spotting. The temptation is to let it file them as tasks.
That trade is worse than it looks. A task list is only useful if you work from it without checking it, and one seeded by an agent that is right most of the time is a list you audit instead. So the agent writes to its own table — proposals, each with a status of proposed, accepted, or dismissed — and a proposal becomes a task only when I accept it, at which point the row keeps a foreign key to the task it created.
How do you stop the same item coming back?
A scan that re-proposes what you already rejected is worse than no scan at all — the second pass buries the new items in items you have already ruled on. The fix is a fingerprint: a hash of the page id and the normalized title, stored as a unique column on the proposal row.
/** Same title on the same page never re-proposes, whatever was decided. */
function fingerprint(pageNotionId: string, title: string): string {
const normalized = title.toLowerCase().replace(/\W+/g, " ").trim()
return createHash("sha256")
.update(`${pageNotionId}|${normalized}`)
.digest("hex")
.slice(0, 32)
}
// fingerprint is a UNIQUE column on the proposals table, and the row
// survives dismissal — so the constraint, not a status check, is what
// stops a rejected item from coming back.
await db.insert(notionProposals).values({ ...item, fingerprint })
.onConflictDoNothing()The property that matters is where the constraint sits. Dismissing a proposal doesn't delete the row, it sets a status — so the unique index still holds that fingerprint, and the next insert conflicts and is dropped. Nothing checks the status at all. A decision sticks because the row survives it, which means a rescan of every page (there is an --all flag for exactly that) surfaces new items and re-raises nothing.
The cost is a decision you can't revisit: an item dismissed in March can't be proposed again in August even if the page has changed underneath it. Normalizing the title before hashing widens that slightly — punctuation and case differences collapse to the same fingerprint, so a lightly reworded item is also caught. For a notebook this size the failure mode I care about is noise, not a missed re-raise.
What does the prompt have to say?
Each page is flattened to numbered blocks, with indentation preserved and to-dos marked as done or not, and the model returns items keyed back to a block number. Two instructions do most of the work:
Propose an item only when the page records something that
still needs doing or following up: a commitment made to the client, a
request from the client, an unanswered question, a decision awaiting
execution, an unchecked to-do. Skip completed work, checked to-dos, pure
reference material, and vague ideas with no implied action.
For each item: title is a short imperative (max 80 chars); detail is one
sentence of context from the page; quote is the exact text of the single
block that best evidences the item, copied verbatim; blockIndex is that
block's number.
Return an empty items array when nothing on the page is actionable.
Fewer, higher-confidence items beat exhaustive lists.The first is the explicit skip list. Without it a model reads a page of notes as a page of suggestions and returns something for every paragraph, checked to-dos included. The second is the closing line — fewer, higher-confidence items beat exhaustive lists — which sets the precision-recall dial in the one direction that matters when a person reviews the output by hand. An empty result is a valid answer, and the prompt says so.
The verbatim quote and the blockIndex are the review surface. The quote is displayed under the proposal as its evidence, and the block index becomes a deep link — the page URL plus the block id with its dashes stripped — so accepting or dismissing takes a glance, and the source is one click away when it doesn't. A model that has to cite the line it read is much easier to check than one that summarizes.
const response = await anthropic.messages.parse({
model: "claude-haiku-4-5",
max_tokens: 16000,
system: [{ type: "text", text: SYSTEM }],
messages: [{ role: "user", content: prompt }],
output_config: { format: zodOutputFormat(ScanResult) },
})
if (response.stop_reason === "refusal") throw new Error("refused")
const parsed = response.parsed_output
if (!parsed) throw new Error("unparseable output")messages.parse() with output_config.format validates the response against a Zod schema, so there is no JSON parsing and no repair prompt. Both ways it can still fail are checked explicitly: a refusal arrives as a successful HTTP response with a refusal stop reason, and parsed_output can be null. Each throws before anything is written, and because the page's scan timestamp is only set on success, a failed page is simply picked up again next run.
What does a scan actually cost?
Measured across one client notebook — token counts from the API's own counting endpoint, on the exact prompts the scanner builds:
8 pages 6,314 input tokens (largest page: 1,451)
11 proposals 592 output tokens
one full pass $0.0093 @ $1 / $5 per MTok
per page $0.0012Under a cent for the whole notebook. Which is worth being honest about: at eight pages, the model choice is not a cost story. The same pass on a frontier model would be a few cents, and nobody would notice either number. Haiku 4.5 earns its place on shape rather than price — this is extraction with a verbatim-quote constraint, not judgment — and on headroom, because a notebook that grows to several hundred pages and rescans on every edit is where per-page cost starts to be a real number.
The actual cost control isn't the model at all. Each mirrored page stores when it was last scanned next to Notion's own last edited time, and a page is only queued when it is new or has changed since. Steady state is a scan of the two pages someone touched this week.
One thing that did not work, and would have gone unnoticed: caching the system prompt.
// System prompt: 891 characters, ~200 tokens.
system: [{ type: "text", text: SYSTEM,
cache_control: { type: "ephemeral" } }]
// Two identical consecutive requests, Haiku 4.5:
// pass 1: cache_creation_input_tokens: 0, cache_read_input_tokens: 0
// pass 2: cache_creation_input_tokens: 0, cache_read_input_tokens: 0
//
// Haiku 4.5's minimum cacheable prefix is 4,096 tokens. Below that the
// marker is accepted and does nothing. No error, no warning.The minimum cacheable prefix is model-dependent and it is not monotonic across generations — Haiku 4.5 wants 4,096 tokens, more than several larger and newer models. A ~200-token system prompt is nowhere near it, so the marker is accepted and ignored. There is no error to catch; the only way to know is to read cache_creation_input_tokens off the response. The general lesson is the one that keeps showing up in other agent-facing work: verify the optimization fired, rather than trusting that the parameter did something.
What transfers to any extraction agent?
- Give the model its own table. An agent that writes into a surface people already trust spends that trust on your behalf.
- Make the human decision durable in a constraint, not a status check. A unique fingerprint that survives dismissal cannot be forgotten by a later code path.
- Require a verbatim quote. It costs output tokens and buys a review you can do at a glance — and it is the cheapest hallucination check there is.
- Tell the model that returning nothing is a valid answer, and which direction to err in when it is unsure.
- Skip unchanged inputs before optimizing the model. A timestamp comparison saved more than any model choice would have.
- Check the response, not just the exception. A refusal and an unparseable result both arrive as successful HTTP calls.
- Read the usage numbers back. Caching, in particular, fails silently — see the repo conventions for agents for the same instinct applied earlier in the stack.
Questions
Why not have the agent create tasks directly?
Because a wrong task costs more than a missed one. A task list you trust is one you work from; a task list seeded by an agent that is right most of the time becomes a list you audit instead. Proposals keep the agent's output in a separate lane until a person moves it across.
Is Haiku 4.5 good enough for extraction work?
For this shape of task, yes. The model isn't judging or drafting — it's finding sentences that imply an action and copying one of them verbatim. The verbatim-quote requirement also makes errors visible: if the quote doesn't support the title, the proposal is obviously wrong at a glance.
How do you avoid re-scanning pages that haven't changed?
Each mirrored page stores a scannedAt timestamp alongside Notion's last_edited_time. A page is queued only when it has never been scanned or has been edited since its last scan. That skip, not the model choice, is what keeps the cost of a growing notebook flat.
What happens when the model refuses or returns bad output?
Both are checked explicitly. A refusal arrives as HTTP 200 with stop_reason “refusal”, so reading the content without checking would silently produce nothing; parsed_output can also be null. Each throws, the page keeps its old scannedAt, and it is picked up on the next run.
Related notes
- Structure a repo so AI agents need less prompting
- SEO reports from the CLI, sized for AI agents
- A local dev environment an AI agent can verify in
More on ai integration
- Reusable prompts for a known tech stack
- Migrate from .cursorrules to .cursor/rules
- Use a content registry instead of a CMS
- Local LLM or hosted API: how to choose
Related service: AI Integration
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session