diff --git a/apps/README.md b/apps/README.md
index 7c85b36c..63e3821b 100644
--- a/apps/README.md
+++ b/apps/README.md
@@ -15,6 +15,7 @@ independent project that can be copied, built, and deployed on its own.
| ----------------------------------------------- | ------------------------------------------------------------------------- |
| [Default app](templates/apps-default/) | A recurring workflow with a durable step and an interactive custom block. |
| [Perfect Meetings](templates/perfect-meetings/) | Calendar connection, deterministic triggers and agentic research |
+| [Statsig Rollout](templates/statsig-rollout/) | Database-triggered workflow that calls an external API with retries |
## Quick start
diff --git a/apps/templates/statsig-rollout/.agents/INSTRUCTIONS.md b/apps/templates/statsig-rollout/.agents/INSTRUCTIONS.md
new file mode 100644
index 00000000..1d34c8b9
--- /dev/null
+++ b/apps/templates/statsig-rollout/.agents/INSTRUCTIONS.md
@@ -0,0 +1,200 @@
+# Apps template guidance
+
+Apps are a private alpha. Check the installed `@notionhq/apps` exports and
+declarations before using a capability; SDK support does not establish that a
+provider is enabled on the server.
+
+## Design the App before implementation
+
+First establish what the complete App should do: its outcome, source data,
+triggers, external services, and every Notion resource it needs. Recommend a
+workflow for most automations; use a sync when the goal is to mirror an external
+collection into a Notion database. An App may contain both.
+
+Before proposing the design, read only enough of this guidance and the
+top-level descriptions of the relevant capability skills (workflow, sync,
+connections, and Notion as Code) to describe concrete options for open
+decisions. Do not read full skill instructions, generated declarations
+(`*.generated.d.ts`), full provider API surfaces, or other implementation
+details until the user agrees on a direction. Verify those details against the
+selected option during implementation.
+
+Present the proposed design concisely and get the user's agreement before
+implementing. Include `APP.md` as the App's home page and primary user
+interface. It should briefly explain the App, embed its most important views,
+and link to supporting Notion resources. The proposal should
+include:
+
+- `APP.md`, the primary views it will embed, and the resources it will link to.
+- Other Notion resources the App will create, including databases, pages, and
+ custom agents; state each resource's purpose and which capabilities use it.
+- Every sync, including its external source, destination database, and
+ synchronization behavior.
+- Every workflow, including its trigger, main actions, resources it reads or
+ changes, and external connections.
+- Open decisions and required access.
+
+Adapt the format to the App. For example:
+
+### Example App design
+
+**Outcome:** Bring support tickets into Notion and escalate urgent tickets to
+the support team.
+
+**Notion resources**
+
+| Kind | Name | Purpose | Used by |
+| ------------ | ----------------- | --------------------------------------------------- | -------------------------------- |
+| `APP.md` | Support App | Explain the App and embed the ticket triage view | Team members |
+| Database | Support tickets | Store synchronized tickets and triage status | Ticket sync, escalation workflow |
+| Page | Support dashboard | Give the team an operational home and database view | Team members |
+| Custom agent | Ticket triage | Classify urgency and summarize a ticket | Escalation workflow |
+
+**Syncs and workflows**
+
+| Kind | Name | Source or trigger | Behavior | Dependencies |
+| -------- | ---------------------- | --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
+| Sync | Ticket sync | Support-system tickets | Upsert by stable external ID | Support-system connection, Support tickets database |
+| Workflow | Escalate urgent ticket | Support tickets page created or updated | Run triage, update status, and notify support channel | Ticket triage agent, Support tickets database, messaging connection |
+
+**Open questions:** Which support system and messaging channel should the App use?
+
+Ask the user to confirm or revise the design. Do not begin implementation
+until they agree. If implementation reveals a material resource or capability
+not covered by the agreed design, update the proposal and confirm the change
+before adding it.
+
+## App home page (APP.md)
+
+The app root includes `APP.md`. This file is the App's home page and, as much
+as the App needs one, its user interface. Design it as the place people come
+to use the App. Update it when the App's behavior or resources change.
+The Apps SDK provisions the page in the
+installation workspace when the file exists. Its page title is the deployed
+App's name, not `APP.md`; do not put a second copy of the title in its body.
+
+Use the installed SDK's `skills/notion-as-code/markdown.md` reference for
+Notion Markdown in `APP.md`, page content, and custom-agent instructions.
+Use resource IDs in mentions and database tags. An optional YAML frontmatter
+`icon` sets the app page icon; the build removes frontmatter from the body.
+
+Use this basic structure, adapting the sections to the App:
+
+1. **Brief description:** Explain what the App does and who it helps in a few
+ sentences.
+2. **Primary views:** Embed the most important databases or specific views,
+ including custom views, in the order people need them. For a CRM, this
+ might be Contacts and a pipeline view; for a meeting App, a next-meeting
+ card and upcoming meetings. Give each view a clear heading and only the
+ explanation needed to use it.
+3. **Common actions:** Explain what to open, add, or change and what happens
+ next. Include views or controls for those actions when available, along
+ with useful status indicators and automatic update behavior.
+4. **Supporting resources:** Link to other pages and databases people may
+ need without embedding every resource on the home page.
+
+Embed linked database views with declared data source or view resource IDs,
+including custom view resource IDs. For example, after declaring the view:
+
+```text
+Contacts
+```
+
+Use a Notion as Code resource reference, such as
+`Resource name`, to link to
+another page declared by the App. Keep the resource ID in sync with the
+corresponding declaration. Use only declared resources in actual embeds and
+links; keep illustrative placeholders in code fences. Do not create a separate
+home page. Keep developer setup, deployment commands, and implementation
+details in the README.
+
+## SDK imports
+
+Import the creation helpers you use directly from the package root:
+
+```ts
+import { database, sync } from "@notionhq/apps"
+```
+
+The root exports `page`, `database`, `teamspace`, `customAgent`, `view`,
+`sync`, `workflow`, and `customBlock`. Workflow helpers `access`, `input`,
+and `events`, plus workflow types such as `WorkflowContext`, are also available
+from the root.
+`sync` takes a Notion as Code data source handle. There is no standalone
+`dataSource`; declare data sources inside `database`.
+
+Keep other imports on their existing subpaths: `Builder` from
+`@notionhq/apps/builder`, value helpers and types from their own modules,
+`connections` from `@notionhq/apps/workflow`, and `events` from
+`@notionhq/apps/events` (or use the workflow's typed trigger callback).
+Provider input and result types come from `@notionhq/apps/connections/`.
+Browser runtime and React APIs also keep their subpaths.
+
+Check that the installed SDK supports these exports before building.
+
+## Capability layout
+
+After the user agrees on the design, compare it with the complete scaffold.
+Remove template workflows, syncs, custom blocks, Notion resource declarations,
+sample assets, and supporting code that the App does not need. Do not leave
+example or placeholder capabilities in discovered capability directories: the
+build can include and deploy them. Preserve shared configuration and
+infrastructure required by the selected capabilities.
+
+Each direct TypeScript file in these directories default-exports a declaration.
+Its filename supplies the capability key. Put capability-specific helpers in
+`src/workflows/lib/` (or the matching capability directory's `lib/`) and shared
+helpers in `src/lib/`. Discovery only reads direct children, so these helpers
+are not treated as capabilities.
+
+The App template's `.agents/skills/` files point to the full SDK skills
+installed under `./node_modules/@notionhq/apps/skills/`. For every area you
+implement, follow the template pointer and read the corresponding installed
+SDK skill listed in this table. If the installed SDK skill is missing, install
+the App's dependencies; if it remains missing, update `@notionhq/apps` to a
+release that includes it. Do not continue without the full SDK guidance.
+
+| Area | Where used | Template pointer | Full SDK skill |
+| -------------- | ------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------ |
+| Workflow | `src/workflows/` | `.agents/skills/workflow/SKILL.md` | `node_modules/@notionhq/apps/skills/workflow/SKILL.md` |
+| Connections | Workflow provider clients and triggers | `.agents/skills/connections/SKILL.md` | `node_modules/@notionhq/apps/skills/connections/SKILL.md` |
+| Database sync | `src/syncs/` | `.agents/skills/sync/SKILL.md` | `node_modules/@notionhq/apps/skills/sync/SKILL.md` |
+| Custom blocks | `src/customBlocks/` | `.agents/skills/custom-blocks/SKILL.md` | `node_modules/@notionhq/apps/skills/custom-blocks/SKILL.md` |
+| Notion as Code | Resources declared in modules imported by workflows or syncs | `.agents/skills/notion-as-code/SKILL.md` | `node_modules/@notionhq/apps/skills/notion-as-code/SKILL.md` |
+
+Notion as Code declarations are not a separate discovered capability
+directory. Syncs can use their declared data source handles.
+
+Prefer Notion as Code over other methods for equivalent supported resource
+setup. Use another method when the user explicitly requests it, an existing
+resource must be attached, or the required operation is not supported by Apps
+Notion as Code. Runtime operations on changing data still use the appropriate API.
+
+Apps do not expose Worker-style tool or webhook registration, Worker fetch
+interceptors, or Worker managed-database configuration. Do not translate those
+APIs by changing package names. Sync contexts have `notion`, but no workflow
+`step` or `connections` client.
+
+## Runtime and credentials
+
+Use Node.js 26 or newer and install from the app root. Use `context.notion`
+for Notion API access. Deployed apps receive Notion credentials automatically;
+local execution needs `NOTION_API_TOKEN` in `.env` before making API requests.
+Never commit tokens or generated `dist/` and `.notion/` state.
+
+## Verification and deployment
+
+Run `npm run check` and `npm run build` from the app root after changes.
+Add offline tests for pagination, transforms, and retry-sensitive behavior.
+Building does not verify server availability, published connection setup, or
+browser interactions. State separately which live checks actually ran.
+
+For a requested deployment, enable `ntn experiments enable apps`, authenticate,
+and use `ntn apps deploy --json` with any required arguments. On the first
+deployment, pass `--name `; omit `--name` for updates. Use the JSON fields
+`worker_url`, `setup_url`, and `is_update` in the handoff. For a first
+deployment (`is_update` is false), show the `setup_url` and tell the user to
+open it to finish setup. For an update (`is_update` is true), show both URLs
+with clear labels: `worker_url` opens the deployed App and `setup_url` revisits
+onboarding. Use the URLs returned by the CLI; do not construct or guess them.
+Read the installed CLI help for other commands.
diff --git a/apps/templates/statsig-rollout/.agents/skills/connections/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/connections/SKILL.md
new file mode 100644
index 00000000..e90c6217
--- /dev/null
+++ b/apps/templates/statsig-rollout/.agents/skills/connections/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: connections
+description: Configure and use typed provider connections and connection-bound triggers in Notion App workflows.
+user-invocable: false
+---
+
+# App connections
+
+Before creating, modifying, or troubleshooting connection code, read
+`node_modules/@notionhq/apps/skills/connections/SKILL.md`.
+Resolve this path relative to the App project root.
+Follow the instructions in that file.
+
+If the file is missing, install the project dependencies.
+If it remains missing, update `@notionhq/apps` to a release that includes the skill.
+Do not continue with connection changes until you can read the SDK skill.
diff --git a/apps/templates/statsig-rollout/.agents/skills/custom-blocks/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/custom-blocks/SKILL.md
new file mode 100644
index 00000000..4d8ff3f0
--- /dev/null
+++ b/apps/templates/statsig-rollout/.agents/skills/custom-blocks/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: custom-blocks
+description: Build interactive browser custom blocks declared by a Notion App.
+user-invocable: false
+---
+
+# App custom blocks
+
+Before creating, modifying, or troubleshooting custom block code, read
+`node_modules/@notionhq/apps/skills/custom-blocks/SKILL.md`.
+Resolve this path relative to the App project root.
+Follow the instructions in that file.
+
+If the file is missing, install the project dependencies.
+If it remains missing, update `@notionhq/apps` to a release that includes the skill.
+Do not continue with custom block changes until you can read the SDK skill.
diff --git a/apps/templates/statsig-rollout/.agents/skills/notion-as-code/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/notion-as-code/SKILL.md
new file mode 100644
index 00000000..a113829a
--- /dev/null
+++ b/apps/templates/statsig-rollout/.agents/skills/notion-as-code/SKILL.md
@@ -0,0 +1,16 @@
+---
+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
+---
+
+# Apps Notion as Code
+
+Before creating, modifying, or troubleshooting Notion as Code code, read
+`node_modules/@notionhq/apps/skills/notion-as-code/SKILL.md`.
+Resolve this path relative to the App project root.
+Follow the instructions in that file.
+
+If the file is missing, install the project dependencies.
+If it remains missing, update `@notionhq/apps` to a release that includes the skill.
+Do not continue with Notion as Code changes until you can read the SDK skill.
diff --git a/apps/templates/statsig-rollout/.agents/skills/sync/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/sync/SKILL.md
new file mode 100644
index 00000000..6aab482f
--- /dev/null
+++ b/apps/templates/statsig-rollout/.agents/skills/sync/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: sync
+description: Build, debug, or review Notion App database syncs with stable keys and resumable pagination.
+user-invocable: false
+---
+
+# App database syncs
+
+Before creating, modifying, or troubleshooting sync code, read
+`node_modules/@notionhq/apps/skills/sync/SKILL.md`.
+Resolve this path relative to the App project root.
+Follow the instructions in that file.
+
+If the file is missing, install the project dependencies.
+If it remains missing, update `@notionhq/apps` to a release that includes the skill.
+Do not continue with sync changes until you can read the SDK skill.
diff --git a/apps/templates/statsig-rollout/.agents/skills/workflow/SKILL.md b/apps/templates/statsig-rollout/.agents/skills/workflow/SKILL.md
new file mode 100644
index 00000000..e68efd8f
--- /dev/null
+++ b/apps/templates/statsig-rollout/.agents/skills/workflow/SKILL.md
@@ -0,0 +1,17 @@
+---
+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
+---
+
+# App workflows
+
+Before creating, modifying, or troubleshooting workflow code, read
+`node_modules/@notionhq/apps/skills/workflow/SKILL.md`.
+Resolve this path relative to the App project root.
+Follow the instructions in that file.
+
+If the file is missing, install the project dependencies.
+If it remains missing, update `@notionhq/apps` to a release that includes the skill.
+Do not continue with workflow changes until you can read the SDK skill.
diff --git a/apps/templates/statsig-rollout/.claude/skills b/apps/templates/statsig-rollout/.claude/skills
new file mode 120000
index 00000000..2b7a412b
--- /dev/null
+++ b/apps/templates/statsig-rollout/.claude/skills
@@ -0,0 +1 @@
+../.agents/skills
\ No newline at end of file
diff --git a/apps/templates/statsig-rollout/.claudeignore b/apps/templates/statsig-rollout/.claudeignore
new file mode 100644
index 00000000..665da455
--- /dev/null
+++ b/apps/templates/statsig-rollout/.claudeignore
@@ -0,0 +1,2 @@
+.env
+.env.*
diff --git a/apps/templates/statsig-rollout/.codexignore b/apps/templates/statsig-rollout/.codexignore
new file mode 100644
index 00000000..665da455
--- /dev/null
+++ b/apps/templates/statsig-rollout/.codexignore
@@ -0,0 +1,2 @@
+.env
+.env.*
diff --git a/apps/templates/statsig-rollout/.env.example b/apps/templates/statsig-rollout/.env.example
new file mode 100644
index 00000000..a4441f7f
--- /dev/null
+++ b/apps/templates/statsig-rollout/.env.example
@@ -0,0 +1,2 @@
+STATSIG_CONSOLE_API_KEY=console-xxxxxxxx
+STATSIG_SEGMENT_ID=my_rollout_segment
diff --git a/apps/templates/statsig-rollout/.gitignore b/apps/templates/statsig-rollout/.gitignore
new file mode 100644
index 00000000..add172fe
--- /dev/null
+++ b/apps/templates/statsig-rollout/.gitignore
@@ -0,0 +1,12 @@
+.notion/
+app.json
+workers.json
+dist/
+node_modules/
+package-lock.json
+.env
+.env.*
+!.env.example
+
+# Local editor state; each installation creates its own.
+.claude/settings.local.json
diff --git a/apps/templates/statsig-rollout/AGENTS.md b/apps/templates/statsig-rollout/AGENTS.md
new file mode 120000
index 00000000..b4012b3d
--- /dev/null
+++ b/apps/templates/statsig-rollout/AGENTS.md
@@ -0,0 +1 @@
+.agents/INSTRUCTIONS.md
\ No newline at end of file
diff --git a/apps/templates/statsig-rollout/APP.md b/apps/templates/statsig-rollout/APP.md
new file mode 100644
index 00000000..e10626e7
--- /dev/null
+++ b/apps/templates/statsig-rollout/APP.md
@@ -0,0 +1,10 @@
+---
+icon:
+ type: notion_icon
+ description: flag
+ color: green
+---
+
+Add a space, user, or other UUID below to add it to your Statsig ID List segment. **Status** shows whether it was added.
+
+Rollout
diff --git a/apps/templates/statsig-rollout/CLAUDE.md b/apps/templates/statsig-rollout/CLAUDE.md
new file mode 120000
index 00000000..b4012b3d
--- /dev/null
+++ b/apps/templates/statsig-rollout/CLAUDE.md
@@ -0,0 +1 @@
+.agents/INSTRUCTIONS.md
\ No newline at end of file
diff --git a/apps/templates/statsig-rollout/README.md b/apps/templates/statsig-rollout/README.md
new file mode 100644
index 00000000..b9bcfc1c
--- /dev/null
+++ b/apps/templates/statsig-rollout/README.md
@@ -0,0 +1,103 @@
+# Statsig Rollout
+
+A Notion App that adds IDs to a Statsig
+[ID List segment](https://docs.statsig.com/segments) from a Notion database.
+Use it to roll a feature gate out to specific spaces, users, or other UUID-keyed
+entities without giving everyone who manages the rollout access to Statsig.
+
+> [!WARNING]
+>
+> Notion Apps and the Apps SDK are early alpha features and can introduce
+> breaking changes.
+
+## How it works
+
+Add a row to **Rollout** with the ID as its title. The `addToSegment` workflow
+then:
+
+1. Normalizes the ID (trims it, lowercases it, and converts the 32-character
+ compact form to dashed UUID form).
+2. Sets **Status** to `invalid` with an explanation in **Error** if it isn't a
+ UUID.
+3. Otherwise sets `adding`, calls
+ `PATCH https://statsigapi.net/console/v1/segments//id_list`,
+ and sets `success` or `fail` (with the Statsig error).
+
+Statsig requests are retried up to 3 times for network, 409, 429, and 5xx
+errors. Adding an ID that is already in the segment succeeds. The App only adds
+IDs; deleting a row does not remove its ID from the segment.
+
+**Re-runs:** only rows at `pending` are processed. To retry an `invalid` or
+`fail` row, fix the ID if needed and set Status back to `pending`. The workflow
+never writes `pending` itself, so the trigger events caused by its own status
+writes are skipped.
+
+## Prerequisites
+
+- Node.js 26 or newer and the `ntn` CLI.
+- A Statsig **Console API** key with write access. A server secret key does not
+ work with the Console API.
+- An existing Statsig segment of type **ID List**. Its ID is shown in the
+ segment's URL in the Statsig console.
+
+## Setup
+
+```shell
+cd apps/templates/statsig-rollout
+npm install
+npm run check
+npm test
+npm run build
+
+ntn experiments enable apps
+ntn login
+ntn apps deploy --name "Statsig Rollout" # omit --name on later deploys
+ntn workers env set STATSIG_CONSOLE_API_KEY=
+ntn workers env set STATSIG_SEGMENT_ID=
+```
+
+After deploying, open the workflow in Notion and save it to activate the page
+triggers. The App's home page embeds the **Rollout** table.
+
+For local execution, copy `.env.example` to `.env` and fill in the values.
+Never commit `.env`.
+
+## Manual debugging
+
+The workflow also has a manual trigger that takes an ID, validates it, and adds
+it to the segment without reading or writing the database. If this succeeds but
+a database row fails, the problem is in the trigger or row handling rather than
+the validation or Statsig call:
+
+```shell
+ntn apps workflows trigger addToSegment --data '{"id":""}'
+ntn apps workflows runs --help # inspect the run's result
+```
+
+An invalid ID or a Statsig error fails the run with the error message.
+
+## Code map
+
+```text
+APP.md Home page: embedded Rollout table
+src/workflows/addToSegment.ts Triggers, status flow, and retries
+src/workflows/lib/notion.ts Rollout database and table view
+src/workflows/lib/uuid.ts UUID normalization (tested)
+src/workflows/lib/statsig.ts Statsig Console API client
+```
+
+## Extension points
+
+- **Non-UUID IDs:** Statsig ID lists accept any string. To roll out by email or
+ another key, replace `normalizeUuid` in `addToSegment.ts` with your own
+ validation.
+- **Several segments:** add a select property to the database and map each
+ option to a segment ID instead of reading `STATSIG_SEGMENT_ID`.
+- **Removal:** Statsig's `DELETE .../id_list` endpoint takes the same body. Add
+ a `remove` status option and call it from the workflow.
+
+## Verification
+
+`npm test` covers UUID normalization and page ID parsing offline. To check the
+live flow, add a known UUID to **Rollout** and confirm that the row reaches
+`success` and the ID appears in the segment in the Statsig console.
diff --git a/apps/templates/statsig-rollout/package.json b/apps/templates/statsig-rollout/package.json
new file mode 100644
index 00000000..8df5fdf6
--- /dev/null
+++ b/apps/templates/statsig-rollout/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@notion-cookbook/statsig-rollout",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Adds IDs from a Notion database to a Statsig ID List segment",
+ "notion": {
+ "name": "Statsig Rollout",
+ "description": "Adds IDs entered in a Notion database to a Statsig ID List segment and reports the result on each row."
+ },
+ "type": "module",
+ "scripts": {
+ "build": "notion-apps build",
+ "check": "tsc --noEmit",
+ "test": "node --experimental-strip-types --test src/workflows/lib/*.test.ts"
+ },
+ "engines": {
+ "node": ">=26.0.0"
+ },
+ "dependencies": {
+ "@notionhq/apps": "latest"
+ },
+ "devDependencies": {
+ "@types/node": "^26.0.0",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/apps/templates/statsig-rollout/pnpm-workspace.yaml b/apps/templates/statsig-rollout/pnpm-workspace.yaml
new file mode 100644
index 00000000..3720bf8c
--- /dev/null
+++ b/apps/templates/statsig-rollout/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+allowBuilds:
+ esbuild: true
+minimumReleaseAgeExclude:
+ - "@notionhq/apps"
diff --git a/apps/templates/statsig-rollout/sketches/statsig-rollout.sketch.json b/apps/templates/statsig-rollout/sketches/statsig-rollout.sketch.json
new file mode 100644
index 00000000..99aec8b8
--- /dev/null
+++ b/apps/templates/statsig-rollout/sketches/statsig-rollout.sketch.json
@@ -0,0 +1,224 @@
+{
+ "version": 0,
+ "name": "Statsig Rollout",
+ "resources": [
+ {
+ "id": "rollout",
+ "label": "Rollout",
+ "properties": {
+ "ID": "title",
+ "Status": "status (pending, adding, success, invalid, fail)",
+ "Error": "text"
+ }
+ }
+ ],
+ "connections": [
+ {
+ "id": "statsig",
+ "label": "Statsig",
+ "notes": "Console API key with write access (STATSIG_CONSOLE_API_KEY) and an ID List segment (STATSIG_SEGMENT_ID)."
+ }
+ ],
+ "state": {
+ "pageId": "1f2e…",
+ "rawId": " 0B9C4D2E8F1A4B3C9D7E6F5A4B3C2D1E ",
+ "status": "pending",
+ "id": "0b9c4d2e-8f1a-4b3c-9d7e-6f5a4b3c2d1e"
+ },
+ "nodes": [
+ {
+ "id": "readRow",
+ "label": "Read the row's ID and Status",
+ "uses": ["rollout"],
+ "reads": ["pageId"],
+ "writes": ["rawId", "status"]
+ },
+ {
+ "id": "gate",
+ "label": "Continue only if the ID is set and Status is pending",
+ "notes": "The workflow never writes pending, so the update events from its own Status writes stop here. Set a row back to pending to retry it.",
+ "reads": ["rawId", "status"],
+ "aux": {
+ "label": "No ID yet, or not pending",
+ "color": "amber",
+ "shape": {
+ "status": "success"
+ }
+ }
+ },
+ {
+ "id": "normalize",
+ "label": "Normalize the ID to a lowercase, dashed UUID",
+ "notes": "Trims, lowercases, and converts the 32-character compact form.",
+ "reads": ["rawId"],
+ "writes": ["id"],
+ "aux": {
+ "label": "Not a UUID",
+ "color": "amber",
+ "shape": {
+ "rawId": "space-123"
+ }
+ }
+ },
+ {
+ "id": "markInvalid",
+ "label": "Set Status to invalid and explain in Error",
+ "uses": ["rollout"],
+ "reads": ["pageId", "rawId"],
+ "out": null
+ },
+ {
+ "id": "markAdding",
+ "label": "Set Status to adding",
+ "uses": ["rollout"],
+ "reads": ["pageId"],
+ "out": null
+ },
+ {
+ "id": "addToSegment",
+ "label": "Add the ID to the Statsig segment",
+ "notes": "PATCH /console/v1/segments/{segment}/id_list. Up to 3 attempts with 1s and 4s backoff for network errors, 409, 429, and 5xx. Adding an ID already in the segment succeeds.",
+ "uses": ["statsig"],
+ "reads": ["id"],
+ "out": null,
+ "error": {
+ "shape": {
+ "status": 403,
+ "body": "…"
+ }
+ }
+ },
+ {
+ "id": "markSuccess",
+ "label": "Set Status to success and clear Error",
+ "uses": ["rollout"],
+ "reads": ["pageId"],
+ "out": null
+ },
+ {
+ "id": "markFail",
+ "label": "Set Status to fail and put the Statsig error in Error",
+ "uses": ["rollout"],
+ "reads": ["pageId"],
+ "out": null
+ },
+ {
+ "id": "manualValidate",
+ "label": "Normalize the ID, failing the run if it isn't a UUID",
+ "notes": "Manual debugging path. It doesn't read or write Rollout.",
+ "reads": ["rawId"],
+ "writes": ["id"],
+ "out": null
+ },
+ {
+ "id": "manualAdd",
+ "label": "Add the ID to the Statsig segment",
+ "notes": "Same Statsig call and retries as addToSegment.",
+ "uses": ["statsig"],
+ "reads": ["id"],
+ "out": null,
+ "error": {
+ "shape": {
+ "status": 403,
+ "body": "…"
+ }
+ }
+ },
+ {
+ "id": "manualFail",
+ "label": "Fail the run with the Statsig error",
+ "out": null
+ }
+ ],
+ "edges": [
+ {
+ "from": null,
+ "to": "readRow",
+ "label": "When a row is added to Rollout",
+ "uses": ["rollout"],
+ "shape": {
+ "pageId": "1f2e…"
+ },
+ "writes": ["pageId"]
+ },
+ {
+ "from": null,
+ "to": "readRow",
+ "label": "When a Rollout row's ID or Status changes",
+ "uses": ["rollout"],
+ "shape": {
+ "pageId": "1f2e…"
+ },
+ "writes": ["pageId"]
+ },
+ {
+ "from": "readRow",
+ "to": "gate"
+ },
+ {
+ "from": "gate",
+ "to": "normalize"
+ },
+ {
+ "from": "gate.aux",
+ "to": null
+ },
+ {
+ "from": "normalize",
+ "to": "markAdding"
+ },
+ {
+ "from": "normalize.aux",
+ "to": "markInvalid"
+ },
+ {
+ "from": "markInvalid",
+ "to": null
+ },
+ {
+ "from": "markAdding",
+ "to": "addToSegment"
+ },
+ {
+ "from": "addToSegment",
+ "to": "markSuccess"
+ },
+ {
+ "from": "addToSegment.error",
+ "to": "markFail"
+ },
+ {
+ "from": "markSuccess",
+ "to": null
+ },
+ {
+ "from": "markFail",
+ "to": null
+ },
+ {
+ "from": null,
+ "to": "manualValidate",
+ "label": "Run manually with an ID (debugging)",
+ "shape": {
+ "id": "0b9c4d2e8f1a4b3c9d7e6f5a4b3c2d1e"
+ },
+ "writes": ["rawId"]
+ },
+ {
+ "from": "manualValidate",
+ "to": "manualAdd"
+ },
+ {
+ "from": "manualAdd",
+ "to": null
+ },
+ {
+ "from": "manualAdd.error",
+ "to": "manualFail"
+ },
+ {
+ "from": "manualFail",
+ "to": null
+ }
+ ]
+}
diff --git a/apps/templates/statsig-rollout/src/workflows/addToSegment.ts b/apps/templates/statsig-rollout/src/workflows/addToSegment.ts
new file mode 100644
index 00000000..fa4a9b33
--- /dev/null
+++ b/apps/templates/statsig-rollout/src/workflows/addToSegment.ts
@@ -0,0 +1,184 @@
+import { access, events, workflow, type WorkflowContext } from "@notionhq/apps"
+import { j } from "@notionhq/apps/schema-builder"
+import { FatalError } from "@notionhq/apps/workflow"
+
+import { rollout, STATUS, type Status } from "./lib/notion.js"
+import { addIdsToSegment, StatsigError } from "./lib/statsig.js"
+import { normalizeUuid, pageIdFromUrl } from "./lib/uuid.js"
+
+const API_KEY_ENV = "STATSIG_CONSOLE_API_KEY"
+const SEGMENT_ENV = "STATSIG_SEGMENT_ID"
+const MAX_ATTEMPTS = 3
+
+export default workflow({
+ name: "Add to segment",
+ description:
+ "Validates an ID added to Rollout and adds it to a Statsig ID List segment.",
+ triggers: [
+ // For debugging: runs the validation and Statsig call without touching
+ // the database, to separate trigger problems from logic problems.
+ events.manual({
+ inputSchema: j.object({
+ id: j.string().describe("ID to add to the segment"),
+ }),
+ }),
+ events.notionPageCreated({ dataSource: rollout.dataSource }),
+ events.notionPageUpdated({
+ dataSource: rollout.dataSource,
+ properties: [
+ rollout.dataSource.properties.ID,
+ rollout.dataSource.properties.Status,
+ ],
+ }),
+ ],
+ access: { spaces: access.edit(rollout.dataSource) },
+ handler: async (event, context) => {
+ if (event.type === "workflow.manual") {
+ const id = normalizeUuid(event.input.id)
+ if (!id) {
+ throw new FatalError(`"${event.input.id}" is not a UUID`)
+ }
+ const result = await context.step(
+ "Add to Statsig segment",
+ { timeoutMs: 60_000, retry: { maxAttempts: 1 } },
+ ({ signal }) => addWithRetry(id, signal)
+ )
+ if (!result.ok) {
+ throw new FatalError(result.error)
+ }
+ console.log(`Added ${id} to ${process.env[SEGMENT_ENV]}`)
+ return
+ }
+
+ const pageId = pageIdFromEvent(event)
+ if (!pageId) {
+ throw new FatalError(
+ "Could not determine the page ID from the trigger event"
+ )
+ }
+
+ const row = await context.step("Read row", async () => {
+ const page = await context.notion.pages.retrieve({ page_id: pageId })
+ const properties = "properties" in page ? page.properties : {}
+ return {
+ id: readTitle(properties.ID),
+ status: readStatus(properties.Status),
+ }
+ })
+
+ const rawId = row.id.trim()
+ // Only pending rows run: new rows, or rows set back to pending to retry.
+ // The workflow never writes pending, so the update events fired by its own
+ // status writes are skipped. Rows with no ID yet stay pending until the ID
+ // is typed in.
+ if (!rawId || (row.status !== null && row.status !== STATUS.pending)) {
+ return
+ }
+
+ const id = normalizeUuid(rawId)
+ if (!id) {
+ await context.step("Mark invalid", () =>
+ setStatus(
+ context.notion,
+ pageId,
+ STATUS.invalid,
+ `"${rawId}" is not a UUID`
+ )
+ )
+ return
+ }
+
+ await context.step("Mark adding", () =>
+ setStatus(context.notion, pageId, STATUS.adding, "")
+ )
+
+ const result = await context.step(
+ "Add to Statsig segment",
+ { timeoutMs: 60_000, retry: { maxAttempts: 1 } },
+ ({ signal }) => addWithRetry(id, signal)
+ )
+
+ await context.step("Record result", () =>
+ result.ok
+ ? setStatus(context.notion, pageId, STATUS.success, "")
+ : setStatus(context.notion, pageId, STATUS.fail, result.error)
+ )
+ },
+})
+
+type AddResult = { ok: true } | { ok: false; error: string }
+
+async function addWithRetry(
+ id: string,
+ signal: AbortSignal
+): Promise {
+ const apiKey = process.env[API_KEY_ENV]
+ const segmentId = process.env[SEGMENT_ENV]
+ if (!apiKey || !segmentId) {
+ return {
+ ok: false,
+ error: `${!apiKey ? API_KEY_ENV : SEGMENT_ENV} is not set`,
+ }
+ }
+ let lastError = ""
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ await addIdsToSegment(apiKey, segmentId, [id], signal)
+ return { ok: true }
+ } catch (error) {
+ signal.throwIfAborted()
+ lastError = error instanceof Error ? error.message : String(error)
+ const retryable = !(error instanceof StatsigError) || error.retryable
+ if (!retryable || attempt === MAX_ATTEMPTS) {
+ break
+ }
+ await new Promise((resolve) =>
+ setTimeout(resolve, 1_000 * 4 ** (attempt - 1))
+ )
+ }
+ }
+ return { ok: false, error: lastError }
+}
+
+async function setStatus(
+ notion: WorkflowContext["notion"],
+ pageId: string,
+ status: Status,
+ error: string
+): Promise {
+ await notion.pages.update({
+ page_id: pageId,
+ properties: {
+ Status: { status: { name: status } },
+ Error: {
+ rich_text: error
+ ? [{ type: "text", text: { content: error.slice(0, 2000) } }]
+ : [],
+ },
+ },
+ })
+}
+
+function pageIdFromEvent(event: {
+ url: string | null
+ [key: string]: unknown
+}): string | null {
+ const page = event.page as { id?: unknown } | undefined
+ if (typeof page?.id === "string") {
+ return page.id
+ }
+ return pageIdFromUrl(event.url)
+}
+
+function readTitle(property: unknown): string {
+ const title =
+ (property as { title?: { plain_text?: string }[] } | undefined)?.title ?? []
+ return title.map((part) => part.plain_text ?? "").join("")
+}
+
+function readStatus(property: unknown): string | null {
+ return (
+ (property as { status?: { name?: string } | null } | undefined)?.status
+ ?.name ?? null
+ )
+}
diff --git a/apps/templates/statsig-rollout/src/workflows/lib/notion.ts b/apps/templates/statsig-rollout/src/workflows/lib/notion.ts
new file mode 100644
index 00000000..b67d6b9f
--- /dev/null
+++ b/apps/templates/statsig-rollout/src/workflows/lib/notion.ts
@@ -0,0 +1,45 @@
+import { database } from "@notionhq/apps"
+
+export const STATUS = {
+ pending: "pending",
+ adding: "adding",
+ invalid: "invalid",
+ success: "success",
+ fail: "fail",
+} as const
+
+export type Status = (typeof STATUS)[keyof typeof STATUS]
+
+export const rollout = database("rollout-db", {
+ dataSourceResourceId: "rollout-source",
+ name: "Rollout",
+ schema: {
+ ID: { resourceId: "rollout-id", type: "title" },
+ Status: {
+ resourceId: "rollout-status",
+ type: "status",
+ options: {
+ todo: [{ name: STATUS.pending, color: "gray", default: true }],
+ inProgress: [{ name: STATUS.adding, color: "blue" }],
+ complete: [
+ { name: STATUS.success, color: "green" },
+ { name: STATUS.invalid, color: "orange" },
+ { name: STATUS.fail, color: "red" },
+ ],
+ },
+ },
+ Error: { resourceId: "rollout-error", type: "text" },
+ },
+})
+
+export const rolloutTable = rollout.addView({
+ resourceId: "rollout-table",
+ name: "All IDs",
+ type: "table",
+ dataSourceResourceId: "rollout-source",
+ properties: [
+ { property: "rollout-id", visible: true },
+ { property: "rollout-status", visible: true },
+ { property: "rollout-error", visible: true },
+ ],
+})
diff --git a/apps/templates/statsig-rollout/src/workflows/lib/statsig.ts b/apps/templates/statsig-rollout/src/workflows/lib/statsig.ts
new file mode 100644
index 00000000..507a5040
--- /dev/null
+++ b/apps/templates/statsig-rollout/src/workflows/lib/statsig.ts
@@ -0,0 +1,41 @@
+const CONSOLE_API = "https://statsigapi.net/console/v1"
+
+/** Adds IDs to a Statsig ID List segment. Adding an existing ID is a no-op. */
+export async function addIdsToSegment(
+ apiKey: string,
+ segmentId: string,
+ ids: string[],
+ signal?: AbortSignal
+): Promise {
+ const response = await fetch(
+ `${CONSOLE_API}/segments/${encodeURIComponent(segmentId)}/id_list`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "STATSIG-API-KEY": apiKey,
+ },
+ body: JSON.stringify({ ids }),
+ signal,
+ }
+ )
+ if (!response.ok) {
+ const body = await response.text().catch(() => "")
+ throw new StatsigError(response.status, body.slice(0, 500))
+ }
+}
+
+export class StatsigError extends Error {
+ readonly status: number
+
+ constructor(status: number, body: string) {
+ super(`Statsig responded ${status}${body ? `: ${body}` : ""}`)
+ this.name = "StatsigError"
+ this.status = status
+ }
+
+ /** 4xx errors other than conflicts and rate limiting will not succeed on retry. */
+ get retryable(): boolean {
+ return this.status === 409 || this.status === 429 || this.status >= 500
+ }
+}
diff --git a/apps/templates/statsig-rollout/src/workflows/lib/uuid.test.ts b/apps/templates/statsig-rollout/src/workflows/lib/uuid.test.ts
new file mode 100644
index 00000000..044e4a4b
--- /dev/null
+++ b/apps/templates/statsig-rollout/src/workflows/lib/uuid.test.ts
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+
+import { normalizeUuid, pageIdFromUrl } from "./uuid.ts"
+
+test("normalizeUuid accepts dashed UUIDs", () => {
+ assert.equal(
+ normalizeUuid(" F04E1A2B-3C4D-4E5F-8A9B-0C1D2E3F4A5B "),
+ "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b"
+ )
+})
+
+test("normalizeUuid converts compact UUIDs", () => {
+ assert.equal(
+ normalizeUuid("f04e1a2b3c4d4e5f8a9b0c1d2e3f4a5b"),
+ "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b"
+ )
+})
+
+test("normalizeUuid rejects non-UUIDs", () => {
+ for (const value of [
+ "",
+ "abc",
+ "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5",
+ "g04e1a2b3c4d4e5f8a9b0c1d2e3f4a5b",
+ "f04e1a2b3c4d-4e5f-8a9b-0c1d2e3f4a5b",
+ ]) {
+ assert.equal(normalizeUuid(value), null, value)
+ }
+})
+
+test("pageIdFromUrl reads the trailing page ID", () => {
+ assert.equal(
+ pageIdFromUrl(
+ "https://www.notion.so/Some-row-f04e1a2b3c4d4e5f8a9b0c1d2e3f4a5b?pvs=4"
+ ),
+ "f04e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b"
+ )
+ assert.equal(pageIdFromUrl(null), null)
+ assert.equal(pageIdFromUrl("https://www.notion.so/no-id"), null)
+})
diff --git a/apps/templates/statsig-rollout/src/workflows/lib/uuid.ts b/apps/templates/statsig-rollout/src/workflows/lib/uuid.ts
new file mode 100644
index 00000000..40c164f5
--- /dev/null
+++ b/apps/templates/statsig-rollout/src/workflows/lib/uuid.ts
@@ -0,0 +1,33 @@
+const DASHED_UUID =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
+const COMPACT_UUID = /^[0-9a-f]{32}$/
+const PAGE_ID_IN_URL =
+ /([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[?#]|$)/i
+
+/**
+ * Normalizes an ID to lowercase dashed UUID form. Accepts the dashed form and
+ * the 32-character compact form used in Notion URLs. Returns null when the
+ * input is not a UUID.
+ */
+export function normalizeUuid(raw: string): string | null {
+ const value = raw.trim().toLowerCase()
+ if (DASHED_UUID.test(value)) {
+ return value
+ }
+ if (COMPACT_UUID.test(value)) {
+ return [
+ value.slice(0, 8),
+ value.slice(8, 12),
+ value.slice(12, 16),
+ value.slice(16, 20),
+ value.slice(20),
+ ].join("-")
+ }
+ return null
+}
+
+/** Extracts the Notion page ID from a page URL such as https://www.notion.so/Title-. */
+export function pageIdFromUrl(url: string | null | undefined): string | null {
+ const match = url ? PAGE_ID_IN_URL.exec(url) : null
+ return match ? normalizeUuid(match[1]!) : null
+}
diff --git a/apps/templates/statsig-rollout/tsconfig.json b/apps/templates/statsig-rollout/tsconfig.json
new file mode 100644
index 00000000..cd746131
--- /dev/null
+++ b/apps/templates/statsig-rollout/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2024",
+ "lib": ["ES2024"],
+ "types": ["node"],
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "strict": true,
+ "noEmit": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "allowImportingTsExtensions": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/catalog.json b/catalog.json
index 3c258515..01c7a9ec 100644
--- a/catalog.json
+++ b/catalog.json
@@ -356,6 +356,25 @@
"deploy": "ntn apps deploy --name \"Perfect Meetings\""
}
},
+ {
+ "id": "statsig-rollout",
+ "title": "Statsig rollout App",
+ "summary": "Add IDs entered in a Notion database to a Statsig ID List segment, with per-row status.",
+ "path": "apps/templates/statsig-rollout",
+ "kind": "app-default",
+ "status": "ready",
+ "language": "typescript",
+ "runtime": "node",
+ "integrations": ["notion-apps", "notion-workflow", "statsig"],
+ "entrypoints": ["APP.md"],
+ "commands": {
+ "install": "npm install",
+ "check": "npm run check",
+ "test": "npm test",
+ "build": "npm run build",
+ "deploy": "ntn apps deploy --name \"Statsig Rollout\""
+ }
+ },
{
"id": "duckdb-query",
"title": "DuckDB query agent tool",
diff --git a/package.json b/package.json
index 67fe4111..c8108123 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,10 @@
"prepare": "husky"
},
"lint-staged": {
- "!(*AGENTS|*CLAUDE).{md,json,yml,yaml}": [
+ "*.{json,yml,yaml}": [
+ "prettier --write"
+ ],
+ "!(*AGENTS|*CLAUDE).md": [
"prettier --write",
"markdownlint --config .markdownlint.jsonc --fix"
],