@dither/plugin SDK reference
Every export from @dither/plugin with signatures, behavior, and examples.
@dither/plugin is the author SDK for dither plugins. It is a tiny module with no transitive runtime deps, designed to be imported under Deno via the import map the host injects.
Every function must be called from inside a plugin run — the host sets up the surrounding environment, and the SDK will throw if invoked outside of it. Runs are supervised by the dither daemon: it writes the run's input.json, spawns the plugin with a scratch run dir, and promotes the *.md files the plugin leaves behind.
interface PluginInput
interface WatchTarget {
path: string;
mtime: string;
}
interface PluginInput {
trigger: "scheduled" | "watch" | "manual";
env: Record<string, string>;
files: Record<string, string>;
targets: WatchTarget[];
net: string[];
}The shape returned by readInput() — i.e. the contents of the run's input.json.
Fields:
trigger— what fired the run. Plugins that behave differently on scheduled vs. manual runs can branch on this.env— resolved env values keyed by the manifest's declaredname. All values are strings. Plugins coerce in code (Number(input.env.MAX_RUNS),input.env.DEBUG === "true"). Optional values that the user did not supply and that have nodefaultwill simply be missing keys.files— absolute paths to the user's file/folder inputs, keyed byid. Each path is on Deno's--allow-readallowlist. Optional file inputs that the user did not supply will simply be missing keys.targets— for watch-triggered runs, the entries queued for this fire:{ path, mtime }wherepathis absolute andmtimeis ISO-8601 UTC (usable as a resume cursor). Empty on manual and scheduled runs unless the caller supplied explicit targets.net— the hosts this run may reach, mirroring the--allow-net=the host spawned it with. Empty means no network; a single"*"means any host. Lets a plugin read its own network grant instead of hardcoding the same host in both code and manifest.
interface EntryOptions
type FrontmatterValue =
| string
| number
| boolean
| null
| readonly FrontmatterValue[]
| { readonly [key: string]: FrontmatterValue | undefined };
interface EntryOptions {
collection: string;
body: string;
frontmatter?: Record<string, FrontmatterValue | undefined>;
filename?: string;
}The argument to writeEntry().
collection— target collection. May be a nested path (e.g."messages/tom"). Promotion matches it against the plugin'screategrants (each is a glob pattern, set at install via--create); if no grant glob matches, the output is skipped. Path rules: per-segment[a-zA-Z0-9._-], no.., no leading/trailing/, no.mdsuffix.body— markdown body. Do not include your own frontmatter fence; the SDK emits one for you.frontmatter— optional fields. Values must be JSON-ish (FrontmatterValue); keys whose value isundefinedare dropped, and abigint, function, or symbol throws. Three keys are reserved:id,source,collection. Any value you put under those keys is overwritten by the SDK before writing:id— if you setfrontmatter.idto a string, it is used as the entry id (and as the default filename). The id must be a flat name — no/or\\, and not.or..— orwriteEntrythrows. Otherwise a UUID is generated.source— always overwritten with the plugin's name.collection— always overwritten withopts.collection.
filename— optional output filename inside the run dir. Defaults to<id>.md. Must be a flat name (same rules asid). Two outputs with the same filename in the same run overwrite each other; at promote, a clash with an existing entry written by a different plugin is skipped unless aneditgrant (--edit) covers that collection (a clash with this plugin's own previous output is always allowed and overwrites).
readInput()
function readInput(): Promise<PluginInput>;Reads and JSON-parses the file at DITHER_INPUT_FILE and returns it as a PluginInput.
Behavior:
- Throws if
DITHER_INPUT_FILEis unset. - Throws if the file is missing or unreadable (it is always present under the host).
- No schema validation — the runtime returns whatever the JSON contains.
Example (from the echo-config fixture):
import { readInput, writeEntry } from "@dither/plugin";
const input = await readInput();
await writeEntry({
collection: "echoed",
frontmatter: {
external_id: "echo-1",
greeting: input.env.GREETING,
max_runs: input.env.MAX_RUNS,
},
body: [
"# Echo result",
"",
"Greeting: " + input.env.GREETING,
"Max runs: " + input.env.MAX_RUNS,
"Token: " + input.env.API_TOKEN,
].join("\n"),
});writeEntry(opts)
function writeEntry(opts: EntryOptions): Promise<string>;Serializes one markdown entry into DITHER_RUN_DIR. Returns the absolute path written.
Behavior:
- Generates a UUID for
idunlessopts.frontmatter.idis a string. - Determines the filename:
opts.filenameif given, else<id>.md. - Builds frontmatter as
{ ...opts.frontmatter, id, source, collection }— your frontmatter values are spread first, then the three reserved keys are written last so they always win. - Emits frontmatter via a minimal YAML serializer that JSON-encodes every value (strings get quoted, numbers/booleans pass through, arrays and objects come out as inline JSON). Most YAML parsers accept this; if you need a particular YAML shape, embed it in
bodyand skip the relevantfrontmatterkey. - Creates
dirname(out)if necessary and writes the file UTF-8. - Throws if
DITHER_RUN_DIRorDITHER_PLUGIN_NAMEis unset.
Resulting file:
---
title: "Hi"
id: "<uuid>"
source: "<plugin name>"
collection: "<your collection>"
---
<your body>(Note the trailing newline after the body — the SDK appends one.)
Example (from the import-folder fixture):
import { writeEntry } from "@dither/plugin";
await writeEntry({
collection: "imported",
frontmatter: { external_id: "fixture-1", title: "Hello from fixture" },
body: "# Hello\n\nThis entry was emitted via the @dither/plugin SDK.",
});readFile(inputId)
function readFile(inputId: string): Promise<string>;Reads a file the user supplied as a files[] input. The SDK looks up the absolute path in input.files[inputId] (which the host already added to Deno's --allow-read allowlist) and returns the UTF-8 contents.
Saves the two-step pattern of importing node:fs/promises and indexing into input.files by hand.
Behavior:
- Throws if the input id is not present in
input.files(i.e. not declared in the manifest'sfiles[], or not provided at install time, or not required and not supplied). - Always reads as UTF-8. For binary files, fall back to
node:fs/promisesandinput.files[id]directly. - Throws if
DITHER_INPUT_FILEis unset.
Example (from the read-file fixture):
import { readFile, writeEntry } from "@dither/plugin";
const body = await readFile("SOURCE");
await writeEntry({
collection: "read",
frontmatter: { external_id: "from-file" },
body,
});readState<T>(initial)
function readState<T>(initial: T): Promise<T>;Reads and JSON-parses the file at DITHER_STATE_FILE. Returns initial when the file is missing or empty, so the plugin never has to handle a null branch.
Behavior:
- Returns
initialif the file does not exist (first run). - Returns
initialif the file exists but is empty after trimming. - Otherwise returns
JSON.parse(content) as T. No schema validation. - Throws if
DITHER_STATE_FILEis unset.
T is inferred from initial, so most call sites can drop the explicit generic.
Example:
import { readState, writeState } from "@dither/plugin";
const state = await readState({ cursor: 0, lastSeen: "" });
state.cursor += 1;
await writeState(state);writeState<T>(state)
function writeState<T>(state: T): Promise<void>;JSON-stringifies state (pretty-printed, 2-space indent) and writes it to DITHER_STATE_FILE, creating the parent directory if necessary. The write goes to a temp file in the same directory and is renamed into place, so a crash mid-write can't leave a truncated state file.
Behavior:
- Overwrites the previous state file in full — there is no merge.
- No size limit, no schema check. Whatever you write is what you get back from
readStatenext run. - Throws if
DITHER_STATE_FILEis unset.
Example:
import { readState, writeState } from "@dither/plugin";
interface State {
lastSeen: string;
}
const prev = await readState<State>({ lastSeen: "" });
await writeState<State>({ lastSeen: new Date().toISOString() });progress(opts)
interface ProgressOptions {
message: string; // required
done?: number;
total?: number;
}
function progress(opts: ProgressOptions): void;Emit a status update to the host. Synchronous: writes one NDJSON line to stderr with a _dither: "progress" sentinel. The host strips control lines from the stderr stream before forwarding the rest to its own stderr / log file.
Behavior:
- Throws synchronously if
messageis missing or empty.messageis what the host displays; the other fields are advisory. doneandtotalare passed through verbatim. Future renderers may use them for percent / progress bars; today the CLI only rendersmessage.- Plain
console.logandconsole.errorare not affected — they keep behaving as normal logging.
Display:
dither plugin run <name>kicks the daemon and tails the run's journal, overwriting a single status line on a TTY. On a non-TTY it appends each message on its own line.dither plugin run <name> --detachreturns immediately; every message still lands in the run's journal under<config-dir>/history/<run-id>/. Tail it later withdither plugin runs <name>.
Example:
import { progress, writeEntry } from "@dither/plugin";
const ids = await fetchIds();
let i = 0;
for (const id of ids) {
await writeEntry({ collection: "items", frontmatter: { id }, body: "…" });
if (++i % 25 === 0)
progress({ message: `synced ${i} / ${ids.length}`, done: i, total: ids.length });
}
progress({ message: `done — ${ids.length} items`, done: ids.length, total: ids.length });reschedule(opts)
interface RescheduleOptions {
afterMs: number; // required, positive; host clamps to >= 1000
reason?: string;
}
function reschedule(opts: RescheduleOptions): void;Ask the host to fire this plugin again later — a rate-limit cooldown, a transient network failure, or self-imposed pacing. Synchronous: writes one NDJSON line to stderr with a _dither: "reschedule" sentinel, same control channel as progress.
Behavior:
- Throws synchronously if
afterMsis missing, non-finite, or not positive. - The host preserves inflight rows (they carry into the refire), schedules the refire at now +
afterMs, and does not increment the consecutive-failure counter — a cooperative pause is not a crash. - Multiple calls in one run are allowed; the last
afterMswins. - Call it and then
return(orDeno.exit(0)) — the plugin should exit cleanly rather than keep processing targets.
Example:
import { reschedule } from "@dither/plugin";
if (res.status === 429) {
reschedule({ afterMs: 60_000, reason: "rate limited" });
Deno.exit(0);
}VERSION
const VERSION: string;The SDK's own version string. Informational only — nothing in the host reads it.
Putting it together
A complete plugin using the common exports:
import { readInput, readState, writeEntry, writeState } from "@dither/plugin";
interface State {
lastSeen: string;
}
const input = await readInput();
const state = await readState<State>({ lastSeen: "" });
console.error(`trigger=${input.trigger}`);
const now = new Date().toISOString();
await writeEntry({
collection: "feed",
frontmatter: { feed_url: input.env.feed_url, fetched_at: now },
body: `# Run at ${now}\n\nPrevious run: ${state.lastSeen ?? "never"}`,
});
await writeState<State>({ lastSeen: now });