notion-skills
CLAUDE.md11 starsChanged 51 days agoMITView on GitHub
Current project behavior: the scheduled GitHub Action has been removed. bun run setup prints local configuration guidance and performs no writes. bun run dry-run previews publication; bun run sync explicitly publishes. The Actions deployment and six-phase wizard notes below describe the upstream implementation and must not be followed for this product. See README.md and web/README.md for the current browser product and operator setup. Operational + deployment context for this repo. The README is the generic, shareable description of the tool;…
- Pipes a download into a shell
- Reads credentials
- Deletes or force-pushes
- Commits and pushes
In this repository16 other files it gives its agents.
- AGENTS.mdAGENTS.md
- campaign-briefSkill
- content-calendarSkill
- content-repurposeSkill
- customer-feedbackSkill
- customer-replySkill
- decision-briefSkill
- document-reviewSkill
- email-draftSkill
- meeting-notesSkill
- onboarding-planSkill
- project-planSkill
- research-summarySkill
- sop-guideSkill
- status-reportSkill
- weekly-planSkill
The file
We split this CLAUDE.md into the rules it gives an agent
8 rules. About 0 are common, 4 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 108
If ntn login is needed, setup opens its device-flow URL, waits with ntn login poll, and names the blocking Notion admin setting ("Limit who …
- Very specific: line 177
Diagnostic log: every run writes a JSONL log to .notion-sync-setup/setup-<ts>.log.jsonl (gitignored).
- Very specific: line 193
No CLI install step: the sync is plain HTTPS on both ends now (Notion Skills API + GitHub Git Data API).
- Distinctive: line 201
Run and watch it manually:
- Distinctive: line 265
When renaming/rotating: set the new secret first, confirm a green run, then delete the old one — never leave a window where the workflow …
- Distinctive: line 326
Prune: delete a plugin in Notion (or revoke the connection's access to it) → re-sync → its whole directory and marketplace entry are …
- Distinctive: line 331
Only sync to the real main once the throwaway-branch run looks right.
- Very specific: line 563
ntnis setup-only, and now structurally so: Every ntn invocation lives in setup/ntn-cli.ts (typed-DB creation via /v1/databases, file …
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Current project behavior: the scheduled GitHub Action has been removed.
bun run setupprints local configuration guidance and performs no writes.bun run dry-runpreviews publication;bun run syncexplicitly publishes. The Actions deployment and six-phase wizard notes below describe the upstream implementation and must not be followed for this product. See README.md and web/README.md for the current browser product and operator setup.
CLAUDE.md — agent & maintainer notes
Operational + deployment context for this repo. The README is the generic, shareable description of the tool; this file is the specifics of how it's actually deployed and the hard-won gotchas. Read both.
One-line mental model: pull whole Agent Plugin directories from Notion's Plugins Public API → add Claude's compatibility manifest plus the client marketplace indexes → commit the whole set into GitHub on a schedule.
The sync reads Notion through the Plugins Public API (
/v1/ai/pluginsto list,/v1/ai/plugins/:idto fetch a whole plugin), not the generic page API. The plugin is the only unit that exists. The API has no skill-level resource at all: the listing reports{id, name, description, version_id}and nothing more, and a plugin's skills are whatever its archive contains. So there is no skill list to reconcile against an archive, no per-skill id, and no per-skill version — caching, pruning, and identity are all per plugin. Notion still renders eachSKILL.md(frontmatter and all), applies the description fallback, and bundles attachments. This tool's job is the GitHub half: plugin manifests, marketplace merges, pruning, and one atomic commit. Don't reintroduce page-property parsing here — if a field is missing, it belongs in the API.
The archive is the plugin directory. An Agent Plugins 1.0 archive holds
skills/<dir>/…under one wrapping directory; strip the wrapper and that subtree is exactly what gets published. The sync preserves the rootplugin.json, derives.claude-plugin/plugin.jsonfor Claude, adds one marker, expands lone per-skill zip attachments, and writes everything else through untouched. Cursor and ChatGPT/Codex consume the standard root manifest.
Configuration overview
Everything is an environment variable. There is no committed config file:
- local —
.env(gitignored; Bun loads it automatically) - CI — repo variables for the non-secret settings, repo secrets for
the two tokens (
NOTION_API_TOKEN,GH_PUSH_TOKEN)
Why: several teams run copies of this same repo, and a committed config.json
made every copy diverge on exactly one file — which is also what made update
conflict on every merge. .env never participates in a merge.
config.json is not read at all any more — it is only detected, so a
deployment that never migrated (or only partially migrated) can't silently
run on defaults. Any key the file sets whose replacing variable is unset is a
hard error naming the mapping (CONFIG_JSON_TO_ENV in src/config.ts); a
leftover file whose settings are all migrated is just a warning telling you to
delete it. Either way the fix is .env / repo variables and
git rm config.json.
GitHub rejects variable/secret names starting with GITHUB_, so
GITHUB_REPO/GITHUB_BRANCH are stored as SKILLS_GITHUB_* variables and
mapped back in the workflow (ciVariableName in src/config.ts). Getting this
wrong looks like a workflow with no configuration at all.
See AGENTS.md for AI agent setup.
The update command
bun run update (src/update.ts) merges tool changes from the upstream
remote, then tells you to push. It refuses on a dirty tree, and since config
moved to .env there is no per-file merge special case left — the prototype's
config.json merge=ours driver is gone. Updating is deliberately manual:
an earlier auto-update step in the workflow was removed because it forced
GH_PUSH_TOKEN to carry contents:write + workflows:write on the sync repo
(and a fetch-depth: 0 PAT checkout) just to push merges nobody reviewed.
The migrate-config command
bun run migrate-config (src/migrate-config.ts) is the one-time path off a
legacy committed config.json: it copies the file's settings into .env
(never overwriting a key the file already sets) and, unless --env-only, sets
the same settings as Actions variables (through ciVariableName, so
githubRepo lands as SKILLS_GITHUB_REPO) on the sync repo — --repo
<owner/name> overrides the origin-remote autodetection. After a full
migration it also git rms + commits config.json (nothing reads it, and it
stays in git history); with --env-only the file is kept deliberately — while
the workflow's variables are unset, a leftover config.json is the tripwire
that makes the sync fail loudly instead of running on defaults. Secrets are
never touched: their values aren't in config.json and GitHub can't read a
secret back, so there is nothing to copy.
Interactive setup
bun run setup is the deterministic, guided setup (formerly wizard). It's
structured to front-load all decisions and then run unattended, in six phases
(one file per phase in setup/steps/). It lives in the top-level setup/
package, separate from the sync core in src/, with its own
README: the wizard shells out to the ntn and gh CLIs,
the sync is plain HTTPS, and the dependency runs one way — setup/ may read
pure helpers out of src/, and the only thing pointing back is src/cli.ts
dispatching the command. Note it creates a plain typed
Skills DB — it no longer PATCHes on Published/Plugins properties, because
the Skills API the sync reads has no notion of either.
- Preflight — tool checks +
ntn/ghCLI auth (setup tooling only, never sync credentials). The database creator must be user-owned because workspace-owned internal connections cannot own top-level private pages. Bun auto-loads.env, so setup ignores an unsuitableNOTION_API_TOKENoverride and retriesntn's cached user login before replacing anything. Ifntn loginis needed, setup opens its device-flow URL, waits withntn login poll, and names the blocking Notion admin setting ("Limit who can create personal access tokens", Admin Center → Connections → Manage) if the login still fails. - Decisions — every question, each with context, then ONE plan-summary
confirm. The DB name isn't asked (auto: "Skills", renameable in Notion;
--db-nameoverrides). Vocabulary used throughout: Notion Skills DB (source of truth), skills repo (plugins are published here; Claude reads it as a marketplace), sync script repo (this code; the hourly workflow runs here — default is to push to a NEW origin the user owns, keeping the old origin asupstream). The skills repo is always private (no public option — a public skills repo makes no sense and private is required for Claude org registration). Repo-owner pickers default to the user's GitHub org (orgs listed first, personal account last and never the default) so org rollouts don't land under a personal account; both repos use the same owner-dropdown-then-name prompts, and the sync script repo's owner defaults to whatever was picked for the skills repo. Choosing an existing skills repo requires an explicit overwrite confirmation (the sync rewrites/prunes the target every run); declining loops back to the choice instead of killing setup. - Resources — creates the Notion Skills DB (+samples via the
shared
setup/skills-db.ts, also used by--ci), the skills repo, and the sync script repo. No prompts; failures abort with a handoff. One sample (Meeting Notes) ships bundled files — a Python script underscripts/and a PNG banner underassets/— zipped (zipSkillFiles) and uploaded viantn files create, then attached to the page'sFilesproperty at creation, so a fresh setup exercises the zip flow out of the box. - Credentials — the single manual pause, deliberately AFTER resources
exist. Two dedicated minimal-blast-radius tokens, never the cached
gh/ntnCLI credentials (those are account-wide; the gh one carriesrepo+admin:public_key): a fine-grained GitHub PAT via a pre-filled URL (buildPatUrl— GitHub's form supports name/owner/expiry/permissions params but NOT repo pre-selection, which is why the skills repo must exist first), and a Notion access token (Connections page → New connection → Access token method). The "connect it to the DB" step is verified by polling the DB with the pasted token — no honor-system confirm. This step also prints the setup-call gotchas inline (seesetup/guidance.ts): the org PAT-approval path (Organization Settings → Personal access tokens → Pending requests) and the Notion "Limit who can create internal connections" admin setting. - Deploy — unattended tail: write
.env→ push sync script repo → secrets → repo variables (the non-secret settings; nothing is committed) → local test sync (run with the SAME dedicated tokens the workflow will use) → dispatch + watch a real Actions run. - Wrap-up — register-the-marketplace steps (Organization settings → Plugins) with a done-confirm to pace the output, then a short summary and an offer to open the Skills DB. Also prints the Claude GitHub-app gotcha: a private skills repo won't appear in Claude's picker unless the org's Claude GitHub app (if set to "Only select repositories") is granted access to it, and the repo is visible to whoever does the Claude-side setup.
- Runs against prod by default. Dev is opt-in with
bun run setup --env dev(internal Notion use). The chosen env is threaded through every Notion call and written to.envasNOTION_ENV— so the database is created in the same env the sync later reads from. (Getting these out of sync is what produced a404 object_not_foundat the test-sync step: DB created in dev, sync configured for prod.) - Test runs:
bun run setup --test-run(interactive only) runs the whole real setup, then adds a final cleanup step (setup/steps/cleanup.ts) that offers to delete the GitHub repos the run created (gh repo delete, with agh auth refresh -h github.com -s delete_repohint if the scope is missing) and restores the rewired git remotes (upstream→origin). Pre-existing repos the user chose to reuse are never deleted; the Notion Skills DB is left for the user to trash in Notion. - Non-interactive:
bun run setup --ci(for agents/CI) — seesetup/non-interactive.ts. Also honors--repo,--db-name,--db-parent-page. CI mode doesn't push the sync script repo or dispatch Actions, and takes credentials from the environment instead of the dedicated-token checkpoint. - Diagnostic log: every run writes a JSONL log to
.notion-sync-setup/setup-<ts>.log.jsonl(gitignored). It's crash-proof (one JSON object per line, flushed as it goes, with acrashrecord + stack on failure) and redacts tokens. Share/read this file to debug a stuck setup. - Setup's spinners are a local shim (
setup/spinner.ts), not@clack's — clack's spinner grabs stdin viablock(), which couldprocess.exit(0)on a stray escape/empty keypress. The shim never touches stdin, so that whole failure mode is gone. Don't reintroducep.spinner().
GitHub Actions runbook
The Action is the production runner. .github/workflows/sync.yml:
- Triggers:
schedule(hourly0 * * * *) andworkflow_dispatch(the manual Run workflow button /gh workflow run). - Steps: checkout → setup Bun →
bun install→bun run src/cli.ts sync. No CLI install step: the sync is plain HTTPS on both ends now (Notion Skills API + GitHub Git Data API). The oldcurl -fsSL https://ntn.dev | bashstep is gone —ntnis only used bysetup, which never runs in CI. Tool updates are manual (bun run update+ push), not a workflow step. - Why a PAT (
GH_PUSH_TOKEN): the job runs in this repo but pushes to a different repo (the target). The built-inGITHUB_TOKENis scoped to the workflow's own repo, so it can't push cross-repo. Hence a PAT secret.
Run and watch it manually:
R=<owner>/<this-repo> # e.g., your-org/notion-skills-github-sync
gh workflow run sync.yml --repo "$R"
id="$(gh run list --workflow sync.yml --repo "$R" --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$id" --repo "$R" --exit-status
gh run view "$id" --repo "$R" --log # full logs if it failsA healthy run ends in the Sync step with either ✓ Up to date — no commit
needed (idempotent) or ✓ Committed <sha> to main.
- Secrets alone are not enough — the non-secret settings are repo
variables, and forgetting them is silent until the run. After the move off
config.jsonthe deployment kept its two secrets and had no variables, so every hourly run failed with✖ Missing GITHUB_REPO(2026-08-10). The Sync step'senv:block echoes every setting, so a log full ofKEY:with empty values is the tell.gh variable list --repo "$R"is the check, and.env.exampleis the full list of names to set. timeout-minutesis sized for a cold sync, not a warm one. The sync commits once, at the end, so a run killed partway writes nothing — set the ceiling too low against a cold target and no number of hourly retries will ever converge. It was 15 while a cold sync took ~45 min; it is now 45 while a cold sync takes ~4.
Secrets & rotation
Repo secrets (Settings > Secrets and variables > Actions > Secrets):
| Secret | What | Scope needed |
NOTION_API_TOKEN | Notion API token, read directly by the sync's HTTP client. Must match NOTION_ENV. Required for local runs too — there is no ntn keychain fallback. | read content on the skills |
GH_PUSH_TOKEN | PAT / fine-grained token used to push to the target repo. | contents:write on the target repo |
Setting secrets via CLI
You can set secrets using the GitHub CLI (gh), which is useful for automated
deployments or when an agent is setting up the repo:
REPO=<owner>/<this-repo>
# Set secrets (use --body to pass value, or pipe it in)
gh secret set NOTION_API_TOKEN --repo "$REPO" --body "$NOTION_API_TOKEN"
gh secret set GH_PUSH_TOKEN --repo "$REPO" --body "$GH_PUSH_TOKEN"Secret rotation
GitHub never lets you read a secret value back, so rotation = re-set. The flow we use (keeps the value out of the terminal/argv and off disk afterward):
REPO=<owner>/<this-repo>
mkdir -p .secrets && : > .secrets/GH_PUSH_TOKEN # .secrets/ is gitignored
# paste the token into the file, then:
printf %s "$(< .secrets/GH_PUSH_TOKEN)" | gh secret set GH_PUSH_TOKEN --repo "$REPO"
rm -rf .secretsValidate a push token before relying on it:
GH_TOKEN="$(< .secrets/GH_PUSH_TOKEN)" gh api repos/<owner>/<target-repo> --jq .permissions.push → expect true.
When renaming/rotating: set the new secret first, confirm a green run, then delete the old one — never leave a window where the workflow references a missing secret.
Local dev
Prereqs for sync: Bun ≥ 1.2, NOTION_API_TOKEN in the
env or .env, and gh auth login (the GitHub client falls back to
gh auth token when GITHUB_TOKEN is unset). The ntn CLI is only needed for
setup (ntn --env dev login).
bun install
cp .env.example .env # fill in settings + tokens
bun run dry-run # preview; pushes nothing
bun run sync # real sync to GITHUB_BRANCH
bun test # tests
bunx tsc --noEmit # typecheckNotion reads are plain HTTPS against the Plugins API and always use
NOTION_API_TOKEN — the same code path locally and in CI. There is no
keychain fallback any more: a local run without the token fails immediately
with a message saying so.
Validation loop
What "done/verified" means here, in order:
bunx tsc --noEmitclean;bun testgreen. The suite is mostly end to end:test/fake-skills-api.tsis an in-memory Plugins API served through the real client (genuine whole-plugin.tar.gzfixtures, pagination, 429s), andsrc/target/memory.tsis the other end, sotest/sync-e2e.test.tsasserts on observable behaviour — resulting file tree, commit count, prune results, marketplace contents for all three clients, idempotency. Unit tests are kept only where the logic is intricate and general: untar, blob sha, slug assignment, retry-delay math, tree chunk boundaries, host resolution, config loading, andupdate's git behaviour (real temp repos). Setup has its own suite undersetup/test/. Keep the fake's response shapes honest. The suite stayed green through the 2026-08-10 API change purely because the fake still served the old listing with a nestedskills[]; production had dropped it and every real sync returned zero skills. A green suite is not evidence the reader matches the API — dry-run against a real workspace before believing it.bun run dry-runagainst the real workspace shows the expected plan.- Safe end-to-end: point
GITHUB_BRANCHat a throwaway branch first if needed,bun run sync, then verify with each client's validator (all three marketplace files should exist and list the same plugins):git clone <target-repo> /tmp/check && cd /tmp/check claude plugin validate .claude-plugin/marketplace.json --strict claude plugin validate plugins/<slug> --strict # Cursor + Codex marketplaces are emitted alongside Claude's: ls .cursor-plugin/marketplace.json .agents/plugins/marketplace.json CODEX_HOME=/tmp/codex-plugin-check codex plugin marketplace add /tmp/check - Idempotency: immediately re-run
sync→ expectUp to date, no commit, and every plugin listed underunchangedin the plan (theversion_idfast path: no archive was downloaded at all — one list call for the whole run). Also worth running once per change toupdate: a merge against a deliberately dirty tree (should refuse). - Prune: delete a plugin in Notion (or revoke the connection's access to it) → re-sync → its whole directory and marketplace entry are removed. To check exact replacement, remove a file from a plugin in Notion and verify the next plugin version deletes that file from the target branch.
Only sync to the real main once the throwaway-branch run looks right.
Where do I change X?
| Goal | Touch |
| Retarget repo / branch | GITHUB_REPO / GITHUB_BRANCH (.env locally, SKILLS_GITHUB_* repo variables in CI) |
| Rename a published plugin directory | Rename the plugin in Notion — directory names are slugified from the API's plugin names. The old directory is pruned on the next sync. An unnamed plugin gets skills-<id-tail> (FALLBACK_SLUG_BASE in src/sync/engine.ts) |
| Switch prod → dev (internal) | Set NOTION_ENV=dev — both hosts come from src/notion/env.ts, so this flips the Plugins API host (api.notion.com → api-dev.notion.com) and the app host in marker URLs together. Also swap NOTION_API_TOKEN and SKILLS_DATA_SOURCE_ID to dev values (the id is only used for the marker, not for reading plugins) |
| Surface a new plugin field | Nothing here — it has to come from the Plugins API. Add it to Plugin in src/notion/plugins.ts once the API returns it, then emit it in src/sync/plan.ts. There is no skill-level field to surface: skill metadata only exists inside SKILL.md, which Notion renders |
| Move a customer off an old-schema DB | Done in-product (Notion's "Turn into → Skills DB"). The Plugins API only reports typed skills, so conversion is now a hard prerequisite rather than a nicety — see the gotcha below |
| Change archive handling | src/notion/archive.ts (extract/zip-expansion) + src/notion/untar.ts (tar reader) + src/sync/plan.ts (subtree prune). Downloading is NotionHttp.fetchBytes, so it retries |
| Speed up / throttle a cold sync | SYNC_CONCURRENCY in src/config.ts (a constant, 8) — applies to Notion archive fetches only, never GitHub writes |
| Change retry behavior | src/notion/http.ts: retryDelayMs (error responses) and transportRetryDelayMs (a fetch that throws). Both flow through the one send loop |
| Change the write-back updater skill | Nothing here — the Plugins API publishes notion-skills-updater itself now; edit it in Notion. See the section below for what the API's copy is missing |
| Add/change a supported client (manifest dir, marketplace path, entry shape) | src/sync/clients.ts (the CLIENTS registry — the ONE place per-client differences live) |
| Change file/marketplace layout | src/sync/plan.ts (paths, manifests, marker, merge/prune) + src/sync/clients.ts (per-client marketplace paths/shapes). Neither SKILL.md nor the skills/ layout is ours — both arrive from the API |
| Change GitHub write behavior | src/target/github.ts (Git Data API + the SyncTarget impl) |
| Publish somewhere other than GitHub | Implement SyncTarget (src/target/target.ts); src/target/memory.ts is the reference. Nothing in src/sync/ needs to change |
| Add a Notion endpoint | src/notion/ — plugins.ts for resources, http.ts for the transport (auth is the client's auth option), and export it from index.ts |
Change what update does | src/update.ts |
Change the config.json migration | src/migrate-config.ts (+ CONFIG_JSON_TO_ENV in src/config.ts) |
| Change the guided setup | setup/ — its own top-level package: steps/ (one file per phase), non-interactive.ts for --ci, and setup/README.md |
Architecture (three layers, one boundary each)
The organising idea: talking to Notion's Plugins API (reusable by anyone) is separate from publishing a plugin marketplace (our application), which is separate from where the files go (the target).
src/
cli.ts commands: setup [--ci] | sync [--dry-run] | update [--ci]
config.ts environment -> Config (a leftover config.json is detected,
never read)
wire.ts assemble a NotionClient + GitHubTarget from a Config
update.ts merge tool changes from `upstream` (manual, guarded)
migrate-config.ts one-time config.json -> .env + repo variables migration
env-file.ts PURE: merge KEY=value lines into .env without overwriting
notion/ <- REUSABLE: reading plugins out of Notion. Single entry point.
index.ts NotionClient; the one import a consumer needs
env.ts host resolution (api / app / mcp) for prod | dev | stg
http.ts transport: auth header, retries, typed NotionApiError
plugins.ts /v1/ai/plugins, /v1/ai/plugins/:id (+ plugins.files(): the
whole-plugin archive, extracted). No skill-level resource.
archive.ts signed URL -> tar.gz -> the files a plugin dir should hold;
strips the wrapper, expands a lone attachment zip per skill
untar.ts PURE: minimal tar reader (ustar + PAX + GNU long names)
sync/ <- OUR APPLICATION: plugins -> plugin marketplace
engine.ts orchestration; target-agnostic (incl. resolvePlugin: the
per-plugin version_id download-skip decision)
plan.ts PURE: plugin paths, derived Claude manifest, the sync marker,
desired file set, plugin-level prune set, marketplace merges
clients.ts PURE: supported clients + their marketplace conventions
slugify.ts PURE: name -> unique slug (dedupes API kebab-case collisions)
pool.ts PURE: bounded-concurrency map (the cold-sync archive fetches)
target/ <- WHERE IT LANDS
target.ts SyncTarget + content ids (git blob sha) + computeChanges
github.ts Git Data API client + GitHubTarget (one atomic commit/sync)
memory.ts in-memory target: the reference impl, and what tests run on
setup/ <- SEPARATE PACKAGE: the one-time guided rollout. steps/ (one
file per phase), non-interactive.ts (--ci), the `ntn`/`gh`
plumbing, crash-proof logger, spinner shim. See its README;
only cli.ts imports it, and only to dispatch `setup`.SyncTarget is the load-bearing boundary. The engine says "here is the
desired set of files and their content ids"; a GitHub target commits them, an
in-memory target records them, a filesystem target would write them. The
version_id caching protocol is identical for all of them — which is what lets
the test suite run a whole sync with no network on either side.
Two constraints on notion/ worth preserving:
- Single export surface. A consumer imports
NotionClientfromsrc/notion/index.tsand gets the whole capability; they should never have to assemble five modules in the right order. - Match
@notionhq/client's conventions (verified against 5.23.3), on the assumption the SDK may absorb these capabilities: a{ auth, baseUrl, notionVersion, fetch, retry }constructor, namespaced resource methods taking argument objects, an error type carrying Notion's owncode, and the standardhas_more/next_cursorenvelope (followed for you byplugins.listAll(), the only paginated call there is). One deliberate divergence: back-off here is deterministic (no jitter) — a single scheduled job has no herd to avoid, and it makes the retry math directly testable.
The PURE modules hold the logic and are covered by the end-to-end suite plus
targeted unit tests; the network edges are thin and swappable.
Gotchas (these bit us — don't relearn them)
- Typed skills DBs (
database_type: skills). Setup creates them through the standardPOST /v1/databasesendpoint withdatabase_type: "skills"andNotion-Version: 2026-03-11; the regular JSON response includes the database URL and data-source ID. Do not use/v1/tools/run: production reserves it for Notion MCP. The canonical schema includes Skill name, Description, Files, Tags, and Created by. Display names follow the token owner's locale, so setup resolves the title/rich-text/files property IDs from the data source before adding samples. Setup does not PATCH onPublished/Pluginsextras (see the Skills API note below). And workspace-level databases/pages cannot be trashed via the API ("Archiving workspace level pages via API not supported") — an API archive of such a DB degrades to a manual instruction. - Conversion to a typed Skills DB is now a hard prerequisite, not a nicety.
Notion converts an existing DB into a typed skills DB in place via "Turn into
→ Skills DB" (notion-next PR #274889, gate
enable_agent_skills_v2). The Skills API only reports rows backed by a live skill prompt — an untyped DB of "skill-ish" pages is invisible to it and syncs as zero skills. Under the old page-API reader we papered over untyped/renamed schemas with a display-name shim (skill-schema.ts'sLEGACY_SHIM); that whole layer is deleted. If a customer's skills don't show up, the first thing to check is whether their DB is actually typed — not whether we're resolving properties right, because we no longer resolve properties at all. The upside: renaming the Skill name / Description columns can no longer break the sync. - Setup-call gotchas live in
setup/guidance.ts. These are the human-in-the-loop snags from real rollout calls, kept as pure string builders so they're reusable and unit-tested (setup/test/setup-guidance.test.ts): the two Notion admin settings that silently block setup ("Limit who can create personal access tokens" blocksntn login; "Limit who can create internal connections" blocks the sync token — both at Admin Center → Connections → Manage, both fixable by an admin, and PAT creation can be re-restricted after setup); the org PAT-approval path (Organization Settings → Personal access tokens → Pending requests); and the Claude GitHub-app "Only select repositories" requirement for the private skills repo. If you touch this content, update the tests too. - Skills repo is always private; owners default to the org. The decisions step no longer offers a public option, and repo-owner pickers list orgs first with an org as the default (personal account requires an explicit pick). Existing-repo reuse needs an explicit overwrite confirmation.
- Marketplace manifest paths (one per client):
.claude-plugin/marketplace.json(Claude),.cursor-plugin/marketplace.json(Cursor), and.agents/plugins/marketplace.json(Codex) — not a rootmarketplace.json. (We shipped a stray root file once.) Cursor and Codex consume each plugin's standard rootplugin.jsondirectly. Claude also gets.claude-plugin/plugin.json, derived from that root manifest by preserving its fields and filling only Claude's missingversion,description, andauthormetadata. The marketplace entry differences live insrc/sync/clients.ts. - Workflow-registration race on a fresh sync repo. GitHub registers
workflows when it processes a push to the repo's configured default branch.
Pushing a differently-named branch first (e.g. a feature branch to an empty
repo) makes that branch the default only after the push is processed — so
sync.ymlsits on the default branch butactions/workflowsstays empty andgh workflow run404s ("workflow not found on the default branch"). Fix: pushHEAD:<configured default branch>(the setup does this now, and pollsrepos/<r>/actions/workflows/sync.ymlforstate: activebefore dispatching). Manual recovery: push any commit to the configured default branch name. - Setup failures abort with a handoff prompt (
setup/handoff.ts) — real failures in the deploy step never fall through to the happy-path wrapup. Skips (user answered "no") do continue. Keep it that way. - A repo variable may not be named
GITHUB_*. GitHub rejects both secrets and variables starting with that prefix, so the two settings that would collide live asSKILLS_GITHUB_REPO/SKILLS_GITHUB_BRANCHand the workflow maps them intoGITHUB_REPO/GITHUB_BRANCH.ciVariableNameinsrc/config.tsis the one place that knows this. Skip the mapping and the workflow silently runs with no target repo configured. - Tool updates are manual on purpose. The workflow used to run
update --cibefore each sync; that forcedGH_PUSH_TOKENto carrycontents:write+workflows:writeon the sync repo (the default token can't push anything touching.github/workflows/**) plus afetch-depth: 0PAT checkout, and shipped unreviewed upstream commits to every team hourly. Removed 2026-08-11:bun run update+git pushis the whole story now, andGH_PUSH_TOKENis back tocontents:writeon the target repo only. - The engine must never learn about GitHub.
SyncTarget(src/target/) is the only write path;src/sync/gets acontentIdfunction and anapply, and that's it. The moment the engine reaches for a blob sha or a branch name, the in-memory target stops being able to stand in for GitHub and the end-to-end suite loses its point. - The Notion half must stay importable on its own. Nothing under
src/notion/may import fromsrc/sync/,src/target/, orsrc/config.ts— it's the reusable half, and a consumer should be able to copy the directory out. The dependency runs one way only. - Notion is the sole source of
pluginsDir, and the sync owns all of it. Any directory underplugins/that a run didn't produce is pruned, and each client's marketplacepluginsarray is replaced, not merged. There is no carve-out for hand-authored plugins — don't put anything there by hand, and nothing is synthesized either: every directory traces to a listed plugin. (We used to gate pruning on the.notion-sync.jsonmarker; that protected hand-authored plugins nobody was using, and left dangling marketplace entries un-healable — we hit that withhello-worldand had to fixmarketplace.jsonby hand. Both are gone.) - One minimal marker per plugin, at the plugin root.
plugins/<slug>/.notion-sync.jsoncarries the plugin's identity,version_id, source context, and target slug. There is no skill inventory and no per-skill marker: the plugin is opaque. A byte-identical marker is the warm-cache key. The sync deliberately does not inspect an unchanged plugin for repo drift; its directory is replaced the next time its Notion version changes. - A marketplace's non-plugin top-level keys are preserved.
name,owner,descriptionand anything else at the root of a marketplace manifest survive every sync — that's the repo's own identity, and nothing in Notion supplies it. Only thepluginsarray is ours. - There is no
Publishedflag any more, and no per-skill opt-out./v1/ai/pluginsreturns every live plugin in the bot's workspace that the token can read; the API has no row-level publish filter and we deliberately don't reimplement one (that would mean going back to querying the data source, which is the thing we removed). Publishing control is now access control: what syncs is exactly what the Notion connection has been granted. Scope the connection, not a checkbox. - The routes moved twice and the listing was hollowed out; skills stopped
being addressable at all. Routes were
/v1/skills/plugins+/v1/skills/directories/:iduntil 2026-07, then/v1/ai/plugins+/v1/ai/skills/:id. With Agent Plugins 1.0 (2026-08) the per-skill archive endpoint was retired —/v1/ai/skills/:idanswers400 invalid_request_url— and on 2026-08-10 the listing dropped its nestedskills[]array. It now returns{id, name, description, version_id}and nothing else. There is no replacement:/v1/ai/skillsand/v1/ai/plugins/:id/skillsboth 400, and?include=skills/?expand=skillsare ignored. The archive is the only source of a plugin's skills — that is why caching is per plugin and why the code has no skill list to reconcile. A stale route is a routing failure (400 invalid_request_url), which looks nothing like the 403 from the feature gate; if every call suddenly 400s, suspect a route rename first. The list is still Notion's standard paginated envelope (results+has_more/next_cursor— the server ignorespage_sizebut emits a cursor, solistfollows it). - Plugin grouping comes from the API, and the grouping is fine-grained.
/v1/ai/pluginsreports one plugin per skills grouping. As of 2026-08-10 that is 420 plugins in dev: a few real team groupings ("Finance", "EPD") and several hundred one-skill plugins. The old built-innotion-workspace-skillsplugin (362 skills in one archive) is gone — those skills now arrive as their own plugins. Each becomes its own directory underpluginsDir, named by slugifying the plugin's name (assignUniqueSlugs, so a duplicate name gets-2). Two consequences: (1) a cold sync now makes ~420 archive requests instead of ~4, which is the dominant cost of a first run (fetchedSYNC_CONCURRENCYat a time — see "cold sync" below); (2) skill directory names come from the archive and are only unique within a plugin, so the same skill title in two plugins is fine and the sync never re-slugs them.FALLBACK_SLUG_BASE(skills) is only the fallback base for a plugin the API returns with an empty name. - The endpoints are feature-gated (
public_api_skills_plugins). A workspace without the gate gets403 restricted_resource/ "Endpoint unavailable." — the same response as a token missing read access, which is why the hints insrc/notion/plugins.tsname both causes. If the sync 403s on a workspace that used to work, check the gate before suspecting the token. ntnis setup-only, and now structurally so. Everyntninvocation lives insetup/ntn-cli.ts(typed-DB creation via/v1/databases, file uploads); there is nontncode undersrc/at all, so the sync path cannot reach for it. That's what keeps CI free of thecurl https://ntn.dev | bashstep.- A plugin directory name must be a function of identity, not list position.
slugifyis ASCII-only, so a fully non-Latin name flattens to""— 7 of dev's 420 plugins have Japanese names, and another 19 come back with no name at all, so 26 plugins have no usable slug. LettingassignUniqueSlugsseparate them positionally (skills-2,skills-3, …) is unstable: delete one plugin and every later one slides onto a different directory, so the next sync rewrites and prunes subtrees that never changed — hundreds of files of pure churn.fallbackNameinengine.tstherefore suffixes the plugin's own id (skills-3b4b35e6). Ugly, stable, traceable. Still open: two plugins with the same real name (13 such pairs in dev, e.g. two "Reformat") are still separated positionally, so which one ownsreformatvsreformat-2depends on listing order. Fixing that means renaming directories in existing deployments, so it wasn't done here. If you do it, the id suffix is the same answer. - The listing and the archive route disagree, and that exact 404 must not sink the
run.
/v1/ai/pluginscan list a plugin that/v1/ai/plugins/:idthen answers404 directory_not_found("not shared by the connected workspace") — seen onhtml explain diffin dev, 2026-08-10. Letting that abort the run discards the whole cold sync's work; the first real cold sync died on plugin ~283 of 420. SoresolvePlugincatches only404 directory_not_foundand retains an existing copy. A plugin that has never downloaded is omitted rather than listed broken. Every other failure — auth, feature gate, exhausted server retries, signed-download failure, corrupt archive — aborts the run. A plugin whose access is genuinely revoked drops out of the listing, and that is what prunes it. - Retries must cover a
fetchthat throws, not just one that answers badly. A cold sync is hundreds of requests over minutes, so a dropped socket is routine: the first parallel CI run died 52 archives in with "The socket connection was closed unexpectedly". That is not an HTTP status, so a status-only retry never sees it. Two rules fall out, both encoded inNotionHttp.send: (1) a throw retries only for idempotent methods — a GET that died in transit can be reissued, a POST may already have been applied; (2) the body read happens inside the retry, because a connection that dies mid-transfer throws fromarrayBuffer()long after the headers arrived — retrying only the initialfetchwould miss exactly the case that bit us. Archive downloads go throughfetchBytesfor this reason; there is deliberately no non-retrying download helper left to reach for. - Never write one blob per file — GitHub's secondary limit will kill a cold
sync. The ceiling is 80 content-creating requests/minute and 500/hour;
a cold sync of the dev workspace needs ~830 files, so
POST /git/blobsper file cannot fit in an hour no matter how you pace it. (We learned this the hard way: 826 blobs, 403 at roughly the 500 mark, four minutes of Notion work discarded.) Insteadsync.tsputs UTF-8 files inline in the tree request viaisInlineableTextand only blobs true binaries — 22 blobs + 4 tree chunks + commit + ref = 28 requests for the same 826 files. Tree entries have no base64 option, which is the whole reason binary is split out.gh.buildTreechunks at 300 entries / 3MB (limit is 100k / ~7MB), chaining each chunk as the nextbase_tree. Corollary: parallelizing GitHub writes is the wrong instinct — the constraint is request count, not latency, so concurrency makes it worse. The Notion side is the opposite (latency-bound), so the two halves need opposite treatments. - The cold path is user-triggerable, not just a first-run event. Plugin
directory names come from the API's plugin names, so renaming a plugin in
Notion rewrites that plugin's whole subtree — 724 files for
notion-workspace-skills. Same for adding a plugin or changing the marker format. Any change to what the marker contains re-writes every skill. - The
version_idfast path compares blob shas, not file contents.resolvePluginhashes the marker it would write and compares against the base tree already in memory. It must stay that way: reading markers back would be one GET per plugin, which at ~420 plugins is the entire cost of an otherwise no-op hourly run. Everything needed is in theexistingmap. Comparing the whole marker's sha (rather than justversion_id) is deliberate — it also catches renames, config changes, and marker-format changes. It does not inspect the files inside an unchanged plugin. - Idempotency is via git blob sha. On top of that, the marker embeds the
API's
version_id, soresolvePlugincompares the marker it would write against the repo's copy and skips the archive download entirely when they match. Building an archive is expensive server-side (render + fetch attachments- upload), so keep this fast path working. Note the granularity tradeoff that came with per-plugin caching: any change inside a plugin re-downloads the whole plugin. That is cheap for the one-skill plugins the API now mostly returns, and byte-identical siblings still produce no writes, so the commit stays minimal even when the download isn't.
- Two archive formats, one inside the other — this confuses everyone once.
The envelope is tar: the Plugins API delivers a whole plugin as a
.tar.gz(you can see it in the signed URL). Inside, each skill sits underskills/<dir>/, and a skill's payload may itself be a zip: whatever the author attached to the Notion page'sFilesproperty, usually a.zipbecause that's what you get when you compress a folder. So the sync untars the envelope (untar.ts) and otherwise preserves the plugin opaquely. The only content-aware step is locating each immediateskills/<dir>/directory and unzipping its lone root attachment (unzipSkillArchive, viafflate). Tar is Notion's transport; zip is the user's attached payload. - A plugin arrives as one
.tar.gz, and the tar reader is ours.src/notion/untar.tsis a hand-rolled reader because Node has no untar and the stream libraries pull a dep tree. It must handle PAX extended headers —tar-stream(what the server uses) emits one for any entry name that is non-ASCII or over 100 bytes, which is routine for Notion page titles and the API's 200-byte attachment names. Don't "simplify" it down to plain ustar. - A lone attachment
.zipis still expanded in place. The API archives an attached zip verbatim rather than unpacking it, so each skill bucket fromextractPluginArchiveexpands it when there's exactly one — otherwise a skill'sscripts/andassets/folders would ship as an opaque zip. Anything else (no zip, several zips) is left as delivered. Zip the contents at the root, not a wrapping folder. The API-renderedSKILL.mdwins over a same-named zip entry. Bytes flow through asFileContent = string | Uint8Array(seesrc/target/target.ts) —gitBlobShaandcreateBlobhandle binary viatoBytes. - Pruning is two rules, and a retained plugin is in neither.
plan.ts: (1) a plugin directory this run didn't publish is deleted whole; (2) a plugin we did download owns its subtree, so any file under it that isn't desired is deleted — which is how a dropped skill or attachment cleans up. A retained plugin (version_id matched, nothing downloaded) contributes no desired files, so it must be excluded from rule 2 or the fast path would delete everything it was meant to leave alone. It survives rule 1 by being indesiredSlugs. See the "re-running a sync" and "pruning" tests intest/sync-e2e.test.ts. Don't hand-add files under a managed plugin dir: a cached plugin is not inspected, but its next Notion version replaces the whole directory and removes anything absent from the archive.
The write-back updater now comes from the API (2026-08-11)
The sync used to inject a synthetic notion-skill-updater plugin
(src/sync/updater.ts) — the Notion MCP wiring plus a skill teaching a client to
edit skills back in Notion. The Plugins API now publishes that skill itself
(notion-skills-updater), so it arrives like any other plugin: a real
version_id, a real marker, the warm-cache fast path, and pruning all apply to
it. src/sync/updater.ts is deleted, along with the whole injected seam in
plan.ts / engine.ts, INJECT_UPDATER, UPDATER_SLUG,
CHANGE_REQUESTS_DATA_SOURCE_ID, and mcpUrl / mcpServerName in
src/notion/env.ts. Nothing is synthesized any more — every published
directory traces back to a listed plugin, which is the invariant to preserve
if someone proposes injecting something again.
Two things the injected version carried that the API's does not, worth knowing when a report comes in:
- No
mcpServersin itsplugin.json. The injected copy bundled the Notion MCP (mcp-<env>.notion.com/mcp, OAuth on first use), so write-back worked out of the box. The API's manifest is name + description only, so the client needs the Notion MCP configured separately or the skill's instructions have nothing to call. Fix belongs in the API's manifest, not here. - No "propose a change for review" path. The Change Requests flow (create a
page in that data source,
Skillrelation, StatusProposed) existed only in our generated skill text. If a deployment needs it back, it has to come from the skill in Notion. Deployments may still have a staleCHANGE_REQUESTS_DATA_SOURCE_IDrepo variable set; it's simply ignored now.
One-time cleanup per deployment: the old plugins/notion-skill-updater/
directory is pruned on the next sync (it's no longer in desiredSlugs), and the
API's plugin lands at plugins/notion-skills-updater/ — note the plural, so
the two don't collide and the transition is a delete plus an add.
Known limitations / future work
- GitHub Actions is the only runner. An unverified Vercel handler used to sit
in
api/; it was deleted rather than carried. Nothing blocks another host — the sync is plain HTTPS on both ends and needs onlyNOTION_API_TOKEN+GITHUB_TOKEN— but whoever adds one has to confirm the Notion API host is reachable from it (the dev workspace in particular may not be). - No per-skill publish control (see gotchas) — access to the Notion connection is the only lever. If customers need finer control, it has to come from the Plugins API, not from this tool.
- prod → dev migration (internal Notion use) is a
NOTION_ENVflip + token swap; prod is now the default for external users. - Cold-sync archive fetches are parallel; GitHub writes are not. This
asymmetry is deliberate and easy to get backwards. The Notion half is
latency-bound — one
/v1/ai/plugins/:id+ one download per plugin, each waiting on a server-side render — somapPool(src/sync/pool.ts) runsSYNC_CONCURRENCY(default 8) at a time. Measured 2026-08-11 on dev's 424 plugins: 45 min serial → 3m50s locally, 4m14s in CI, with 1–3 requests rate-limited per run and absorbed by the back-off. The GitHub half is request-count-bound, so parallelizing it makes things strictly worse (see the blob-limit gotcha). Warm runs touch neither path — one list call, no downloads, ~25s.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

