GUIDE

Claude Code + Notion: The API Setup That Actually Works

How to wire Notion into Claude Code without the MCP. A small Node.js CLI, an integration token, and a SKILL.md that teaches Claude the commands. The setup my agents have used daily for months.

Most guides will tell you to install the Notion MCP and call it a day. I tried that. It works for ten minutes, then it doesn't. The tools are slow, the responses are huge and burn context for nothing, the connection drops randomly, and the whole thing breaks the moment you try to script it from a cron job or a subagent.

What I do instead: a small Node.js CLI at ~/.config/notion-tools/notion.js that talks straight to the Notion REST API. About 1,000 lines, no dependencies beyond fetch, exposes the eight or nine operations my agents actually need. Claude Code calls it through Bash like any other shell tool. My chief-of-staff agent, marketing-brain plugin, and student-experience plugin have been hitting it daily for months. It hasn't gone down once.

This guide is the version of the setup I'd hand to a friend who wants Claude Code to read and write Notion reliably.

Why API over MCP for Notion specifically

I love MCP servers. The Playwright MCP is non-negotiable in my stack. The Figma MCP saves hours. Notion is a special case for three reasons:

  1. The Notion MCP returns a lot of metadata you don't want in context. Every tool call drags page IDs, parent IDs, block IDs, timestamps, user IDs, rich-text arrays. For one read it's fine. For an agent that does ten Notion operations in a session, you're paying for tokens that contribute nothing to the answer.
  2. The MCP makes the agent decide how to talk to Notion. That's flexible but inconsistent. Sometimes it queries the database the right way, sometimes it tries to filter on a property that doesn't exist, sometimes it passes the wrong type. With a CLI, you bake the right query patterns into the script and the agent just picks a subcommand.
  3. MCP servers don't run in cron jobs. I have nightly automations that update Notion. The API setup runs anywhere Node runs. The MCP only runs inside an active Claude Code session.

Use the MCP when you want a human in the loop and the queries are exploratory. Use the API plus a CLI when you want a system that cron, subagents, and other plugins can all share.

What you're building

Three pieces:

  1. A Notion internal integration that owns an API token and is granted access to specific databases.
  2. A Node.js CLI at ~/.config/notion-tools/notion.js that wraps the half-dozen Notion API endpoints you'll actually use.
  3. A SKILL.md in your Claude Code skills directory that teaches Claude when to reach for which subcommand.

That's it. No SDK, no MCP server, no background process to babysit.

Step 1: Create the Notion integration

Go to https://www.notion.so/profile/integrations and create a new internal integration. Give it a name like "Claude Code." Notion gives you a secret that starts with ntn_. Copy it somewhere you'll find it again.

Then go to each Notion database or page you want Claude to read or write, click the three-dot menu, hit Connections, and add your integration. Notion is opt-in by page. Your integration can't see anything you haven't explicitly shared with it. That's a feature. It means a leaked token doesn't hand someone your whole workspace.

I share three things with my Claude Code integration: my Life Backlog database (where every cross-life-area task lives), my AI Output Library (where research artifacts get saved), and a handful of working pages.

Step 2: Get the database ID

Open the database in Notion in the browser. The URL looks like this:

https://www.notion.so/yourworkspace/My-Database-6014a1b687444e26bee8002a1a80b7fc?v=...

The 32-character hex string before the ? is your database ID. Save it next to your token.

Step 3: Write the CLI

Create the directory and the file:

mkdir -p ~/.config/notion-tools
touch ~/.config/notion-tools/notion.js
chmod +x ~/.config/notion-tools/notion.js

Here's the spine. This is a trimmed version of mine, with the operations that earn their keep first:

#!/usr/bin/env node
const NOTION_TOKEN = process.env.NOTION_TOKEN;
const NOTION_VERSION = "2022-06-28";
const DATABASE_ID = process.env.NOTION_DATABASE_ID;

const headers = {
  Authorization: `Bearer ${NOTION_TOKEN}`,
  "Notion-Version": NOTION_VERSION,
  "Content-Type": "application/json",
};

async function api(method, path, body) {
  const res = await fetch(`https://api.notion.com/v1${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
  return data;
}

function rt(text) {
  return [{ type: "text", text: { content: text } }];
}

function extractTitle(props) {
  for (const v of Object.values(props)) {
    if (v.type === "title") return v.title.map(t => t.plain_text).join("");
  }
  return "";
}

async function listTasks(filters = {}) {
  const body = { page_size: 100 };
  const conditions = Object.entries(filters).map(([prop, value]) => ({
    property: prop,
    select: { equals: value },
  }));
  if (conditions.length) body.filter = { and: conditions };
  const data = await api("POST", `/databases/${DATABASE_ID}/query`, body);
  for (const page of data.results) {
    console.log(`${page.id.slice(0, 8)}  ${extractTitle(page.properties)}`);
  }
}

async function createTask(title, status = "To Do") {
  await api("POST", "/pages", {
    parent: { database_id: DATABASE_ID },
    properties: {
      Name: { title: rt(title) },
      Status: { select: { name: status } },
    },
  });
  console.log(`Created: ${title}`);
}

async function updateStatus(pageId, status) {
  await api("PATCH", `/pages/${pageId}`, {
    properties: { Status: { select: { name: status } } },
  });
}

async function readPage(pageId) {
  const blocks = await api("GET", `/blocks/${pageId}/children`);
  for (const b of blocks.results) {
    const text = (b[b.type]?.rich_text || []).map(t => t.plain_text).join("");
    if (text) console.log(text);
  }
}

const [cmd, ...args] = process.argv.slice(2);
const handlers = {
  tasks: () => listTasks(Object.fromEntries(args.map(a => a.split("=")))),
  create: () => createTask(args[0], args[1]),
  status: () => updateStatus(args[0], args[1]),
  read: () => readPage(args[0]),
};

(handlers[cmd] || (() => console.log("commands: tasks | create | status | read")))();

Set the env vars in your shell profile:

export NOTION_TOKEN="ntn_your_token_here"
export NOTION_DATABASE_ID="6014a1b687444e26bee8002a1a80b7fc"

Test it:

node ~/.config/notion-tools/notion.js tasks
node ~/.config/notion-tools/notion.js create "Try this" "To Do"

You'll see your task list and a new entry. That's the entire round trip. Integration token, fetch, JSON, done.

Step 4: Teach Claude when to use it

A working CLI is half the system. The other half is the SKILL.md that lives at ~/.claude/skills/notion-backlog/SKILL.md and tells Claude what each subcommand does.

Mine looks like this in spirit:

# Notion Life Backlog

My task system. Used by chief-of-staff, marketing-brain, and dev agents.

**ALWAYS use the CLI at `~/.config/notion-tools/notion.js`. Do NOT use Notion MCP tools.**

## Schema

| Property | Type   | Options                                |
|----------|--------|----------------------------------------|
| Name     | title  | (free text)                            |
| Status   | select | Backlog, To Do, In Progress, Done      |
| Area     | select | ClaudeFluent, Boostly, Life Admin      |

## Read tasks

  node ~/.config/notion-tools/notion.js tasks
  node ~/.config/notion-tools/notion.js tasks status="To Do"
  node ~/.config/notion-tools/notion.js tasks area="ClaudeFluent"

## Create a task

  node ~/.config/notion-tools/notion.js create "Task name" "To Do"

## Update status

  node ~/.config/notion-tools/notion.js status <pageId> "In Progress"

Two things make the SKILL.md work.

The schema table. Claude can't query a database it doesn't understand. The table pins the property names and the valid select values, so it doesn't make up "ToDo" when your option is actually "To Do".

The "do not use the MCP" line. If the Notion MCP is configured anywhere on your machine, Claude will reach for it the moment you mention Notion. You have to explicitly tell it not to. I learned this the hard way after watching an agent burn a five-figure context window crawling through paginated MCP responses when a single CLI call would've answered the question.

Why this beats the MCP for everyday agent work

A few wins from running this for the last several months:

  • Deterministic. Every cron, every subagent, every session calls the same script. The behavior doesn't drift across model versions.
  • Cheap on context. Each call returns exactly what I want, formatted for an agent to read. A tasks query returns one line per page instead of a wall of JSON.
  • Versionable. The CLI is a file in my dotfiles. When I change how a task gets created, every agent that uses the script picks it up the next time it runs.
  • Composable. I can pipe the output into other commands, run it from launchd, call it from a Slack bot. The MCP can't do any of that.

If your Notion is mostly read-only and you only touch it during interactive sessions, the MCP is fine. If you're building agents and automations that lean on Notion as their source of truth, write a CLI.

What to build next

Once the basic CLI is working, the operations that have paid me back the most:

  • append <pageId>: append markdown blocks to an existing page. Great for logging what a subagent did.
  • search <query>: hit the /search endpoint and return matching page titles plus IDs.
  • comment <pageId>: add a comment to a page so a human reviewer gets a notification.
  • create-child <parentId> <title>: create a child page under an existing one. Useful for daily notes and meeting captures.

Each is twenty lines of fetch wrapping. Add them as you hit the use case, not before.

If you want to see the same pattern composed across more services, the chief-of-staff plugin guide walks through how Notion, Gmail, calendar, and Slack stack into one agent. For the broader case of when a SKILL.md beats raw tool access, read the Claude Code skills guide. And if you're still deciding which integrations belong as MCP and which belong as a CLI, the MCP servers guide is the counterpoint to this one.

Related Guides

WANT MORE LIKE THIS?

Learn to build with Claude Code

6 hours of hands-on training. Build real projects. Ship without waiting on engineering.

View Class Details