Skip to content

Writing a provider

Notewright is open core: the built-in providers are just packages that implement the public contracts, and yours can be too. A provider is a small package with zero Effect knowledge. You implement a plain Promise-returning contract, declare the config you read, and (if you need scheduled or stateful work) contribute a Durable Object.

Implement FollowUpProvider and export it as a FollowUpExtension:

import {
ProviderError,
type CreateFollowUpResult,
type FollowUpExtension,
type FollowUpItem,
type ProviderCtx,
} from "@notewright/core";
export const mySink: FollowUpExtension = {
id: "my-sink",
configSchema: { secrets: ["MY_SINK_TOKEN"], vars: ["MY_SINK_PROJECT"] },
async createFollowUp(item: FollowUpItem, ctx: ProviderCtx): Promise<CreateFollowUpResult> {
const token = ctx.config.MY_SINK_TOKEN ?? "";
const response = await ctx.fetch("https://api.example.com/tasks", {
method: "POST",
headers: { authorization: token, "content-type": "application/json" },
body: JSON.stringify({ title: item.title, body: item.description }),
});
if (response.status === 429 || response.status >= 500) {
throw new ProviderError({ message: `upstream ${response.status}`, kind: "transient", status: response.status });
}
if (!response.ok) {
throw new ProviderError({ message: `upstream ${response.status}`, kind: "permanent", status: response.status });
}
const created = (await response.json()) as { id: string; url?: string };
return { status: "created", id: created.id, url: created.url };
},
};

Key rules:

  • Read configuration only from ctx.config, and only keys you declared in configSchema. Declare required non-secret keys (like MY_SINK_PROJECT) in vars; put keys that have a code default in optionalVars so doctor does not require them.
  • Make outbound calls with ctx.fetch.
  • Throw ProviderError to signal failure. Use kind: "transient" for retryable failures (rate limits, 5xx) and kind: "permanent" for everything else.
  • Return { status: "created", id, url? } or { status: "duplicate" }.

Implement NotesSourceProvider and export it as a NotesExtension. A notes source must implement listUpdatedNotes(since, ctx) and getNoteWithTranscript(id, ctx). If your source needs scheduled sync, contribute a sync Durable Object and declare a sync binding so the /sync route forwards to it:

export const mySource: NotesExtension = {
...myNotesProvider,
runtime: {
durableObjects: [
{ binding: "MY_SYNC_AGENT", className: "MySyncAgent", exportName: "MySyncAgent" },
],
migrations: [{ tag: "my-source-v1", new_sqlite_classes: ["MySyncAgent"] }],
},
sync: { binding: "MY_SYNC_AGENT" },
};

Declaring a sync binding means core forwards POST /sync to that DO, so the DO must handle it in onRequest (see the sample below). If you omit the sync binding, the /sync route runs the sync inline in the Worker instead of forwarding to a DO.

A DO host (for example a sync agent) extends the public NotewrightAgent base class. Extending it gives you the uniform status contract that /status and /start enumerate, so your DO shows up in notewright status and notewright start automatically:

import { NotewrightAgent, runNotesSync, type SyncAgentState, type SyncMode } from "@notewright/core";
const json = (body: unknown, init?: ResponseInit): Response =>
new Response(JSON.stringify(body), {
...init,
headers: { "content-type": "application/json", ...init?.headers },
});
export class MySyncAgent extends NotewrightAgent<Env, SyncAgentState> {
async onStart(): Promise<void> {
await this.schedule("0 0 * * 2-6", "syncFromSchedule", undefined, { idempotent: true });
}
async syncFromSchedule(): Promise<void> {
await runNotesSync(myNotesProvider, this.env, "scheduled" as SyncMode);
}
// Because the extension declares a `sync` binding, core forwards `POST /sync`
// to this DO. Without this handler `notewright sync` hits the Agent default
// and 404s; only the scheduled cron path would work.
async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== "/sync" || request.method !== "POST") {
return json({ error: "Not found" }, { status: 404 });
}
const result = await runNotesSync(myNotesProvider, this.env, "manual" as SyncMode);
return json(result);
}
}

Keep the DO module import-safe under Node (no Cloudflare API access at import time) so notewright doctor can import the composed entry that re-exports your class.

The sync and extraction internals use Effect, but no public export may expose an Effect type. Your provider’s exports must be plain data, classes, and Promise-returning functions. Signal failures by throwing ProviderError, not by returning an Effect error. This boundary is what keeps provider authoring free of any Effect knowledge.