@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.
interface PluginInput
interface PluginInput {
trigger: "scheduled" | "watch" | "manual";
env: Record<string, string>;
files: Record<string, string>;
targets: string[];
}The shape returned by readInput().
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— populated for watch-triggered runs with the paths that changed. Empty for manual and scheduled runs.
interface EntryOptions
interface EntryOptions {
collection: string;
body: string;
frontmatter?: Record<string, unknown>;
filename?: string;
}The argument to writeEntry().
collection— target collection. May be a nested path (e.g."messages/tom"). Promotion matches it against the plugin'sdither.collectionsgrants (each is a glob pattern); if no grant glob matches, the file is rejected. 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. 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 rejected (a clash with this plugin's own previous output is 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.
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>(blocking) overwrites a single status line on a TTY. On a non-TTY it appends each message on its own line.dither plugin run <name> --detachwrites every message into the log file at<dither-home>/logs/<plugin>-<ms>.log(where<ms>is the spawn epoch in milliseconds).
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 });Putting it together
A complete plugin that uses every export:
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 });