For the complete documentation index, see llms.txt. This page is also available as Markdown.

Plugins

Plugins are how dotagent talks to the outside world for preflight checks, output sinks, and third-party notifiers. Anything that isn't scheduling the agent and isn't a built-in notifier lives in a plugin.

Looking for notifications? The common notifiers (desktop, imessage, slack, ntfy, pushover, telegram) are now built into the daemon — they are not plugins. See notifications.md for the [[notifiers]] shape. The plugin protocol below is still how you wire a custom notifier (Discord, Teams, etc.) via driver = "plugin".

This guide covers:

  1. What is a plugin

  2. Using a plugin — declaring, configuring, debugging

  3. Built-in plugins — first-party preflights and sinks

  4. Creating a plugin — protocol, languages, examples

  5. Publishing a plugin — distribution, signing, brew

  6. Best practices — single-purpose, secrets, idempotency

  7. Debugging — stderr, dotagent plugin invoke, audit log

For the formal protocol spec (verbs, payload shapes, exit codes) see plugin-protocol.md.


What is a plugin

A dotagent plugin is a separate binary that follows a tiny CLI protocol over JSON stdio. dotagent invokes it as a subprocess at specific lifecycle moments and acts on what comes back.

Why subprocess + JSON instead of an SDK / dylib / WASM

  • Any language. Rust, Go, Python, Bash, Node — if it can read stdin and write JSON to stdout, it can be a plugin.

  • Crash isolation. A panicking plugin doesn't take down the daemon.

  • Independent versioning. Plugins ship and update on their own cadence.

  • No ABI. No cdylib, no extern "C", no symbol versioning headaches.

Trade-off: every invocation pays fork+exec (~5–10ms). Plugins fire on discrete events (notification, preflight, sink-on-success), not in hot loops, so the cost is invisible.

Three kinds of plugins

Kind
When dotagent invokes it
Decides what?

preflight

BEFORE spawning the agent

Can the agent run? (ok=true = proceed)

notify

On failure events (attempt_failed, given_up, recovered)

Sends a human-facing message

sink

On successful run

Persists the agent's output

A plugin can declare multiple kinds in its info response if it makes sense — but most plugins are single-kind for clarity.

What a plugin is NOT

  • Not a place for agent business logic. Your agent already does that.

  • Not a way to schedule other work. Scheduling lives in agent.toml.

  • Not a service. No long-running process, no IPC, no daemon-to-daemon. Each invocation is a one-shot.


Using a plugin

1. Declare it in your agent.toml

Top-level keys:

  • plugin — the short name. dotagent resolves it to a binary called dotagent-plugin-<name> (see Discovery below).

  • config — opaque JSON forwarded to the plugin's invoke verb. The plugin's info response describes the schema.

  • events (optional, only on on_success / on_failure) — restrict firing to specific events. Empty / omitted = all events. Valid values: attempt_failed, given_up, stale, recovered, timed_out, preflight, success, daily_summary.

2. Validate at install time

This iterates every manifest and:

  • Resolves every plugin reference to a binary path.

  • Calls info to confirm the plugin actually responds.

  • Reports ✗ plugin <name> not found if discovery failed.

3. List discovered plugins

Outputs a table of every plugin referenced by any manifest, with its version, kinds, and resolved path.

4. Invoke manually (debug)

When a plugin misbehaves and you want to see exactly what's happening:

Stdout = the JSON response. Stderr = the plugin's human-readable log.

Discovery order

When you write plugin = "sink-roam" in a manifest, dotagent looks for a binary called dotagent-plugin-sink-roam in this order:

  1. Every directory in $DOTAGENT_PLUGIN_PATH (colon-separated)

  2. ~/.config/dotagent/plugins/

  3. /usr/local/lib/dotagent/plugins/

  4. Every directory in $PATH

First match wins. The Homebrew formula drops every first-party plugin into the same bin/ as dotagent, which means $PATH resolution covers the default install with zero config.

To verify which binary will be used:

The events filter

on_success / on_failure entries can filter on event name. Events dotagent emits:

Event
When

success

agent exit code 0

attempt_failed

agent exit ≠ 0 but more retries available

timed_out

agent killed for exceeding timeout_seconds

given_up

retries exhausted (max_retries reached), and repeated while it holds

stale

the schedule stopped running at all — window aged past stale_after_minutes

recovered

success on a window that had ≥1 previous failed attempt

preflight

preflight plugin returned ok=false and the run was aborted

daily_summary

the daemon's health summary at [daily_summary].time (default 22:45), or dotagent daily-summary

daily_summary is the one event a manifest cannot subscribe to. It belongs to the daemon, not to any agent, so it is delivered only to [[daily_summary.notifiers]] in config.toml — and those entries ignore events entirely, since a list already scoped to one event can only be subtracted from. Writing events = ["daily_summary"] on a manifest entry matches nothing.

Example — fire a sink on every successful run (no filter):

Notifications follow the same event semantics but are configured under [[notifiers]] — see notifications.md.


Built-in plugins

These ship with the Homebrew install and live under plugins/ in this repo.

Plugin
Kind
Purpose

preflight-warp

preflight

Checks warp-cli status reports "Connected".

preflight-cmd

preflight

Generic: runs an arbitrary command, checks exit code + stdout.

sink-roam

sink

Publishes hierarchical content to Roam Research via mcp CLI.

sink-outl

sink

Publishes hierarchical content to Outl via mcp outl_batch.

sink-file

sink

Writes the message to a file (overwrite or append).

Notifications (desktop, imessage, slack, ntfy, pushover, telegram) are not plugins anymore — they ship as in-process drivers inside the daemon. See notifications.md.

For per-plugin details (config schema, examples, troubleshooting) see docs/plugins/.

Quick config reference

Run dotagent-plugin-<name> info | jq .schema for the full JSON schema of each plugin's config.


Creating a plugin

The full protocol spec is in plugin-protocol.md. This section is the friendly quickstart.

Anatomy of a plugin

A plugin is any executable named dotagent-plugin-<kind>-<name> (e.g., dotagent-plugin-notify-discord) that accepts one of three verbs as its first positional argument:

info — no stdin, prints metadata

validate — stdin is the config object

dotagent calls validate when loading manifests. It's how dotagent doctor catches typos before runtime.

invoke — stdin is the full payload

Stdout is REQUIRED to be valid JSON with at least {"ok": bool}. Extra fields are forwarded into dotagent's audit log and --verbose output.

Stderr is for human-readable logs and is captured by the daemon (visible via dotagent logs dotagent).

Minimal plugins, three languages

Rust (single-file)

Python

Drop this in ~/.config/dotagent/plugins/dotagent-plugin-notify-discord, chmod +x, and dotagent finds it on the next doctor.

Bash

Local testing

If all three return valid JSON and exit 0 on the happy path, the plugin integrates.

Walking through a build — Discord notify plugin (third-party)

Discord, Teams, and other custom notifiers don't ship as built-in drivers — wire them as plugins and reference them with driver = "plugin" under [[notifiers]]. The pattern below scaffolds a Discord notify plugin from scratch.

For the agent-side ergonomics see the new-plugin skill — it's the quickest scaffold.


Publishing a plugin

Option A — bundle with your dotfiles

Put the binary or script in ~/.config/dotagent/plugins/ and check it into version control. No further work. This is the right move for plugins that wrap your private secrets.

Option B — Homebrew (first-party or community)

The dotagent Homebrew formula installs every plugin shipped with the core release. For a community plugin, create a separate formula:

Users tap your repo and install:

The plugin lands in bin/ and dotagent finds it via $PATH.

Option C — cargo install, go install, pip install

Whatever package manager — as long as the binary lands in a directory on $PATH, dotagent finds it.

Versioning

Plugins are versioned independently of dotagent. The info response reports the plugin's own version so the daemon can record it in audit events (which version actually ran).

dotagent itself only commits to keeping the protocol stable — specifically the three verbs and the JSON shape of invoke's payload.


Best practices

Single-purpose

Each plugin does ONE thing. Don't combine notify + sink into one binary. Easier to test, easier for others to discover, easier to swap.

Stdout = JSON, stderr = humans

Never log info text to stdout. Stdout is parsed strictly. Use stderr for "connecting to webhook…", "retrying after 503…", etc.

Don't leak secrets

The agent's config is opaque to dotagent — but if your plugin logs the config to stderr, those secrets show up in the daemon's log file. Redact or omit.

Be idempotent where it makes sense

A sink plugin running twice on the same window with the same input should produce the same end state. sink-roam does this via marker_regex (delete the old block before writing the new one).

Handle external failures gracefully

If your plugin makes a network call, don't panic. Return {"ok": false, "error": "503 from upstream"} and exit non-zero. dotagent records this as a plugin_invoked audit event with ok=false but doesn't crash.

Respect platform declarations

If your plugin only works on macOS, declare "platforms": ["darwin"] in info. dotagent doctor warns when a manifest references a plugin that won't work on the current host.

Rate-limit external services

Plugins that send to humans (custom Discord/Teams notifiers, etc.) should support a rate_limit_minutes config so the user can throttle without modifying the plugin. See the built-in imessage driver (dotagent-notify/src/drivers/imessage.rs) for the canonical pattern — state lives under $DOTAGENT_HOME/state/notify/<driver>/<slug>.json for built-ins; mirror that under ~/.config/dotagent/state/plugins/<name>/ for plugins.

Validate early, fail clearly

validate is your chance to reject bad config when the user runs dotagent doctor — long before a real event fires. Be strict.


Debugging

"Plugin not found"

dotagent doctor couldn't resolve the binary. Check:

"Plugin failed (exit N): …"

Stderr is captured. Either run the plugin manually with the same payload or watch the daemon's structured log:

(Not run.avelino.dotagent-error.log — under launchd / systemd that file only receives daemon crashes, not per-plugin output.)

"Plugin returned ok=false"

The plugin spoke the protocol but reported failure. The audit log has the plugin response:

Manifest references plugin but it never fires

Check the events filter. If you wrote:

…that filter can never match: daily_summary is the daemon's own event and never reaches a manifest entry. The plugin stays silent on ordinary success too, because the filter excludes it. Remove the filter, or list the event you actually wanted.

Plugin works manually but not from the daemon

The daemon clears $DOTAGENT_PLUGIN_PATH if you start it from a fresh launchd session. Check launchctl getenv DOTAGENT_PLUGIN_PATH or move the plugin into ~/.config/dotagent/plugins/ (which is always searched).

See every plugin invocation in real time

Each invocation logs invoking plugin <name> plus the resolved binary path.

Run a plugin with the exact payload the daemon would send

(dotagent plugin invoke accepts the payload as a positional arg or - to read from stdin.)


Last updated

Was this helpful?