dither
Plugins

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. Set DITHER_USE_SYSTEM_DENO=1 to use a system deno (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.ts

That'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-world

You'll see something like:

installed hello-world@0.0.1
  → /Users/you/.dither/plugins/hello-world

next: dither plugin run hello-world

run 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

FieldTypeNotes
display_namestringHuman label shown in dither plugin list and (eventually) UI.
taglinestringOne-liner.
iconstringIdentifier or path. Schema-only today; nothing renders icons yet.

Triggers

FieldTypeNotes
schedulestring (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:

  1. A literal passed via --env "NAME=VALUE,...".
  2. A grant to read from the global dither env store, 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.
  3. The manifest default.
  4. 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 literal notes collection).
  • messages/**messages itself and every descendant. The runner special-cases the <X>/** form to also cover the bare parent.
  • messages/* — direct children of messages only (messages/tom, not messages/tom/anything, and not messages itself).
  • 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 string

4d. 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:

FlagShapeEffect
--envNAME=VALUE,...Literal env values; written into the grants file.
--allow-envNAME,NAME,...Names this plugin may read from the global dither env store at run time.
--fileID=PATH,...Paths for declared files[]. Resolved to absolute, checked to exist, kind-asserted.
--allow-nethost,host,...Network hosts. Manifest net is the install-time default (used when this flag is omitted); when supplied, the flag wins.
--createcoll,coll,...Collections the plugin may create entries in. Glob patterns supported (messages/**, notes/*). Manifest create is the install-time default; the flag wins.
--editcoll,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.json and validates the dither block.
  • Resolves env: literal → --allow-env ref → manifest default. Missing required → fails.
  • Resolves files: each path must exist and match its declared kind.
  • Resolves net, create and edit grants: 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>.json with the manifest, env literals, env refs, file paths, net hosts, the consented schedule / 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_TOKEN

These 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 run

Every 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:

  1. Loads the grants file.
  2. Unions any per-run overrides on top.
  3. Resolves env values: literals → global dither env for granted refs → manifest defaults.
  4. Creates <config-dir>/runs/<runId>/, seeds a run-local state.json from the committed one, and writes input.json:
    {
      "trigger": "manual",
      "env": { "GREETING": "hi", "MAX_RUNS": "5", "API_TOKEN": "sk-…" },
      "files": { "SOURCE": "/abs/path/to/notes.md" },
      "targets": [],
      "net": ["api.example.com"]
    }
  5. Spawns deno run with permissions derived from the grant.
  6. After the plugin exits cleanly, scans the run dir for *.md, validates frontmatter against the create / edit grants, 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 string id you supplied in frontmatter).
  • source — the plugin's name (from package.json).
  • collection — the value you passed to writeEntry({ 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

body

The 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>.json and <config-dir>/env.json are 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 schedule or watch grant lazily starts the daemon, and dither plugin run starts 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 same frontmatter.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>/; use dither 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 run refuse rather than queue a second fire; tail the in-flight one instead.