with one click
sync
// Scaffold a new sync capability with guided setup — asks about data source, mode, pagination, and cursor design, then generates working code
// Scaffold a new sync capability with guided setup — asks about data source, mode, pagination, and cursor design, then generates working code
Guide to setting up third-party authentication for a Notion Worker. Covers external-service API keys / personal access tokens and OAuth. Use when the worker needs credentials for a non-Notion API, not for Notion API tokens or `ntn login`.
Comprehensive guide to building Notion Workers syncs — covers the two-sync architecture (backfill+delta), replace mode, pagination, consistency buffers, pacers, deletion strategies, and common pitfalls. Auto-loads when sync-related work is detected.
Diagnose a failing or misbehaving sync — fetch run logs, identify errors, cross-reference with code, and suggest fixes
Review a sync capability for common bugs — cursor advancement, pagination termination, state persistence, bi-modal correctness, consistency buffers, and deletion handling
| name | sync |
| description | Scaffold a new sync capability with guided setup — asks about data source, mode, pagination, and cursor design, then generates working code |
| user-invocable | true |
| disable-model-invocation | true |
| allowed-tools | ["Read","Edit","Write","Bash","Glob","Grep","Agent"] |
You are helping the user create a new sync capability for their Notion Worker. Walk through each step, asking questions and making recommendations. Generate working code at the end.
Before you begin, read these reference files to understand sync patterns:
.agents/skills/sync-guide/SKILL.md — concepts, modes, patterns, common mistakes.agents/skills/sync-guide/api-pagination-patterns.md — real-world API strategies.agents/skills/sync-guide/examples/ — working code templatesAlso read the current src/index.ts to understand what already exists.
Ask the user:
If they name a well-known API, look up its pagination mechanism and change-tracking capabilities (does it have updated_at? an events endpoint? cursor-based pagination?).
Based on what you know about the data source, recommend one of two architectures:
Use when: the source is small (<1k records) OR the API has no change tracking
(no updated_at, no event feed).
One sync, replace mode. Re-fetches everything each cycle. The runtime auto-deletes records that disappear from the source. Simplest option.
Use when: the API supports change tracking (updated_at, events, changelog) —
this covers most enterprise APIs (Salesforce, Stripe, Linear, GitHub, HubSpot).
Two separate syncs writing to the same database:
schedule: "manual"): Paginates the full
dataset. Triggered manually via CLI when a full re-import is needed. Replace
mode's mark-and-sweep automatically cleans up records deleted from the source.schedule: "5m" or similar): Fetches only
recently changed records. Runs on a timer for low-latency updates.Advantages over a single bi-modal sync:
Change tracking drives the decision, not dataset size. A Linear workspace
may only have a few thousand issues, but its API supports the queries needed
for delta sync, so backfill+delta is the right choice. Conversely, a website
listing local pickleball courts has no updated_since endpoint regardless of
how many records it has.
Recommend an architecture with a brief explanation. Let the user override if they disagree.
Based on the API's response shape, propose a schema. Look up what fields the API returns and map the most useful ones to Schema types. Don't ask the user to enumerate fields — propose a sensible default and let them adjust.
For example, if syncing Jira issues, propose:
const issuesDb = worker.database("issuesDb", {
type: "managed",
initialTitle: "Jira Issues",
primaryKeyProperty: "Issue Key",
schema: {
properties: {
"Issue Key": Schema.richText(), // primaryKeyProperty — the unique ID
"Summary": Schema.title(), // the main display field
"Status": Schema.select([...]), // mapped from Jira statuses
"Assignee": Schema.richText(), // or Schema.people() if email available
"Updated": Schema.date(),
},
},
});
Guidelines:
worker.database() and reference the handle in worker.sync()Schema.title() — pick the most descriptive fieldSchema.richText() for the primary key property (the unique ID)Schema.url(), Schema.email(), Schema.date(), Schema.number(),
Schema.checkbox(), Schema.select() where the data type fitsSchema.relation("otherDatabaseKey") for relations to another managed database.agents/skills/sync-guide/SKILL.md under "Schema Reference"Present the proposed schema to the user and ask if they want to add, remove, or change any fields before generating code.
Research the API to determine its pagination and change-tracking mechanisms. Do NOT ask the user about pagination details — figure it out from the API docs, your knowledge of the API, or by looking up the API. The user shouldn't need to know whether their API uses opaque cursors vs page numbers.
You need to determine:
Then design the state accordingly:
For simple replace syncs: State is just within-cycle pagination.
{ cursor: string | null }{ page: number }For backfill + delta pairs: Each sync has its own simple state — no bi-modal discriminated union needed.
Backfill state: Just pagination cursor for walking the full dataset. Depends on how the API paginates its list endpoint:
{ cursor: string | null }{ page: number }{ cursorTimestamp: string | null; cursorId: string | null }Delta state: Change-tracking cursor for fetching recent modifications. Depends on how the API exposes changes:
{ cursor: string | null }{ cursorTimestamp: string; cursorId: string }{ eventCursor: string }Consistency buffer (delta syncs only): In incremental mode the cursor never resets, so if you advance past a record that hasn't been indexed yet, it's lost permanently. Apply a consistency buffer: never advance the cursor closer than 10-15 seconds to "now". This is especially important for APIs with eventual consistency (Stripe, Salesforce, etc.).
Deletion handling: There are three cases to consider:
*.deleted types,
APIs that return deleted: true in change feeds): Emit
{ type: "delete", key } in the delta sync. This is the cleanest approach.If the API has no change tracking at all, go back and recommend a simple replace sync instead.
Present your state design to the user as a brief summary (e.g., "This API uses
cursor-based pagination and has an updated_at field, so I'll use a
backfill+delta pair with opaque cursor for backfill and timestamp keyset for
delta"). Let them confirm or adjust before generating code.
Before generating code, determine what auth the API needs and set it up so you can test locally.
There are two patterns:
Pattern A: Static API token/key For APIs where the user has a personal token or API key (e.g., Jira API token, GitHub PAT, simple API keys).
Ask the user for their token and add it to .env:
JIRA_API_TOKEN=...
JIRA_EMAIL=user@example.com
If .env doesn't exist, create it. The .env file is automatically loaded
during local execution (--local flag).
Pattern B: OAuth For APIs that require OAuth (e.g., Google, Salesforce, HubSpot). This has two parts:
Client credentials — the OAuth app's client ID and secret. These go in .env:
MY_OAUTH_CLIENT_ID=...
MY_OAUTH_CLIENT_SECRET=...
User token — obtained through the OAuth flow after deploying. This is
handled by the runtime automatically via worker.oauth() and .accessToken().
For OAuth syncs, you'll add a worker.oauth() call in the generated code.
Always use UserManagedOAuthConfiguration (the shape with explicit endpoints and
client credentials) rather than the { provider: "..." } shorthand, as
Notion-managed OAuth is in alpha and the user likely does not have access.
const myAuth = worker.oauth("myAuth", {
name: "my-provider",
authorizationEndpoint: "https://provider.example.com/oauth/authorize",
tokenEndpoint: "https://provider.example.com/oauth/token",
scope: "read write",
clientId: process.env.MY_OAUTH_CLIENT_ID ?? "",
clientSecret: process.env.MY_OAUTH_CLIENT_SECRET ?? "",
});
Then use await myAuth.accessToken() in the execute function instead of
reading a static token from process.env.
Note: OAuth syncs can't be fully tested locally since the OAuth flow requires
a deployed worker. Local testing will fail at the .accessToken() call. This
is fine — proceed to deploy and test via preview (Step 8).
Write the sync into src/index.ts. Use the closest example from .agents/skills/sync-guide/examples/ as a starting point:
replace-simple.ts — static data, no APIreplace-paginated.ts — paginated replace mode (also used for backfill syncs)incremental-basic.ts — delta sync with opaque cursorincremental-bimodal.ts — full backfill + delta pair exampleincremental-events.ts — delta sync with event feedInclude in the generated code:
Worker, Builder, Schema)worker.database() with schema and primaryKeyPropertyworker.pacer() — and await pacer.wait() before every API requestworker.sync() call(s) referencing the database handleschedule: "manual", delta with a timed schedulefetch with auth from process.envCode generation checklist:
worker.database() and referenced by handleworker.pacer() for the upstream APIawait pacer.wait() called before every fetch to the upstream APImode: "replace" and schedule: "manual" (if applicable)mode: "incremental" with a timed schedule (if applicable)Test the sync before deploying. This catches bugs early without a deploy cycle.
For syncs using static API tokens (Pattern A):
Run npm run check to verify TypeScript types compile. Fix any errors.
Run ntn workers exec <key> --local to execute the sync locally.
This runs the execute function on your machine with .env loaded.
hasMore look right? Does the cursor advance?If it returns hasMore: true, test the next page:
ntn workers exec <key> --local -d '<nextState from previous output>'
If there are errors (auth failures, wrong field mappings, crashes): fix the code and re-run — no deploy needed, iteration is fast.
For backfill+delta pairs, test each sync independently:
ntn workers exec <backfillKey> --localntn workers exec <deltaKey> --localWrite a test file (test.ts) that exercises the sync. Import the worker
directly and call its .run() method.
If the user has API credentials in .env, write a test that hits the real
API — this is the most valuable test because it validates actual field
mappings, pagination behavior, and auth against the real service. If
credentials aren't available, stub the HTTP calls instead.
Integration test (preferred when credentials are available):
import "dotenv/config"; // load .env
import worker from "./src/index.ts";
import assert from "node:assert";
async function test() {
// First page (backfill start, no prior state)
const page1 = await worker.run("mySync", undefined, { concreteOutput: true });
console.log(`Page 1: ${page1.changes.length} records, hasMore: ${page1.hasMore}`);
assert(page1.changes.length > 0, "Should return records");
// Verify fields are populated
const first = page1.changes[0];
assert(first.key, "Record should have a key");
console.log("Sample record:", JSON.stringify(first, null, 2));
// Test pagination
if (page1.hasMore) {
const page2 = await worker.run("mySync", page1.nextState, { concreteOutput: true });
console.log(`Page 2: ${page2.changes.length} records, hasMore: ${page2.hasMore}`);
assert(page2.changes.length > 0, "Second page should return records");
}
console.log("All tests passed!");
}
test().catch((err) => { console.error(err); process.exit(1); });
Run with npx tsx test.ts. Adapt to the specific sync: use the actual
capability key, add assertions for specific field values, verify both
backfill and delta syncs for backfill+delta pairs, etc.
For syncs using OAuth (Pattern B):
Local execution won't work because .accessToken() requires a deployed worker
with a completed OAuth flow. Skip to Step 8 (deploy + preview) instead.
You can still run npm run check to verify types compile.
Once local testing passes (or immediately for OAuth syncs), deploy and test remotely.
If secrets need to be available at deploy time (e.g., OAuth clientSecret read
from process.env during capability registration), create the worker and push
secrets first:
ntn workers create --name <name> — create the worker without deployingntn workers env push — push .env secrets to remotentn workers deploy — now deploy with secrets availableOtherwise, the simpler flow:
ntn workers deploy — build and publishntn workers env push — push .env secrets to remoteThen, if the sync uses OAuth, complete the OAuth flow before previewing.
Important: env push must happen before oauth start — the deployed worker needs the client secret to exchange the authorization code for tokens.
ntn workers oauth show-redirect-url — get the redirect URLntn workers oauth start <oauthKey> — opens browser to complete the OAuth flowntn workers sync trigger <syncKey> --preview — execute remotely without writing to Notion
hasMore: true, continue: ntn workers sync trigger <syncKey> --preview --context '<nextState>'For backfill+delta pairs, preview both syncs:
ntn workers sync trigger <backfillKey> --previewntn workers sync trigger <deltaKey> --previewWhen the preview looks good:
ntn workers sync trigger <key> — trigger a real syncntn workers sync status — check that the sync is running and progressingntn workers runs list then ntn workers runs logs <runId> — check for errorsntn workers sync status again to confirm progress (record count increasing, no errors)For backfill+delta pairs, trigger the backfill first to load all data, then let the delta sync's schedule handle ongoing changes:
ntn workers sync trigger <backfillKey> — start the full dataset loadntn workers sync status until the backfill completesTell the user: the first sync run is the backfill, which may take a while
depending on dataset size. They should periodically run ntn workers sync status
to monitor progress until the initial backfill completes. After that, the delta
sync runs automatically on its configured schedule. To re-backfill later:
ntn workers sync state reset <backfillKey> && ntn workers sync trigger <backfillKey>