-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add notion-agent example #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # 21st SDK credentials (https://21st.dev/agents/api-keys) | ||
| API_KEY_21ST=an_sk_3d11e249701bd71aeb4ec039f8062520af1a4f11099fd69283476d6881690263 | ||
|
|
||
| # Notion integration key is provided by users via the UI at runtime. | ||
| # It gets injected into the agent sandbox automatically. | ||
| # NOTION_API_KEY=ntn_... | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| # 21st SDK — Notion Agent | ||
|
|
||
| Build a Notion workspace assistant that searches, reads, creates, and updates pages through natural language. | ||
|
|
||
| ## What you'll build | ||
|
|
||
| A Next.js app with a chat UI where users connect their own Notion integration key and then interact with their workspace. The agent runs as a Claude Code sandbox and calls the Notion API directly via bash. | ||
|
|
||
| - **Search pages and databases** — find content across your entire workspace | ||
| - **Read page content** — retrieve blocks, properties, and database entries | ||
| - **Create pages** — add new pages inside databases or as subpages | ||
| - **Update and append** — modify page properties and append new content blocks | ||
| - **Per-user key injection** — each user connects with their own Notion integration key; the key is injected into the sandbox at creation time and never stored server-side | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Node.js 20+ | ||
| - A [21st Agents](https://21st.dev/agents) account with an API key | ||
| - A [Notion](https://notion.so) account with an integration key (`ntn_...`) | ||
|
|
||
| ## Environment variables | ||
|
|
||
| | Variable | Where | Description | | ||
| |----------|-------|-------------| | ||
| | `API_KEY_21ST` | `.env.local` | Server-side API key (`an_sk_`) for sandbox creation and token exchange | | ||
| | `NOTION_API_KEY` | Sandbox environment | Injected at sandbox creation via `envs` from the user's UI input; available as `process.env.NOTION_API_KEY` inside the sandbox | | ||
|
|
||
| > `NOTION_API_KEY` is injected into the sandbox process environment via the `envs` option when calling `sandboxes.create()`. Do not use the `files` option for this — the platform overwrites `/home/user/.env` with its own relay config after creation. | ||
|
|
||
| ## Quick start | ||
|
|
||
| ### 1. Clone and install | ||
|
|
||
| ```bash | ||
| git clone https://github.com/21st-dev/an-examples.git | ||
| cd an-examples/notion-agent | ||
| npm install | ||
| ``` | ||
|
|
||
| ### 2. Deploy the agent | ||
|
|
||
| ```bash | ||
| npx @21st-sdk/cli login | ||
| npx @21st-sdk/cli deploy | ||
| ``` | ||
|
|
||
| ### 3. Configure and run | ||
|
|
||
| ```bash | ||
| cp .env.example .env.local | ||
| # Add your API_KEY_21ST to .env.local | ||
| npm run dev | ||
| ``` | ||
|
|
||
| Open [http://localhost:3000](http://localhost:3000), enter your Notion integration key, and start chatting. | ||
|
|
||
| ### 4. Set up your Notion integration | ||
|
|
||
| 1. Go to [notion.so/my-integrations](https://www.notion.so/my-integrations) and create a new integration | ||
| 2. Copy the integration key (`ntn_...`) | ||
| 3. In Notion, open any page you want the agent to access, click **···** → **Connect to** → select your integration | ||
| 4. Paste the key into the app's connect form | ||
|
|
||
| ## Code walkthrough | ||
|
|
||
| ### Agent definition (`agents/notion-agent.ts`) | ||
|
|
||
| Uses `runtime: "claude-code"` so the agent can run bash commands. The Notion API key is read from `process.env.NOTION_API_KEY` — no file parsing needed. All async calls are wrapped in an IIFE because Node 24 rejects top-level `await` in `-e` scripts: | ||
|
|
||
| ```typescript | ||
| import { agent } from "@21st-sdk/agent" | ||
|
|
||
| export default agent({ | ||
| model: "claude-sonnet-4-6", | ||
| runtime: "claude-code", | ||
| permissionMode: "bypassPermissions", | ||
| maxTurns: 20, | ||
| systemPrompt: `You are a Notion workspace assistant... | ||
|
|
||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/search', { ... }); | ||
| console.log(JSON.stringify(await r.json(), null, 2)); | ||
| })()"`, | ||
| }) | ||
| ``` | ||
|
|
||
| ### Key injection (`app/api/agent/sandbox/route.ts`) | ||
|
|
||
| The Notion API key is sent from the browser to the Next.js server, which injects it as an environment variable at sandbox creation time via the `envs` option. The server never stores it — it lives only inside the sandbox process: | ||
|
|
||
| ```typescript | ||
| const sandbox = await client.sandboxes.create({ | ||
| agent: NOTION_AGENT_NAME, | ||
| envs: body.notionApiKey | ||
| ? { NOTION_API_KEY: body.notionApiKey } | ||
| : undefined, | ||
| }) | ||
| const thread = await client.threads.create({ sandboxId: sandbox.id, name: "Notion Chat" }) | ||
| ``` | ||
|
|
||
| ### Token handler (`app/api/agent/token/route.ts`) | ||
|
|
||
| Exchanges your server-side `an_sk_` key for a short-lived JWT. The client never sees your API key: | ||
|
|
||
| ```typescript | ||
| import { createTokenHandler } from "@21st-sdk/nextjs/server" | ||
|
|
||
| export const POST = createTokenHandler({ | ||
| apiKey: process.env.API_KEY_21ST!, | ||
| }) | ||
| ``` | ||
|
|
||
| ### Connect form (`app/page.tsx`) | ||
|
|
||
| Before the first chat, the user enters their Notion integration key. The key is saved to `localStorage` so returning users don't have to re-enter it. On submit, a new sandbox is created with the key injected: | ||
|
|
||
| ```typescript | ||
| // Save key to localStorage for returning users | ||
| localStorage.setItem(NOTION_KEY_STORAGE_KEY, notionApiKey) | ||
|
|
||
| // Create sandbox with key injected into filesystem | ||
| const res = await fetch("/api/agent/sandbox", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ notionApiKey }), | ||
| }) | ||
| ``` | ||
|
|
||
| ## How it works | ||
|
|
||
| - **bash-based API calls** — the agent uses `runtime: "claude-code"` to execute `node` one-liners that call the Notion REST API. This avoids custom tools entirely and works reliably on Node 24. | ||
| - **Key injection via `envs`** — `client.sandboxes.create({ envs: { NOTION_API_KEY: "..." } })` injects the key directly into the sandbox process environment. The `files` option cannot be used here because the platform overwrites `/home/user/.env` with relay config after sandbox creation. | ||
| - **Async IIFE in bash** — Node 24 rejects top-level `await` in `-e` scripts when `require` is also present. All commands wrap async code in `(async () => { ... })()`. | ||
| - **Per-session sandboxes** — each chat session gets its own sandbox. The key is scoped to that sandbox and is not shared across sessions. | ||
| - **localStorage persistence** — sessions, messages, and the Notion key are persisted locally so the UI survives page reloads. | ||
|
|
||
| ## Try it out | ||
|
|
||
| - "Search for pages about product roadmap" | ||
| - "Show me all entries in my Tasks database" | ||
| - "Read the content of my Meeting Notes page" | ||
| - "Create a new page called 'Q2 Planning' in my Projects database" | ||
| - "Add a bullet list to my Weekly Review page" | ||
|
|
||
| ## Project structure | ||
|
|
||
| ``` | ||
| notion-agent/ | ||
| ├── agents/ | ||
| │ └── notion-agent.ts # Agent definition (runtime, system prompt, bash examples) | ||
| ├── app/ | ||
| │ ├── api/agent/ | ||
| │ │ ├── sandbox/route.ts # Creates sandbox with NOTION_API_KEY injected | ||
| │ │ ├── threads/route.ts # Lists and creates threads | ||
| │ │ └── token/route.ts # Token exchange (API_KEY_21ST stays server-side) | ||
| │ ├── components/ | ||
| │ │ └── thread-sidebar.tsx # Thread navigation sidebar | ||
| │ ├── constants.ts # NOTION_AGENT_NAME | ||
| │ ├── types.ts # ChatSession interface | ||
| │ ├── globals.css # CSS variables and utility classes | ||
| │ ├── layout.tsx # Root layout | ||
| │ └── page.tsx # Connect form + chat UI + session management | ||
| ├── .env.example | ||
| └── package.json | ||
| ``` | ||
|
|
||
| ## Commands | ||
|
|
||
| ```bash | ||
| npm run dev # Run dev server | ||
| npm run build # Production build | ||
| npm run login # Authenticate with the platform | ||
| npm run deploy # Deploy the agent | ||
| npm run typecheck # TypeScript type checking | ||
| ``` | ||
|
|
||
| ## Next steps | ||
|
|
||
| - Add more Notion operations (update page properties, move pages, work with databases) | ||
| - Restrict agent capabilities with a `CLAUDE.md` file — see [Skills](https://21st.dev/agents/docs/skills) | ||
| - Learn about sandbox environment injection — see [Build & Deploy](https://21st.dev/agents/docs/agent-projects) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { agent } from "@21st-sdk/agent" | ||
|
|
||
| export default agent({ | ||
| model: "claude-sonnet-4-6", | ||
| runtime: "claude-code", | ||
| permissionMode: "bypassPermissions", | ||
| maxTurns: 20, | ||
|
|
||
| systemPrompt: `You are a Notion workspace assistant. You help users search, read, create, and update pages in their Notion workspace. | ||
|
|
||
| The Notion API key is available as the NOTION_API_KEY environment variable. Use it in every node command like this: | ||
|
|
||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| // your code here | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| Notion API version: 2022-06-28 | ||
| Base URL: https://api.notion.com/v1 | ||
|
|
||
| ## Search pages | ||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/search', { | ||
| method: 'POST', | ||
| headers: { 'Authorization': 'Bearer ' + key, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ query: 'YOUR_QUERY', page_size: 20 }) | ||
| }); | ||
| const d = await r.json(); | ||
| console.log(JSON.stringify(d, null, 2)); | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| ## Get page content (blocks) | ||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/blocks/PAGE_ID/children', { | ||
| headers: { 'Authorization': 'Bearer ' + key, 'Notion-Version': '2022-06-28' } | ||
| }); | ||
| console.log(JSON.stringify(await r.json(), null, 2)); | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| ## Get page properties | ||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/pages/PAGE_ID', { | ||
| headers: { 'Authorization': 'Bearer ' + key, 'Notion-Version': '2022-06-28' } | ||
| }); | ||
| console.log(JSON.stringify(await r.json(), null, 2)); | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| ## Query database | ||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/databases/DB_ID/query', { | ||
| method: 'POST', | ||
| headers: { 'Authorization': 'Bearer ' + key, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ page_size: 50 }) | ||
| }); | ||
| console.log(JSON.stringify(await r.json(), null, 2)); | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| ## Create page in database | ||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/pages', { | ||
| method: 'POST', | ||
| headers: { 'Authorization': 'Bearer ' + key, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| parent: { database_id: 'DB_ID' }, | ||
| properties: { title: { title: [{ text: { content: 'Page Title' } }] } } | ||
| }) | ||
| }); | ||
| console.log(JSON.stringify(await r.json(), null, 2)); | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| ## Append blocks to a page | ||
| \`\`\`bash | ||
| node -e "(async () => { | ||
| const key = process.env.NOTION_API_KEY; | ||
| const r = await fetch('https://api.notion.com/v1/blocks/PAGE_ID/children', { | ||
| method: 'PATCH', | ||
| headers: { 'Authorization': 'Bearer ' + key, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| children: [{ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: 'Your text here' } }] } }] | ||
| }) | ||
| }); | ||
| console.log(JSON.stringify(await r.json(), null, 2)); | ||
| })()" | ||
| \`\`\` | ||
|
|
||
| Rules: | ||
| - Always use process.env.NOTION_API_KEY to get the API key | ||
| - Always wrap async calls in (async () => { ... })() — never use top-level await | ||
| - Parse and display results in a clean, readable format — show page titles, IDs, and relevant properties | ||
| - If a search returns many results, show the most relevant ones first | ||
| - If an API call fails, show the error message and explain what went wrong | ||
| - When creating or updating content, confirm the action with a summary of what was done`, | ||
|
|
||
| onError: async ({ error }) => { | ||
| console.error("[notion-agent] error:", error) | ||
| }, | ||
|
|
||
| onFinish: async ({ cost, duration, turns }) => { | ||
| console.log(`[notion-agent] Done: ${turns} turns, ${duration}ms, $${cost.toFixed(4)}`) | ||
| }, | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { AgentClient } from "@21st-sdk/node" | ||
| import { NextRequest, NextResponse } from "next/server" | ||
| import { NOTION_AGENT_NAME } from "@/app/constants" | ||
|
|
||
| const client = new AgentClient({ apiKey: process.env.API_KEY_21ST! }) | ||
|
|
||
| export async function POST(req: NextRequest) { | ||
| try { | ||
| const body = (await req.json()) as { notionApiKey?: string } | ||
|
|
||
| console.log("[sandbox] Creating new sandbox...") | ||
| const sandbox = await client.sandboxes.create({ | ||
| agent: NOTION_AGENT_NAME, | ||
|
Comment on lines
+12
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This endpoint performs Useful? React with 👍 / 👎. |
||
| envs: body.notionApiKey | ||
| ? { NOTION_API_KEY: body.notionApiKey } | ||
| : undefined, | ||
| }) | ||
|
|
||
| const thread = await client.threads.create({ | ||
| sandboxId: sandbox.id, | ||
| name: "Notion Chat", | ||
| }) | ||
|
|
||
| console.log(`[sandbox] Created sandbox ${sandbox.id} with thread ${thread.id}`) | ||
| return NextResponse.json({ | ||
| sandboxId: sandbox.id, | ||
| threadId: thread.id, | ||
| createdAt: thread.createdAt, | ||
| }) | ||
| } catch (error) { | ||
| console.error("[sandbox] Failed to create sandbox:", error) | ||
| return NextResponse.json( | ||
| { error: "Failed to create sandbox" }, | ||
| { status: 500 }, | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { AgentClient } from "@21st-sdk/node" | ||
| import { NextRequest, NextResponse } from "next/server" | ||
|
|
||
| const client = new AgentClient({ apiKey: process.env.API_KEY_21ST! }) | ||
|
|
||
| export async function GET(req: NextRequest) { | ||
| const sandboxId = req.nextUrl.searchParams.get("sandboxId") | ||
| if (!sandboxId) { | ||
| return NextResponse.json({ error: "sandboxId required" }, { status: 400 }) | ||
| } | ||
|
|
||
| try { | ||
| const threads = await client.threads.list({ sandboxId }) | ||
| return NextResponse.json(threads) | ||
| } catch (error) { | ||
| console.error("[threads] Failed to list threads:", error) | ||
| return NextResponse.json( | ||
| { error: "Failed to list threads" }, | ||
| { status: 500 }, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| export async function POST(req: NextRequest) { | ||
| const { sandboxId, name } = await req.json() | ||
| if (!sandboxId) { | ||
| return NextResponse.json({ error: "sandboxId required" }, { status: 400 }) | ||
| } | ||
|
|
||
| try { | ||
| const thread = await client.threads.create({ sandboxId, name }) | ||
| return NextResponse.json(thread) | ||
| } catch (error) { | ||
| console.error("[threads] Failed to create thread:", error) | ||
| return NextResponse.json( | ||
| { error: "Failed to create thread" }, | ||
| { status: 500 }, | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { createTokenHandler } from "@21st-sdk/nextjs/server" | ||
|
|
||
| export const POST = createTokenHandler({ | ||
| apiKey: process.env.API_KEY_21ST!, | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example env file checks in a concrete
an_sk_...credential instead of a placeholder, which exposes that account to unauthorized use and makes it easy for anyone cloning the repo to accidentally run sandboxes against someone else’s billing quota. Replace it with a non-secret placeholder (for examplean_sk_your_key_here) and rotate the leaked key.Useful? React with 👍 / 👎.