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.",
    "collections": ["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
run 5f1a…  promoted 1 entries:
  /Users/you/Documents/dither/notes/9c2b….md

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 daily/hourly)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.

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 <dither-home>/env.json.
  3. The manifest default.
  4. Otherwise install fails: Required env '<name>' was not provided ….

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;
  kind: "file" | "folder";
  extensions?: string[];   // schema-only today; not validated
  required?: boolean;
}

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. Empty / absent → no network. The permissions block from earlier drafts is gone; net lives at the top level of the dither block.

collections

collections?: string[];

Glob patterns naming the collections this plugin may emit into. The manifest list seeds the install grant when the user doesn't pass --allow-collection. Output whose frontmatter collection value isn't matched by any glob in the resolved grant set is rejected at promote.

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.

There is no separate reads / writes split today. 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 <dither-home>/plugins/<name>/state/state.json:

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" \
  --allow-collection "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.
--allow-collectioncoll,coll,...Collections the plugin may write to. Glob patterns supported (messages/**, notes/*). Manifest collections is the install-time default; when supplied, the flag wins.

All five 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.

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 and collections 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 <dither-home>/plugins/<name>/.
  • Writes <dither-home>/grants/<name>.json with the manifest, env literals, env refs, file paths, net hosts, and collections.

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 <dither-home>/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; shows live status
dither plugin run hello-world --detach    # forks into the background
dither plugin run ./path/to/plugin        # auto-installs from path, then runs

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 --allow-collection "experiments"

run blocks by default. While the plugin executes, the host overwrites a single status line with the latest progress() message (see §6a). When the plugin exits, the status line is cleared and added documents are printed.

--detach forks the run into the background and returns immediately. Stdout and stderr are captured to <dither-home>/logs/<plugin>-<ms>.log (where <ms> is the spawn epoch in milliseconds). The plugin keeps running after your shell exits; tail the log to watch progress, or use dither plugin runs <runId> once the runner has emitted a journal entry.

The host:

  1. Loads the grants file.
  2. Layers any per-run overrides on top.
  3. Resolves env values: literals → global dither env for granted refs → manifest defaults.
  4. Creates <dither-home>/runs/<runId>/ and writes input.json:
    {
      "trigger": "manual",
      "env": { "GREETING": "hi", "MAX_RUNS": "5", "API_TOKEN": "sk-…" },
      "files": { "SOURCE": "/abs/path/to/notes.md" },
      "targets": []
    }
  5. Spawns deno run with permissions derived from the grant.
  6. After the plugin exits, scans the run dir for *.md, validates frontmatter, promotes valid entries to <library>/<collection>/, refreshes the index for the touched collections, 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 host parses it out of the stream and overwrites a single status line in blocking mode (or appends a line in non-TTY contexts and detached log files).

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 host 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>
  • --allow-write=<stateDir>,<runDir>
  • --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 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. <dither-home>/grants/<name>.json and <dither-home>/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 must be running for schedule and watch to fire. Installing a plugin with either declaration lazily starts the daemon; if you kill it, the triggers stop. Manual dither plugin run always works regardless.
  • 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.
  • Detached runs aren't supervised after the fork. --detach writes a log file and unref()s the child, so there's no dither plugin stop. The run is still recorded in <dither-home>/history/<runId>/ — use dither plugin runs (no arg lists; pass a runId or plugin name to inspect a specific run) or tail the printed log path; kill via kill <pid> if needed.