@notionhq/apps
v0.0.59
Published
An SDK for building workflow apps for Notion
Maintainers
Keywords
Readme
Apps SDK
Imports
The package root exports only resource and capability creation helpers:
import {
access,
page,
database,
teamspace,
customAgent,
sync,
workflow,
customBlock,
} from "@notionhq/apps";Import the helpers your app uses:
import { page, workflow } from "@notionhq/apps";
import { notion } from "@notionhq/apps/notion-as-code";
const home = page({
resourceId: "home",
properties: { title: notion.text("App home") },
});
export default workflow({
name: "Follow up",
description: "Runs when a page is created",
triggers: ({ events }) => [events.notionPageCreated()],
handler: async () => {},
});Namespace imports (import * as Notion from "@notionhq/apps") are also supported
for autocomplete and API exploration.
sync accepts a Notion-as-Code data-source handle. customBlock creates a custom-block
capability; it is not the browser runtime object. The capability factories use these
short names on both the root and their subpaths. Their behavior is unchanged.
The old capability factory names are no longer exported. Update existing imports and calls:
| Removed name | Use instead | Subpath |
| ---------------------- | ------------- | ----------------------------- |
| createWorkflow | workflow | @notionhq/apps/workflow |
| createDataSourceSync | sync | @notionhq/apps/sync |
| createCustomBlock | customBlock | @notionhq/apps/custom-block |
Everything else keeps its subpath import, including builders, value helpers, types, connections, triggers, pacers, and the custom-block browser runtime:
import { Builder } from "@notionhq/apps/builder";
import { notion } from "@notionhq/apps/notion-as-code";
import type { DataSourceSyncConfiguration } from "@notionhq/apps/sync";Existing subpaths remain available, with the capability factory renames above. The root
uses explicit exports and does not re-export utility types or the custom-block browser runtime. Browser code should
keep using @notionhq/apps/custom-blocks and @notionhq/apps/react; the root includes
Node-only workflow code. Styles stay in @notionhq/apps/nds.css.
Agent skills
The package includes skills for workflows, connections, database syncs, custom blocks, and Notion as Code. Read the matching skill before creating, modifying, or troubleshooting an App capability. Shipping the skills with the SDK keeps their instructions up to date with the installed API.
To agree on an App's architecture before building it, use the sketch skill. It writes the App as a small JSON file and renders it as a flow diagram you can review and edit in the browser.
Development
- Install mise
- Run
mise install - Run
mise run setup - Run
mise run check && mise run test
The SDK requires Node 26 or newer. It supports workflows and database syncs.
import { events } from "@notionhq/apps/events";
import { workflow } from "@notionhq/apps";
import { connections } from "@notionhq/apps/workflow";Add a connection requirement to a workflow when it needs Calendar:
export default workflow({
name: "Schedule follow-up",
description: "Schedules a follow-up after a page is created",
triggers: [events.notionPageCreated()],
connections: {
calendar: connections.calendar({
targets: {
followUps: {
description: "Create follow-up events",
permissions: "read_write",
multiple: false,
},
},
}),
},
handler: async () => {},
});The property name is the connection key. Access its typed client with context.connections.calendar; use separate keys for separate connections to the same provider, except Calendar. Declare one Calendar connection and reusable targets; each target chooses its required permissions and whether multiple calendars are allowed. For a multiple-calendar write target, call listCalendars({ calendars: target }) and pass one whole returned calendar object to each write (for example, calendar: selectedCalendar). The same object works for reads with calendars: selectedCalendar.
Every Calendar read requires calendars: pass a named target or a returned calendar object. Omitting it fails instead of reading every calendar in the connection.
Workflow reliability
A workflow's retry policy is the default for every step. A step's own retry
replaces it; the two never stack. A run is only retried for failures outside a
step, such as an error thrown directly in the handler. When a step fails after
using its retries, the run fails without being retried.
import { FatalError, RetryableError } from "@notionhq/apps/workflow";
export default workflow({
name: "Charge customer",
description: "Charges a customer when an invoice is created",
triggers: [events.notionPageCreated()],
deadline: { afterMs: 7 * 24 * 60 * 60 * 1000 },
retry: { maxAttempts: 3, retryOn: PaymentGatewayError },
handler: async (_event, context) => {
await context.step(
"Charge customer",
{ timeoutMs: 30_000, retry: { maxAttempts: 2 } },
chargeCustomer,
);
},
});maxAttempts includes the initial attempt. Throw RetryableError to explicitly
permit another attempt, optionally with retryAfterMs. Throw FatalError to
skip remaining retries. Set retryOn to an error class, a predicate, or an
array of them to retry only matching errors; RetryableError is always retried
and FatalError never is.
| Setting | Default | Limit |
| ------------------------- | ------------------------------- | ---------- |
| retry.maxAttempts | 3 | 1 to 3 |
| retry.initialDelayMs | 1 second | 7 days |
| retry.backoffMultiplier | 2 | at least 1 |
| retry.maxDelayMs | 5 minutes | 7 days |
| retry.retryOn | every error except FatalError | — |
| deadline.afterMs | none | 7 days |
| step timeoutMs | none | — |
A workflow invocation runs for at most 5 minutes, and step retries wait inside
it. A step's longest possible retry delays plus timeoutMs for every attempt
must fit in 5 minutes, and so must the workflow retry policy because steps
use it by default. Policies that don't fit are rejected. Time already spent in
the invocation counts too: if the next retry would run past 5 minutes from the
handler's start, the step stops retrying and the run is retried instead.
Use context.wait.until() for longer waits.
When a step exceeds timeoutMs, the SDK aborts the attempt's signal
(context.step(name, options, ({ signal }) => ...)) and retries. The same
signal also aborts when the run is cancelled. It can't stop
code that ignores the signal, so pass signal to fetch and other cancellable
calls to keep a timed-out attempt from repeating side effects alongside the
retry.
Step retry delays use jittered exponential backoff. A delay from
RetryableError (retryAfterMs) or RateLimitError (retryAfter, in seconds)
replaces the computed backoff.
Manual workflows
Use events.manual({ inputSchema }) to declare user-supplied inputs. The SDK
infers event.input from a j.object(...) schema builder and validates it with Ajv.
Schemas support primitive values, nested objects, and arrays. Every key is required;
use .nullable() to allow null. Objects reject unknown keys automatically, so you
do not need to specify additionalProperties.
See the manual workflow example for the supported schema subset and a complete typed workflow.
Workflow outputs
Use outputSchema to declare the object a workflow returns. It takes the same
j.object(...) schema builder as manual inputs. The handler's return type is
inferred from the schema, and the SDK validates the returned value before the run
succeeds. The output must be at most 256 KB as JSON. A workflow without
outputSchema returns nothing.
See the workflow output example.
Invoking workflows
A workflow can start another workflow in the same app with
context.workflows.start(workflow, input). Pass the imported workflow; it must
have a manual trigger, and its input is typed from that trigger's
inputSchema. It resolves with { runId } once the run is created and does not
wait for it to finish.
Starting is durable: retries and replays reuse the run already started. Call it
directly from the handler, where it is its own step, or inside context.step(),
where it is part of that step. Pass { key } to start the same workflow more
than once.
See the workflow invocation example.
Notion API access
Use context.notion for Notion API calls. Deployed apps receive Notion API
credentials automatically, so do not configure or push NOTION_API_TOKEN.
Local execution needs the token in .env before making a Notion API request.
For a resource declared with Notion as Code, add a named runtime binding with
access: { handbook: access.view(handbook) }. Cloud deployment
resolves the declaration's NaC resourceId to a live ID and exposes it to the
handler as context.access.handbook.id. Deployment also reconciles the declared
access through the existing Notion-module permissions. Those permissions, not
the bindings, authorize context.notion calls.
Resources granted to the workflow in Notion's UI do not need an access entry, a
NaC declaration, or a NaC resourceId. Pass the live record ID directly to the
appropriate context.notion call, for example
context.notion.pages.retrieve({ page_id: "page-id" }); the call remains subject
to the existing workflow permissions. UI grants do not create context.access
aliases. Pages, databases, data sources, and custom agents are supported by
named bindings. See the workflow skill
for access levels and a complete example.
Data-source syncs
Declare a database with Notion as Code, then pass one of its data-source handles to sync:
import { database, sync } from "@notionhq/apps";
import { Builder } from "@notionhq/apps/builder";
const issues = database("issues-db", {
dataSourceResourceId: "issues-source",
name: "GitHub Issues",
schema: {
Name: { resourceId: "name", type: "title" },
"GitHub ID": { resourceId: "github-id", type: "text" },
},
});
export default sync({
dataSource: issues.dataSource,
primaryKey: "GitHub ID",
handler: async () => ({
changes: [
{
type: "upsert",
key: "123",
properties: { Name: Builder.title("Fix the bug") },
},
],
}),
});Put each default-exported sync directly in src/syncs/. A primary key must be a title
or text property. Each upsert must include at least one known non-primary-key schema
property, while any other non-primary-key properties may be omitted; unknown property keys
are rejected by TypeScript. Do not include the primary-key property in an upsert: the SDK
fills it from the change's key.
Notion-as-Code databases
Use schema and an explicit dataSourceResourceId to declare a database with one data source:
import { notion } from "@notionhq/apps/notion-as-code";
const singleSourceIssues = notion.database("branch-check-issues", {
dataSourceResourceId: "branch-check-issues-source",
name: "Issues",
schema: {
Name: { resourceId: "issue-title", type: "title" },
ID: { resourceId: "issue-id", type: "text" },
},
});Pass singleSourceIssues.dataSource as the dataSource argument to sync from @notionhq/apps, or use
singleSourceIssues.dataSource.addPage(...) to declare a page in the source. Both database forms expose addView.
For multiple sources, use datasources instead of the top-level schema:
const issues = notion.database("issues-db", {
datasources: {
Issues: {
resourceId: "issues-source",
schema: {
Name: { resourceId: "issue-title", type: "title" },
"GitHub ID": { resourceId: "github-id", type: "text" },
},
},
},
});
issues.datasources.Issues.schema.Name;schema and datasources are mutually exclusive. A database declaration must
include at least one data source or at least one view. A linked-only database
may omit datasources, or use datasources: {}, only when views is nonempty;
{}, { datasources: {} }, and { datasources: {}, views: [] } are invalid.
Page and teamspace child helpers accept the same forms through
page.addDatabase("database-id", args) and teamspace.addDatabase("database-id", args).
Each datasources key is the source's name, and each schema key is the
property's name used for typed access, sync keys, and serialized properties.
Nested data-source configs and property configs do not accept name. The
top-level database name remains supported; in the single-source shorthand it
supplies both the database and data-source display names. In a multi-source
declaration, it names the database while each data-source name comes from its key.
All database, source, and property IDs remain explicit and must be globally unique.
No IDs are generated from keys or names. The build emits the existing dataSources
and properties arrays with exact IDs and names from those keys; authoring
schema, datasources, and database-level dataSourceResourceId fields do not
leak into the serialized database intent. The provisioning JSON envelope and
server API are unchanged. See the build guide
for complete single-, multi-source, and linked-only examples.
Declare database views with views or addView, or use the top-level view
function from @notionhq/apps (notion.view is also available). The standalone
function accepts ViewArgs: a ViewSchema plus the owning databaseResourceId,
and returns a handle with resourceId. Both types are exported from
@notionhq/apps/notion-as-code, with table, board, calendar, list, gallery, feed,
and timeline variants. View types include sorting, nested filters, grouping,
property visibility, and layout-specific options.
import { view } from "@notionhq/apps";
view({
databaseResourceId: issues.resourceId,
resourceId: "issues-table",
type: "table",
dataSourceResourceId: issues.datasources.Issues.resourceId,
properties: [{ property: "issue-title", visible: true }],
sorts: [{ propertyId: "issue-title", direction: "ascending" }],
});Each view requires a dataSourceResourceId. Property references use property
resource IDs, not display names. Calendar views require calendarBy; timeline
views require timelineBy.
Custom blocks
Declare browser blocks in src/customBlocks/<key>.ts:
import { customBlock } from "@notionhq/apps";
export default customBlock({
path: "./blocks/hello",
slashCommand: "hello",
dataSources: {},
});path is relative to the project root. Keep frontend project code out of src/ to avoid hitting upload caps.
notion-apps build-blocks and notion-apps upload-blocks are used for local builds.
See the build and deployment guide for provisioning artifacts, cloud builds with server coordination, and local builds with CLI coordination.
Local / Boxy end to end testing
To build and deploy the app template against a running local Notion server:
# Installed ntn and the notion-cookbook template from main
scripts/smoketest.sh
# CLI binary and template from sibling checkouts
CLI=local TEMPLATE=local scripts/smoketest.shPublishing New Versions
Publishing is initiated only from the GitHub Actions UI:
- Open the Publish workflow.
- Select Run workflow on the
mainbranch.
The workflow always increments the patch version. It builds, checks, and tests the
SDK before committing the new version, creating a v* GitHub release,
and publishing @notionhq/apps to npm.
Configured workflow events
Use events from @notionhq/apps (or @notionhq/apps/events) inside the workflow's
triggers array. The old triggers helper and /triggers export have been removed;
replace helper imports and calls, but keep the triggers configuration key.
Connection-scoped callbacks now use triggers: ({ events }) => [...].
import { database, events, workflow } from "@notionhq/apps";
const tasks = database("tasks-db", {
dataSourceResourceId: "tasks-source",
name: "Tasks",
schema: {
Name: { resourceId: "task-name", type: "title" },
Status: { resourceId: "task-status", type: "select", options: [] },
},
});
export default workflow({
name: "Task maintenance",
description: "Processes tasks on a schedule and when they change",
triggers: [
events.scheduled({
frequency: "week",
interval: 1,
weekdays: { MO: true, WE: true, FR: true },
start: "2026-09-23T09:00:00",
timeZone: "America/Los_Angeles",
}),
events.notionPageCreated({ dataSource: tasks.dataSource }),
events.notionPageUpdated({
dataSource: tasks.dataSource,
properties: [tasks.dataSource.properties.Status],
}),
],
handler: async (event, context) => {
await context.step("Log event", () => console.log(event.type));
},
});Property handles autocomplete from the declared schema; use handles, not property-name
strings. To watch page content without properties, pass includePageContent: true.
Page events watch rows in the selected data source, not arbitrary individual pages.
Scheduled events accept hourly through yearly recurrence settings.
Deploy configured events with ntn apps deploy after the backend support is
deployed. Deployment updates the workflow draft's configured
trigger set while preserving triggers added in Notion. Open the workflow in Notion
and save it to activate the schedule or page watch; redeployments also require a
save to apply changes. Watched databases receive read access; add access
declarations when the workflow needs more access. Zero-argument helpers such
as events.scheduled() leave configuration to Notion instead.
