# Apps SDK

[![CI](https://github.com/makenotion/apps-sdk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/makenotion/apps-sdk/actions/workflows/ci.yml?query=branch%3Amain)
[![Publish](https://github.com/makenotion/apps-sdk/actions/workflows/publish.yml/badge.svg?branch=main)](https://github.com/makenotion/apps-sdk/actions/workflows/publish.yml?query=branch%3Amain)

## Imports

The package root exports only resource and capability creation helpers:

```ts
import {
	access,
	page,
	database,
	teamspace,
	customAgent,
	sync,
	workflow,
	customBlock,
} from "@notionhq/apps";
```

Import the helpers your app uses:

```ts
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:

```ts
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](./skills/workflow/SKILL.md),
[connections](./skills/connections/SKILL.md),
[database syncs](./skills/sync/SKILL.md),
[custom blocks](./skills/custom-blocks/SKILL.md), and
[Notion as Code](./skills/notion-as-code/SKILL.md). 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](./skills/sketch/SKILL.md) 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

1. Install [mise](https://mise.jdx.dev/getting-started.html)
2. Run `mise install`
3. Run `mise run setup`
4. Run `mise run check && mise run test`

The SDK requires Node 26 or newer. It supports workflows and database syncs.

```ts
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:

```ts
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.

```ts
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](./skills/workflow/SKILL.md#manual-workflow-inputs)
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](./skills/workflow/SKILL.md#workflow-outputs).

## 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](./skills/workflow/SKILL.md#invoking-workflows).

## 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](./skills/workflow/SKILL.md#resources-created-with-the-app)
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`:

```ts
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:

```ts
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`:

```ts
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](docs/BUILD.md#declaring-notion-as-code-databases)
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.

```ts
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`:

```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](docs/BUILD.md) 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:

```sh
# 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.sh
```

## Publishing New Versions

Publishing is initiated only from the GitHub Actions UI:

1. Open the [Publish workflow](https://github.com/makenotion/sdk/actions/workflows/publish.yml).
2. Select **Run workflow** on the `main` branch.

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 }) => [...]`.

```ts
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.
