README
¶
English · Italiano
notion-track
A small, opinionated Go CLI to keep a Notion task-tracking database in sync from your terminal — and from CI. Free and open-source (MIT).
notion-track knows about one thing: a Notion database where each row is a ticket, identified by a ticket key, with a status and a handful of other properties. Its core operation is an idempotent upsert — given a ticket key, find the row and update it, or create it if it doesn't exist. Run it twice, get one row.
It authenticates with a Notion internal integration token only — no browser OAuth. That token is a bot with its own, narrowly-shared permissions, which is what makes it work identically for workspace members and workspace guests alike, and what keeps it usable behind firewalls that block Notion's hosted MCP endpoint.
Features
- Idempotent upsert (
upsert) — create-or-update a ticket row by ticket key. Two runs, one row. - Update-only write (
set) — fails with a distinct exit code if the ticket doesn't exist yet, instead of silently creating it. - Read (
get,list) — one row or many, optionally filtered by status, assignee or priority, human-readable or--json;get --body/--body-onlyreads the page body back as Markdown too. - Interactive browsing (
notion-trackwith no arguments, at a terminal) — a TUI over the tracked rows: filter by status, change a status inline, open a row in Notion, create one without leaving the view. - Diagnostics (
doctor) — checks the token, data source access, the property mapping (including type drift sinceinit), scans the whole data source for duplicate ticket keys, and warns if a git-tracked file looks like it carries your integration token. - Guided setup (
init) — a barenotion-track initat a terminal opens a wizard that picks the data source and proposes the property mapping for you; the flag form writes the same profile non-interactively, validated against the data source's live schema before anything is saved.init --listdiscovers the data source ids your integration can see. At an interactive terminal it also offers to collect and save the integration token if none is found (see Configuration). - Profiles — several named database configurations in one YAML config file, selectable by flag, environment variable, or a configured default.
- Bulk writes (
apply) — many upserts and sets from one JSON or CSV manifest, applied in order, stopping at the first failure. - Dry run (
--dry-runonupsert/set) — reports whether it would create or update, which row and which columns, and writes nothing. - Non-destructive append (
--append-fileonupsert/set) — adds Markdown to the end of a page body without touching what is already there, as an alternative to--body-file's replace. --jsoneverywhere — every command that produces output (get,list,doctor,upsert,set) can emit machine-readable JSON with a documented, stable shape.- CI-friendly by design — quiet on success, a distinct exit code per failure class (auth, not found, duplicate, usage, generic), no interactive prompts.
- Retries with backoff on Notion's rate limiting (429) and transient 502/503/504/529 responses, honoring
Retry-Afterwhen Notion sends one. - A single static Go binary — no Node runtime, no Python venv.
Requirements
- Go 1.26 or newer — needed to build or install from source. Not needed once a release is published: tagged releases ship prebuilt binaries (see Installation).
- A Notion internal integration token (
ntn_...), created by a Workspace Owner at https://www.notion.so/my-integrations. - A Notion database shared with that integration.
Installation
go install is how the team installs notion-track — and how it updates: rerunning the exact same command always fetches the newest release.
go install github.com/marcoarnulfo/notion-cli/cmd/notion-track@latest
It needs a Go toolchain (see Requirements above) and puts the binary at $(go env GOPATH)/bin/notion-track — make sure that directory is on your PATH. Pin an exact release instead of @latest — e.g. @v0.6.0 — when you need to reproduce something against a known version rather than whatever is newest. Either way, notion-track --version reports the version you actually have installed, which is what makes "what version do you have?" a useful question to ask.
Build from source
git clone https://github.com/marcoarnulfo/notion-cli.git
cd notion-cli
go build -o notion-track ./cmd/notion-track
./notion-track --help
Prebuilt binaries
Every tagged release publishes static binaries for macOS, Linux and Windows (amd64 and arm64) on the releases page, with a checksums.txt to verify them against:
tag=v0.6.0 # pick the release you want
os=linux arch=amd64 # or darwin/arm64
archive="notion-track_${tag#v}_${os}_${arch}.tar.gz"
gh release download "$tag" --repo marcoarnulfo/notion-cli \
--pattern "$archive" --pattern checksums.txt
grep -E " ${archive}\$" checksums.txt > expected.txt # `sha256sum -c --ignore-missing` on the raw file can exit 0 without checking anything if the archive name matches no line — this makes a missing line a hard error instead
sha256sum --check expected.txt # or: shasum -a 256 --check expected.txt
tar -xzf "$archive" notion-track
Windows ships as a .zip of the same name — swap the pattern and unzip it instead.
The binaries carry no cgo, so they run on any image with or without a libc. notion-track --version reports the release tag on a prebuilt binary or through go install, and on a local build either a commit-derived pseudo-version or dev, depending on the checkout it came from.
macOS Gatekeeper: a binary downloaded through a browser carries the com.apple.quarantine attribute and gets blocked on first run ("cannot verify the developer"); downloading it with gh or curl, as above, never sets that attribute, so it just runs.
Note: go install above needs no release at all — it builds straight from source through the Go module proxy — so it always works, regardless of whether this repository has a tagged release yet.
Quick start
- Create the integration. A Workspace Owner goes to https://www.notion.so/my-integrations, creates a new internal integration, and copies its token (
ntn_...). Only a Workspace Owner can do this step. - Share the database with it. Still as a Workspace Owner, open the tracking database in Notion → ••• (top right) → Connections → add the integration. Without this step every request
notion-trackmakes will 404, token or no token. - Give
notion-trackthe token. Either export it yourself:
or skip this step and letexport NOTION_TOKEN=ntn_...init(step 5) ask for it interactively — at a real terminal it prompts without echoing the token, and offers to save it tocredentials.ymlso you don't have to export it again next session. See Configuration for where that file lives and how it differs fromconfig.yml. - Find the data source id. A database can hold more than one data source, so ask the integration what it can see:
notion-track init --list - Configure a profile, mapping notion-track's concepts onto your database's real property names —
initvalidates every property against the live schema before writing anything:notion-track init \ --data-source-id <id> \ --ticket-prop Ticket \ --status-prop Status \ --title-prop Name - Sanity-check the setup:
notion-track doctor - Create or update a row — this is the command you'll actually run day to day:
notion-track upsert --ticket TASK-231 --title "Hardening" --status "In progress" notion-track upsert --ticket TASK-231 --status "Done" # updates the same row, no duplicate - (Optional) Track who owns each row. Map a
selectcolumn with--assignee-prop, and save your own identity alongside it with--me(see Usage below), andmeworks everywhere--assigneeis accepted:notion-track init --data-source-id <id> --ticket-prop Ticket --status-prop Status --title-prop Name \ --assignee-prop Referente --me "Jordan Lee" notion-track set --ticket TASK-231 --status "In progress" --assignee me notion-track list --assignee me --status "To do" notion-track list --unassigned - (Optional) Track how urgent each row is. Map a
selectcolumn with--priority-prop(see Usage below); there's no identity to configure for this one, so it's ready to use as soon as it's mapped:notion-track list --priority ALTA --status "To do" notion-track list --priority ALTA --assignee me notion-track set --ticket TASK-1 --priority alta --assignee sam
Usage
Global flags, available on every command:
| Flag | Meaning |
|---|---|
--profile string |
config profile to use (see Configuration) |
--config string |
path to an explicit config file, instead of the default OS location |
notion-track — the browsing TUI
notion-track
With no arguments at a terminal, notion-track opens an interactive view over the tracked rows: one line each, showing the ticket key, the title, the status and the due date. enter opens a full-screen detail; s moves the selected row to another status, picked from the values the schema actually accepts; f narrows the list to one status; n creates a row without leaving the view; o opens it in Notion, y copies its URL, r reloads, / filters by text, q quits.
It is a view over the same internal/service layer every command uses — no separate logic, and nothing it can do that the flags cannot.
Creating a row while a status filter is active gives it that status, so the new row lands in the view you are looking at. A write that fails leaves the list on screen and reports the reason in one line, rather than tearing the UI down: the rows are still readable and still correct.
Without a terminal — piped, redirected, in CI — notion-track with no arguments prints help and exits, exactly as before.
init — configure a profile
Two forms. At a terminal, with nothing else on the command line:
notion-track init
opens a wizard: it picks up your token (asking for it only if there isn't one yet), lists the data sources shared with your integration, and lets you choose one with the arrow keys. It then proposes a property mapping, guessed from your column names and types, for you to confirm or change — each role offering only columns it can actually use, so a mapping that would break on first use cannot be chosen. enter saves; esc or Ctrl-C cancels, writing nothing and exiting non-zero so a script can tell the two apart. --profile and --config work here too: they say where the profile goes, not what is in it.
The wizard needs a terminal and an otherwise-bare command line. Passing any configuring flag, or running without a TTY — CI, a pipe, an agent — takes the explicit form below, unchanged:
notion-track init --data-source-id <id> --ticket-prop <name> --status-prop <name> --title-prop <name> [--due-prop <name>] [--assignee-prop <name>] [--priority-prop <name>] [--id-prop <name>] [--me <value>] [--database-id <id>] [--list]
| Flag | Meaning |
|---|---|
--data-source-id string |
data source id (required, unless --list) |
--ticket-prop string |
property holding the ticket key — must be rich_text or title (required) |
--status-prop string |
property holding the status — must be status or select (required) |
--title-prop string |
title property (required) |
--due-prop string |
date property (optional) |
--assignee-prop string |
select property naming who owns the row (optional) |
--priority-prop string |
select property ranking how urgent the row is (optional) |
--id-prop string |
unique_id property holding the row's board id, e.g. TASK-271 (optional) |
--me string |
the value --assignee me resolves to; resolved and validated against the assignee column's options before being saved (optional — needs --assignee-prop as part of a full init, and nothing else at all when passed on its own, see below) |
--database-id string |
database id, recorded for reference only — every read/write is keyed off --data-source-id, not this |
--list |
list the data source ids shared with the integration, and exit |
Each mapped property is checked against the data source's live schema; init refuses to write a profile that would break on first use (wrong type, or a property that doesn't exist). --ticket-prop, --status-prop, and --title-prop are required in practice — init returns a usage error naming which one is missing — even though --due-prop, --assignee-prop and --priority-prop are optional. The profile is written under the name given by --profile (default "default"); if this is the first profile in the file it also becomes default_profile. Running init again with the same --profile name overwrites that profile without touching the others.
--assignee-prop behaves like --due-prop: a board that tracks nobody in particular simply leaves it unmapped, and every command behaves exactly as it did before this feature. --me resolves its value against --assignee-prop's options the same way --assignee me does, so a typo can't reach the file, and saves the canonical name — to credentials.yml, not config.yml, since the identity is personal and config.yml is meant to be committed and shared (see Environment variables).
Setting only the identity. --me on its own is a command of its own:
notion-track init --me "Jordan Lee"
It configures no profile and writes nothing to config.yml. It reads the profile you are already using, resolves the name against the assignee column that profile maps, and saves the canonical spelling to credentials.yml under that profile's name — printing which profile it was saved for. This is the form every "run notion-track init --me <name>" message in the tool points at: doctor's warning about an identity still living in config.yml, and the error --assignee me gives when nothing says who you are. The profile is the resolved one (--profile → NOTION_TRACK_PROFILE → default_profile), i.e. the same one every other command reads the identity back under — whereas a full init, which creates a profile, files the identity under the profile it just wrote. It's a usage error if no profile is configured yet, or if the one in use maps no assignee column.
--priority-prop behaves like --due-prop too: a board with no notion of urgency simply leaves it unmapped, and every command behaves exactly as it did before this feature. Unlike --assignee-prop, there is no --priority-me equivalent — a priority belongs to no one, so there is no identity to resolve it against.
--id-prop maps Notion's own row identifier — a unique_id column, the kind that renders as TASK-271 on the board — so rows can be addressed by that short id instead of by ticket key or page id (see --id under get and set below). It behaves like --due-prop: a board with no such column simply leaves it unmapped, and --id is then unavailable — the row is still reachable the other two ways. init requires the mapped property to actually be unique_id, the same way --ticket-prop requires rich_text or title.
Token prompt. If no token is found in NOTION_TOKEN or credentials.yml, init behaves differently depending on how it's run:
- At an interactive terminal, it asks for the token (input isn't echoed to the screen) and offers to save it to
credentials.yml— bare Enter accepts the recommended default. Decline and it prints theexport NOTION_TOKEN=...line to run for the current session instead, without ever printing the token itself: a child process has no way to modify its parent shell's environment, so this is the closest it can get to doing that for you. - Non-interactively — CI, a pipe, a script, an agent — it never prompts. Same as every other command: exit code 5 and a message pointing at
NOTION_TOKEN.
upsert — create or update a row by ticket key
notion-track upsert --ticket <key> [--title <title>] [--status <status>] [--due YYYY-MM-DD] [--assignee <value>] [--unassign] [--priority <value>] [--json]
The flagship command. Queries the data source for the row whose ticket property equals --ticket: updates it if found, creates it otherwise. 0 matches → create, 1 match → update, >1 matches → fails with exit code 4 (see Limitations). Silent on success; with --json it prints {"action": "created"|"updated", "page": {...}}.
set — update an existing row only
notion-track set (--ticket <key> | --id <board-id> | --page-id <id>) [--title <title>] [--status <status>] [--due YYYY-MM-DD] [--assignee <value>] [--unassign] [--priority <value>] [--json]
Same fields as upsert, but fails with exit code 3 if the row doesn't exist yet, instead of creating it. Use this where a missing row is a symptom worth surfacing rather than something to paper over.
notion-track set --id TASK-271 --status "Done"
--ticket, --id and --page-id are mutually exclusive and exactly one is required. --page-id addresses a row directly by its Notion page id — no query by ticket key at all — which is faster and unambiguous when you already have it (e.g. from a prior --json call's page_id, see JSON output). It accepts the full page URL you'd copy out of Notion's browser address bar, a bare 32-character hex id, or a dashed UUID; any other input fails immediately with exit code 2, before any request is made. Because GETting a page by id works for anything shared with the integration — not just rows of the configured data source — a page id that resolves to a different data source than the active profile is rejected with exit code 2 rather than left to fail later with a confusing property-name error from Notion. set --page-id also rejects, with the same exit code, a page whose parent carries no data source at all — its membership can never be confirmed, and a write must not proceed on a page that cannot prove it belongs to this profile.
--id addresses a row by its board id — the short identifier Notion shows on the row and the one people read aloud (TASK-271, or the bare number 271 on its own) — resolved with a query against the mapped unique_id column, the same way --ticket resolves against the ticket property; Notion's API filters on unique_id natively, so this needs no client-side scan. It needs a unique_id property mapped first (init --id-prop, see init above); without one mapped, --id fails with exit code 2 — the same class of mistake as running before notion-track init — naming the fix. An empty --id fails the same way, before any request is made. A malformed --id — the wrong prefix, or not a number at all — is exit code 2 too, but not that early: telling a bad prefix from a good one needs the data source's schema first, so a malformed --id costs one request before it fails, short of the row query itself.
--assignee / --unassign — set or clear who owns a row
notion-track set --ticket TASK-231 --assignee "Sam Rivera"
notion-track set --ticket TASK-231 --assignee sam # a partial name is enough when it's unambiguous
notion-track set --ticket TASK-231 --assignee me # resolves to your configured identity — see below
notion-track set --ticket TASK-231 --unassign # clears the column
Available on upsert and set. --assignee resolves what you type against the mapped column's options, trying an exact match, then an exact case-insensitive match, then a case-insensitive substring match, and stopping at whichever pass finds exactly one candidate — so sam reaches Notion as Sam Rivera. Zero matches and more than one are both usage errors (exit code 2): the first names the values the column actually offers, the second names which ones matched and asks for more of the name.
me is a reserved value: before resolution runs, it is replaced by the configured identity — NOTION_TRACK_ME if set, otherwise the value notion-track init --me <name> (or the wizard) saved to credentials.yml, otherwise the profile's legacy me: field (see Environment variables for the full precedence). NOTION_TRACK_ME=jordan therefore overrides whatever is on file, which is what CI and one-off runs want. Using me with no identity configured anywhere is a usage error naming the fix.
Not passing --assignee at all leaves the column untouched — the same "empty means leave it alone" rule every other field follows. --assignee "" is therefore a usage error, not a way to clear the column; use --unassign for that. --assignee and --unassign are mutually exclusive, and a select column holds one value, so --assignee cannot be repeated.
If the role isn't mapped, passing --assignee or --unassign fails the same way any other unmapped role does — exit code 1, not 2, see Exit codes — with a message pointing at init --assignee-prop.
--priority — how urgent a row is
notion-track set --ticket TASK-231 --priority ALTA
notion-track set --ticket TASK-231 --priority alta # a partial value is enough when it's unambiguous
notion-track list --priority ALTA
Available on upsert and set to write it, and on list to filter by it. --priority resolves what you type against the mapped column's options the same way --assignee does: an exact match, then an exact case-insensitive match, then a case-insensitive substring match, stopping at whichever pass finds exactly one candidate — so alta reaches Notion as ALTA. Zero matches and more than one are both usage errors (exit code 2), naming the values the column actually offers or which ones matched, exactly like --assignee.
Not passing --priority at all leaves the column untouched — the same "empty means leave it alone" rule every other field follows.
If the role isn't mapped, passing --priority fails the same way any other unmapped role does — exit code 1, not 2, see Exit codes — with a message pointing at init --priority-prop.
What it does not have, unlike --assignee: there is no --unpriority flag — nothing in this tool can clear a priority once set; that has to be done in Notion. There is no list --unprioritized to find rows with none, the way --unassigned does for the assignee. And there is no me-like reserved value: a priority belongs to no one, so there is no identity to resolve.
--body-file — write the page body from Markdown
notion-track upsert --ticket <key> --body-file notes.md
notion-track set --page-id <id> --body-file -
Available on both upsert and set. --body-file takes a path to a Markdown file, or - to read from stdin; its content becomes the row's Notion page body, converted to native blocks. Properties (--title, --status, --due) and the body are independent — pass both, either, or neither.
Replace semantics. --body-file owns the page body: every run makes the body equal to the file's content, deleting whatever blocks were already there — including anything added by hand in Notion since the last run. Running it twice on the same file yields the same body, not a duplicate. There is no undo, so treat the file as the single source of truth for that page — but there is a way to see what a run would replace before running it: get --body (see Reading the page body below) prints the current body without opening Notion. If replacing everything is more than you want, --append-file (see below) adds to the body instead of replacing it. Sub-pages and child databases sitting at the top level of the body are never touched — they're skipped rather than archived, and a warning on stderr names each one that was kept. That skip only reaches the body's direct children: a sub-page nested inside a toggle, a column or a callout goes when the block containing it goes.
Supported Markdown. Headings (#/##/###, deeper levels flatten to h3), paragraphs, bulleted and numbered lists, task checkboxes (- [ ] / - [x]), fenced and indented code blocks, blockquotes, --- dividers, and inline bold, italic, code, strikethrough, and links. List and quote nesting is supported to 2 levels. Tables, images, raw HTML, and nesting past 2 levels aren't dropped — each degrades to the closest supported block (a table becomes a plain-text code block, an image becomes a link, deeper nesting is promoted up a level) and prints a warning to stderr naming what happened, so nothing silently disappears but nothing blocks the write either. A file over 1 MiB is rejected before any request is made (exit code 2).
Cost. There's no bulk-delete endpoint in Notion's API, so replacing a body is O(n) in the number of blocks already on the page: append the new content, then delete the old blocks one by one. A page with a lot of existing content takes correspondingly longer, and notion-track prints progress lines to stderr (blocks appended, blocks deleted so far) so a long run doesn't look hung.
Placeholders (--expand). With --expand, {{ticket}} and {{date}} in the body file are substituted before the file is parsed — {{date}} being today, as YYYY-MM-DD. Whitespace inside the braces is fine ({{ ticket }}).
notion-track upsert --ticket TASK-231 --body-file release-notes.md --expand
A placeholder nothing can fill in is a usage error naming the line, rather than a body reaching Notion with a literal {{tikcet}} in it that nobody notices until they read the page. Expansion is off by default and there is no escape syntax: a body that legitimately contains braces — a document about templating, a snippet of Handlebars — simply does not pass the flag. Addressing a row with --page-id or --id leaves {{ticket}} empty, since no ticket key was given.
Concurrency. Two --body-file runs against the same page racing each other can both append before either deletes, leaving the body duplicated — there's no lock to take on a Notion page. Don't run concurrent body writes against one page.
With --json, a successful write adds a body object: {"blocks_written": N, "blocks_deleted": N}. If the properties write succeeds but the body replace fails partway, the command still exits 1 (not 0), and --json prints body: {"written": false, "error": "...", "blocks_written": N, "blocks_deleted": N} — written tells you the body is not in the state the file describes, while page in the same output still reflects whatever properties were applied, since those are two separate Notion API calls and the first can succeed even if the second doesn't.
--append-file — add to the page body without replacing it
notion-track upsert --ticket <key> --append-file notes.md
notion-track set --page-id <id> --append-file -
Available on both upsert and set, --append-file takes a path to a Markdown file, or - for stdin, same as --body-file. --expand works on it exactly as it does on --body-file.
The size limit is measured on the request, not on the file. --body-file is parsed into blocks and sent as several batched requests, so its 1 MiB cap is a total. An append is sent verbatim as a single insert_content payload, and Notion caps one payload at 500 KB — so what counts is the size of the serialized request, which is always larger than the file it came from: Markdown is escaped into a JSON string, so every newline costs two bytes, and --expand may have grown the text before that. A 450 KB changelog of short lines can serialize past 500 KB.
notion-track therefore builds the payload and measures it, after expansion, before making any request. Over the limit is exit code 2 with the request size named; an append can't be split, so send it in two runs or use --body-file.
Append semantics, not replace. --append-file adds the file's content to the end of the page body and deletes nothing — the opposite of --body-file's "make the body equal to this file" rule. That makes it the right choice for a status update, a changelog entry or a comment that should accumulate rather than overwrite whatever a person added by hand in Notion. --body-file and --append-file are mutually exclusive: a single run picks one page-body strategy or the other. An empty file is a usage error (exit code 2), the same as an empty --body-file — not a silent no-op, so a broken build step that produces an empty file fails loudly instead of doing nothing and reporting success.
Not idempotent. Unlike --body-file, running --append-file twice with the same content appends it twice — there is no "already there" check. Wire it into a retry loop with that in mind.
Ambiguous failures do not retry themselves. An append makes exactly one API call, so there is no partially-applied state to converge the way a failed replace has. But if that call itself fails ambiguously (a transport error, a 502/504), notion-track cannot tell whether Notion applied it before failing. In that case it exits with the outcome reported as unknown and says so — it does not retry automatically, because retrying an append that did land duplicates the content. Check the page (get --body is one way) before re-running.
With --json, a successful append adds body: {"appended": true} — a different shape from --body-file's blocks_written/blocks_deleted, since an append either landed or it did not; there is no per-block count to report.
On failure --json reports body: {"appended": false}, and on the ambiguous path above it adds "ambiguous": true. Branch on that key rather than on appended alone: appended: false covers both "Notion refused it, nothing changed" and "the outcome is unknown", and those want opposite responses — the first is safe to re-run, the second is the one that duplicates.
get — read one row
notion-track get (--ticket <key> | --id <board-id> | --page-id <id>) [--json]
# by exact title (or ticket key)
notion-track get --ticket "Sistemare visualizzazione da telefono"
# by board id, the one people say out loud
notion-track get --id TASK-271
# by Notion page id or URL, stable across renames
notion-track get --page-id https://notion.so/...
Prints the row's board id (when mapped), ticket, title, status and URL. --ticket, --id and --page-id are mutually exclusive and exactly one is required — see set above for what --id and --page-id accept and how they're validated. Fails with exit code 3 if not found (Notion's 404 doesn't distinguish "no such page" from "never shared with this integration" — the error message says so), 4 if a ticket key matches more than one row (see Limitations), or 2 for a malformed --id or --page-id, an id role that isn't mapped, or a page id outside the active profile's data source. Unlike set, get --page-id accepts a page whose parent carries no data source at all — a read cannot do any harm with an unconfirmed page the way a write could.
Reading the page body
notion-track get --ticket <key> --body
notion-track get --page-id <id> --body-only > notes.md
--body and --body-only add the page body, as Markdown, to what get prints — and they work with all three addressing forms, --ticket, --id or --page-id. --body prints the row as usual, then the body underneath; --body-only prints only the Markdown, nothing else, so notion-track get --ticket TASK-231 --body-only > notes.md produces a valid Markdown file rather than one with a row summary glued to the top of it. --body and --body-only are mutually exclusive.
With --json, --body nests the existing row shape under page and adds the body under body: {"page": {...}, "body": {"markdown": "...", "truncated": false, "unknown_block_ids": []}}. Without --body, the JSON is unchanged from before this feature — flat, no page/body wrapper — so existing scripts parsing get --json keep working untouched. --body-only --json prints the body object alone, unwrapped, matching what --body-only does for the human-readable form: the body and nothing else.
truncated and unknown_block_ids. Notion truncates a page body around 20,000 blocks; truncated: true means the Markdown is real but incomplete, not that something went wrong. unknown_block_ids lists blocks Notion cannot render as Markdown at all — a bookmark, an embed, a link preview, a breadcrumb, a template button — which show up in the Markdown as <unknown/>. Both cases print a warning to stderr (never stdout, so --body-only > notes.md stays clean). Neither means content was lost: an <unknown/> block is still on the page, exactly as it was; only the text export of it doesn't exist.
Reading is for inspection, not a round trip. The Markdown get --body returns is not guaranteed to reproduce the page if fed back through --body-file: URLs for uploaded files and images in the returned Markdown are pre-signed by Notion and expire, and an <unknown/> block has no Markdown form to write back in the first place. Use --body/--body-only to see what's on a page — including, per the note above, to check what a --body-file run would replace before running it — not to download a page, edit the file, and re-upload it as a general workflow.
list — read many rows
notion-track list [--status <status>] [--assignee <value>] [--unassigned] [--priority <value>] [--json]
Lists every row, or narrows it by --status, by --assignee, to --unassigned rows, or by --priority — --assignee and --unassigned are mutually exclusive. An unknown status, assignee or priority value fails fast with exit code 2, naming the values Notion actually allows for that property; --assignee resolves partial names and me exactly as it does on upsert/set, and --priority resolves partial values the same way (see --assignee / --unassign and --priority under Usage above). Filtering by assignee or priority on a profile that doesn't map the role fails like any other unmapped role (exit code 1). Unlike --unassigned, there is no --unprioritized to find rows with no priority.
The human-readable form appends !<value> and @<name> to a row that has them; rows with neither, and every row on a profile that doesn't map either role at all, print exactly as they did before this feature. When the id role is mapped, each row is also prefixed with its board id, the same way get shows it.
When nothing matches, the human-readable form prints no matching tasks to stderr and exits 0 — stdout stays empty, so list | wc -l counts rows and nothing else. list --json prints [] and says nothing on stderr.
apply — many writes from one manifest
notion-track apply --file tasks.json [--dry-run] [--expand] [--json]
Applies a list of writes from a JSON or CSV file, one entry at a time, in order. The format is chosen from the extension.
[
{"op": "upsert", "ticket": "TASK-1", "title": "Hardening", "status": "In progress", "assignee": "sam", "priority": "alta"},
{"op": "set", "ticket": "TASK-2", "status": "Done", "unassign": true}
]
op,ticket,title,status,due,body_file,assignee,unassign,priority
upsert,TASK-1,Hardening,In progress,2026-08-01,notes.md,sam,,alta
set,TASK-2,,Done,,,,true,
Fields: op (upsert or set, defaulting to upsert — the idempotent one, so a manifest run twice by mistake leaves the board as it was), ticket (required), title, status, due, body_file, assignee, unassign, priority. An unknown field is an error rather than something quietly ignored: a manifest with stuats in it would otherwise leave every row's status unset and say nothing.
assignee accepts the same partial names and the reserved me that --assignee does; unassign accepts true/false/empty (case-insensitive) and is registered in both formats, so it is just as legal in CSV as in JSON. Passing both assignee and unassign: true on the same entry is rejected the same way --assignee and --unassign are on the flags. priority accepts the same partial values that --priority does; there is no unpriority field, the same way there is no --unpriority flag.
body_file paths are resolved relative to the manifest, not to your working directory, so a manifest and the files it names travel together.
It stops at the first entry that fails, reports which one and how many were applied, and exits with that entry's own code — so a pipeline branching on 3 (not found) or 4 (duplicate) still learns why the run stopped. Entries are applied sequentially, never in parallel: two writes racing on the same ticket key can create a duplicate, and a manifest is exactly where the same key is most likely to appear twice.
1/3 upsert TASK-1 updated
2/3 upsert TASK-2 failed: unknown status "Nonexistent"; allowed values are: To do, In progress, Done
stopped at entry 2 of 3: 1 applied, 2 not applied
--dry-run and --expand work here exactly as they do on upsert and set, which makes apply --dry-run the way to check a manifest before running it for real.
--dry-run — see what a write would do
notion-track upsert --ticket TASK-231 --status Done --dry-run
would update 1f2e3d4c-...
Ticket TASK-231
Stato Done
https://notion.so/...
Available on upsert and set. It reports whether the row would be created or updated, which row, and which columns would be written — and writes nothing. With --json the output is {"dry_run": true, "plan": {...}}, so a script can tell it apart from a write that actually happened.
--unassign --dry-run prints a clear line naming the column instead of a value — without it, clearing the assignee would be the one write a dry run has nothing to say about, the most destructive one in this feature and invisible in the very command that exists to show it:
$ notion-track set --ticket TASK-231 --unassign --dry-run
would update 1f2e3d4c-...
clear Referente
https://notion.so/...
"Without touching the API" can only mean without writing: whether a ticket key resolves to a create or an update, and whether a status value even exists, are questions only the live data source can answer. A dry run therefore makes the same reads a real run does and stops before the first write — including the same validation, so a status your board would reject fails now rather than on the run you were about to do for real.
doctor — check the setup
notion-track doctor [--json]
Runs five checks — token, data_source, properties, duplicates, secrets — plus a sixth, assignee, between properties and duplicates, when the role is mapped; each prints as ok, warn, or fail with an actionable detail message. A warn (e.g. the status property's type changed since init ran) does not fail the command; any fail makes it exit non-zero. Of the checks that talk to Notion, only duplicates still runs when data_source fails — it doesn't need the schema, so a broken setup at least gets scanned for duplicate ticket keys instead of stopping there; properties and assignee both need the live schema, so a data_source failure skips them until it's fixed. (secrets also still runs, but it never talks to Notion in the first place — see below.)
assignee verifies that the configured identity still resolves to an option the mapped column offers — an option renamed in Notion would otherwise turn every --assignee me into a runtime failure discovered only when a write is attempted. It only ever reports ok or warn, never fail; the one thing it warns about, beyond an identity that no longer resolves, is an identity still coming from config.yml's legacy me: field instead of credentials.yml or NOTION_TRACK_ME — see Environment variables for why that's worth moving. It also warns when credentials.yml exists but can't be read: an identity may well be in there, and reporting "none configured" would send you to set up something you already have.
secrets is the only check that looks at your machine rather than at Notion: it scans the files the current git repository tracks for anything shaped like an integration token, and warns with the file and line number — never with the matched text, which would leak the secret a second time into scrollback and CI logs. Untracked files are left alone: a token in an ignored .env is not the mistake this is for. Running outside a repository, or without git installed, reports ok with the reason rather than a warning nobody can act on.
Configuration
notion-track keeps two files side by side in os.UserConfigDir()/notion-track/ — respecting $XDG_CONFIG_HOME on Linux:
| File | Holds | Safe to commit? |
|---|---|---|
config.yml |
profiles: data source id, property mapping | Yes — no secret |
credentials.yml |
the integration token | No — never commit this |
They're two files, not one, for exactly this reason: config.yml is meant to be committed to a project repo so CI and every teammate share the same property mapping (see CI usage); credentials.yml holds the one thing that must never end up in that repo. Splitting them makes "the token can't leak through the committed config" a property of the file layout, not a rule someone has to remember while editing YAML.
| OS | Default directory |
|---|---|
| macOS | ~/Library/Application Support/notion-track/ |
| Linux | ~/.config/notion-track/ |
| Windows | %AppData%\notion-track\ |
Pass --config /path/to/file.yml to point config.yml at a different file entirely — this is how you use a config file committed to a project repo instead of the per-user default (see CI usage). There is no equivalent flag for credentials.yml: it is deliberately always the per-user, per-machine default location, never something a project repo points at.
# config.yml
schema_version: 1 # written by `init`; don't hand-edit it
default_profile: work # used when --profile and NOTION_TRACK_PROFILE are both unset
profiles:
work:
database_id: "1a2b3c4d..." # optional, informational only — nothing reads it
data_source_id: "5e6f7a8b..." # required — every query, create and update is keyed off this
status_type: status # "status" or "select", recorded by `init`; see doctor's "properties" check
properties:
ticket: Ticket # rich_text or title property holding the ticket key
status: Status # status or select property
title: Name # title property
due: Due # optional: date property
assignee: Referente # optional: select property naming who owns the row
priority: Urgenza # optional: select property ranking how urgent the row is
id: ID # optional: unique_id property holding the row's board id
# credentials.yml — never commit this file
schema_version: 1
token: ntn_...
identities:
work: Jordan Lee # what `--assignee me` resolves to for the "work" profile; written by `init --me`
Both files are replaced atomically (a temporary file in the same directory, then a rename), but only credentials.yml is guaranteed 0600: its temp file has a random suffix and its permissions are set explicitly, immune to anything already sitting at a guessable temp path. config.yml's temp file has a fixed name and its permissions are not forced onto a pre-existing file there, so a leftover config.yml.tmp from an earlier run can leave it at whatever mode that leftover already had (e.g. 0644) — acceptable only because, unlike credentials.yml, it holds no secret of its own.
credentials.yml is written in exactly one place: init, when it runs at an interactive terminal, finds no token in NOTION_TOKEN or the file already, and you accept the "save it?" prompt (the default — see Quick start). Nothing writes a token to config.yml, ever.
Environment variables
| Variable | Effect |
|---|---|
NOTION_TOKEN |
the integration token. Always wins over credentials.yml when set — this is what lets CI pass a token that never touches disk. |
NOTION_TRACK_PROFILE |
which profile to resolve, unless --profile is also given |
NOTION_TRACK_DB |
overrides the resolved profile's database_id |
NOTION_TRACK_DATA_SOURCE |
overrides the resolved profile's data_source_id |
NOTION_TRACK_ME |
overrides the configured identity — the value --assignee me resolves to |
Precedence:
- Profile selection:
--profileflag →NOTION_TRACK_PROFILE→default_profilein the config file. database_id/data_source_id: the env vars above always override whatever the resolved profile has on file, regardless of how that profile was chosen — this is what lets a CI job point an existing profile at a different data source without touching the committed file.- Identity (
--assignee me):NOTION_TRACK_ME→credentials.yml'sidentitiesmap for the resolved profile → the profile'sme:field inconfig.yml(legacy).notion-track init --me <name>(or the wizard, offered whenever an assignee column is mapped) is the default way to set it up, and it writes tocredentials.yml— the per-user file — never toconfig.yml, which is meant to be committed and shared (see Configuration).NOTION_TRACK_MEis the override on top of that, for CI and one-off runs. The profile'sme:field still works forever, for configurations written before the identity moved — but ame:in a committedconfig.ymlis everyone's identity, sodoctorwarns when a profile's identity is still coming from there, naminginit --meas the fix. - Token:
NOTION_TOKEN→credentials.yml. A token read from the environment is never written back tocredentials.yml— a CI secret can never leak onto disk through a normal run. Runnotion-track doctorif you need to see which source actually won. - Config file location:
--configflag → the OS default path above. There is no environment variable for the path itself, and no equivalent flag forcredentials.yml.
JSON output
Every --json shape below is a documented, stable scripting contract: a key is never renamed or removed without a breaking-change announcement.
A row (get --json, and each entry of list --json):
{
"id": "TASK-271",
"ticket": "TASK-231",
"title": "Hardening",
"status": "In progress",
"page_id": "1a2b3c4d-...",
"url": "https://www.notion.so/...",
"last_edited_time": "2026-07-23T10:15:00Z",
"assignee": "Sam Rivera",
"priority": "ALTA"
}
id comes first because it is the row's identity, the same order the board displays it in. If the configured property mapping names a column the row doesn't actually carry, the corresponding field comes back as an empty string rather than an error — a broken mapping is doctor's job to report, not a reason to fail every read. id follows the same rule as assignee and priority below: always present, empty whenever the row carries no value or the role isn't mapped, so a script never has to branch on whether the key is present — only on whether it's empty. assignee follows the same rule and is additionally empty whenever nobody is assigned. priority follows the same rule too: always present, empty whenever the row carries no value or the role isn't mapped.
upsert --json / set --json:
{
"action": "created",
"page": { "id": "TASK-271", "ticket": "TASK-231", "title": "Hardening", "status": "In progress", "page_id": "...", "url": "...", "last_edited_time": "...", "assignee": "Sam Rivera", "priority": "ALTA" }
}
action is "created" or "updated".
doctor --json — an array of checks, one per token / data_source / properties / assignee (only when the role is mapped) / duplicates / secrets:
[
{ "name": "token", "status": "ok", "detail": "token from environment\n authenticated as notion-track" },
{ "name": "data_source", "status": "ok", "detail": "reachable: Tasks" },
{ "name": "properties", "status": "ok", "detail": "all mapped properties exist with the expected types" },
{ "name": "assignee", "status": "ok", "detail": "--assignee me resolves to Sam Rivera" },
{ "name": "duplicates", "status": "ok", "detail": "42 rows, no repeated ticket keys" },
{ "name": "secrets", "status": "ok", "detail": "37 tracked files scanned, no token-looking strings" }
]
status is "ok", "warn", or "fail"; detail is omitted only when empty, which does not happen in practice.
Exit codes
Pipelines can branch on these without parsing any message text:
| Code | Name | Meaning |
|---|---|---|
0 |
OK | success |
1 |
Error | a generic failure — a network/API error, doctor reporting a failed check other than token, or a value passed for --assignee, --priority or --due when that role isn't mapped in the active profile (an unmapped id role is the one exception — it's exit code 2, see below) |
2 |
Usage | the invocation cannot work as written: a missing/invalid flag, more than one of --ticket/--id/--page-id given or none of the three given, an unknown command, no config yet (notion-track init was never run), a status value the data source doesn't allow, a malformed --page-id or --id, an --id used on a profile with no id role mapped, a --page-id that resolves outside the active profile's data source, an --assignee value that resolves to zero or more than one option, an empty --assignee, --assignee me with no configured identity, --assignee combined with --unassign (or with --unassigned on list), a --priority value that resolves to zero or more than one option, or an empty --priority |
3 |
Not found | the requested ticket, board id, or page id has no matching row (or, for a page id, one not shared with this integration) (get, set) |
4 |
Duplicate | the ticket key matches more than one row (upsert, set, get) |
5 |
Auth | no token was found (including a credentials.yml that exists but can't be read), or Notion rejected it (401/403) — including doctor, when its token check is the only one that failed. --assignee me also exits 5 when credentials.yml can't be read: an identity may be in there, and falling back to the profile's shared me: would assign the row to somebody else |
CI usage
Because the config file has no secret in it, the common pattern is to commit it to the repository and point at it explicitly with --config, while the token comes from a CI secret:
# .github/workflows/notion.yml
- name: Install notion-track
uses: marcoarnulfo/notion-cli/action@main
with:
version: v0.6.0 # or "latest"
- name: Mark the ticket done
run: notion-track upsert --ticket "$TICKET" --status "Done" --config notion-track.yml
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
TICKET: ${{ github.event.inputs.ticket }}
The action downloads the release archive for the runner it is on, checks it against the release's checksums.txt, and puts the binary on PATH — no Go toolchain, no compile. Linux, macOS and Windows runners (Windows through Git Bash), amd64 and arm64; anywhere else it fails with a message that says so. It installs from a published release matching the version input, so it needs at least one v* tag pushed to this repository before it has anything to install; go install github.com/marcoarnulfo/notion-cli/cmd/notion-track@latest needs none of that and always works.
@main is a moving reference: you get whatever is on the branch at the time. Pin it to a commit SHA if you want a workflow that cannot change under you — a @v1 tag will exist once this project reaches 1.0.
Limitations
These are current, deliberate tradeoffs — not bugs to be surprised by:
- Every change is attributed to the integration, not to you. Notion records edits made through the API as made by the integration's bot identity. If you check a page's edit history in Notion, you will see the integration's name, never the human or CI job that ran the command.
upsertandgetfail on duplicate ticket keys instead of picking one. If more than one row shares the same ticket key,notion-trackrefuses to guess which one you meant — it exits with code 4 and lists the offending rows. Runnotion-track doctorto find and clean them up.- Two concurrent jobs creating the same new ticket can race into a duplicate.
upsert's create-or-update decision reads the current rows, then writes; the Notion API offers no unique-constraint or compare-and-swap primitive to close that window. This is not preventable client-side —doctor's duplicate scan is the mitigation, not a fix. - Only a Workspace Owner can set this up. Creating the integration and sharing a database with it both require Workspace Owner permissions in Notion. A workspace guest — one of the reasons this tool exists in the first place — cannot do either step, but can use the tool freely once someone with Owner rights has.
--body-filereplaces the whole page body, with no lock and no undo. It owns the body: each run overwrites everything there, hand-edited content included, and two runs racing the same page can duplicate it. See--body-fileunder Usage above.
Use it from an AI agent
Because the tool is quiet on success, speaks --json with a stable schema, and returns differentiated exit codes, an agent can drive it as reliably as a script does — no scraping of human output. A ready-made Claude Code skill lives in skills/notion-track/: it teaches an agent which command to reach for and how to stay safe (read before writing, never invent a status, branch on exit codes). Install it by copying its SKILL.md into ~/.claude/skills/notion-track/, then ask your agent to "mark that task done on Notion". For hosts that speak MCP rather than the shell, notion-track mcp serves the same operations as tools over stdio:
{
"mcpServers": {
"notion-track": { "command": "notion-track", "args": ["mcp"] }
}
}
It exposes upsert_task, set_task, get_task and list_tasks, returning the same JSON shape documented above. upsert_task and set_task both take ticket, title, status, due, assignee, unassign and priority — one argument per CLI flag; get_task takes only ticket. assignee and unassign resolve exactly like the CLI's flags do — a partial name, or the reserved me; list_tasks accepts assignee and unassigned, mutually exclusive with each other. priority resolves the same way --priority does — a partial value is enough when unambiguous; list_tasks accepts priority too, narrowing to rows carrying that value. There is no unpriority argument, the same way the CLI has no --unpriority flag. It is an adapter, not a second implementation: every tool reaches the same code the CLI commands do, so the duplicate check, the status validation and the property mapping behave identically for an agent. stdout carries the JSON-RPC protocol and nothing else.
Addressing is the one place the two surfaces diverge: over MCP a row is found only by ticket key — get_task's and set_task's only argument for finding a row is ticket (set_task's other arguments say what to write, not which row to write it to). The CLI's --id and --page-id have no MCP equivalent, even though the JSON a tool returns still carries the board id under id, same as --json does on the CLI.
This does not contradict the reason this tool exists. Notion's hosted MCP endpoint is the one blocked by corporate firewalls; a local server, running on your machine with your own integration token, reaches agents exactly where the hosted one cannot.
Contributing
Contributions are welcome — this is a free, open-source project. See CONTRIBUTING.md for the development setup, the checks to run before opening a PR, and the project's non-negotiable architectural rules. Please also read the Code of Conduct. Found a security issue? See SECURITY.md instead of opening a public issue.
Roadmap
Implemented today: init (interactive wizard and flag-driven, with --list), the browsing TUI, upsert, set, get, list, doctor; --dry-run on upsert/set; apply for bulk writes from a manifest; --body-file on upsert/set to write the page body from Markdown, with --expand for {{ticket}}/{{date}} placeholders; --append-file on upsert/set to add Markdown to the end of a page body without replacing it; get --body/--body-only to read a page body back as Markdown; --json on every command that produces output; mcp to serve the same operations as MCP tools; an optional assignee role with --assignee/--unassign, list --assignee/--unassigned and the me identity; an optional priority role with --priority on upsert/set/list; an optional id role mapped with init --id-prop, addressing a row by its Notion board id with --id on get/set; profiles; retry with backoff.
Release automation: the GoReleaser pipeline (.goreleaser.yaml plus a release workflow triggered on v* tags) builds and publishes the binaries and checksums.txt described under Installation every time a tag is pushed. The composite GitHub Action in action/ installs from whichever release its version input resolves to (see CI usage). Both are exercised, in two places. The release workflow's own smoke job installs each release it has just published, with the action, on Linux, macOS and Windows — so the archive names GoReleaser writes are checked against the ones the action reconstructs, every time. Separately, the action-smoke workflow installs the current release whenever the action changes, weekly against the drifting runner images, and through the marcoarnulfo/notion-cli/action@main reference this README documents. Two published archives are outside both: windows/arm64 has no GitHub-hosted runner to install on, and darwin/amd64 has no row in either matrix — tracked in #30.
License
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
notion-track
command
Command notion-track keeps a Notion task database in sync.
|
Command notion-track keeps a Notion task database in sync. |
|
internal
|
|
|
cli
Package cli wires the cobra command tree.
|
Package cli wires the cobra command tree. |
|
config
Package config reads and writes notion-track's YAML configuration.
|
Package config reads and writes notion-track's YAML configuration. |
|
manifest
Package manifest parses the bulk file `notion-track apply` works from: a list of writes to perform, one per entry, in JSON or CSV.
|
Package manifest parses the bulk file `notion-track apply` works from: a list of writes to perform, one per entry, in JSON or CSV. |
|
markdown
Package markdown converts Markdown into Notion blocks.
|
Package markdown converts Markdown into Notion blocks. |
|
mcp
Package mcp exposes notion-track's operations as MCP tools over stdio.
|
Package mcp exposes notion-track's operations as MCP tools over stdio. |
|
notion
Package notion is a minimal client for the Notion REST API.
|
Package notion is a minimal client for the Notion REST API. |
|
secrets
Package secrets scans the files a git repository tracks for strings shaped like a Notion integration token.
|
Package secrets scans the files a git repository tracks for strings shaped like a Notion integration token. |
|
service
Package service orchestrates the client, the config and the domain.
|
Package service orchestrates the client, the config and the domain. |
|
template
Package template expands the placeholders notion-track understands in a Markdown body, before it is parsed into blocks.
|
Package template expands the placeholders notion-track understands in a Markdown body, before it is parsed into blocks. |
|
tracker
Package tracker holds notion-track's domain logic.
|
Package tracker holds notion-track's domain logic. |
|
tui
Package tui holds notion-track's terminal interfaces.
|
Package tui holds notion-track's terminal interfaces. |