---
name: workflow
description: Build or review Notion App workflows for typed triggers, durable replay-safe steps, and idempotent effects.
user-invocable: true
disable-model-invocation: true
---

# Workflow

Use this skill to add or review a workflow in this template.

## Build or change a workflow

1. Read `AGENTS.md` and the existing files in `src/workflows/`.
2. Inspect the installed workflow and trigger declarations.
3. Choose the trigger, outcome, step boundaries, and required configuration.
4. Create one camelCase file directly in `src/workflows/`.
5. Default-export `workflow(...)` and use typed trigger creators.
6. Put all non-deterministic work in awaited `context.step(...)` calls.
7. Give each step a stable display name. For repeated steps, keep the name
   constant and pass a stable, unique composite `key`, such as
   `{ key: ["process-page", page.id] }`.
8. Return JSON-safe values needed by later steps.
9. Use the step `id` as an idempotency key when supported.
10. Run `npm run check` and `npm run build`.

Each direct `src/workflows/*.ts` file must default-export `workflow(...)`.
The camelCase file name becomes its workflow key.
Keep workflow-specific helpers in `src/workflows/lib/` and shared helpers in
`src/lib/`; direct children of `src/workflows/` are discovered as workflows.

For provider triggers, follow [trigger connection binding](../connections/SKILL.md#bind-triggers-to-connections).
Use the typed trigger callback to check `connectionKey` against the declared
provider connections.

Use a step for every value that can change and every external effect: network
and Notion API calls, mutable state reads, timestamps, random values, generated
IDs, messages, creates, and updates. Keep deterministic transforms of the event
and completed step results outside a step.

Workflow-level key-value state is available through the step closure's `state`.
State is shared across runs of the same workflow and every operation executes
inside that step's durability boundary:

```ts
await context.step("Update cursor", async ({ state }) => {
	state.set("cursor", nextCursor);
	const pendingCursor = await state.get("cursor"); // Reads the buffered write.
	if (!(await saveCursor(pendingCursor))) {
		throw new Error("Save failed"); // The state write is discarded.
	}
});
```

Use `get` and `set`; passing `undefined` to `set` deletes the key, while `null`
is stored as a value. Mutations are buffered and committed atomically only
after the step callback succeeds. Repeated mutations of one key are allowed,
reads observe the latest buffered value, and only the final mutation is
committed. If the callback throws, none of that step's state mutations are
persisted.

Use `context.wait.until(name, { after })` for a relative wait shorter than seven
days, or `context.wait.until(name, { at })` for an absolute date within the next
seven days. The runtime checkpoints the target time, releases the workflow's
compute, and resumes it from the same point later:

```ts
await context.wait.until("Cooling-off period", {
	after: { days: 1, hours: 6 },
});
await context.wait.until("Launch time", { at: launchAt });
```

Relative durations can combine `milliseconds`, `seconds`, `minutes`, `hours`,
`days`, and `weeks`. Every specified value must be a positive finite integer.
Repeated waits can keep one stable display name when each call has a unique
composite `key`:

```ts
for (const page of pages) {
	await context.wait.until("Rate limit delay", {
		key: ["rate-limit", page.id],
		after: { seconds: 1 },
	});
}
```

Use `context.wait.forEvent(name, { event, scope, timeout })` to suspend until an
event occurs. Create `event` with the same `events` creators used in `triggers`,
and describe where it comes from in `scope`: a `dataSourceId` for Notion page
and comment events, `channelIds` for Slack and Discord, an `emailAccountId` for
Mail, `calendars` for Calendar, and a folder or drive for Google Drive. The wait
resolves with the typed event. If `timeout` elapses first, the wait throws and
the run fails without retrying. `timeout` follows the same duration rules as
`wait.until` and must be shorter than seven days:

```ts
const comment = await context.wait.forEvent("Reviewer replies", {
	event: events.notionCommentAdded(),
	scope: { dataSourceId: context.access.reviews.id },
	timeout: { days: 3 },
});
```

An event either starts the workflow or resumes a wait, never both: a workflow
cannot wait for an event type it also has as a trigger on the same connection.
Only events after the wait starts resolve it, and the first matching event wins.
Google Drive changes are polled hourly. Scheduled, webhook, and button events
cannot be waited for. Provider events must use a declared `connectionKey`.

To wait for one specific event, check it and wait again under a new composite
key:

```ts
for (let attempt = 0; ; attempt++) {
	const comment = await context.wait.forEvent("Reply on launch page", {
		key: ["launch-reply", String(attempt)],
		event: events.notionCommentAdded(),
		scope: { dataSourceId: context.access.reviews.id },
		timeout: { days: 3 },
	});
	if (comment.discussion.pageId === launchPageId) break;
}
```

Completed steps replay saved results. Do not rely on in-memory mutations inside
a step. Return the values required by later code. Keep calls in a stable order
and give every step a stable display name. The name is the replay key by
default. For a repeated step, do not interpolate an item ID or loop index into
the name; pass a stable composite key instead:

```ts
await context.step("Process page", { key: ["process-page", page.id] }, async ({ id }) =>
	processPage(page, { idempotencyKey: id }),
);
```

Keys must be stable across retries and unique within one workflow run.

Configure a workflow deadline and retry policy only when the platform defaults do
not fit the workflow:

```ts
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,
		initialDelayMs: 5_000,
		maxDelayMs: 5 * 60 * 1000,
		backoffMultiplier: 2,
	},
	handler: async (_event, context) => {
		await context.step(
			"Charge customer",
			{ timeoutMs: 30_000, retry: { maxAttempts: 2 } },
			chargeCustomer,
		);
	},
});
```

The workflow `retry` policy is the default for every step; a step's `retry`
replaces it rather than stacking. The run itself is retried only for failures
outside a step. A step that fails after using its retries fails the run.

`maxAttempts` includes the initial attempt and is at most 4. Without any policy,
steps make 3 attempts with jittered backoff starting at 1 second, doubling, and
capped at 5 minutes. A step retries ordinary errors and `RetryableError`, but
stops immediately for `FatalError`. Set `retryOn` to an error class, a predicate,
or an array of them to retry only matching errors, for example
`retryOn: TimeoutError` or `retryOn: [TimeoutError, RateLimitError]`. Pass
`retryAfterMs` to `RetryableError` when the failing service provides an explicit
delay. Workflow deadlines include retry delays and are at most 7 days, and step
timeouts apply to one callback attempt.

The step callback's `signal` aborts when the attempt exceeds `timeoutMs` or the
run is asked to stop. It can't stop code that ignores it, so pass `signal` to
`fetch` and other cancellable calls. Otherwise a timed-out attempt can keep
running and repeat its side effects alongside the retry:

```ts
await context.step("Charge customer", { timeoutMs: 30_000 }, ({ id, signal }) =>
	fetch(chargeUrl, { method: "POST", headers: { "Idempotency-Key": id }, signal }),
);
```

Step retries wait inside one workflow invocation, which runs for at most 5
minutes. The longest possible retry delays plus `timeoutMs` for every attempt
must fit in 5 minutes, for step policies and for the workflow `retry` policy
that steps inherit. Earlier work in the invocation counts against the same 5
minutes; a retry that no longer fits is left to a run retry. Use
`context.wait.until()` for longer waits.

An external effect can succeed before its step result is saved. Pass the
callback `id` as an idempotency key when supported; otherwise use a stable
external ID, upsert, or duplicate check.

`context.signal` aborts when the run is asked to stop. Pass it to `fetch` and
other abortable calls, and check it between units of long work, inside or
outside a step:

```ts
const { signal } = context;
await context.step("Process records", async () => {
	for (const record of records) {
		signal.throwIfAborted();
		await processRecord(record, { signal });
	}
});
```

Cancellation is cooperative. Code that ignores `signal` keeps running until the
process is killed. A step that throws because of it fails and is not saved. New
steps do not start after the signal aborts.

Do not write credentials. Add only environment variable names and safe
placeholders to `.env.example` when configuration is required. Return only
JSON-serializable step values, throw on failed requests and missing required
configuration, and do not log secrets or private payloads.

## Manual workflow inputs

Provide up to one `events.manual()` to allow triggering the workflow from the UI
or CLI on demand. For scheduled workflows, include it alongside
`events.scheduled()` so users can run the workflow without waiting for the next
scheduled time.

Specify the inputs accepted using `@notionhq/apps/schema-builder`.

```ts
import { workflow } from "@notionhq/apps/workflow";
import { events } from "@notionhq/apps/events";
import { j } from "@notionhq/apps/schema-builder";

export default workflow({
	name: "Prepare releases",
	description: "Prepares the supplied releases.",
	triggers: [
		events.manual({
			inputSchema: j.object({
				releases: j.array(
					j.object({
						version: j.string(),
						dryRun: j.boolean().nullable().describe("Whether to preview the release"),
					}),
				),
			}),
		}),
	],
	handler: async (event, context) => {
		// event.input: { releases: { version: string; dryRun: boolean | null }[] }
		await context.step("Log releases", () => console.log(event.input.releases));
	},
});
```

## Workflow outputs

Declare `outputSchema` when a workflow returns a value. Use the same
`@notionhq/apps/schema-builder` builders as manual inputs; the schema must be a
`j.object(...)`. The handler must return a matching object of at most 256 KB as
JSON, and the run fails without retrying when it does not. Omit `outputSchema` for workflows that return
nothing.

```ts
import { workflow } from "@notionhq/apps/workflow";
import { events } from "@notionhq/apps/events";
import { j } from "@notionhq/apps/schema-builder";

export default workflow({
	name: "Prepare releases",
	description: "Prepares the supplied releases.",
	triggers: [
		events.manual({
			inputSchema: j.object({ releases: j.array(j.object({ version: j.string() })) }),
		}),
	],
	outputSchema: j.object({ prepared: j.integer() }),
	handler: async (event) => {
		return { prepared: event.input.releases.length };
	},
});
```

## Invoking workflows

Use `context.workflows.start(workflow, input)` to start another workflow in the
same app. Import the target's default export; it must have an `events.manual()`
trigger, and the input you pass is checked against that trigger's
`inputSchema`. It resolves with `{ runId }` once the run is created. The caller
continues right away, and the started run's outcome does not affect it.

Starts are durable: retries and replays return the same run instead of starting
another. Called directly from the handler, a start is its own step. Called
inside `context.step()`, it is part of that step, and a retried step gets the
same run back. Each start needs a unique key within its step, or within the
handler outside a step. The key defaults to the target's workflow key, so pass
`{ key }` when starting the same workflow more than once. Starts can nest at
most 5 levels deep.

```ts
import { workflow } from "@notionhq/apps/workflow";
import { events } from "@notionhq/apps/events";
import { j } from "@notionhq/apps/schema-builder";
import prepareRelease from "./prepareRelease.js";

export default workflow({
	name: "Ship releases",
	description: "Starts a preparation run for each release.",
	triggers: [events.manual({ inputSchema: j.object({ versions: j.array(j.string()) }) })],
	handler: async (event, context) => {
		for (const version of event.input.versions) {
			await context.workflows.start(prepareRelease, { version }, { key: version });
		}
	},
});
```

## Resources created with the App

Use [Notion as Code](../notion-as-code/SKILL.md) for pages, databases, and custom agents
that should be created during deployment. Prefer it over equivalent manual setup
or API calls that create the App's resources. Use runtime API calls for dynamic
data changes, not as a substitute for supported Notion as Code setup.
Keep declarations at module scope. A small workflow-specific resource can be
declared inline. A larger set can live in a local module; for example, declare a
guide page in `src/workflows/sayHello/lib/notion.ts`:

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

export const guide = page({
	resourceId: "workflow-guide",
	properties: { title: notion.text("Workflow guide") },
	content: "This App runs a scheduled workflow.",
});
```

Import the handle into the workflow and declare the named binding it needs:

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

import { guide } from "./sayHello/lib/notion.js";

export default workflow({
	name: "Read guide",
	description: "Reads the App's guide on a recurring schedule.",
	triggers: [events.scheduled(), events.manual()],
	access: { guide: access.view(guide) },
	handler: async (_event, context) => {
		await context.step("Read guide", () =>
			context.notion.pages.retrieve({ page_id: context.access.guide.id }),
		);
	},
});
```

The page is provisioned during deployment, not on every workflow run. Inline
declarations in `access` work too. Importing a declaration alone provisions the
resource but does not create a `context.access` alias. An `access` entry declares
the needed permission level and a named binding for that NaC resource.

`access.view()`, `access.comment()`, `access.edit()`, and `access.fullAccess()`
accept page, database, and data-source handles. `access.call()` accepts custom-agent
handles. Keys are workflow-local aliases. The handler's
typed `context.access` exposes each declared alias as a readonly `{ type, id }`,
not the declaration handle or a permission-management API. UI-granted resources
are permissions only: they do not create `context.access` aliases.

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

For a resource already granted to the workflow in Notion's UI, use its live
record ID directly in a `context.notion` call:

```ts
await context.step("Read granted page", () =>
	context.notion.pages.retrieve({ page_id: "page-id" }),
);
```

Such a UI grant needs no `access` entry, NaC declaration, or NaC `resourceId`.
The call remains subject to the workflow's existing Notion-module permissions.

Use a data-source handle when calling `context.notion.dataSources`: its resolved
ID is a collection ID. A database handle resolves to the database block ID;
these identities are not interchangeable. Notion as Code `resourceId` values
remain declaration identities, not live Notion UUIDs.

Cloud deployment resolves declared handles to live IDs and reconciles their
grants through the existing Notion-module permissions. It saves permissions and
bindings together; bindings themselves do not authorize calls. Changing `access`
updates code-managed grants and bindings while preserving manually configured
UI grants. Normal draft/publish and runtime authorization still apply. Missing
or mismatched required bindings fail before the handler runs.

Deploy workflows with nonempty `access` using `ntn apps deploy`. Runtime API
calls still belong in durable steps. For syncs, see the
[data-source example](../sync/SKILL.md#choose-the-database-source).

## Configure event schedules and watched data sources

Use `events` from `@notionhq/apps` or `@notionhq/apps/events`. Keep the workflow
configuration key `triggers`.

- `events.scheduled({ frequency, interval, start, timeZone, ... })` configures a
  recurrence. Weekly schedules also require a nonempty `weekdays` selection.
- `events.notionPageCreated({ dataSource: tasks.dataSource })` watches new rows.
- `events.notionPageUpdated({ dataSource: tasks.dataSource,
properties: [tasks.dataSource.properties.Status] })` watches property edits.
  Reference the typed property handles, not strings. Use `includePageContent: true`
  for page-content changes, alone or with properties.

Deploy configured triggers with `ntn apps deploy`. Deployment updates the workflow
draft and removes configured triggers omitted on redeploy, while preserving
triggers added in Notion.
Open the workflow in Notion and save it to activate or change its schedule and
page watches. Deployment grants read access to watched databases. Add explicit
`access` declarations for writes. Zero-argument event helpers remain available
for UI configuration.

## Run or update an App sync

Import the sync declaration from `src/syncs/` and keep sync operations inside
durable steps. Trigger the sync when its existing handler should run immediately
with its persisted state:

```ts
await context.step("Refresh issues", () => context.sync.trigger(issuesSync));
```

`trigger` queues the sync and returns when accepted; it does not wait for the
whole sync cycle and does not accept state or input overrides.

When the workflow already has an event-driven delta, apply it directly through
the sync's typed change contract:

```ts
await context.step("Apply issue event", () =>
	context.sync.apply(issuesSync, {
		changes: [
			{
				type: "upsert",
				key: issue.id,
				upstreamUpdatedAt: issue.updatedAt,
				properties: { Name: Builder.title(issue.title) },
			},
		],
	}),
);
```

`apply` writes the changes without running the sync handler and records them
in the sync's stats. It leaves the sync's persisted state alone unless you pass
`state`, which must match the state type the sync handler uses:

```ts
await context.step("Apply issue event", () =>
	context.sync.apply(issuesSync, { changes, state: { cursor: event.cursor } }),
);
```

A batch is not a full snapshot, so `apply` never deletes records it was not
given. In replace mode, the scheduled sync still sweeps records it did not see
during its cycle, and records written by `apply` during that cycle are kept.
This lets one sync combine event-driven writes with a periodic backfill that
cleans up missed records. Use stable record keys and include
`upstreamUpdatedAt` so an older write from either path cannot overwrite a newer
one.

## Review a workflow

Review every file in `src/workflows/` and the modules it calls. Report each
finding with its file, line, impact, and fix. Treat these as errors:

1. A workflow is not a direct file or does not default-export `workflow`.
2. Trigger-specific event fields are used without type narrowing.
3. Non-deterministic work occurs outside an awaited step.
4. Step order or names can change between retries.
5. A step result is not JSON-serializable, or later code needs an in-memory mutation.
6. A retry-sensitive write lacks an idempotency key or duplicate guard.
7. External failures are ignored, or credentials are hard-coded or logged.
8. An explicit trigger connection key is undeclared, belongs to the wrong
   provider, or repeats the same trigger-type/key pair.

Run `npm run check` and `npm run build` when dependencies are installed.
