If you landed here searching for “how to build an OpenClaw skill for Notion automation,” this is the deep-dive I wanted a month ago. We’ll go from zero to a working skill that lets you type “/todo buy milk” in WhatsApp (or Slack, Telegram… pick your poison) and have it hit the Notion API in under a second. No sales talk, just code, config, and the trade-offs I hit along the way.
Why wire Notion and OpenClaw at all?
Notion already has a decent API and an ecosystem of zaps, make.com scenarios, and widgets. The missing piece is interface fatigue: I don’t want yet another tab, I want to stay in chat where I spend most of my day. OpenClaw’s skill system solves that—one Node.js module, 100 lines of glue, and any chat becomes a universal remote for Notion.
Use-cases engineers in the GitHub issues keep mentioning:
- Task inbox: dump GTD-style tasks from phone to Notion database while commuting.
- Meeting notes: spin up a templated page, pre-filled with attendees and agenda, directly from a Slack channel.
- Content calendar: ask the bot “what’s publishing next week?” and get a quick table in chat.
Prerequisites & versions
- Node.js 22 or newer (OpenClaw hard-fails on 20).
- OpenClaw CLI 0.13.4 (
npm i -g openclaw). - Notion API key (internal integration, no public OAuth flow yet).
- A Notion database you can experiment with—mine is named
Automations.
Everything below runs fine on macOS 14.4 and Ubuntu 24.04. Windows folks confirmed the same steps in Discord, but be aware of PowerShell quoting.
1. Create a Notion integration and grab a token
- Go to Settings & Members → Integrations → Develop your own.
- Name it OpenClaw Skill, set Internal Integration, click Submit.
- Copy the long secret that starts with
secret_. This never shows again. - Share your target database with the integration (top-right Share → invite your integration).
We’ll store the secret in .env later; do not commit it.
2. Scaffold the OpenClaw skill
The OpenClaw SDK ships with a generator. I usually keep skills in ~/claw-skills but folder names don’t matter.
$ mkdir notion-skill && cd notion-skill
$ openclaw init
? Skill name notion
? Description Chat-first automation for Notion
? Author your@email
? Language TypeScript
? Needs persistent state? no
? Needs scheduler? yes
This spits out:
skill.json– manifest read by the gateway.src/index.ts– entry point.src/commands– folder for chat handlers.- ESM-ready
tsconfig.json.
Install Notion client
$ npm i @notionhq/client dotenv
$ npm i -D @types/node
Wire the environment file
# .env
NOTION_TOKEN=secret_xxx
NOTION_TASK_DB=xxxxxxxxxxxxxxxxxxxxxx # 32-char database ID
Database ID is the part after https://www.notion.so/…/ without the dashes.
3. Skill manifest: expose chat commands
skill.json is what the OpenClaw gateway reads to know what our skill can do.
{
"name": "notion",
"description": "Create and manage Notion content from chat",
"version": "0.1.0",
"entry": "dist/index.js",
"commands": [
{
"name": "todo",
"description": "Add a task to the Notion inbox",
"examples": ["/todo buy milk", "/todo review PR #42"]
},
{
"name": "tasks",
"description": "List open tasks for this week",
"examples": ["/tasks", "/tasks urgent"]
},
{
"name": "note",
"description": "Spawn a meeting notes template",
"examples": ["/note Project Kickoff"]
}
],
"scheduler": true
}
You can leave intents out of the manifest and register them dynamically, but static JSON makes the web UI nicer.
4. Implement Notion helpers (CRUD)
src/notion.ts
import { Client } from "@notionhq/client";
import type { QueryDatabaseResponse, PageObjectResponse } from "@notionhq/client/build/src/api-endpoints";
const notion = new Client({ auth: process.env.NOTION_TOKEN });
export async function addTask(title: string): Promise {
return notion.pages.create({
parent: { database_id: process.env.NOTION_TASK_DB! },
properties: {
Name: {
title: [{ text: { content: title } }]
},
Status: {
select: { name: "Inbox" }
},
"Created via": {
select: { name: "OpenClaw" }
}
}
}) as unknown as PageObjectResponse;
}
export async function listTasks(filter?: string): Promise {
return notion.databases.query({
database_id: process.env.NOTION_TASK_DB!,
filter: {
and: [
{ property: "Status", select: { equals: "Inbox" } },
filter
? { property: "Name", title: { contains: filter } }
: undefined
].filter(Boolean) as any
},
sorts: [{ property: "Created", direction: "ascending" }]
});
}
export async function closeTask(pageId: string) {
return notion.pages.update({
page_id: pageId,
properties: {
Status: { select: { name: "Done" } }
}
});
}
Yes, the TypeScript types are verbose; autocomplete is worth it.
5. Glue chat commands to helpers
src/commands/todo.ts
import { CommandContext } from "openclaw-sdk";
import { addTask } from "../notion.js";
export default async function todo(ctx: CommandContext) {
const title = ctx.args.join(" ");
if (!title) return ctx.reply("Syntax: /todo ");
const page = await addTask(title);
await ctx.reply(`Task added → ${page.url}`);
}
src/commands/tasks.ts
import { CommandContext } from "openclaw-sdk";
import { listTasks } from "../notion.js";
export default async function tasks(ctx: CommandContext) {
const filter = ctx.args.join(" ") || undefined;
const result = await listTasks(filter);
if (!result.results.length) return ctx.reply("No tasks found.");
const lines = result.results.map(r => `• ${(r as any).properties.Name.title[0].plain_text} — ${(r as any).url}`);
await ctx.reply(lines.join("\n"));
}
src/commands/note.ts
import { CommandContext } from "openclaw-sdk";
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const MEETING_DB = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"; // another DB id
export default async function note(ctx: CommandContext) {
const title = ctx.args.join(" ") || `Meeting ${new Date().toISOString()}`;
const page: any = await notion.pages.create({
parent: { database_id: MEETING_DB },
properties: {
Name: { title: [{ text: { content: title } }] },
Status: { select: { name: "Draft" } }
},
children: [
{
object: "block",
type: "heading_2",
heading_2: { rich_text: [{ text: { content: "Agenda" } }] }
},
{
object: "block",
type: "heading_2",
heading_2: { rich_text: [{ text: { content: "Notes" } }] }
}
]
});
await ctx.reply(`Notes page created → ${page.url}`);
}
6. Scheduled jobs: clean up done tasks
The scheduler flag we enabled earlier gives us a cron-like API.
src/schedule.ts
import { schedule } from "openclaw-sdk";
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const DB = process.env.NOTION_TASK_DB!;
schedule("0 3 * * *", "archive-done", async () => {
const { results } = await notion.databases.query({
database_id: DB,
filter: { property: "Status", select: { equals: "Done" } }
});
for (const page of results) {
await notion.pages.update({
page_id: (page as any).id,
archived: true
});
}
console.log(`Archived ${results.length} done tasks at ${new Date().toISOString()}`);
});
The cron expression runs daily at 03:00 UTC. Logs show up in ClawCloud’s “Daemon → Logs” UI or your local console.
7. Local test loop
$ npm run build && openclaw dev
Common pain points:
- “Missing notion_token”: forgot to
source .envor add env vars in ClawCloud dashboard. - “unauthorized”: you shared the database, but not sub-pages; Notion permissions are per page.
- EMFILE on macOS: Node 22.0 shipped with a tighter file descriptor limit; bump with
ulimit -n 4096.
8. Push to ClawCloud
Once it works locally:
$ openclaw deploy --skill ./dist
You’ll get a URL like https://gateway.claw.cloud/agent/apricot-otter. The web UI has an Environment tab—paste the same NOTION_TOKEN, NOTION_TASK_DB, etc. ClawCloud restarts the daemon automatically.
Real-world automations worth stealing
Task triage
- Send
/todo <thing>from your phone. - A scheduled job pings you every Friday with
/taskstop 10 oldest. - A second job auto-archives tasks older than 90 days.
Stand-up reporter
- At 09:00, ClawCloud sends “What did you do yesterday?” prompt to the team’s Telegram group.
- Replies are captured and appended as blocks under a daily notes page.
- At 09:15, the bot summarizes via OpenAI and posts back.
Content calendar roll-up
- Type
/content week→ bot queries NotionCalendarDB forStatus = ScheduledandDate within 7 days. - It returns a Markdown table right in chat.
- Editors can reply “/bump 2d” to push a page’s Publish Date property by two days via an update helper.
Security & rate limits
Notion’s internal integrations are scoped to pages you share, so blast radius is small by default. Still:
- Use a separate integration per environment (dev, prod) to keep access logs sane.
- Rotate the
NOTION_TOKENevery quarter; ClawCloud’s Secrets UI supports versioning. - Notion hard-caps at ~3 requests/sec. Add
await new Promise(r => setTimeout(r, 350))in bulk loops or you’ll hit 429s, especially in content-calendar exports.
Things I’d improve next
- Interactive selects: OpenClaw’s UI supports
await ctx.select()but mobile chat platforms don’t. For now I use numbered lists. - OAuth: when Notion launches public OAuth for personal workspaces, swap the static token with per-user tokens to avoid acting as a super-bot.
- Tests: the Notion SDK has a mock mode (
NOTION_BASE_URL=http://localhost:3000) that pairs nicely with MSW for offline CI.
Next step: fork the repo
I pushed a working reference at github.com/pspdfkit-labs/openclaw-notion-skill. Fork it, add your DB IDs, and you’ll have chat-first Notion automation in under half an hour—and plenty of places to pry it open and make it your own.