---
name: notion-as-code
description: Declare Notion pages, databases, teamspaces, and custom agents in an App, including data sources used by syncs.
user-invocable: false
---

# Notion as Code for Apps

Prefer Notion as Code whenever it supports the requested resource setup. Use declarations
for pages, databases, teamspaces, and custom agents instead of equivalent
manual setup or runtime creation calls. Respect explicit user choices and
existing resource attachments; use other methods for unsupported operations
or runtime data changes.

Import the resource creators you use, such as
`import { database, page } from "@notionhq/apps"`. These functions declare
resources for deployment; they do not make Notion API requests when called.
Use `context.notion` for runtime API operations instead.

Declare resources at module scope, not inside handlers or durable steps. Keep a
small declaration inline when it belongs to one capability. For a larger set
owned by one capability, use a local module such as
`src/workflows/myWorkflow/lib/notion.ts`. Use `src/notion.ts` when declarations
are shared across capabilities or do not have a natural capability owner. Module
evaluation must work without credentials or network access.

To configure a workflow's access to a declared page, database, data source, or
custom agent, use the matching helper, such as
`access: { guide: access.view(guide), analyst: access.call(analyst) }`.
The handler receives the live identity as `context.access.alias.id`; importing
a declaration alone does not request access. See the
[workflow skill](../workflow/SKILL.md#resources-created-with-the-app) for levels
and examples. Nonempty workflow `access` requires cloud deployment; `--local-build`
rejects it before upload.

## Where declarations are picked up

The Apps build discovers direct `.ts` children of `src/workflows/`,
`src/syncs/`, and `src/customBlocks/`, then evaluates their imports with the Notion as Code recorder active.
Pages, databases, data sources, teamspaces, and custom agents all follow this
same rule. Their resource type does not determine their file location.

```text
src/
  notion.ts               App-wide resource declarations
  syncs/
    issues.ts             Default-exported sync; imports ../notion.js
  workflows/
    notify.ts             May declare a small related resource inline
    myWorkflow.ts         Imports ./myWorkflow/lib/notion.js
    myWorkflow/
      lib/
        notion.ts         Resources owned by myWorkflow
  customBlocks/
    board.ts              May import shared resource declarations
```

Choose the location based on ownership, not resource type. Export an app-wide
database handle from `src/notion.ts` and import it in `src/syncs/issues.ts` when
the sync uses it. A declaration-only module can be loaded with a side-effect
import. Keep these imports at module scope so build evaluation runs the
declarations.

Resource helper modules, including `src/notion.ts`, are not automatically scanned.
There is no automatically scanned `src/pages/` or `src/databases/` directory
either. Helpers under capability-specific `lib/` directories are not discovered
on their own.
Every direct file in a capability directory must still default-export that
capability, so do not put a resource-only file there. Imports reached only
through browser code do not enter the Notion as Code recording phase. An App containing
only unimported resource declarations is not a
standalone Notion as Code project and has no discovered capabilities.

## Declare resources

Read the installed Notion as Code types before choosing fields. The Apps
root exports support:

- `teamspace({ resourceId, name, accessLevel })`, with `addPage` and
  `teamspace.addDatabase(id, args)` on the returned handle.
- `page({ resourceId, parent?, icon?, properties?, content? })`; regular page titles use
  `properties: { title: notion.text("My Page Title") }`. It also has
  `addPage` and `page.addDatabase(id, args)` for children.
- `database(resourceId, { dataSourceResourceId, name, schema, parent?, views? })`,
  returning a single source as `handle.dataSource`, or
  `database(resourceId, { datasources: { Key: { resourceId, schema } }, views?, parent? })`,
  returning sources as `handle.datasources.Key`. A source's `addPage` declares a row;
  the database handle also exposes `addView` and `addCustomView`.
- `customAgent({ resourceId, name, ... })`; only `resourceId` and `name` are
  required. Choose optional fields individually as described below, and inspect
  the installed types before using them.

When an App calls a custom agent, read the
[Notion Agent APIs overview](https://developers.notion.com/guides/notion-agent-apis/overview)
first.

- `view({ databaseResourceId, resourceId, type, dataSourceResourceId, ... })`,
  returning a handle with `resourceId`. This is also available as `notion.view`.
  Use property resource IDs in view filters, sorts, and layout options; calendar
  views require `calendarBy`, and timeline views require `timelineBy`.

For a regular page, set the title with
`properties: { title: notion.text("My Page Title") }`. A Markdown heading in
`content` creates a child heading block; it does not set the page title. For a
database page, use the title property's schema name instead of `title`.

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; widened or dynamic maps and arrays
are checked at runtime as well as by the type-level forms.

```ts
const linkedIssues = database("project-issues-linked", {
	views: [
		{
			resourceId: "project-issues-table",
			type: "table",
			dataSourceResourceId: "issues-source",
			properties: [{ property: "issue-title", visible: true }],
		},
	],
});
```

The table view above is typed by its `type: "table"` schema and links to the
explicit `issues-source` resource ID.

Attach a declared custom block as a database view with `addCustomView`. The
custom block must be declared in `src/customBlocks/<key>.ts`. If its default
bindings match exactly one data source owned by the database, the SDK infers
that source. Otherwise pass one of the database's own data-source handles.

```ts
import { customBlock, database } from "@notionhq/apps";

const tasks = database("tasks-db", {
	dataSourceResourceId: "tasks-source",
	name: "Tasks",
	schema: { Name: { type: "title", resourceId: "task-name" } },
});
const board = customBlock({
	path: "./blocks/board",
	dataSources: { tasks },
});

tasks.addCustomView({
	resourceId: "tasks-board-view",
	name: "Board",
	customBlock: board,
});
```

Custom views do not accept standard view properties, sorts, or filters. Keep
the view resource ID and the custom-block filename stable across builds.

Place a custom block on a page (including `APP.md`) with a self-closing tag.
The key is the filename in `src/customBlocks/`, so `board.ts` supplies `board`:

```ts
import { notion } from "@notionhq/apps/notion-as-code";

notion.page({
	resourceId: "team-home",
	properties: { title: notion.text("Team home") },
	content: [
		"Tasks for this week",
		'<custom-block definition-key="board" />',
		"Keep this board up to date.",
	].join("\n"),
});
```

Instances inherit the definition's default database and property bindings.
Use exactly one of `definition-key` or `definition-id`; do not add instance
bindings, other attributes, or a tag body. Keys require an App deployment
context and must name a declared capability. Concrete definition IDs must
refer to a live, readable definition in the same workspace, with custom
blocks enabled. The public Markdown API accepts `definition-id` only.

Reading a page returns `<custom-block definition-id="UUID" />`. This ID is
workspace-specific; portable export back to App keys is deferred. NaC
redeployment preserves page and definition identities but replaces page
content, including custom block instances and manually added blocks. Do not
store instance IDs as stable deployment identifiers.

Keep resource IDs stable across builds. They identify declarations, not live
Notion UUIDs. Do not create resources whose IDs use the reserved
`__notion_apps_` prefix. Parenting to reserved IDs such as
`__notion_apps_app_md__` is allowed. Explicit parents use
`{ type: "resourceId", resourceId: parent.resourceId }`; child helpers set
this reference for you. Pages and databases without a parent default to private
top-level resources in the Apps workspace.

Page and database covers accept `{ type: "url", url, position? }`.

Pages accept a standard emoji or a standard Notion icon:

```ts
import { page } from "@notionhq/apps";

page({
	resourceId: "app-guide",
	icon: { type: "emoji", emoji: "🕵️‍♀️" },
});

page({
	resourceId: "release-guide",
	icon: { type: "notion_icon", description: "rocket", color: "blue" },
});
```

Prefer an exact Notion icon slug for `description`, such as `rocket`. Omit `color`
to use gray. Colors are `gray`, `lightgray`, `brown`, `yellow`, `orange`, `green`,
`blue`, `purple`, `pink`, and `red`. Workspace custom emoji and file-backed page
icons are not supported. Omitting `icon` leaves it out of the declaration.

## App page icon

Set the deployed app page icon with YAML frontmatter at the start of `APP.md`:

```yaml
---
icon: 🕵️‍♀️
---
```

For a colored Notion icon, use the same object shape as a page declaration:

```yaml
---
icon:
    type: notion_icon
    description: rocket
    color: blue
---
```

The build removes the frontmatter from the page body. Only `icon` is read from
frontmatter; other metadata is ignored. Omitting it leaves the icon out of the
declaration. Markdown without frontmatter is unchanged. Workspace custom emoji,
file icons, YAML aliases, and custom YAML tags are not supported.

## Page content and agent instructions

Use [Notion Markdown vNext](./markdown.md) for page `content`, custom-agent
`instructions`, and root `APP.md`. Inline resource references use
`<mention url="resource-id">Label</mention>`, not `<mention-page>` or
`<mention-database>`.

## Configure a custom agent

Start with the smallest declaration that describes the requested agent:

```ts
import { customAgent } from "@notionhq/apps";

const analyst = customAgent({
	resourceId: "project-analyst",
	name: "Project analyst",
	instructions: "Summarize project status and identify blocked work.",
});
```

Do not add every optional field by default. Decide on each field separately:

- `instructions`: set when the App should define the agent's purpose or
  behavior. Omit rather than inventing placeholder instructions.
- `icon`: set only when the agent needs a specified visual identity. Custom
  agents accept an emoji or Notion icon, but not a file-backed icon.
- `model`: set only when the user or product requirement calls for one of the
  exact model values in the installed declarations. Otherwise omit it and let
  Notion choose the model.
- `access`: set only for declared pages or databases the agent must use. Grant
  the least-capable suitable helper: `access.view(handle)` for reading,
  `access.comment(handle)` for comments, `access.edit(handle)` for editing, or
  `access.fullAccess(handle)` only when broader access is required. Pass a page
  or database handle, not a resource ID or data-source handle. Custom-agent
  declarations do not accept `access.call(agent)`.
- `browserUse`: set `true` only when the agent needs the Web Browser. Set
  `false` to explicitly disable it; omission preserves the existing setting.
- `webAccess`: set `true` only when the agent must search the web. Set `false`
  when the declaration must explicitly disable web search. For a new agent,
  omission defaults to `false`; for an existing agent, omission preserves its
  current web access and domain restrictions.
- `trustedUrls`: set `"all"` only when the agent must trust every HTTP and
  HTTPS URL, `"none"` to disable trust, or a nonempty array of up to 20 URL
  globs to trust specific URLs. URL trust is independent of web search, so do
  not add it merely because `webAccess` is enabled. Omission disables URL trust
  for a new agent and preserves the current policy for an existing agent.
- `triggers`: set only when the agent should run automatically. Use
  `recurrence` for a schedule, `page_added` for new rows in a data source, and
  `property_updated` for selected property edits and/or page-content edits.
  Do not add a trigger merely because its containing database also appears in `access`.

For an existing agent, `access` is additive: each entry adds a grant or updates
that resource's role, while unlisted grants remain unchanged. Omitting `access`
and passing `access: []` both preserve existing grants. Reuse declared resource
handles and choose levels deliberately:

```ts
import { access, customAgent } from "@notionhq/apps";

customAgent({
	resourceId: "project-analyst",
	name: "Project analyst",
	instructions: "Summarize project status and identify blocked work.",
	access: [access.view(projects)],
	webAccess: true,
});
```

For a recurrence trigger, always set `interval`, `start`, and `timeZone`; add
`end` only for a finite schedule. Weekly triggers also require a nonempty
`weekdays` object, and monthly triggers require `monthlyRestriction`. Page-added
and property-updated triggers reference a declared data source by
`dataSourceResourceId`. Property-updated triggers require property conditions
or `triggerWhenPageContentEdited: true`. For either data-source trigger,
Apps defaults `waitForEditsToFinish` to `false`, so the agent runs without
waiting for follow-up edits. Set it to `true` when the agent should wait.
Give every trigger its own stable `resourceId`.

Database page layouts are typed against their keyed data-source schema. Reference
property resource IDs, not schema keys. The SDK restricts relation groups to
relation properties and narrows standalone property styles by property type.
Database views are typed with `ViewSchema`; standalone declarations accept
`ViewArgs`, which adds the owning `databaseResourceId`.

The Apps SDK exposes a subset of Notion as Code. Data sources are nested inside
`database(resourceId, { datasources: { ... } })`, not declared by a separate
`dataSource` function. It has no `space` workspace declaration:
Apps deployment rejects workspace creation or changes and supplies the App's
workspace binding itself. Value helpers and types stay on their existing
subpaths. For example, import `{ notion }` from `@notionhq/apps/notion-as-code`
for `notion.text(...)`. These helpers are not root exports.
CLI acceptance of a serialized intent envelope alone does not establish server
support for its contents.

## Use a declared data source in a sync

This sync-specific example declares the resource inline in `src/syncs/issues.ts`:

```ts
import { database, sync } from "@notionhq/apps";
import { Builder } from "@notionhq/apps/builder";

const issues = database("issues-db", {
	dataSourceResourceId: "issues-source",
	name: "Issues",
	schema: {
		Name: { resourceId: "issue-name", type: "title" },
		"External ID": { resourceId: "issue-id", type: "text" },
	},
});

export default sync({
	dataSource: issues.dataSource,
	primaryKey: "External ID",
	mode: "incremental",
	handler: async () => ({
		changes: [
			{
				type: "upsert",
				key: "example-123",
				properties: { Name: Builder.title("Example issue") },
			},
		],
		hasMore: false,
	}),
});
```

Pass a data source handle, not the whole database handle or a UUID. The SDK
derives the sync schema from that handle. `primaryKey` names a title or text
property by its schema key, which is also its serialized property name, not its
resource ID. Omit that property from upsert values; the SDK fills it from `key`.
Use Apps `Builder` for sync values. Follow the [sync skill](../sync/SKILL.md)
for pagination and reconciliation.

Author properties in a keyed `schema` with explicit `resourceId` and `type`.
Both `datasources` keys and `schema` keys are names: each data-source key is
that source's name, and each schema key is the property's name used for typed
access, sync values, and serialized property arrays. 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 source name comes from its key. The serialized intent retains the
server's existing arrays. Authoring-only `schema`, `datasources`, and
database-level `dataSourceResourceId` fields do not appear in the serialized
database intent. The provisioning JSON envelope is unchanged.
No IDs are generated.
Each data source needs exactly one title property
and unique property names and IDs. The current adapter supports title, text,
number, select, multi-select, status, date, checkbox, URL, email, phone, and file
properties. It rejects other kinds, including relation, formula, rollup, and
person, even though some appear in the declaration type. Status options use
`todo`, `inProgress`, and `complete` arrays. Read the installed adapter before
extending a schema.

## Build and deployment

Run the app's check and build commands, then inspect `dist/provisioning.json`
alongside the manifest. The provisioning artifact contains recorded resource
declarations. Building it does not create live resources. Do not hand-edit
generated artifacts or deploy output from a failed build.

Deployment applies provisioning and connects resources to the app. Cloud
deployment stores resource mappings on the server; local-build deployment
uses local state. Switching modes can recreate resources because their state
is independent. A failed deployment can leave partial changes; there is no
automatic rollback. Report the result before attempting recovery.

Use `ntn apps deploy` for the App workflow. The default path uploads source
for a cloud build and server-side provisioning. With `--local-build`, the
CLI builds the App, reads `dist/provisioning.json` and `dist/manifest.json`,
deploys code, applies Notion as Code intents, and reconciles sync attachments. It matches
each sync's manifest `databaseKey` to a declared data source's `resourceId`,
then resolves the resulting live data source from provisioning state.
`sync` supplies this matching key from the handle. An existing
binding to a different database causes an error instead of silent rebinding.

The standalone command `ntn notion-as-code apply <dir>` is a different
project flow: it builds that directory and reads `dist/intents.json`.
Do not use it as a substitute for Apps deployment or rename the Apps artifact
to fit it. It does not perform the App's sync attachment reconciliation.

Local deployment defaults to state named `apps-<worker-id>` in the CLI's
environment/workspace-scoped config store. The
`--notion-as-code-state-name` option requires `--local-build`. Preserve that
state when updating; local and cloud mappings are not interchangeable.

Removing all declarations removes the build artifact, but does not delete
previously provisioned resources. Keep local deployment state out of Git.
Validate declarations and sync transforms offline; perform deployment only
as part of a requested live task.
