create-ncblock 0.0.39 → 0.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/init.ts +39 -23
- package/scripts/utils/templates.ts +53 -8
- package/sdk-version.json +1 -1
- package/templates/worker/.agents/INSTRUCTIONS.md +593 -0
- package/templates/worker/.agents/skills/auth-guide/SKILL.md +227 -0
- package/templates/worker/.agents/skills/sync/SKILL.md +368 -0
- package/templates/worker/.agents/skills/sync-debug/SKILL.md +101 -0
- package/templates/worker/.agents/skills/sync-guide/SKILL.md +253 -0
- package/templates/worker/.agents/skills/sync-guide/api-pagination-patterns.md +661 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-basic.ts +103 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-bimodal.ts +207 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-events.ts +132 -0
- package/templates/worker/.agents/skills/sync-guide/examples/replace-paginated.ts +79 -0
- package/templates/worker/.agents/skills/sync-guide/examples/replace-simple.ts +57 -0
- package/templates/worker/.agents/skills/sync-validate/SKILL.md +60 -0
- package/templates/worker/.claudeignore +2 -0
- package/templates/worker/.codexignore +2 -0
- package/templates/worker/.examples/automation-example.ts +60 -0
- package/templates/worker/.examples/oauth-example.ts +79 -0
- package/templates/worker/.examples/sync-example.ts +184 -0
- package/templates/worker/.examples/tool-example.ts +37 -0
- package/templates/worker/.examples/webhook-example.ts +66 -0
- package/templates/worker/README.md +765 -0
- package/templates/worker/_gitignore +6 -0
- package/templates/worker/docs/custom-tool.png +0 -0
- package/templates/worker/notionhq-workers-0.4.0.tgz +0 -0
- package/templates/worker/package.json +25 -0
- package/templates/worker/src/index.ts +8 -0
- package/templates/worker/tsconfig.json +16 -0
- package/templates/worker/views/empty/AGENTS.md +71 -0
- package/templates/worker/views/empty/README.md +10 -0
- package/templates/worker/views/empty/_gitignore +2 -0
- package/templates/worker/views/empty/custom_blocks.json +4 -0
- package/templates/worker/views/empty/index.html +15 -0
- package/templates/worker/views/empty/package.json +23 -0
- package/templates/worker/views/empty/src/index.css +33 -0
- package/templates/worker/views/empty/src/index.tsx +20 -0
- package/templates/worker/views/empty/tsconfig.json +17 -0
- package/templates/worker/views/empty/vite.config.ts +7 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
# Repository Guidelines
|
|
2
|
+
|
|
3
|
+
Overall workers documentation lives at https://developers.notion.com/workers/get-started/overview.md.
|
|
4
|
+
|
|
5
|
+
## Project Structure & Module Organization
|
|
6
|
+
- `src/index.ts` defines the worker and capabilities.
|
|
7
|
+
- `.examples/` has focused samples (sync, tool, automation, OAuth, webhook).
|
|
8
|
+
- `views/` holds custom-block views (frontend React apps) shipped by the worker. Each view is its own buildable project (`views/<name>/`) with its own `AGENTS.md` describing how to author it. See [Custom blocks (views)](#custom-blocks-views).
|
|
9
|
+
- Shared agent skills live in `.agents/skills/`. `.claude/skills` is kept as a compatibility symlink for Claude-specific discovery.
|
|
10
|
+
- Generated: `dist/` build output, `workers.json` CLI config.
|
|
11
|
+
|
|
12
|
+
## Worker & Capability API (SDK)
|
|
13
|
+
- `@notionhq/workers` provides `Worker`, schema helpers, and builders; the `ntn` CLI powers worker management.
|
|
14
|
+
- Capability keys are unique strings used by the CLI (e.g., `ntn workers exec tasksSync`).
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { Worker } from "@notionhq/workers";
|
|
18
|
+
import * as Builder from "@notionhq/workers/builder";
|
|
19
|
+
import * as Schema from "@notionhq/workers/schema";
|
|
20
|
+
|
|
21
|
+
const worker = new Worker();
|
|
22
|
+
export default worker;
|
|
23
|
+
|
|
24
|
+
// Declare a sync target database (only written to by syncs — not for general-purpose storage)
|
|
25
|
+
const tasks = worker.database("tasks", {
|
|
26
|
+
type: "managed",
|
|
27
|
+
initialTitle: "Tasks",
|
|
28
|
+
primaryKeyProperty: "ID",
|
|
29
|
+
schema: { properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Declare a pacer for the upstream API
|
|
33
|
+
const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
|
|
34
|
+
|
|
35
|
+
// Declare a sync that writes to the database
|
|
36
|
+
worker.sync("tasksSync", {
|
|
37
|
+
database: tasks,
|
|
38
|
+
execute: async (state) => {
|
|
39
|
+
await myApi.wait();
|
|
40
|
+
const items = await fetchItems(state?.page ?? 1);
|
|
41
|
+
return {
|
|
42
|
+
changes: items.map((i) => ({
|
|
43
|
+
type: "upsert" as const, key: i.id,
|
|
44
|
+
properties: { Name: Builder.title(i.name), ID: Builder.richText(i.id) },
|
|
45
|
+
})),
|
|
46
|
+
hasMore: false,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
worker.tool("sayHello", {
|
|
52
|
+
title: "Say Hello",
|
|
53
|
+
description: "Return a greeting",
|
|
54
|
+
schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
55
|
+
execute: ({ name }, { notion }) => `Hello, ${name}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
worker.oauth("googleAuth", {
|
|
59
|
+
name: "my-google-auth",
|
|
60
|
+
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
61
|
+
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
62
|
+
scope: "openid email",
|
|
63
|
+
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
|
64
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
worker.webhook("onGithubPush", {
|
|
68
|
+
title: "GitHub Push Webhook",
|
|
69
|
+
description: "Handles push events from GitHub",
|
|
70
|
+
execute: async (events, { notion }) => {
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
console.log("Push:", event.body);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Ship a frontend view (custom block) rendered inside a Notion block.
|
|
78
|
+
// The view lives in views/dashboard/ and deploys with the worker.
|
|
79
|
+
worker.customBlock("dashboard", {
|
|
80
|
+
path: "./views/dashboard",
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Notion API access (`context.notion`)
|
|
85
|
+
|
|
86
|
+
All `execute` handlers receive a `context.notion` object (a `@notionhq/client` SDK instance). You can use this to make API requests to Notion.
|
|
87
|
+
|
|
88
|
+
However, `context.notion` is only **pre-authenticated** when it's a tool capability invoked by a Custom Agent. In that case, the platform sets `NOTION_API_TOKEN` automatically, using the permissions of the Custom Agent — no setup required.
|
|
89
|
+
|
|
90
|
+
For all other capabilities (syncs, automations, webhooks), `context.notion` is **not** pre-authenticated. The user must set the `NOTION_API_TOKEN` environment variable themselves by:
|
|
91
|
+
1. Creating a connection at https://app.notion.com/developers/connections
|
|
92
|
+
2. Giving that connection access to the relevant pages and databases in Notion
|
|
93
|
+
3. Adding the token to `.env` locally, or pushing it with `ntn workers env push` for deployed workers
|
|
94
|
+
|
|
95
|
+
Before writing code that uses `context.notion` in a non-tool capability, check whether `NOTION_API_TOKEN` is configured: look for it in `.env` (e.g. `grep -q '^NOTION_API_TOKEN=' .env`). If it is not set, prompt the user to create a connection at https://app.notion.com/developers/connections and add the token to `.env`.
|
|
96
|
+
|
|
97
|
+
- For user-managed OAuth (shown above), supply `name`, `authorizationEndpoint`, `tokenEndpoint`, `clientId`, `clientSecret`, and `scope` (optional: `authorizationParams`, `callbackUrl`, `accessTokenExpireMs`).
|
|
98
|
+
- **Note:** A Notion-managed OAuth shorthand (`{ provider: "google" }`) also exists but is in alpha and most users will not have access. Use the user-managed configuration above.
|
|
99
|
+
- After deploying a worker with an OAuth capability, the user must configure their OAuth provider's redirect URL to match the one assigned by Notion. Run `ntn workers oauth show-redirect-url` to get the redirect URL, then set it in the provider's OAuth app settings. **Always remind the user of this step after deploying any OAuth capability.**
|
|
100
|
+
|
|
101
|
+
### Sync
|
|
102
|
+
|
|
103
|
+
#### Databases, Pacers, and Syncs
|
|
104
|
+
|
|
105
|
+
`worker.database()` declares a sync target — a Notion database that syncs write into. **Databases are read-only from the worker's perspective: the only way to write to them is through syncs.** Do not use `worker.database()` to create general-purpose databases (e.g., for storing webhook payloads, tool results, or scratch data). For non-sync writes to Notion, use `context.notion` (the Notion SDK client) directly.
|
|
106
|
+
|
|
107
|
+
Databases are declared separately and referenced by handle:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// 1. Declare a database
|
|
111
|
+
const tasks = worker.database("tasks", {
|
|
112
|
+
type: "managed",
|
|
113
|
+
initialTitle: "Tasks",
|
|
114
|
+
primaryKeyProperty: "Task ID",
|
|
115
|
+
schema: {
|
|
116
|
+
properties: {
|
|
117
|
+
"Task Name": Schema.title(),
|
|
118
|
+
"Task ID": Schema.richText(),
|
|
119
|
+
Status: Schema.select([{ name: "Open" }, { name: "Done", color: "green" }]),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 2. Declare a pacer for the upstream API
|
|
125
|
+
const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
|
|
126
|
+
|
|
127
|
+
// 3. Declare a sync
|
|
128
|
+
worker.sync("tasksSync", {
|
|
129
|
+
database: tasks,
|
|
130
|
+
schedule: "30m",
|
|
131
|
+
execute: async (state) => {
|
|
132
|
+
await myApi.wait();
|
|
133
|
+
const { items, hasMore } = await fetchTasks(state?.page ?? 1);
|
|
134
|
+
return {
|
|
135
|
+
changes: items.map((item) => ({
|
|
136
|
+
type: "upsert" as const,
|
|
137
|
+
key: item.id,
|
|
138
|
+
properties: {
|
|
139
|
+
"Task Name": Builder.title(item.name),
|
|
140
|
+
"Task ID": Builder.richText(item.id),
|
|
141
|
+
Status: Builder.select(item.status),
|
|
142
|
+
},
|
|
143
|
+
})),
|
|
144
|
+
hasMore,
|
|
145
|
+
nextState: hasMore ? { page: (state?.page ?? 1) + 1 } : undefined,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Multiple syncs can write to the same database. Multiple syncs can share a pacer — the server apportions the budget evenly across all syncs that use it.
|
|
152
|
+
|
|
153
|
+
#### Pacers (Rate Limiting)
|
|
154
|
+
|
|
155
|
+
**Always declare a pacer** for any sync that calls an external API. Research the API's rate limits before implementing. If the limits are variable (e.g. Salesforce, where you can purchase more API calls), ask the user what budget to allocate.
|
|
156
|
+
|
|
157
|
+
- Call `await pacer.wait()` before **every** API request inside `execute`.
|
|
158
|
+
- The pacer ensures requests are evenly spaced over the interval window.
|
|
159
|
+
- If 4 syncs share a pacer with `allowedRequests: 100, intervalMs: 60_000`, each sync gets ~25 requests/minute.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
|
|
163
|
+
|
|
164
|
+
// Inside execute:
|
|
165
|
+
await myApi.wait();
|
|
166
|
+
const data = await fetchFromApi();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### Choosing a Sync Strategy
|
|
170
|
+
|
|
171
|
+
**Simple replace sync** — For truly small data sources (<1k records) or APIs with no change-tracking support. One sync, replace mode. Every cycle returns the full dataset; records not returned are deleted via mark-and-sweep.
|
|
172
|
+
|
|
173
|
+
**Backfill + delta pair** — For everything else (recommended for most real integrations). Two syncs writing to the same database:
|
|
174
|
+
- **Backfill** (replace mode, `schedule: "manual"`): Paginates the entire upstream dataset. Triggered manually via CLI. Cleans up drift, backfills new schema properties, catches deletes the delta can't detect.
|
|
175
|
+
- **Delta** (incremental mode, frequent schedule like `"5m"` or `"30m"`): Fetches only recent changes via `updated_since`, change feeds, etc. Keeps Notion current with minimal API usage.
|
|
176
|
+
|
|
177
|
+
Use backfill + delta whenever the upstream API supports any form of change tracking (`updated_since`, `modified_after`, change feeds, webhooks). Most enterprise APIs do (Salesforce, Jira, Linear, Stripe, GitHub, etc.).
|
|
178
|
+
|
|
179
|
+
##### Delete handling
|
|
180
|
+
|
|
181
|
+
- **API supports delta deletes** (returns deleted records in change feed): Emit `{ type: "delete", key }` in the delta sync.
|
|
182
|
+
- **API doesn't, but deletes are rare or irrelevant** (e.g. Stripe subscriptions are canceled not deleted, Jira issues are closed not deleted): No action needed — the upstream record still exists, just in a different state.
|
|
183
|
+
- **API doesn't, and deletes matter**: The backfill sync handles this. Its replace-mode mark-and-sweep deletes records no longer present upstream.
|
|
184
|
+
|
|
185
|
+
#### Simple Replace Sync Example
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const records = worker.database("records", {
|
|
189
|
+
type: "managed",
|
|
190
|
+
initialTitle: "Records",
|
|
191
|
+
primaryKeyProperty: "ID",
|
|
192
|
+
schema: { properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
|
|
196
|
+
|
|
197
|
+
worker.sync("recordsSync", {
|
|
198
|
+
database: records,
|
|
199
|
+
mode: "replace",
|
|
200
|
+
schedule: "1h",
|
|
201
|
+
execute: async (state) => {
|
|
202
|
+
const page = state?.page ?? 1;
|
|
203
|
+
await myApi.wait();
|
|
204
|
+
const { items, hasMore } = await fetchPage(page, 100);
|
|
205
|
+
return {
|
|
206
|
+
changes: items.map((item) => ({
|
|
207
|
+
type: "upsert" as const,
|
|
208
|
+
key: item.id,
|
|
209
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
210
|
+
})),
|
|
211
|
+
hasMore,
|
|
212
|
+
nextState: hasMore ? { page: page + 1 } : undefined,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### Backfill + Delta Example
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
const tasks = worker.database("tasks", {
|
|
222
|
+
type: "managed",
|
|
223
|
+
initialTitle: "Tasks",
|
|
224
|
+
primaryKeyProperty: "Task ID",
|
|
225
|
+
schema: {
|
|
226
|
+
properties: {
|
|
227
|
+
"Task Name": Schema.title(),
|
|
228
|
+
"Task ID": Schema.richText(),
|
|
229
|
+
Status: Schema.select([{ name: "Open" }, { name: "Done", color: "green" }]),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const taskApi = worker.pacer("taskApi", { allowedRequests: 10, intervalMs: 1000 });
|
|
235
|
+
|
|
236
|
+
// Backfill: paginates full dataset, runs manually.
|
|
237
|
+
// To re-backfill: ntn workers sync state reset tasksBackfill && ntn workers sync trigger tasksBackfill
|
|
238
|
+
worker.sync("tasksBackfill", {
|
|
239
|
+
database: tasks,
|
|
240
|
+
mode: "replace",
|
|
241
|
+
schedule: "manual",
|
|
242
|
+
execute: async (state) => {
|
|
243
|
+
const page = state?.page ?? 1;
|
|
244
|
+
await taskApi.wait();
|
|
245
|
+
const { items, hasMore } = await fetchAllTasks(page, 100);
|
|
246
|
+
return {
|
|
247
|
+
changes: items.map((item) => ({
|
|
248
|
+
type: "upsert" as const,
|
|
249
|
+
key: item.id,
|
|
250
|
+
properties: {
|
|
251
|
+
"Task Name": Builder.title(item.name),
|
|
252
|
+
"Task ID": Builder.richText(item.id),
|
|
253
|
+
Status: Builder.select(item.status),
|
|
254
|
+
},
|
|
255
|
+
})),
|
|
256
|
+
hasMore,
|
|
257
|
+
nextState: hasMore ? { page: page + 1 } : undefined,
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Delta: fetches recent changes, runs every 5 minutes.
|
|
263
|
+
worker.sync("tasksDelta", {
|
|
264
|
+
database: tasks,
|
|
265
|
+
mode: "incremental",
|
|
266
|
+
schedule: "5m",
|
|
267
|
+
execute: async (state) => {
|
|
268
|
+
const cursor = state?.cursor;
|
|
269
|
+
await taskApi.wait();
|
|
270
|
+
const { items, nextCursor } = await fetchTaskChanges(cursor);
|
|
271
|
+
return {
|
|
272
|
+
changes: items.map((item) => ({
|
|
273
|
+
type: "upsert" as const,
|
|
274
|
+
key: item.id,
|
|
275
|
+
properties: {
|
|
276
|
+
"Task Name": Builder.title(item.name),
|
|
277
|
+
"Task ID": Builder.richText(item.id),
|
|
278
|
+
Status: Builder.select(item.status),
|
|
279
|
+
},
|
|
280
|
+
})),
|
|
281
|
+
hasMore: Boolean(nextCursor),
|
|
282
|
+
nextState: nextCursor ? { cursor: nextCursor } : undefined,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### Pagination
|
|
289
|
+
|
|
290
|
+
Syncs run in a "sync cycle": a back-to-back chain of `execute` calls that starts at a scheduled trigger and ends when an execution returns `hasMore: false`.
|
|
291
|
+
|
|
292
|
+
- Always paginate. Returning too many changes in one execution will fail. Start with batch sizes of ~100.
|
|
293
|
+
- Return `hasMore: true` and `nextState` to continue; `hasMore: false` to finish.
|
|
294
|
+
- `nextState` can be any serializable value: cursor string, page number, timestamp, or complex object.
|
|
295
|
+
|
|
296
|
+
#### Schedule
|
|
297
|
+
|
|
298
|
+
Set `schedule` on a sync to control how often it runs:
|
|
299
|
+
- `"continuous"`: run as fast as possible
|
|
300
|
+
- `"manual"`: only via CLI trigger
|
|
301
|
+
- Interval string: `"5m"`, `"30m"`, `"1h"`, `"1d"` (min `"1m"`, max `"7d"`)
|
|
302
|
+
- Default: `"30m"`
|
|
303
|
+
|
|
304
|
+
#### Relations
|
|
305
|
+
|
|
306
|
+
Two databases can relate to one another using `Schema.relation(syncKey)` and `Builder.relation(primaryKey)`:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const projects = worker.database("projects", {
|
|
310
|
+
type: "managed",
|
|
311
|
+
initialTitle: "Projects",
|
|
312
|
+
primaryKeyProperty: "Project ID",
|
|
313
|
+
schema: { properties: { "Project Name": Schema.title(), "Project ID": Schema.richText() } },
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const tasks = worker.database("tasks", {
|
|
317
|
+
type: "managed",
|
|
318
|
+
initialTitle: "Tasks",
|
|
319
|
+
primaryKeyProperty: "Task ID",
|
|
320
|
+
schema: {
|
|
321
|
+
properties: {
|
|
322
|
+
"Task Name": Schema.title(),
|
|
323
|
+
"Task ID": Schema.richText(),
|
|
324
|
+
// Reference the sync key that populates the related database
|
|
325
|
+
Project: Schema.relation("projectsSync", { twoWay: true, relatedPropertyName: "Tasks" }),
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
worker.sync("projectsSync", { database: projects, execute: async () => { ... } });
|
|
331
|
+
worker.sync("tasksSync", {
|
|
332
|
+
database: tasks,
|
|
333
|
+
execute: async () => ({
|
|
334
|
+
changes: [{
|
|
335
|
+
type: "upsert" as const,
|
|
336
|
+
key: "task-1",
|
|
337
|
+
properties: {
|
|
338
|
+
"Task Name": Builder.title("Write docs"),
|
|
339
|
+
"Task ID": Builder.richText("task-1"),
|
|
340
|
+
Project: [Builder.relation("proj-1")], // array of relation refs
|
|
341
|
+
},
|
|
342
|
+
}],
|
|
343
|
+
hasMore: false,
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Webhooks
|
|
349
|
+
|
|
350
|
+
Webhooks expose HTTP endpoints that external services can call. After deploying, the CLI prints the webhook URL. Use `ntn workers webhooks list` to see URLs at any time.
|
|
351
|
+
|
|
352
|
+
The execute handler receives an array of `WebhookEvent` objects. Each event contains `deliveryId` (stable idempotency key across retries), `body` (parsed JSON), `rawBody` (string, for signature verification), `headers`, and `method`.
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
worker.webhook("onExternalEvent", {
|
|
356
|
+
title: "External Event Handler",
|
|
357
|
+
description: "Processes incoming webhook requests",
|
|
358
|
+
execute: async (events, { notion }) => {
|
|
359
|
+
for (const event of events) {
|
|
360
|
+
console.log("Method:", event.method);
|
|
361
|
+
console.log("Body:", JSON.stringify(event.body));
|
|
362
|
+
// Use event.headers to access request headers
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**Security:** Each webhook gets a unique ID in the URL path that acts as a shared secret. The URL format is:
|
|
369
|
+
```text
|
|
370
|
+
https://www.notion.so/webhooks/worker/{spaceId}/{workerId}/{uniqueWebhookId}/{webhookName}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
This full URL can be retrieved using the `ntn workers webhooks list` command.
|
|
374
|
+
|
|
375
|
+
It is also the responsibility of the worker to verify the webhook. Throw `WebhookVerificationError` if the payload is not valid. 5 invalid payloads in a row will cause webhooks to short circuit until redeployed.
|
|
376
|
+
|
|
377
|
+
### Custom blocks (views)
|
|
378
|
+
|
|
379
|
+
A custom block is a **frontend view** — a sandboxed React app rendered inside a Notion block. It ships *with* the worker: there is no separate CLI. `ntn workers deploy` builds the view, bundles it, and binds its data sources in one step. **Do not use the `ncblock` CLI here** (e.g. `npx ncblock connect` / `deploy`) — that flow is for standalone custom-block projects and does not apply inside a worker.
|
|
380
|
+
|
|
381
|
+
Declare a view with `worker.customBlock(key, config)` in `src/index.ts`:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
worker.customBlock("dashboard", {
|
|
385
|
+
path: "./views/dashboard", // buildable project dir, relative to the worker root
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- `type` defaults to `"project"`: the view is built by running its `command` (default `npm run build`) and its `output` dir (default `dist`) is bundled. Pass `type: "static"` to serve an already-built directory with no build step.
|
|
390
|
+
- The view itself is a separate project under `views/<name>/`. Author it there — see `views/<name>/AGENTS.md` for the frontend rules (React hooks, sizing, forbidden APIs). It uses the `ncblock` package (React hooks + Vite plugin), which is the SDK surface, not the CLI.
|
|
391
|
+
|
|
392
|
+
#### Wiring data sources
|
|
393
|
+
|
|
394
|
+
A view reads data through **managed databases declared in the worker**, not through `ncblock connect`. Declare the database with `worker.database(...)`, then bind it in the `dataSources` map (data-source key → database key). The server binds the block to that managed database on deploy, and the SDK generates the view's `custom_blocks.json` from this declaration — so any hand-written or `ncblock connect`-generated `custom_blocks.json` in the view is ignored.
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
const issues = worker.database("issues", {
|
|
398
|
+
type: "managed",
|
|
399
|
+
initialTitle: "Issues",
|
|
400
|
+
primaryKeyProperty: "Issue ID",
|
|
401
|
+
schema: { properties: { Title: Schema.title(), "Issue ID": Schema.richText() } },
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
worker.sync("issuesSync", { database: issues, execute: async () => { /* ... */ } });
|
|
405
|
+
|
|
406
|
+
worker.customBlock("dashboard", {
|
|
407
|
+
path: "./views/dashboard",
|
|
408
|
+
// Bind the view's "issues" data-source key to the managed `issues` database.
|
|
409
|
+
dataSources: { issues: "issues" },
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Inside the view, read that data source with the `ncblock` hooks (e.g. `useDataSource("issues")`). A view with no `dataSources` is fine — it just renders without host data.
|
|
414
|
+
|
|
415
|
+
#### Developing and deploying
|
|
416
|
+
|
|
417
|
+
```shell
|
|
418
|
+
# Iterate on the view locally (run inside views/<name>/):
|
|
419
|
+
npm run dev # Vite dev server; preview against a live or mock Notion host
|
|
420
|
+
|
|
421
|
+
# Build + deploy the worker and all its views together:
|
|
422
|
+
ntn workers deploy
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
See `views/<name>/AGENTS.md` for preview options (live Notion host vs. mock dev shell).
|
|
426
|
+
|
|
427
|
+
### Sync Management (CLI)
|
|
428
|
+
|
|
429
|
+
**Monitor sync status:**
|
|
430
|
+
```shell
|
|
431
|
+
ntn workers sync status # live-updating watch mode (polls every 5s)
|
|
432
|
+
ntn workers sync status <key> # filter to a specific sync capability
|
|
433
|
+
ntn workers sync status --no-watch # print once and exit
|
|
434
|
+
ntn workers sync status --interval 10 # custom poll interval in seconds
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Status labels:
|
|
438
|
+
- **HEALTHY** — last run succeeded
|
|
439
|
+
- **INITIALIZING** — deployed but hasn't succeeded yet
|
|
440
|
+
- **WARNING** — 1–2 consecutive failures
|
|
441
|
+
- **ERROR** — 3+ consecutive failures
|
|
442
|
+
- **DISABLED** — capability is disabled
|
|
443
|
+
|
|
444
|
+
**Preview a sync (inspect output without writing):**
|
|
445
|
+
```shell
|
|
446
|
+
ntn workers sync trigger <key> --preview # run execute, show objects, don't write to the database
|
|
447
|
+
ntn workers sync trigger <key> --preview --context '{"page":2}' # resume from a previous preview's nextContext
|
|
448
|
+
```
|
|
449
|
+
Preview calls your sync's `execute` function and shows the objects it would produce, but **does not write anything to the Notion database**. Use it to verify your sync logic and inspect the data before committing to a real run. When piped, outputs raw JSON.
|
|
450
|
+
|
|
451
|
+
**Trigger a sync (write immediately, bypass schedule):**
|
|
452
|
+
```shell
|
|
453
|
+
ntn workers sync trigger <key>
|
|
454
|
+
```
|
|
455
|
+
Trigger starts a **real** sync cycle that writes to the database, bypassing the normal schedule. Use it to push changes immediately rather than waiting for the next scheduled run.
|
|
456
|
+
|
|
457
|
+
**Reset sync state (restart from scratch):**
|
|
458
|
+
```shell
|
|
459
|
+
ntn workers sync state reset <key>
|
|
460
|
+
```
|
|
461
|
+
Clears the cursor and stats so the next run starts from the beginning.
|
|
462
|
+
|
|
463
|
+
**Enable / disable a sync:**
|
|
464
|
+
```shell
|
|
465
|
+
ntn workers capabilities list # show all capabilities
|
|
466
|
+
ntn workers capabilities disable <key> # pause a sync
|
|
467
|
+
ntn workers capabilities enable <key> # resume a sync
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
> **Note:** `ntn workers deploy` does **not** reset sync state. Syncs resume from their last cursor position after a deploy. Use `ntn workers sync state reset <key>` to explicitly restart from scratch.
|
|
471
|
+
|
|
472
|
+
### Querying a database
|
|
473
|
+
|
|
474
|
+
Use `ntn datasources query <data-source-id>` to list pages in a database. **The argument is a data source ID, not a database ID** — a database in Notion is a container for one or more data sources, and the public API queries data sources directly.
|
|
475
|
+
|
|
476
|
+
If you only have a database ID, run `ntn datasources resolve <database-id>` first to list the data sources it contains:
|
|
477
|
+
|
|
478
|
+
```shell
|
|
479
|
+
ntn datasources resolve <database-id>
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
If exactly one data source is returned, retry the query with that ID. If multiple are returned, pick the one whose name matches what you want.
|
|
483
|
+
|
|
484
|
+
When `ntn datasources query <id>` returns 404 or "Could not find data source", the ID is most likely a database ID — run `resolve` against it and retry with one of the data source IDs it lists.
|
|
485
|
+
|
|
486
|
+
## Build, Test, and Development Commands
|
|
487
|
+
- Node >= 22 and npm >= 10.9.2 (see `package.json` engines).
|
|
488
|
+
- `npm run build`: compile TypeScript to `dist/`.
|
|
489
|
+
- `npm run check`: type-check only (no emit).
|
|
490
|
+
- `ntn login`: connect to a Notion workspace.
|
|
491
|
+
- `ntn workers deploy`: build and publish capabilities. Does not reset sync state.
|
|
492
|
+
- `ntn workers exec <capability>`: run a sync or tool.
|
|
493
|
+
- `ntn workers sync status`: monitor sync health (live-updating).
|
|
494
|
+
- `ntn workers sync trigger <key> --preview`: preview sync output without writing to the database.
|
|
495
|
+
- `ntn workers sync trigger <key>`: trigger a real sync immediately (writes to the database).
|
|
496
|
+
|
|
497
|
+
## Debugging & Monitoring Runs
|
|
498
|
+
Use `ntn workers runs` to inspect run history and logs.
|
|
499
|
+
|
|
500
|
+
**List recent runs:**
|
|
501
|
+
```shell
|
|
502
|
+
ntn workers runs list
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Get logs for a specific run:**
|
|
506
|
+
```shell
|
|
507
|
+
ntn workers runs logs <runId>
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**Get logs for the latest run (any capability):**
|
|
511
|
+
```shell
|
|
512
|
+
ntn workers runs list --plain | head -n1 | cut -f1 | xargs -I{} ntn workers runs logs {}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**Get logs for the latest run of a specific capability:**
|
|
516
|
+
```shell
|
|
517
|
+
ntn workers runs list --plain | grep tasksSync | head -n1 | cut -f1 | xargs -I{} ntn workers runs logs {}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
The `--plain` flag outputs tab-separated values without formatting, making it easy to pipe to other commands.
|
|
521
|
+
|
|
522
|
+
### Debugging Syncs
|
|
523
|
+
|
|
524
|
+
**Check sync health:**
|
|
525
|
+
```shell
|
|
526
|
+
ntn workers sync status
|
|
527
|
+
```
|
|
528
|
+
Look at failure counts, error messages, and last succeeded times.
|
|
529
|
+
|
|
530
|
+
**Sync not running?** Check if the capability is disabled:
|
|
531
|
+
```shell
|
|
532
|
+
ntn workers capabilities list
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Preview what a sync would produce (without writing):**
|
|
536
|
+
```shell
|
|
537
|
+
ntn workers sync trigger <key> --preview
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Retry a failed sync (writes to the database):**
|
|
541
|
+
```shell
|
|
542
|
+
ntn workers sync trigger <key>
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
**Sync in a bad state?** Reset the cursor and restart:
|
|
546
|
+
```shell
|
|
547
|
+
ntn workers sync state reset <key>
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
## Coding Style & Naming Conventions
|
|
551
|
+
- TypeScript with `strict` enabled; keep types explicit when shaping I/O.
|
|
552
|
+
- Use tabs for indentation; capability keys in lowerCamelCase.
|
|
553
|
+
|
|
554
|
+
## Testing Guidelines
|
|
555
|
+
- No test runner configured; validate with `npm run check` and end-to-end testing via `ntn workers exec`.
|
|
556
|
+
- Write a test script that exercises each tool capability using `ntn workers exec`. This can be a bash script (`test.sh`) or a TypeScript script (`test.ts`, run via `npx tsx test.ts`). Use the `--local` flag for local execution or omit it to run against the deployed worker.
|
|
557
|
+
|
|
558
|
+
**Local execution** runs your worker code directly on your machine. Any `.env` file in the project root is automatically loaded, so secrets and config values are available via `process.env`.
|
|
559
|
+
|
|
560
|
+
**Remote execution** (without `--local`) runs against the deployed worker. Any required secrets must be pushed to the remote environment first using `ntn workers env push`.
|
|
561
|
+
|
|
562
|
+
**Example bash test script (`test.sh`):**
|
|
563
|
+
```shell
|
|
564
|
+
#!/usr/bin/env bash
|
|
565
|
+
set -euo pipefail
|
|
566
|
+
|
|
567
|
+
# Run locally (uses .env automatically):
|
|
568
|
+
ntn workers exec sayHello --local -d '{"name": "World"}'
|
|
569
|
+
|
|
570
|
+
# Or run against the deployed worker (requires `ntn workers deploy` and `ntn workers env push` first):
|
|
571
|
+
# ntn workers exec sayHello -d '{"name": "World"}'
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
**Example TypeScript test script (`test.ts`, run with `npx tsx test.ts`):**
|
|
575
|
+
```ts
|
|
576
|
+
import { execSync } from "child_process";
|
|
577
|
+
|
|
578
|
+
function exec(capability: string, input: Record<string, unknown>) {
|
|
579
|
+
const result = execSync(
|
|
580
|
+
`ntn workers exec ${capability} --local -d '${JSON.stringify(input)}'`,
|
|
581
|
+
{ encoding: "utf-8" },
|
|
582
|
+
);
|
|
583
|
+
console.log(result);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
exec("sayHello", { name: "World" });
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Use this pattern to build up a suite of exec calls that covers each tool with representative inputs.
|
|
590
|
+
|
|
591
|
+
## Commit & Pull Request Guidelines
|
|
592
|
+
- Messages typically use `feat(scope): ...`, `TASK-123: ...`, or version bumps.
|
|
593
|
+
- PRs should describe changes, list commands run, and update examples if behavior changes.
|