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.
A follow-up sink
Section titled “A follow-up sink”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 inconfigSchema. Declare required non-secret keys (likeMY_SINK_PROJECT) invars; put keys that have a code default inoptionalVarssodoctordoes not require them. - Make outbound calls with
ctx.fetch. - Throw
ProviderErrorto signal failure. Usekind: "transient"for retryable failures (rate limits,5xx) andkind: "permanent"for everything else. - Return
{ status: "created", id, url? }or{ status: "duplicate" }.
A notes source
Section titled “A notes source”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.
Contributing a Durable Object
Section titled “Contributing a Durable Object”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.
Effect stays internal
Section titled “Effect stays internal”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.