Notion Skill for Hermes Agent: API, ntn CLI, and Workers
Notion API + ntn CLI: pages, databases, markdown, Workers.
Written by Neura Market from the official Hermes Agent documentation for Notion. Commands, paths, and version numbers are reproduced from the source unchanged.
Read the official documentationThe Notion skill gives a Hermes Agent session read and write access to a Notion workspace: searching pages, pulling page bodies as Markdown, creating and patching pages, querying databases, uploading files, and, on paid plans, deploying Notion Workers. It is the skill you want when an agent needs to file meeting notes, keep a task database current, or pull a spec into context before answering a question. It is not the right tool for editing Notion's UI settings or for anything that depends on a browser session.
What it does
The skill wraps two interchangeable routes to the same Notion API. The first is ntn, Notion's official command-line client, which shortens the syntax, turns file uploads into a single command, and is the only route that can deploy Workers. The second is plain HTTP with curl, which works on every platform the skill supports. Both routes authenticate with the same integration token, so you can switch between them without reconfiguring anything.
The practical effect is that an agent can treat Notion as a file system of sorts. It can list what exists, read a page as Markdown that fits comfortably in a model context, write new content back, and query structured data out of a database. The Markdown endpoints matter more than they look: block JSON is verbose and awkward for a model to reason over, while Markdown is close to what the model already reads well.
Before you start
Get an integration token
Both routes need a Notion integration. Create one at https://notion.so/my-integrations, copy the API key (it starts with ntn_ or secret_), and store it in ${HERMES_HOME:-~/.hermes}/.env:
NOTION_API_KEY=ntn_your_key_here
The step people skip is sharing. In Notion, open the target page or database menu, choose ... → Connect to, and pick your integration by name. Until you do that, the API returns 404 for a page that plainly exists. This is the single most common cause of a first run that looks broken when it is not.
Install ntn (macOS and Linux)
# Recommended
curl -fsSL https://ntn.dev | bash
# Or via npm (needs Node 22+, npm 10+)
npm install --global ntn
ntn --version # verify
Do not run ntn login. The integration token is enough, and it works without a browser, which is what you need in an agent runtime:
export NOTION_API_TOKEN=$NOTION_API_KEY # ntn reads NOTION_API_TOKEN
export NOTION_KEYRING=0 # don't try to use the OS keychain
Put those exports in your shell profile or in ${HERMES_HOME:-~/.hermes}/.env so every session inherits them. If you skip NOTION_KEYRING=0, the CLI may try to reach the OS keychain, which is not available in a headless context.
Choose the route at runtime
if command -v ntn >/dev/null 2>&1; then
# use ntn
else
# fall back to curl
fi
On Windows, skip the ntn install entirely until native support ships. The curl route works as-is. If you want the CLI ergonomics now, install ntn inside WSL2.
API basics
Every HTTP request must carry Notion-Version: 2025-09-03. ntn adds it for you. In this version, the objects users call databases are called data sources in the API, which changes which endpoint you call and which ID you pass. Keep that distinction in mind for the rest of this guide.
Path A: the ntn CLI
Raw API calls
ntn api is a shorthand for curl. The same endpoints, with less ceremony:
ntn api v1/users # GET
# POST with inline body
ntn api v1/pages parent[page_id]=abc123 \
properties[title][0][text][content]="Notes"
ntn api v1/pages/abc123 -X PATCH archived:=true # PATCH; := is non-string (bool/num/null)
The syntax has three forms worth memorising: key=value for strings, key[nested]=value for nested objects, and key:=value for typed values such as booleans, numbers, null, and arrays. The last one is what lets you send archived:=true without quoting it as a string.
Search
ntn api v1/search query="page title"
Read page metadata
ntn api v1/pages/{page_id}
Read page as Markdown
ntn api v1/pages/{page_id}/markdown
This is the endpoint to reach for when the agent needs to summarise or reason over a page. It returns Markdown rather than block JSON.
Read page content as blocks
ntn api v1/blocks/{page_id}/children
Use this when you need the structure itself, for example to find a specific block type or to walk children.
Create page from Markdown
ntn api v1/pages \
parent[page_id]=xxx \
properties[title][0][text][content]="Notes from meeting" \
markdown="# Agenda
- Q3 roadmap
- Hiring"
Patch a page with Markdown
ntn api v1/pages/{page_id}/markdown -X PATCH \
markdown="## Update
Shipped the prototype."
Query a database (data source)
ntn api v1/data_sources/{data_source_id}/query -X POST \
filter[property]=Status filter[select][equals]=Active
For complex queries with sorts, multiple filter clauses, or compound logic, pipe JSON in:
echo '{"filter": {"property": "Status", "select": {"equals": "Active"}}, "sorts": [{"property": "Date", "direction": "descending"}]}' | \
ntn api v1/data_sources/{data_source_id}/query -X POST --json -
File uploads
ntn files create < photo.png
ntn files create --external-url https://example.com/photo.png
ntn files list
This is the biggest practical win of the CLI. The HTTP route needs three steps (create upload, PUT bytes, reference the upload ID); here it is one command.
Useful environment variables
| Var | Effect |
|---|---|
NOTION_API_TOKEN | Auth token (overrides keychain), set this to your integration token |
NOTION_KEYRING=0 | File-based creds at ~/.config/notion/auth.json instead of OS keychain |
NOTION_WORKSPACE_ID | Skip the workspace picker prompt |
Path B: HTTP and curl
Every request follows the same shape:
curl -s -X GET "https://api.notion.com/v1/..." \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json"
On Windows, the curl that ships with Windows 10 and later works as-is. PowerShell users can also use Invoke-RestMethod.
Search
curl -s -X POST "https://api.notion.com/v1/search" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"query": "page title"}'
Read page metadata
curl -s "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03"
Read page as Markdown
curl -s "https://api.notion.com/v1/pages/{page_id}/markdown" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03"
Read page content as blocks
curl -s "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03"
Create page from Markdown
POST /v1/pages accepts a markdown body param.
curl -s -X POST "https://api.notion.com/v1/pages" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "xxx"},
"properties": {"title": [{"text": {"content": "Notes from meeting"}}]},
"markdown": "# Agenda\n\n- Q3 roadmap\n- Hiring\n\n## Decisions\n- Ship MVP Friday"
}'
Patch a page with Markdown
curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}/markdown" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"markdown": "## Update\n\nShipped the prototype."}'
Create page in a database
curl -s -X POST "https://api.notion.com/v1/pages" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"database_id": "xxx"},
"properties": {
"Name": {"title": [{"text": {"content": "New Item"}}]},
"Status": {"select": {"name": "Todo"}}
}
}'
Query a database (data source)
curl -s -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"filter": {"property": "Status", "select": {"equals": "Active"}},
"sorts": [{"property": "Date", "direction": "descending"}]
}'
Create a database
curl -s -X POST "https://api.notion.com/v1/data_sources" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "xxx"},
"title": [{"text": {"content": "My Database"}}],
"properties": {
"Name": {"title": {}},
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
"Date": {"date": {}}
}
}'
Update page properties
curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
Append blocks to a page
curl -s -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"children": [
{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello from Hermes!"}}]}}
]
}'
File uploads (three steps)
# 1. Create upload
curl -s -X POST "https://api.notion.com/v1/file_uploads" \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"filename": "photo.png", "content_type": "image/png"}'
# 2. PUT bytes to the upload_url returned above
curl -s -X PUT "{upload_url}" --data-binary @photo.png
# 3. Reference {file_upload_id} in a page/block payload
Property types
When you write to a database, each property has a shape. These are the common ones:
- Title:
{"title": [{"text": {"content": "..."}}]} - Rich text:
{"rich_text": [{"text": {"content": "..."}}]} - Select:
{"select": {"name": "Option"}} - Multi-select:
{"multi_select": [{"name": "A"}, {"name": "B"}]} - Date:
{"date": {"start": "2026-01-15", "end": "2026-01-16"}} - Checkbox:
{"checkbox": true} - Number:
{"number": 42} - URL:
{"url": "https://..."} - Email:
{"email": "user@example.com"} - Relation:
{"relation": [{"id": "page_id"}]}
Databases versus data sources
Version 2025-09-03 split the old database concept in two. Databases became data sources, and each database now has two IDs:
database_idwhen creating pages:parent: {"database_id": "..."}data_source_idwhen querying:POST /v1/data_sources/{id}/query
Search returns databases as "object": "data_source" with the data_source_id field. If a query fails with a not-found error, check which ID you passed before anything else.
Notion Workers
Workers are TypeScript programs that Notion hosts for you. A single worker can expose any combination of three capabilities:
- Syncs pull data from external APIs into a Notion database on a schedule (default 30 minutes).
- Tools appear as callable tools inside Notion's Custom Agents.
- Webhooks receive HTTP events from external services such as GitHub or Stripe and act in Notion.
Workers are gated. The CLI works on all plans, but deploying Workers requires Business or Enterprise. ntn is macOS and Linux only as of May 2026, so Windows users need WSL2 or have to wait for native support. Workers are free through August 11, 2026, and metered on Notion credits after that.
Minimal worker
ntn workers new my-worker # scaffold
cd my-worker
# Edit src/index.ts
ntn workers deploy --name my-worker
src/index.ts:
import { Worker } from "@notionhq/workers";
const worker = new Worker();
export default worker;
worker.tool("greet", {
title: "Greet a User",
description: "Returns a friendly greeting",
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
execute: async ({ name }) => `Hello, ${name}!`,
});
Webhook capability
worker.webhook("onGithubPush", {
title: "GitHub Push Handler",
execute: async (events, { notion }) => {
for (const event of events) {
// event.body, event.rawBody (for signature verification), event.headers
console.log("got delivery", event.deliveryId);
}
},
});
After deploy, ntn workers webhooks list shows the URL Notion generates. Treat that URL as a secret: anyone who has it can POST events unless you add signature verification.
Worker lifecycle commands
ntn workers deploy
ntn workers list
ntn workers exec <capability-key> -d '{"name": "world"}'
ntn workers sync trigger <key> # run a sync now
ntn workers sync pause <key>
ntn workers env set GITHUB_WEBHOOK_SECRET=...
ntn workers runs list # recent invocations
ntn workers runs logs <run-id>
ntn workers webhooks list
When asked to build a Worker, scaffold with ntn workers new, write the code in src/index.ts, set any secrets with ntn workers env set, and deploy. Notion's own docs at https://developers.notion.com/workers cover the full API surface.
Notion-flavored Markdown
The /markdown endpoints accept standard CommonMark plus XML-like tags for Notion-specific blocks. Indent with tabs.
Blocks beyond CommonMark:
<callout icon="🎯" color="blue_bg">
Ship the MVP by **Friday**.
</callout>
<details color="gray">
<summary>Toggle title</summary>
Children indented one tab
</details>
<columns>
<column>Left side</column>
<column>Right side</column>
</columns>
<table_of_contents color="gray"/>
Inline features include mentions, underline, color, math (inline $x^2$, block $$ ... $$), and citations written as [^https://example.com]. Colors are gray brown orange yellow green blue purple pink red, plus *_bg variants for backgrounds.
Two rules catch people out. Headings 5 and 6 collapse to H4. And multiple > lines render as separate quote blocks, so use a single > with line breaks inside it for a multi-line quote.
Choosing the right path
| Task | mac / Linux | Windows |
|---|---|---|
| Read/write pages, search, query databases | ntn api ... | curl |
| Read a page for an agent to summarize | ntn api v1/pages/{id}/markdown | curl /markdown endpoint |
| Upload a file | ntn files create < file | 3-step HTTP flow |
| One-off API exploration | ntn api ... | curl |
| Build a sync / webhook / agent tool hosted by Notion | ntn workers ... | WSL2 + ntn workers ... |
When not to use it
The skill covers the API, not the UI. Anything that lives only in Notion's interface, such as database view filters, cannot be set through these calls. If you need streaming Notion access from inside a live session rather than one-shot calls, Notion also ships an MCP server (Notion MCP, roughly 91% more token-efficient on database operations than the previous version), which you can wire through Hermes' MCP support. For most one-shot tasks, the two paths in this guide are enough.
Limits and gotchas
- Page and database IDs are UUIDs. Both the dashed and undashed forms are accepted.
- The rate limit is about 3 requests per second on average. The CLI does not bypass it, so a tight loop of calls will still hit the ceiling.
- The API cannot set database view filters. That is UI-only.
- When creating a data source, pass
"is_inline": trueto embed it in a page. - Always pass
-sto curl. It suppresses progress bars and keeps agent output clean. - Pipe JSON through
jqwhen reading, for example... | jq '.results[0].properties'. - A 404 on a page you can see in the browser almost always means the integration was not shared with that page.
Related
Notion's own developer documentation at https://developers.notion.com/workers is the reference for the Workers API surface. The integration management page at https://notion.so/my-integrations is where tokens are created and rotated. If you want in-session streaming access instead of one-shot calls, look at Hermes' MCP support and the Notion MCP server.