Authoring a plugin
Build, install, and run a dither plugin end-to-end.
This page walks from a one-file "Hello world" plugin to one with env values, file inputs, and persistent state. Every snippet is copy-paste runnable.
Prerequisites
- Deno — dither downloads and pins its own Deno on first plugin install/run, so you don't need it on
PATH. SetDITHER_USE_SYSTEM_DENO=1to use a systemdeno(CI / dev escape hatch). - dither installed and on your
PATH.
1. Directory layout
A plugin is a directory with two files:
hello-world/
package.json
plugin.tsThat's it. No node_modules, no build step. Deno resolves @dither/plugin via an import map the host injects at run time.
2. The minimal "Hello world"
package.json:
{
"name": "hello-world",
"version": "0.0.1",
"license": "MIT",
"dither": {
"display_name": "Hello World",
"tagline": "Writes one entry and exits.",
"create": ["notes"]
}
}plugin.ts:
import { writeEntry } from "@dither/plugin";
await writeEntry({
collection: "notes",
body: "# Hello\n\nFrom my first dither plugin.",
});Install and run:
dither plugin install ./hello-world
dither plugin run hello-worldYou'll see something like:
installed hello-world@0.0.1
→ /Users/you/.dither/plugins/hello-world
next: dither plugin run hello-worldrun hands the fire to the daemon and tails that run's journal — one NDJSON
event per line, ending with a terminal _result line naming the promoted
entries under your library.
3. The dither block — every field
Every field below is optional unless noted. The full schema lives in packages/cli/src/manifest.ts.
Identity
| Field | Type | Notes |
|---|---|---|
display_name | string | Human label shown in dither plugin list and (eventually) UI. |
tagline | string | One-liner. |
icon | string | Identifier or path. Schema-only today; nothing renders icons yet. |
Triggers
| Field | Type | Notes |
|---|---|---|
schedule | string (cron expression or every 15min) | Fired by the dither daemon. Installing a plugin with schedule lazily starts the daemon. Manual dither plugin run still works. |
watch | { collections: string[]; glob?: string } | Fired by the daemon via chokidar when a matching file changes in any of the declared collections. Triggering paths land in input.targets. |
Both are declarations; the consented values land at the top level of the
grants file. dither plugin run <name> --every "<schedule>" and
--watch <collection-or-dir> edit them after the fact (they configure and
reload the daemon instead of firing a run). There are no dither schedule /
dither watch commands.
env[]
User-supplied env values. All values are plain strings; if your plugin needs a number or a bool, coerce inside plugin code (Number(input.env.MAX_RUNS), input.env.DEBUG === "true"). Each entry:
{
name: string; // key the plugin reads via input.env[name]
description?: string; // human-readable help
default?: string; // if absent and not provided, install fails
}A value is resolved in this order at install time:
- A literal passed via
--env "NAME=VALUE,...". - A grant to read from the global
dither envstore, passed via--allow-env "NAME,...". The literal value is not copied into the grants file; it's looked up at run time from<config-dir>/env.json. - The manifest
default. - Otherwise install fails:
Required env '<name>' was not provided ….
<config-dir> is dither's own state directory, resolved first-match-wins:
$DITHER_DIR → $XDG_CONFIG_HOME/dither → ~/.dither.
Example, lifted from the echo-config fixture:
"env": [
{ "name": "GREETING", "description": "A non-secret string the plugin will echo." },
{ "name": "MAX_RUNS", "default": "3" },
{ "name": "API_TOKEN", "description": "A secret-like value the plugin will echo (proves delivery)." }
]files[]
User-supplied filesystem paths. Each entry:
{
id: string;
name?: string;
description?: string; // human-readable help, shown when prompting
kind: "file" | "folder";
extensions?: string[]; // schema-only today; not validated
required?: boolean;
default?: string; // canonical location; `~` expands to $HOME
}At install time the path is resolved to absolute, checked to exist, and asserted against kind (file vs folder). At run time the absolute path is delivered as input.files[id], and the path is added to Deno's --allow-read allowlist so the plugin can open it.
net
net?: string[];Top-level list of hosts the plugin may reach (e.g. ["api.example.com", "files.example.com:443"]). Becomes --allow-net=<csv> on the Deno spawn. A sole ["*"] entry means "any host" and becomes a bare --allow-net — opt-in at install time, for plugins that fetch arbitrary URLs. Empty / absent → no network. The permissions block from earlier drafts is gone; net lives at the top level of the dither block. The resolved list is mirrored back to the plugin as input.net, so you don't have to hardcode the same host in code and manifest.
create and edit
create?: string[];
edit?: string[];Glob patterns naming the collections this plugin may write into. create covers entries this plugin makes; edit covers whole-entry overwrites of entries another plugin created (and does not imply create). The manifest lists seed the install grants when the user doesn't pass --create / --edit. Output whose frontmatter collection value isn't matched by any glob in the resolved create set is rejected at promote; an output landing on another plugin's entry without an edit grant is skipped (journaled, never an error).
The old collections field is gone — a manifest still using it fails to parse with manifest 'collections' was renamed: use 'create' (and optionally 'edit') instead.
Collection paths are nestable. Each entry is a glob over a path identifier:
notes— exact match (only the literalnotescollection).messages/**—messagesitself and every descendant. The runner special-cases the<X>/**form to also cover the bare parent.messages/*— direct children ofmessagesonly (messages/tom, notmessages/tom/anything, and notmessagesitself).messages/2026-*— partial-segment match.
Path identifiers in writeEntry({ collection }) and in entry frontmatter must use [a-zA-Z0-9._-] per segment, with no leading-dot segments. Joined by /, no .., no leading/trailing /, no empty segments, no .md suffix.
Collections are created on demand at promote time.
4. Writing the plugin
4a. Just writeEntry
The minimal case (see import-folder fixture). One write, no inputs:
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.",
});4b. Adding env values
Read env values supplied at install time via input.env. All values are strings — coerce in your plugin if you need numbers or booleans:
import { readInput, writeEntry } from "@dither/plugin";
const input = await readInput();
const greeting = input.env.GREETING;
const maxRuns = Number(input.env.MAX_RUNS);
const token = input.env.API_TOKEN;
await writeEntry({
collection: "echoed",
frontmatter: {
external_id: "echo-1",
greeting,
max_runs: maxRuns,
},
body: [
"# Echo result",
"",
`Greeting: ${greeting}`,
`Max runs: ${maxRuns}`,
`Token: ${token}`,
].join("\n"),
});4c. Adding file inputs
The SDK's readFile(id) looks up the file path the user supplied at install time, reads it, and returns the UTF-8 contents. One import, one call:
import { readFile, writeEntry } from "@dither/plugin";
const body = await readFile("SOURCE");
await writeEntry({
collection: "read",
frontmatter: { external_id: "from-file" },
body,
});If you need the raw path (for example to record it in frontmatter, or for a non-utf-8 read), it's still available on input.files[id]:
import { readInput } from "@dither/plugin";
import { readFile as fsReadFile } from "node:fs/promises";
const input = await readInput();
const buf = await fsReadFile(input.files.SOURCE!); // Buffer, not string4d. State between runs
readState(initial) takes the value the plugin should see on its first run and returns it when no state has been written yet — no null branch to handle. writeState() persists JSON to a run-local copy, which the host commits to <config-dir>/plugins/<name>/state/state.json only after the plugin exits cleanly — a failed run leaves the previous state untouched:
import { readState, writeState, writeEntry } from "@dither/plugin";
interface State {
lastSeen: string;
}
const state = await readState<State>({ lastSeen: "" });
const now = new Date().toISOString();
await writeEntry({
collection: "notes",
body: `Last run: ${state.lastSeen || "never"}\nThis run: ${now}`,
});
await writeState<State>({ lastSeen: now });5. Installing
dither plugin install ./path/to/plugin \
--env "GREETING=hi,MAX_RUNS=5" \
--allow-env "API_TOKEN" \
--file "SOURCE=/abs/path/to/notes.md" \
--allow-net "api.example.com" \
--create "echoed"The flag set:
| Flag | Shape | Effect |
|---|---|---|
--env | NAME=VALUE,... | Literal env values; written into the grants file. |
--allow-env | NAME,NAME,... | Names this plugin may read from the global dither env store at run time. |
--file | ID=PATH,... | Paths for declared files[]. Resolved to absolute, checked to exist, kind-asserted. |
--allow-net | host,host,... | Network hosts. Manifest net is the install-time default (used when this flag is omitted); when supplied, the flag wins. |
--create | coll,coll,... | Collections the plugin may create entries in. Glob patterns supported (messages/**, notes/*). Manifest create is the install-time default; the flag wins. |
--edit | coll,coll,... | Collections where the plugin may overwrite another plugin's entries. Flags-only beyond the manifest — the interactive prompt never offers a widen here. |
All six flags are accepted on both dither plugin install and dither plugin run. The split is the same comma-separated KEY=VALUE parser used in v0; values may contain = but not ,, and there is no escape syntax.
Two more flags on install itself: --dry-run prints the fields and grants the install would ask for and exits, and --symlink points the install destination at your source directory instead of copying it — the dev loop, since edits then take effect without a reinstall.
What install does:
- Parses
package.jsonand validates theditherblock. - Resolves env: literal →
--allow-envref → manifestdefault. Missing required → fails. - Resolves files: each path must exist and match its declared
kind. - Resolves
net,createandeditgrants: if you supplied a flag, that's the grant; otherwise the manifest declaration is the default. The manifest is not a ceiling — install grants can widen past or differ from the manifest. The grants file is the source of truth at promote time. - Copies the plugin source to
<config-dir>/plugins/<name>/(or symlinks it, with--symlink). - Writes
<config-dir>/grants/<name>.jsonwith the manifest, env literals, env refs, file paths, net hosts, the consentedschedule/watch, and the create/edit grants.
On a TTY, anything still missing (required env, required files) is prompted for interactively rather than failing; on a pipe or in CI the whole missing set is reported in one error and the install exits 1.
Reinstalling overwrites the previous install and grants file.
Global env values
For values you'd rather not paste into every install command (API tokens, base URLs), use dither env:
dither env set API_TOKEN sk-…
dither env list
dither env unset API_TOKENThese live in <config-dir>/env.json. A plugin only sees a name if you grant it via --allow-env. The literal value is not copied into the grants file; it's read from the global store at run time, so updating it once via dither env set is enough for every plugin that has the grant.
6. Running
dither plugin run hello-world # blocks; tails the run journal
dither plugin run hello-world --detach # kicks the daemon and returns
dither plugin run ./path/to/plugin # auto-installs from path, then runs
dither plugin run ./path/to/plugin --symlink # dev loop: symlink-install, then runEvery run is supervised by the dither daemon; the CLI is a thin client that writes a kick, makes sure the daemon is up, and tails. The daemon is started lazily if it isn't already running.
The positional accepts an installed plugin name or a path to a plugin directory. If it's a path (a directory containing package.json), the CLI installs it first using the same grant flags as the persisted grants, then runs it.
If it's a name, any grant flags you pass are per-run overrides: layered on top of the existing grants for this single run, then discarded. Use this to grant an extra collection or env value temporarily without rewriting the grants file:
dither plugin run hello-world --env "GREETING=ahoy"
dither plugin run hello-world --create "experiments"Two flags don't fire a run at all — they persist a trigger into the grants file and reload the daemon: --every "<cron or 'every 15min'>" sets the schedule, and --watch <collection-or-dir> adds a watch target (a /abs or ./rel path is watched literally; a bare name is a library collection). --backfill fires a watch plugin once with every existing entry under its watched collections as targets.
run blocks by default, streaming the run's journal events (including your progress() messages, see §6a) until the run terminates. Ctrl-C clears the kick this command wrote.
--detach skips the tail and returns as soon as the daemon has been kicked, printing the run id. The daemon keeps supervising the run after your shell exits; pick it back up with dither plugin runs <runId>.
The host:
- Loads the grants file.
- Unions any per-run overrides on top.
- Resolves env values: literals → global
dither envfor granted refs → manifest defaults. - Creates
<config-dir>/runs/<runId>/, seeds a run-localstate.jsonfrom the committed one, and writesinput.json:{ "trigger": "manual", "env": { "GREETING": "hi", "MAX_RUNS": "5", "API_TOKEN": "sk-…" }, "files": { "SOURCE": "/abs/path/to/notes.md" }, "targets": [], "net": ["api.example.com"] } - Spawns
deno runwith permissions derived from the grant. - After the plugin exits cleanly, scans the run dir for
*.md, validates frontmatter against thecreate/editgrants, promotes valid entries to<library>/<collection>/, refreshes the index for the touched collections, commits the run-local state, and deletes the run dir.
A plugin process that exits non-zero aborts the run; nothing is promoted.
6a. Reporting progress
Long-running plugins (DB syncs, API backfills) should call progress() so the host can show the user what's happening. Each call emits one NDJSON line on stderr; the daemon's supervisor parses it out of the stream and records it in the run journal, which is what a blocking dither plugin run (or a later dither plugin runs <runId>) tails.
import { progress, writeEntry } from "@dither/plugin";
const total = messages.length;
for (const [i, m] of messages.entries()) {
await writeEntry({ collection: "messages", body: m.text });
if (i % 50 === 0) progress({ message: `synced ${i} / ${total}`, done: i, total });
}
progress({ message: "done", done: total, total });message is required; done and total are advisory. console.log / console.error keep working for plain logging — only progress() goes through the control channel.
7. Inspecting outputs
Promoted entries live at <library>/<collection>/<id>.md.
The SDK always overwrites three frontmatter keys, regardless of what your plugin sets:
id— UUID generated by the SDK (or a stringidyou supplied infrontmatter).source— the plugin'sname(frompackage.json).collection— the value you passed towriteEntry({ collection }).
Anything else you put in frontmatter is preserved verbatim. So given:
await writeEntry({
collection: "notes",
frontmatter: { title: "Hi", tags: ["demo"] },
body: "# Hi\n\nbody",
});The promoted file looks like:
---
title: "Hi"
tags: ["demo"]
id: "9c2b…"
source: "hello-world"
collection: "notes"
---
# Hi
bodyThe SDK uses a tiny YAML emitter that JSON-encodes every value (so strings get quoted, arrays/objects come out as inline JSON). Most YAML parsers accept this. If you need richer YAML, generate the body yourself and keep frontmatter minimal.
8. Permissions in practice
The Deno spawn is built like this (from packages/cli/src/plugin-run.ts):
--allow-read=<pluginDir>,<runDir>,<sdkPath>,<…each granted file path>,<…each watched root>--allow-write=<runDir>— the run dir only; state is written to the run-local copy, never straight to the persistent path--allow-env=DITHER_RUN_DIR,DITHER_INPUT_FILE,DITHER_STATE_FILE,DITHER_TRIGGER,DITHER_PLUGIN_NAME--allow-net=<csv of granted net hosts>(only added if the list is non-empty; a sole*becomes a bare--allow-net)
A plugin cannot read or write outside those paths, cannot reach hosts off the net grant, and cannot read process env vars beyond the DITHER_* set. User-supplied env values reach the plugin through input.env (which it reads from DITHER_INPUT_FILE), not through the OS environment.
9. Sharp edges
- Env values are stored plaintext.
<config-dir>/grants/<name>.jsonand<config-dir>/env.jsonare JSON files. Don't store credentials you wouldn't put there. Keychain integration is a later phase. - No git/registry installs. Only local paths. Cloning your plugin yourself first is the workaround.
- The daemon runs everything. Installing a plugin with a
scheduleorwatchgrant lazily starts the daemon, anddither plugin runstarts it too if it isn't up. Kill the daemon and both the triggers and manual runs stop until it comes back. - CLI flag parsing has no escape syntax. A comma in a value will be treated as a pair separator. If you need a comma in a value, install programmatically via the
installPlugin()API instead. - Output filename collisions overwrite. If two
writeEntry()calls in one run produce the same<id>.md(e.g. you supplied the samefrontmatter.id), the second overwrites the first inside the run dir, and only one is promoted. - There's no
dither plugin stop. Detaching only ends the tail — the daemon keeps supervising. Runs are recorded in<config-dir>/history/<runId>/; usedither plugin runs(no arg lists; pass a runId or plugin name to inspect a specific run) to catch up. - One run per plugin at a time. A pending kick or a held lock makes
dither plugin runrefuse rather than queue a second fire; tail the in-flight one instead.