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. Seenotifications.mdfor the[[notifiers]]shape. The plugin protocol below is still how you wire a custom notifier (Discord, Teams, etc.) viadriver = "plugin".
This guide covers:
Using a plugin — declaring, configuring, debugging
Built-in plugins — first-party preflights and sinks
Creating a plugin — protocol, languages, examples
Publishing a plugin — distribution, signing, brew
Best practices — single-purpose, secrets, idempotency
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, noextern "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
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 calleddotagent-plugin-<name>(see Discovery below).config— opaque JSON forwarded to the plugin'sinvokeverb. The plugin'sinforesponse describes the schema.events(optional, only onon_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
infoto confirm the plugin actually responds.Reports
✗ plugin <name> not foundif 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:
Every directory in
$DOTAGENT_PLUGIN_PATH(colon-separated)~/.config/dotagent/plugins//usr/local/lib/dotagent/plugins/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:
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]]— seenotifications.md.
Built-in plugins
These ship with the Homebrew install and live under plugins/ in this repo.
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. Seenotifications.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.)
Related docs
notifications.md— built-in notifier drivers (replaces the oldnotify-*plugins)plugin-protocol.md— formal protocol specagent-spec.md— manifest schema (where[[preflight]]/[[on_*]]/[[notifiers]]live)threat-model.md— security considerations (especially around untrusted plugins).claude/skills/new-plugin/SKILL.md— scaffolding shortcut
Last updated
Was this helpful?