A TypeScript/JavaScript SDK for interacting with Notion Agents via the Notion Agents Public API.
Status: Alpha
Notion Agents API reference: developers.notion.com/reference/internal/list-agents
This SDK is a thin, typed layer on top of the official Notion SDK (@notionhq/client). In addition to agent-specific helpers, you can call standard Notion API endpoints (pages, databases, blocks, …) through the same client instance.
- Requirements
- Installation
- Quickstart (async)
- Quickstart (streaming)
- Concepts
- Verbose output:
content_parts - API reference (SDK)
- Errors
- Examples
- Node.js 18+ (the streaming API uses
fetchand Web Streams) - ESM: this package is ESM-only (
"type": "module") - A Notion API token (internal integration secret or OAuth access token)
- Notion getting started guide: developers.notion.com
- Alpha access & Custom Agents access
-
Clone the repository containing this SDK.
-
Build the SDK:
cd notion-agents-sdk-js npm install npm run build -
In your project, install from the local path:
npm install /absolute/path/to/notion-agents-sdk-js
If you want a single file you can share internally (e.g. attach to a GitHub Release), you can build a tarball:
cd notion-agents-sdk-js
npm install
npm run build
npm packThat produces a file like notionhq-agents-client-1.0.0.tgz, which you can install from a path or URL:
npm install /absolute/path/to/notionhq-agents-client-1.0.0.tgz
# or: npm install https://host/path/notionhq-agents-client-1.0.0.tgzMost examples assume your token is set as:
export NOTION_API_TOKEN="secret_..."The async flow returns immediately with a thread_id, then you poll for completion and fetch messages separately.
import { NotionAgentsClient } from "@notionhq/agents-client"
const client = new NotionAgentsClient({
auth: process.env.NOTION_API_TOKEN!,
})
// Pick an agent
const agents = await client.agents.list({ page_size: 10 })
const agent = client.agents.agent(agents.results[0].id)
// Start a conversation (returns quickly with pending status)
const invocation = await agent.chat({ message: "Hello!" })
// Poll until the thread is completed or failed
const thread = agent.thread(invocation.thread_id)
const threadInfo = await thread.poll()
console.log(`Thread status: ${threadInfo.status}`)
// Fetch messages (separate endpoint)
const messages = await thread.listMessages({ page_size: 50, verbose: true })
for (const message of messages.results) {
console.log(`${message.role}: ${message.content}`)
}The streaming flow returns newline-delimited JSON (NDJSON) under the hood. The SDK exposes it as an async generator of parsed chunks.
import { NotionAgentsClient, stripLangTags } from "@notionhq/agents-client"
const client = new NotionAgentsClient({
auth: process.env.NOTION_API_TOKEN!,
})
const agent = client.agents.personal()
for await (const chunk of agent.chatStream({ message: "Summarize my week" })) {
if (chunk.type === "message" && chunk.role === "agent") {
process.stdout.write(stripLangTags(chunk.content))
}
if (chunk.type === "error") {
throw new Error(`Stream error [${chunk.code}]: ${chunk.message}`)
}
}chatStream() yields chunks as they arrive, and also returns a final value (ThreadInfo) when the stream ends (thread id, agent id, and the ordered, upserted messages).
To capture that return value, you can iterate manually:
const stream = agent.chatStream({ message: "Hello" })
let threadInfo
while (true) {
const { value, done } = await stream.next()
if (done) {
threadInfo = value
break
}
}
console.log(threadInfo.thread_id, threadInfo.messages.length)- Custom agents are user-created agents in a workspace. They appear in
client.agents.list(). - The personal agent is Notion AI, addressed by a reserved UUID:
- Use
client.agents.personal(), orclient.agents.agent(PERSONAL_AGENT_ID). - Note: internal integrations can’t access the personal agent, since internal integrations are generally owned by workspace owners rather than any specific user. The standard agent won’t appear in
client.agents.list(), and requests targeting it will fail withobject_not_found.
- Use
A chat happens inside a thread:
agent.chat()creates/continues a thread and returns{ thread_id, status: "pending" }.thread.poll()checksGET /v1/agents/:agent_id/threads?id=<thread_id>until the thread iscompletedorfailed.thread.listMessages()fetches messages fromGET /v1/threads/:thread_id/messages.
Important: polling returns thread metadata (status/title/creator/version). Messages are fetched separately via listMessages().
When available, agent messages can include a structured representation of what the agent is doing, in content_parts.
- Streaming:
agent.chatStream()includescontent_partsby default. Passverbose: falseto omit it. - Message listing:
thread.listMessages({ verbose: true })may includecontent_partsfor agent messages. Passverbose: falseto omit it.
content_parts is an ordered array. You can think of it as “UI-friendly structure”, while content remains plain text.
High-level part types you may encounter:
{ type: "text", text: string }— model text{ type: "thinking", text: string }— model reasoning (encrypted thinking is not surfaced){ type: "tool_call", tool_call_id: string | null, tool_name: string, input: string, results?: ToolResult[] }— tool invocation and any associated tool results{ type: "follow_ups", follow_ups: Array<{ label: string, message: string }> }— suggested follow-ups{ type: "custom_agent_template_picker" }— non-text UI state used by the agent runtime
If you don’t need this level of detail, prefer verbose: false and use content only.
In addition to the core classes, the package exports:
PERSONAL_AGENT_ID— reserved UUID for the personal agent (Notion AI)stripLangTags(text: string): string— removes<lang ...>tags from agent outputisPersonalAgent(agentId: string): boolean— checks whether an ID is the personal agent- Pagination helpers:
iterateAgents/collectAgents/iterateThreads/collectThreads/iterateMessages/collectMessages - TypeScript types for requests/responses/streaming chunks (see
dist/index.d.tsonce built)
import { NotionAgentsClient } from "@notionhq/agents-client"
const client = new NotionAgentsClient({
auth: string, // required
baseUrl?: string, // defaults to "https://api.notion.com"
notionVersion?: string, // defaults to "2025-09-03"
})Notes:
- Extends
@notionhq/client’sClient, so you can also callclient.pages,client.databases, etc. - Adds
client.agentsfor agent-specific operations.
Lists all accessible agents (including the personal agent on the first page when accessible).
await client.agents.list({
name?: string,
page_size?: number,
start_cursor?: string,
})Returns Promise<AgentListResponse>.
Creates an Agent instance:
const agent = client.agents.agent(agentId)Convenience accessor for the personal agent:
const personal = client.agents.personal()An Agent represents a single agent (custom or personal).
Starts or continues a conversation (async invocation).
await agent.chat({
message?: string,
attachments?: Array<{ fileUploadId: string, name?: string }>,
threadId?: string,
})You must provide either a non-empty message or at least one attachment.
Attachments must reference files uploaded via the Notion File Upload API. The agent runtime may surface attachments back to you as signed URLs (with an expiry_time) in user message chunks and in listMessages() responses.
Returns Promise<ChatInvocationResponse>.
Starts or continues a conversation (streaming). Yields StreamChunk objects as they arrive.
const stream = agent.chatStream({
message?: string,
attachments?: Array<{ fileUploadId: string, name?: string }>,
threadId?: string,
verbose?: boolean, // default true
onMessage?: (message: StreamMessage) => void,
})Notes:
- Agent message chunks may be emitted multiple times for the same
idas more information becomes available; treat them as upserts keyed byid. - When
verbose: false, agent message chunks omitcontent_partsand only returncontent.
Returns AsyncGenerator<StreamChunk, ThreadInfo, undefined>.
const thread = agent.thread(threadId)
await agent.getThread(threadId) // == agent.thread(threadId).get()
await agent.pollThread(threadId) // == agent.thread(threadId).poll()
await agent.listThreads({...}) // list/paginate/filter threadsFetches thread metadata:
const threadInfo = await thread.get()Returns Promise<ThreadListItem>.
Polls until the thread is completed or failed, with exponential backoff:
await thread.poll({
maxAttempts?: number, // default 60
baseDelayMs?: number, // default 1000
maxDelayMs?: number, // default 10000
initialDelayMs?: number, // default 1000
onPending?: (thread, attempt) => void,
onThreadNotFound?: (attempt) => void,
})Returns Promise<ThreadListItem>. Throws PollingTimeoutError if attempts are exceeded.
Lists messages in a thread:
await thread.listMessages({
verbose?: boolean, // default true
role?: "user" | "agent",
page_size?: number,
start_cursor?: string,
})Returns Promise<ThreadMessageListResponse>.
The SDK exports pagination helpers that automatically manage start_cursor:
import {
iterateAgents,
collectAgents,
iterateThreads,
collectThreads,
iterateMessages,
collectMessages,
} from "@notionhq/agents-client"The SDK provides error classes for common scenarios:
import {
NotionAgentsError,
AgentNotFoundError,
ThreadNotFoundError,
PollingTimeoutError,
StreamError,
} from "@notionhq/agents-client"AgentNotFoundError: thrown when an agent is missing/inaccessible for certain SDK calls.ThreadNotFoundError: thrown when a thread cannot be found via the thread listing endpoint.PollingTimeoutError: thrown whenpoll()exceedsmaxAttempts.StreamError: thrown for streaming-specific failures (HTTP errors, missing body, malformed stream).
Streaming can also produce chunk.type === "error" with a machine-readable code and a message; handle both patterns.
See examples/README.md for runnable scripts:
examples/basic-usage.ts— async chat + polling + message fetchexamples/streaming.ts— streaming chunks + incremental displayexamples/personal-agent.ts— using the personal agentexamples/pagination.ts— iterators/collectors for pagination
To run examples from the SDK repo:
npm install
npm run build
npx tsx examples/basic-usage.ts