| name | notion-cli |
| description | Token-efficient Notion access from the terminal using notion_cli.py (session-token v3 API — acts as the user, like slack-user-cli). Use for reading Notion pages, querying databases with filters and column selection, searching the workspace, listing discussions incl. RESOLVED ones, and writing — creating pages, updating properties, appending markdown, search-and-replace edits, comments. Prefer it over the Notion MCP for READS (compact markdown, signed URLs stripped, --select projection); output is pipeable through jq/python so only the projection you need enters context. Triggers: "read notion page", "query notion database", "notion cli", "update notion row", "create notion page".
|
Notion CLI
Session-token Notion client at ~/.claude/skills/notion-cli/notion_cli.py,
built to minimize the tokens a read costs an agent: compact line-oriented
output, flattened properties, signed-S3 URLs stripped, --select column
projection, and --json for programmatic consumers.
It authenticates as the user with the token_v2 browser cookie (the
slack-user-cli model) against the same private API the Notion web client uses
(notion.so/api/v3), so it sees everything the user sees and needs no
workspace integration or admin approval. That API also exposes resolved
comments, which the official API hides.
Running
uv run ~/.claude/skills/notion-cli/notion_cli.py <command> [options]
Authentication
Preferred — automatic extraction from a local logged-in app (decrypts the
Notion-desktop/Chrome/Arc/Brave cookie store via the macOS keychain; stale
sessions are skipped, the first token that validates wins; may pop one
keychain "Allow" dialog per app):
notion_cli.py login
notion_cli.py login --source arc --space "My Workspace"
Manual fallback — grab token_v2 from a logged-in browser (devtools →
Application → Cookies → https://www.notion.so → token_v2, value starts
with v03:), then:
notion_cli.py auth
notion_cli.py auth --import
notion_cli.py auth --space "My Workspace"
Auth binds a (user, space) pair and stores it chmod-600 in
~/.config/notion-cli/config.json; $NOTION_TOKEN_V2 overrides. The
x-notion-active-user-header matters: without the bound user id the API
returns HTTP 200 with permission-filtered EMPTY results — looks like missing
content, is actually missing auth context. whoami verifies the binding.
Tokens survive ~1 year unless the user logs out; a 401 — token_v2 expired
means re-grab the cookie and re-run auth.
Output conventions
- Default output is compact text, one line per row/property — designed to be
cheap in an agent context. Reference ids/urls are appended tab-separated.
--json = flattened JSON (properties reduced to plain values, body as one
markdown string). Use for piping to jq/python.
--raw = untouched API JSON. Token-expensive; debugging only.
- Big pulls: redirect to a file and filter, don't read the whole thing back
(
… query DB --json > /tmp/rows.json && jq -r '.[].Status' /tmp/rows.json).
Commands
Read
notion_cli.py page <id_or_url>
notion_cli.py page <id_or_url> --props-only
notion_cli.py page <id_or_url> --no-props
notion_cli.py page <id_or_url> --depth 2
notion_cli.py pages <id1> <id2> <id3> ...
notion_cli.py query <db_or_ds> --select ID,Title,Status
notion_cli.py query <db_or_ds> --filter 'Status=Done' --filter 'ID>195'
notion_cli.py query <db_or_ds> --filter 'Title~vault' --sort 'ID:desc' --limit 20
notion_cli.py query <db_or_ds> --filter 'Due is_empty' --json
notion_cli.py query <db_or_ds> --filter 'Status=In progress' --with-body
notion_cli.py query <db_or_ds> --edited-after 2026-07-20 --with-body
notion_cli.py schema <db_or_ds>
notion_cli.py search "vault launch" --limit 10
notion_cli.py comments <page_id>
notion_cli.py comments <page_id> --open-only
notion_cli.py users [query]
notion_cli.py resolve <id> [<id> ...]
notion_cli.py blocks <page_id> --depth 2
Prefer resolve <id> over re-running users "<name>" to look up a
single id — users re-fetches the entire workspace member list every
call, while resolve checks the permanent local cache
(~/.config/notion-cli/cache/id_names.json, no TTL since Notion ids are
immutable) first and only hits the API for ids it hasn't seen before.
page/query/users all populate this cache automatically as a side
effect of normal reads, so by the time you need to resolve an id you've
already encountered (a mentioned user, a linked/breadcrumb page), it's
usually already cached for free. This also means the same page never
needs re-fetching within a run just to check a different string in it —
save it once (notion_cli.py page <id> --no-props > page.md) and grep the
file, don't re-run page for every subsequent check.
Never loop page <id> over a query's rows — use --with-body or pages
instead. A page call per row is the single biggest source of avoidable
tool round-trips in a read-heavy session (measured: 60 separate page
calls in one run, one per tracker row — each one a full extra agent turn
resending the whole growing context, not just an extra API hit). If the ids
come from a query you're running right now, add --with-body to that same
query call — it fetches every matched row's full body in one shot. If the
ids come from somewhere else (search, resolve, already known), pass them
all to pages <id1> <id2> ... in one call instead of iterating.
On a repeat pass over the same database (a periodic digest, a daily
sync), add --edited-after <date> to --with-body so bodies are fetched
only for rows that actually changed since the last pass, not every row every
time (verified on a real 181-row table: --edited-after narrowed it to 18).
It filters on the row's own last_edited_time, which Notion propagates up
from any edited descendant block — verified by recursively walking a page's
real children and confirming the row's own timestamp matched its
most-recently-edited descendant exactly — so it reliably catches
body-content edits, not just title/property changes.
Write
notion_cli.py create --parent <db_or_ds> \
--prop 'Title=RFQ swap beta' --prop 'Status=Triage' \
--prop 'Owner=user://<user-uuid>…' --prop 'Parent item=https://notion.so/<id>' \
--prop 'Due=2026-07-31' --icon 💸 --md body.md
notion_cli.py templates <db_or_ds>
notion_cli.py create --parent <db_or_ds> --prop 'Title=…' --template 'AI new item'
notion_cli.py update <page> --prop 'Status=Done' --prop 'Due='
notion_cli.py update <page> --archive
notion_cli.py append <page> "one liner"
notion_cli.py append <page> --md notes.md
cat notes.md | notion_cli.py append <page> --md -
notion_cli.py edit <page> "1,296,000" "1,335,000"
notion_cli.py comment <page> "done — see @page(<id>)"
notion_cli.py delete-block <block_id>
Notion-writes etiquette (project rules)
- Reference Notion pages inside bodies with
@page(<id>) (renders as a real
mention card) and people with @user(<uuid>) — never plain-text names or
bare notion.so links.
- New tracker-style rows: TL;DR callout (
> [!💸:blue_bg] …) + ## Why +
## What todos + ## Sources.
- Rewrite, don't stack: prefer
edit (search-replace) over append when
updating existing content.
Gotchas
- Unofficial API (
api/v3, the web client's own): endpoint names drift —
writes go through saveTransactionsFanout today (submitTransaction is
gone). If a write starts 404-ing with an HTML body, probe the sibling
endpoint names before assuming breakage.
- Filters/sorts run client-side over flattened values:
= exact, ~
case-insensitive contains, comparisons numeric when both sides parse as
numbers (ISO dates compare correctly as strings). Multiple --filter AND.
query needs a view on the database (any view); schema prints the
collection + view ids it resolved.
- Deleted pages still resolve (trash):
page marks them deleted: true.
create --template clones a template's body by deep-copying its blocks in
the same transaction (templates are just pages with is_template: true);
it does NOT call the API's async template instantiation, so there is no
placeholder-stacking race. Auto-increment IDs are still assigned lazily by
the server.
- Formula/rollup values are computed server-side and not stored on the row
record — they flatten to None.
page --depth default is 6; deeper nesting truncates with an explicit
[…children truncated] marker rather than silently.
- The client honors
Retry-After and backs off on 429/5xx.
Tests
uv run --with pytest --with click --with requests -- pytest tests/ -q