create-ncblock 0.0.38 → 0.0.40

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/scripts/utils/templates.ts +37 -7
  3. package/sdk-version.json +1 -1
  4. package/templates/worker/.agents/INSTRUCTIONS.md +536 -0
  5. package/templates/worker/.agents/skills/auth-guide/SKILL.md +227 -0
  6. package/templates/worker/.agents/skills/sync/SKILL.md +368 -0
  7. package/templates/worker/.agents/skills/sync-debug/SKILL.md +101 -0
  8. package/templates/worker/.agents/skills/sync-guide/SKILL.md +253 -0
  9. package/templates/worker/.agents/skills/sync-guide/api-pagination-patterns.md +661 -0
  10. package/templates/worker/.agents/skills/sync-guide/examples/incremental-basic.ts +103 -0
  11. package/templates/worker/.agents/skills/sync-guide/examples/incremental-bimodal.ts +207 -0
  12. package/templates/worker/.agents/skills/sync-guide/examples/incremental-events.ts +132 -0
  13. package/templates/worker/.agents/skills/sync-guide/examples/replace-paginated.ts +79 -0
  14. package/templates/worker/.agents/skills/sync-guide/examples/replace-simple.ts +57 -0
  15. package/templates/worker/.agents/skills/sync-validate/SKILL.md +60 -0
  16. package/templates/worker/.claudeignore +2 -0
  17. package/templates/worker/.codexignore +2 -0
  18. package/templates/worker/.examples/automation-example.ts +60 -0
  19. package/templates/worker/.examples/oauth-example.ts +79 -0
  20. package/templates/worker/.examples/sync-example.ts +184 -0
  21. package/templates/worker/.examples/tool-example.ts +37 -0
  22. package/templates/worker/.examples/webhook-example.ts +66 -0
  23. package/templates/worker/README.md +765 -0
  24. package/templates/worker/_gitignore +6 -0
  25. package/templates/worker/docs/custom-tool.png +0 -0
  26. package/templates/worker/notionhq-workers-0.4.0.tgz +0 -0
  27. package/templates/worker/package.json +25 -0
  28. package/templates/worker/src/index.ts +8 -0
  29. package/templates/worker/tsconfig.json +16 -0
  30. package/templates/worker/views/empty/AGENTS.md +67 -0
  31. package/templates/worker/views/empty/README.md +10 -0
  32. package/templates/worker/views/empty/_gitignore +2 -0
  33. package/templates/worker/views/empty/custom_blocks.json +4 -0
  34. package/templates/worker/views/empty/index.html +15 -0
  35. package/templates/worker/views/empty/package.json +23 -0
  36. package/templates/worker/views/empty/src/index.css +33 -0
  37. package/templates/worker/views/empty/src/index.tsx +20 -0
  38. package/templates/worker/views/empty/tsconfig.json +17 -0
  39. package/templates/worker/views/empty/vite.config.ts +7 -0
@@ -0,0 +1,765 @@
1
+ # Notion Workers [beta]
2
+
3
+ A worker is a small Node/TypeScript program hosted by Notion. Workers have three capability types:
4
+
5
+ - **Tools** — callable functions for Notion custom agents
6
+ - **Syncs** — sync external data sources into Notion
7
+ - **Webhooks** — receive HTTP events from external services
8
+
9
+ > [!NOTE]
10
+ >
11
+ > Notion Workers is currently in beta. APIs, CLI commands, templates, and
12
+ > hosting behavior may continue to evolve.
13
+
14
+ ## Quick start
15
+
16
+ Install the `ntn` CLI and scaffold a new worker:
17
+
18
+ ```shell
19
+ curl -fsSL https://ntn.dev | bash
20
+ ntn workers new my-worker
21
+ cd my-worker
22
+ ```
23
+
24
+ > [!TIP]
25
+ > The template ships with a `/sync` slash command in your coding agent (Claude Code, Codex, etc.) that scaffolds a new sync capability interactively — it asks about your data source, mode, and pagination, then generates working code.
26
+
27
+ ## Tools quickstart
28
+
29
+ A tool gives your Notion agent a new ability. Here's a simple greeting tool in `src/index.ts`:
30
+
31
+ ```ts
32
+ import { Worker } from "@notionhq/workers";
33
+ import { j } from "@notionhq/workers/schema-builder";
34
+
35
+ const worker = new Worker();
36
+ export default worker;
37
+
38
+ worker.tool("sayHello", {
39
+ title: "Say Hello",
40
+ description: "Returns a friendly greeting for the given name.",
41
+ schema: j.object({
42
+ name: j.string().describe("The name to greet."),
43
+ }),
44
+ execute: ({ name }) => `Hello, ${name}!`,
45
+ });
46
+ ```
47
+
48
+ Deploy and add the tool to your agent:
49
+
50
+ ```shell
51
+ ntn workers deploy
52
+ ```
53
+
54
+ ![Adding a custom tool to your Notion agent](docs/custom-tool.png)
55
+
56
+ ## Syncs quickstart
57
+
58
+ A sync pulls data from an external source into a Notion database.
59
+
60
+ **Fast path:** run the `/sync` slash command in your coding agent (Claude Code, Codex, etc.) to scaffold a sync interactively. It walks through the data source, picks `replace` vs `incremental` + backfill, designs pagination, and writes the code into `src/index.ts`.
61
+
62
+ **Manual path:** here's a simple sync in `src/index.ts`:
63
+
64
+ ```ts
65
+ import { Worker } from "@notionhq/workers";
66
+ import * as Builder from "@notionhq/workers/builder";
67
+ import * as Schema from "@notionhq/workers/schema";
68
+
69
+ const worker = new Worker();
70
+ export default worker;
71
+
72
+ const issues = worker.database("issues", {
73
+ type: "managed",
74
+ initialTitle: "Issues",
75
+ primaryKeyProperty: "Issue ID",
76
+ schema: {
77
+ properties: {
78
+ Title: Schema.title(),
79
+ "Issue ID": Schema.richText(),
80
+ },
81
+ },
82
+ });
83
+
84
+ const issueTracker = worker.pacer("issueTracker", { allowedRequests: 10, intervalMs: 1000 });
85
+
86
+ worker.sync("issuesSync", {
87
+ database: issues,
88
+ execute: async () => {
89
+ await issueTracker.wait();
90
+ const items = await fetchIssues(); // your data source
91
+ return {
92
+ changes: items.map((issue) => ({
93
+ type: "upsert" as const,
94
+ key: issue.id,
95
+ properties: {
96
+ Title: Builder.title(issue.title),
97
+ "Issue ID": Builder.richText(issue.id),
98
+ },
99
+ })),
100
+ hasMore: false,
101
+ };
102
+ },
103
+ });
104
+ ```
105
+
106
+ Deploy and your sync runs automatically on a schedule (default: every 30 minutes):
107
+
108
+ ```shell
109
+ ntn workers deploy
110
+ ntn workers sync status
111
+ ```
112
+
113
+ ## Webhooks quickstart
114
+
115
+ A webhook exposes an HTTP endpoint that external services (GitHub, Stripe, etc.) can call to push events into your worker:
116
+
117
+ ```ts
118
+ import { Worker } from "@notionhq/workers";
119
+
120
+ const worker = new Worker();
121
+ export default worker;
122
+
123
+ worker.webhook("onExternalEvent", {
124
+ title: "External Event Handler",
125
+ description: "Processes incoming webhook requests",
126
+ execute: async (events) => {
127
+ for (const event of events) {
128
+ console.log("Delivery:", event.deliveryId);
129
+ console.log("Method:", event.method);
130
+ console.log("Body:", event.body);
131
+ }
132
+ },
133
+ });
134
+ ```
135
+
136
+ Deploy and grab the webhook URL to give to the external service:
137
+
138
+ ```shell
139
+ ntn workers deploy
140
+ ntn workers webhooks list
141
+ ```
142
+
143
+ ## Tools reference
144
+
145
+ ### Schema builder
146
+
147
+ Use the schema builder (`j`) to define tool inputs. It auto-sets `required` and `additionalProperties`, and provides TypeScript type inference:
148
+
149
+ ```ts
150
+ import { j } from "@notionhq/workers/schema-builder";
151
+
152
+ schema: j.object({
153
+ query: j.string().describe("Search query"),
154
+ limit: j.number().describe("Max results").nullable(),
155
+ })
156
+ ```
157
+
158
+ Use `.nullable()` to mark a field as optional. Use `.describe()` to tell the agent what the field is for.
159
+
160
+ ### Output schema
161
+
162
+ Optionally define the shape of what your tool returns:
163
+
164
+ ```ts
165
+ worker.tool("search", {
166
+ title: "Search",
167
+ description: "Search for items",
168
+ schema: j.object({
169
+ query: j.string().describe("Search query"),
170
+ }),
171
+ outputSchema: j.object({
172
+ results: j.array(j.string()),
173
+ }),
174
+ execute: async ({ query }) => {
175
+ return { results: [] };
176
+ },
177
+ });
178
+ ```
179
+
180
+ ### Execute function
181
+
182
+ The `execute` function receives the validated input and a context object:
183
+
184
+ ```ts
185
+ execute: async (input, context) => {
186
+ // context.notion — authenticated Notion SDK client
187
+ const { query, limit = 10 } = input;
188
+ return { results: [] };
189
+ }
190
+ ```
191
+
192
+ ## Syncs reference
193
+
194
+ ### Databases and schema
195
+
196
+ Declare databases with `worker.database()` and define schemas with `Schema` helpers. Build property values with `Builder`:
197
+
198
+ ```ts
199
+ import * as Schema from "@notionhq/workers/schema";
200
+ import * as Builder from "@notionhq/workers/builder";
201
+
202
+ const records = worker.database("records", {
203
+ type: "managed",
204
+ initialTitle: "My Data",
205
+ primaryKeyProperty: "ID",
206
+ schema: {
207
+ properties: {
208
+ Name: Schema.title(),
209
+ ID: Schema.richText(),
210
+ },
211
+ },
212
+ });
213
+
214
+ // In execute, return changes with matching property values:
215
+ properties: {
216
+ Name: Builder.title("Item name"),
217
+ ID: Builder.richText("item-1"),
218
+ }
219
+ ```
220
+
221
+ `primaryKeyProperty` specifies which property to use as the unique key for each record.
222
+
223
+ ### Sync modes
224
+
225
+ **Replace** (`mode: "replace"`) — each sync cycle returns the full dataset. After the final `hasMore: false`, any records not seen are deleted:
226
+
227
+ ```ts
228
+ worker.sync("teamsSync", {
229
+ database: teams,
230
+ mode: "replace",
231
+ execute: async (state) => {
232
+ const page = state?.page ?? 1;
233
+ await myApi.wait();
234
+ const { items, hasMore } = await fetchPage(page, 100);
235
+ return {
236
+ changes: items.map((item) => ({
237
+ type: "upsert" as const,
238
+ key: item.id,
239
+ properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
240
+ })),
241
+ hasMore,
242
+ nextState: hasMore ? { page: page + 1 } : undefined,
243
+ };
244
+ },
245
+ });
246
+ ```
247
+
248
+ **Incremental** (`mode: "incremental"`) — each cycle returns only what changed since the last run. Records not mentioned are left unchanged. Deletions must be explicit:
249
+
250
+ ```ts
251
+ worker.sync("eventsSync", {
252
+ database: events,
253
+ mode: "incremental",
254
+ execute: async (state) => {
255
+ await myApi.wait();
256
+ const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
257
+ return {
258
+ changes: [
259
+ ...upserts.map((item) => ({
260
+ type: "upsert" as const,
261
+ key: item.id,
262
+ properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
263
+ })),
264
+ ...deletes.map((id) => ({ type: "delete" as const, key: id })),
265
+ ],
266
+ hasMore: Boolean(nextCursor),
267
+ nextState: nextCursor ? { cursor: nextCursor } : undefined,
268
+ };
269
+ },
270
+ });
271
+ ```
272
+
273
+ Use `replace` for smaller datasets (<1k records). Use `incremental` for larger datasets or when the API supports change tracking.
274
+
275
+ ### Pagination
276
+
277
+ Syncs run as a chain of `execute` calls within a sync cycle:
278
+
279
+ 1. Return changes with `hasMore: true` and a `nextState` value
280
+ 2. The runtime calls `execute` again with that state
281
+ 3. Continue until you return `hasMore: false`
282
+
283
+ `nextState` can be any serializable value — a cursor string, page number, timestamp, or object. Start with batch sizes of ~100 changes.
284
+
285
+ ### Schedule
286
+
287
+ By default syncs run every 30 minutes. Set `schedule` to an interval like `"15m"`, `"1h"`, `"1d"` (min `"1m"`, max `"7d"`), `"continuous"`, or `"manual"`:
288
+
289
+ ```ts
290
+ worker.sync("fastSync", {
291
+ database: myDb,
292
+ schedule: "5m",
293
+ // ...
294
+ });
295
+ ```
296
+
297
+ ### Relations
298
+
299
+ Two databases can relate to each other using `Schema.relation()` and `Builder.relation()`:
300
+
301
+ ```ts
302
+ const projects = worker.database("projects", {
303
+ type: "managed",
304
+ initialTitle: "Projects",
305
+ primaryKeyProperty: "Project ID",
306
+ schema: {
307
+ properties: {
308
+ Name: Schema.title(),
309
+ "Project ID": Schema.richText(),
310
+ },
311
+ },
312
+ });
313
+
314
+ const tasks = worker.database("tasks", {
315
+ type: "managed",
316
+ initialTitle: "Tasks",
317
+ primaryKeyProperty: "Task ID",
318
+ schema: {
319
+ properties: {
320
+ Name: Schema.title(),
321
+ "Task ID": Schema.richText(),
322
+ Project: Schema.relation("projectsSync", {
323
+ twoWay: true,
324
+ relatedPropertyName: "Tasks",
325
+ }),
326
+ },
327
+ },
328
+ });
329
+
330
+ worker.sync("projectsSync", {
331
+ database: projects,
332
+ execute: async () => { /* ... */ },
333
+ });
334
+
335
+ worker.sync("tasksSync", {
336
+ database: tasks,
337
+ execute: async () => {
338
+ const items = await fetchTasks();
339
+ return {
340
+ changes: items.map((task) => ({
341
+ type: "upsert" as const,
342
+ key: task.id,
343
+ properties: {
344
+ Name: Builder.title(task.name),
345
+ "Task ID": Builder.richText(task.id),
346
+ Project: [Builder.relation(task.projectId)],
347
+ },
348
+ })),
349
+ hasMore: false,
350
+ };
351
+ },
352
+ });
353
+ ```
354
+
355
+ ### Sync CLI commands
356
+
357
+ ```shell
358
+ ntn workers sync status # live-updating status
359
+ ntn workers sync trigger <key> --preview # preview output without writing to the database
360
+ ntn workers sync trigger <key> # trigger a real sync immediately
361
+ ntn workers sync state reset <key> # restart from scratch
362
+ ntn workers capabilities disable <key> # pause a sync
363
+ ntn workers capabilities enable <key> # resume a sync
364
+ ```
365
+
366
+ > [!NOTE]
367
+ > Deploying does **not** reset sync state — syncs resume from their last cursor position. Use `ntn workers sync state reset <key>` to restart from scratch.
368
+
369
+ ## Webhooks reference
370
+
371
+ ### The event object
372
+
373
+ The `execute` function receives an array of `WebhookEvent` objects:
374
+
375
+ | Property | Type | Description |
376
+ | :-- | :-- | :-- |
377
+ | `deliveryId` | `string` | Unique ID for this delivery, stable across retries. |
378
+ | `body` | `Record<string, unknown>` | Parsed JSON body (`{}` if not a JSON object). |
379
+ | `rawBody` | `string` | Original request body as a string. Use for signature verification. |
380
+ | `headers` | `Record<string, string>` | Request headers (lowercased names). |
381
+ | `method` | `string` | HTTP method (webhook URLs accept `POST`). |
382
+
383
+ ### Verifying requests
384
+
385
+ Most webhook providers sign requests with a shared secret. Verify using `event.rawBody` and `event.headers`, and throw `WebhookVerificationError` when verification fails:
386
+
387
+ ```ts
388
+ import * as crypto from "node:crypto";
389
+ import { WebhookVerificationError, Worker } from "@notionhq/workers";
390
+
391
+ const worker = new Worker();
392
+ export default worker;
393
+
394
+ function verifyGitHubSignature(
395
+ rawBody: string,
396
+ headers: Record<string, string>,
397
+ ): void {
398
+ const secret = process.env.GITHUB_WEBHOOK_SECRET;
399
+ if (!secret) {
400
+ throw new WebhookVerificationError("GITHUB_WEBHOOK_SECRET not configured");
401
+ }
402
+
403
+ const signature = headers["x-hub-signature-256"];
404
+ if (!signature?.startsWith("sha256=")) {
405
+ throw new WebhookVerificationError("Invalid GitHub signature");
406
+ }
407
+
408
+ const expected = `sha256=${crypto
409
+ .createHmac("sha256", secret)
410
+ .update(rawBody)
411
+ .digest("hex")}`;
412
+
413
+ if (
414
+ signature.length !== expected.length ||
415
+ !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
416
+ ) {
417
+ throw new WebhookVerificationError("Invalid GitHub signature");
418
+ }
419
+ }
420
+
421
+ worker.webhook("onGithubPush", {
422
+ title: "GitHub Push Webhook",
423
+ description: "Handles push events from GitHub repositories",
424
+ execute: async (events) => {
425
+ for (const event of events) {
426
+ verifyGitHubSignature(event.rawBody, event.headers);
427
+ console.log("Verified GitHub event:", event.body);
428
+ }
429
+ },
430
+ });
431
+ ```
432
+
433
+ Store the signing secret before deploying:
434
+
435
+ ```shell
436
+ ntn workers env set GITHUB_WEBHOOK_SECRET=your-secret
437
+ ```
438
+
439
+ > [!WARNING]
440
+ > After 5 consecutive `WebhookVerificationError` failures, Notion blocks the webhook. Redeploy the worker to reset the counter.
441
+
442
+ ### Webhook URLs
443
+
444
+ Each webhook gets a unique URL that acts as a shared secret:
445
+
446
+ ```text
447
+ https://www.notion.so/webhooks/worker/{spaceId}/{workerId}/{uniqueWebhookId}/{webhookName}
448
+ ```
449
+
450
+ Use the CLI to view URLs:
451
+
452
+ ```shell
453
+ ntn workers webhooks list
454
+ ```
455
+
456
+ > [!WARNING]
457
+ > Treat webhook URLs as secrets. Anyone with the full URL can send events unless you add signature verification.
458
+
459
+ ### Execution and retries
460
+
461
+ Webhook requests are acknowledged with `202 Accepted` and your handler runs asynchronously. If your handler throws a non-verification error, Notion retries the run up to 3 times. `WebhookVerificationError` is never retried.
462
+
463
+ ### Webhook CLI commands
464
+
465
+ ```shell
466
+ ntn workers webhooks list # show webhook URLs
467
+ ```
468
+
469
+ ## Authentication & secrets
470
+
471
+ ### Secrets
472
+
473
+ Store API keys and credentials:
474
+
475
+ ```shell
476
+ ntn workers env set API_KEY=your-secret
477
+ ```
478
+
479
+ For local development, pull secrets to a `.env` file:
480
+
481
+ ```shell
482
+ ntn workers env pull
483
+ ```
484
+
485
+ Access them via `process.env`:
486
+
487
+ ```ts
488
+ const apiKey = process.env.API_KEY;
489
+ ```
490
+
491
+ ### OAuth
492
+
493
+ For services requiring user authorization (GitHub, Google, etc.):
494
+
495
+ ```ts
496
+ const githubAuth = worker.oauth("githubAuth", {
497
+ name: "github-oauth",
498
+ authorizationEndpoint: "https://github.com/login/oauth/authorize",
499
+ tokenEndpoint: "https://github.com/login/oauth/access_token",
500
+ scope: "repo user",
501
+ clientId: process.env.GITHUB_CLIENT_ID ?? "",
502
+ clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
503
+ });
504
+ ```
505
+
506
+ > [!NOTE]
507
+ > A Notion-managed OAuth shorthand (`{ provider: "google" }`) also exists but is in alpha and behind a feature flag. Most developers should use the user-managed approach shown above.
508
+
509
+ After deploying, configure your OAuth provider's redirect URL:
510
+
511
+ ```shell
512
+ ntn workers oauth show-redirect-url
513
+ ntn workers oauth start githubAuth
514
+ ```
515
+
516
+ Use the token in your tools:
517
+
518
+ ```ts
519
+ worker.tool("getGitHubRepos", {
520
+ title: "Get GitHub Repos",
521
+ description: "Fetch user's GitHub repositories",
522
+ schema: j.object({}),
523
+ execute: async () => {
524
+ const token = await githubAuth.accessToken();
525
+ const response = await fetch("https://api.github.com/user/repos", {
526
+ headers: { Authorization: `Bearer ${token}` },
527
+ });
528
+ return response.json();
529
+ },
530
+ });
531
+ ```
532
+
533
+ ## What you can build
534
+
535
+ <details open>
536
+ <summary><strong>Sync external data into Notion</strong></summary>
537
+
538
+ ```ts
539
+ const customers = worker.database("customers", {
540
+ type: "managed",
541
+ initialTitle: "Customers",
542
+ primaryKeyProperty: "Customer ID",
543
+ schema: {
544
+ properties: {
545
+ Name: Schema.title(),
546
+ "Customer ID": Schema.richText(),
547
+ Email: Schema.richText(),
548
+ },
549
+ },
550
+ });
551
+
552
+ const crm = worker.pacer("crm", { allowedRequests: 10, intervalMs: 1000 });
553
+
554
+ worker.sync("customersSync", {
555
+ database: customers,
556
+ execute: async (state) => {
557
+ const page = state?.page ?? 1;
558
+ await crm.wait();
559
+ const { customers: items, hasMore } = await fetchCustomers(page);
560
+ return {
561
+ changes: items.map((c) => ({
562
+ type: "upsert" as const,
563
+ key: c.id,
564
+ properties: {
565
+ Name: Builder.title(c.name),
566
+ "Customer ID": Builder.richText(c.id),
567
+ Email: Builder.richText(c.email),
568
+ },
569
+ })),
570
+ hasMore,
571
+ nextState: hasMore ? { page: page + 1 } : undefined,
572
+ };
573
+ },
574
+ });
575
+ ```
576
+
577
+ </details>
578
+
579
+ <details>
580
+ <summary><strong>Give agents a phone with Twilio</strong></summary>
581
+
582
+ ```ts
583
+ worker.tool("sendSMS", {
584
+ title: "Send SMS",
585
+ description: "Send a text message to a phone number",
586
+ schema: j.object({
587
+ to: j.string().describe("Phone number in E.164 format"),
588
+ message: j.string().describe("Message to send"),
589
+ }),
590
+ execute: async ({ to, message }) => {
591
+ const response = await fetch(
592
+ `https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_ACCOUNT_SID}/Messages.json`,
593
+ {
594
+ method: "POST",
595
+ headers: {
596
+ Authorization: `Basic ${Buffer.from(
597
+ `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`,
598
+ ).toString("base64")}`,
599
+ "Content-Type": "application/x-www-form-urlencoded",
600
+ },
601
+ body: new URLSearchParams({
602
+ To: to,
603
+ From: process.env.TWILIO_PHONE_NUMBER ?? "",
604
+ Body: message,
605
+ }),
606
+ },
607
+ );
608
+
609
+ if (!response.ok) throw new Error(`Twilio API error: ${response.statusText}`);
610
+ return "Message sent successfully";
611
+ },
612
+ });
613
+ ```
614
+
615
+ </details>
616
+
617
+ <details>
618
+ <summary><strong>Post to Discord, WhatsApp, and Teams</strong></summary>
619
+
620
+ ```ts
621
+ worker.tool("postToDiscord", {
622
+ title: "Post to Discord",
623
+ description: "Send a message to a Discord channel",
624
+ schema: j.object({
625
+ message: j.string().describe("Message to post"),
626
+ }),
627
+ execute: async ({ message }) => {
628
+ const response = await fetch(process.env.DISCORD_WEBHOOK_URL ?? "", {
629
+ method: "POST",
630
+ headers: { "Content-Type": "application/json" },
631
+ body: JSON.stringify({ content: message }),
632
+ });
633
+
634
+ if (!response.ok) throw new Error(`Discord API error: ${response.statusText}`);
635
+ return "Posted to Discord";
636
+ },
637
+ });
638
+ ```
639
+
640
+ </details>
641
+
642
+ <details>
643
+ <summary><strong>Turn a Notion page into a podcast with ElevenLabs</strong></summary>
644
+
645
+ ```ts
646
+ worker.tool("createPodcast", {
647
+ title: "Create Podcast from Page",
648
+ description: "Convert page content to audio using ElevenLabs",
649
+ schema: j.object({
650
+ content: j.string().describe("Page content to convert"),
651
+ voiceId: j.string().describe("ElevenLabs voice ID"),
652
+ }),
653
+ execute: async ({ content, voiceId }) => {
654
+ const response = await fetch(
655
+ `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
656
+ {
657
+ method: "POST",
658
+ headers: {
659
+ "xi-api-key": process.env.ELEVENLABS_API_KEY ?? "",
660
+ "Content-Type": "application/json",
661
+ },
662
+ body: JSON.stringify({ text: content, model_id: "eleven_monolingual_v1" }),
663
+ },
664
+ );
665
+
666
+ if (!response.ok) throw new Error(`ElevenLabs API error: ${response.statusText}`);
667
+ const audioBuffer = await response.arrayBuffer();
668
+ return `Generated ${audioBuffer.byteLength} bytes of audio`;
669
+ },
670
+ });
671
+ ```
672
+
673
+ </details>
674
+
675
+ <details>
676
+ <summary><strong>Get live stocks, weather, and traffic</strong></summary>
677
+
678
+ ```ts
679
+ worker.tool("getWeather", {
680
+ title: "Get Weather",
681
+ description: "Get current weather for a location",
682
+ schema: j.object({
683
+ location: j.string().describe("City name or zip code"),
684
+ }),
685
+ execute: async ({ location }) => {
686
+ const response = await fetch(
687
+ `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${process.env.OPENWEATHER_API_KEY}&units=metric`,
688
+ );
689
+
690
+ if (!response.ok) throw new Error(`Weather API error: ${response.statusText}`);
691
+
692
+ const data = await response.json();
693
+ return `${data.name}: ${data.main.temp}°C, ${data.weather[0].description}`;
694
+ },
695
+ });
696
+ ```
697
+ </details>
698
+
699
+ ## Helpful CLI commands
700
+
701
+ ```shell
702
+ # Deploy your worker to Notion
703
+ ntn workers deploy
704
+
705
+ # Test a tool locally
706
+ ntn workers exec <toolName> --local -d '{"key": "value"}'
707
+
708
+ # Monitor sync status (live-updating)
709
+ ntn workers sync status
710
+
711
+ # Preview sync output without writing to the database
712
+ ntn workers sync trigger <syncKey> --preview
713
+
714
+ # Trigger a real sync immediately (writes to the database, bypasses schedule)
715
+ ntn workers sync trigger <syncKey>
716
+
717
+ # Reset sync state (restart from scratch)
718
+ ntn workers sync state reset <syncKey>
719
+
720
+ # List all capabilities
721
+ ntn workers capabilities list
722
+
723
+ # Pause / resume a sync
724
+ ntn workers capabilities disable <syncKey>
725
+ ntn workers capabilities enable <syncKey>
726
+
727
+ # List webhook URLs
728
+ ntn workers webhooks list
729
+
730
+ # Manage authentication
731
+ ntn login
732
+ ntn logout
733
+
734
+ # Store API keys and secrets
735
+ ntn workers env set API_KEY=your-secret
736
+
737
+ # View execution logs
738
+ ntn workers runs logs <runId>
739
+
740
+ # Start OAuth flow
741
+ ntn workers oauth start <oauthName>
742
+
743
+ # Show OAuth redirect URL (set this in your provider's app settings)
744
+ ntn workers oauth show-redirect-url
745
+
746
+ # Display help for all commands
747
+ ntn --help
748
+ ```
749
+
750
+ ## Local development
751
+
752
+ ```shell
753
+ npm run check # type-check
754
+ npm run build # emit dist/
755
+ ```
756
+
757
+ Store secrets in `.env` for local development:
758
+
759
+ ```shell
760
+ ntn workers env pull
761
+ ```
762
+
763
+ ## Learn more
764
+
765
+ Read the full [Workers documentation](https://developers.notion.com/workers/get-started/overview) or join the [Notion Dev Slack](https://join.slack.com/t/notiondevs/shared_invite/zt-3u9oid9q8-HLUBmMVWYK~g9HFo4U4raA).