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

Architecture

What the daemon does, what it doesn't, and which crate owns each decision.

dotagent is built like a coffee shop, not a kitchen brigade. One person behind the counter takes every order. The "daemon" is that one person — it watches the schedules, decides what to fire next, supervises each agent, and goes back to waiting. There is no per-agent process running in the background. There is no scheduler poll loop.

Read this if you want to:

  • Picture what happens between dotagent install and your agent runs.

  • Know which crate to touch when changing behavior.

  • Understand why a panicking plugin doesn't take down everything.

For the user-facing concepts see agents.md and plugins.md. For the schema details see the reference docs.


The 30-second mental model

Three things to internalize:

  1. The OS owns when the daemon runs. launchd / systemd keep dotagent daemon alive. dotagent does not install itself as a service-aware program — dotagent install writes a unit file, and the OS does the rest.

  2. The daemon owns when each agent runs. Not the OS. There is one unit file (run.avelino.dotagent), not one per agent. The daemon's adaptive scheduler computes the next event across every schedule of every agent and sleeps until that exact moment.

  3. Your agent is a one-shot subprocess. It reads env vars, does its thing, writes stdout, exits. No SDK, no IPC, no long-running state. The one exception is opt-in: an agent declaring [lifecycle] mode = "persistent" is kept alive between runs and handed requests over JSON lines. Same supervisor, same deadline, same reaping — see lifecycle.md.

  4. The trigger gateway is transport and policy, not a conversation runtime. It admits and orders Telegram/local triggers, caps concurrent conversations, and delivers output through the submitting transport. Session ids are opaque; transcripts, model state, and conversation persistence belong to the agent.

Everything else in this doc is detail.


Crate layout

The workspace has eleven crates. Each has one responsibility and a single direction of "depends on" — no cycles.

Two edges surprise people:

core → notify, not the other way. The manifest re-exports NotifierEntry, so the type crate depends on the driver crate. It also means cargo publish has to publish notify before core.

notify → secrets, and nothing depends on secrets. It exists as its own crate precisely to avoid a core → notify → core cycle: the Telegram driver needs ${VAR} resolution, and putting that loader in core would close the loop.

Crate
Owns

dotagent-core

Shared types: AgentManifest, Schedule, Heartbeat, WindowState, Config, AuditEvent, TriggerRequest.

dotagent-scheduler

Pure scheduling math. compute_next_event, expected_at, should_retry, health_state. No IO.

dotagent-runner

Spawn the agent subprocess. Timeout, env injection, stdio capture, heartbeat lifecycle, hook firing. Also the pool of live processes for [lifecycle] mode = "persistent" agents.

dotagent-state

Filesystem state: heartbeats, window state, audit log, plugin state, manifest cache.

dotagent-notify

Built-in notifier drivers (desktop, slack, ntfy, pushover, telegram, imessage) and inbound Telegram transport.

dotagent-secrets

Loader for secrets.env. Separate crate to keep core → notify acyclic.

dotagent-plugin

PluginClient — discover + spawn + JSON-stdio for preflight / sink / third-party notify.

dotagent-mcp

JSON-RPC 2.0 + Model Context Protocol wire types. No IO, no agent knowledge.

dotagent-supervisor

Subprocess lifecycle: deadlines, kill-tree via POSIX process groups, live registry.

dotagent-telemetry

tracing setup, JSON file logging, daily rotation, retention sweep, optional OTLP export.

dotagent-unit-gen

Render daemon.plist (macOS) / daemon.service (Linux) from templates.

dotagent (binary)

CLI subcommands plus daemon wiring for scheduling, Telegram ingress, the local UDS API, and the trigger gateway.

Rule of thumb: if you can write it as fn f(now: DateTime, ...) -> X with no std::fs, it goes in dotagent-scheduler. Anything that touches disk or processes goes in dotagent-state, dotagent-runner, or dotagent-plugin.


Lifecycle of a single run

The full path from "the daemon wakes up" to "your agent has run and the audit is written" — step by step.

What's worth pointing out:

  • Step 4 (sleep). No polling. The daemon computes ts once and tokio::time::sleep until then. MAX_SLEEP_MINUTES = 30 is the safety cap so a new manifest dropped into agents/ is picked up within that window even if no scheduled event fires. The daily summary is a third input to that min(): it is the one scheduled thing the daemon does that no agent schedule accounts for, so it schedules its own wake-up rather than relying on the cap to land inside its window.

  • Step 7-9 (preflight). If any preflight returns ok=false, the agent is never spawned. dotagent emits PreflightFailed to audit and fires the matching notifier.

  • Step 10 (heartbeat start). Written before the spawn. Crash detection: if started_at exists but finished_at doesn't, the previous run died.

  • Step 11 (env injection). dotagent sets nine AGENT_* env vars plus whatever [env].extra declares. See reference/env-vars.md.

  • Step 14-15 (sink). Fires only when the agent exited zero. stdout_tail (last 500 lines) is the payload.

  • Step 16 (notifier). Built-in drivers run in-process — no fork. Rate-limit state is read from state/notify/<driver>/<slug>.json.


Where each decision lives

When debugging "why did X happen", you need to know which crate to grep.

Question
Where to look

When does a schedule fire next?

dotagent-schedulercompute_next_event

Is this window expected to have run already?

dotagent-schedulerexpected_at

Should this failed window be retried?

dotagent-schedulershould_retry

Is this agent ok / degraded / failing / stale?

dotagent-schedulerhealth_state

How does the agent receive env vars?

dotagent-runnerapply_env

When does the agent get killed for timeout?

dotagent-runnertokio::time::timeout then kill + SIGKILL after 5s grace

Where is the heartbeat written?

dotagent-stateStateStore::write_heartbeat

How is marker_regex resolved?

The sink plugin itself (e.g., plugins/sink-roam/src/main.rs)

How are plugins discovered?

dotagent-pluginPluginClient::resolve

Where does dotagent install write the unit?

dotagent-unit-genlaunchd::generate_daemon / systemd::generate_daemon

What's logged where?

dotagent-telemetryinit_from_default_config


Crash isolation

A single panicking plugin must not take down the daemon. Same for a runaway agent or a misconfigured notifier. The boundaries:

Failure
Containment

Agent script panics / segfaults

Subprocess dies. exit_code captured. Daemon writes the heartbeat and moves on. Retry policy kicks in.

Persistent instance dies

The next request finds the dead pipe, discards the instance and spawns a replacement. A death in the window between setting the deadline and writing the request costs one retry, not the message.

Agent hangs

agent.timeout_seconds triggers SIGTERM, then SIGKILL after 5s. exit_code = 124.

Plugin panics

Subprocess dies. dotagent records PluginInvoked { ok: false } and continues. No retry of the plugin.

Notifier driver fails (e.g., Slack 503)

The notifier call returns Err but the run already happened. Audit records the failure. The run is still considered successful.

Daemon crashes

launchd KeepAlive=true / systemd Restart=always brings it back. Audit reconstructs state on startup.

Audit log tampered

verify_chain() on startup catches it. Emits AuditChainBroken (which is itself a chained audit entry) and fires the configured Critical-severity notifier. A log that merely rotated (32MB → audit.log.<stamp> + a hash seam) verifies clean, and a segment the operator deleted reads as "intact since <ts>" rather than broken — see security/threat-model.md.

The trade-off: fork+exec per plugin (~5-10ms). Plugins fire on discrete events (preflight, sink-on-success, third-party notify), never in hot loops, so the cost is invisible. The built-in notifiers were promoted out of the plugin protocol precisely because they fire often (every failure attempt) — see notifications.md for the trade-off.


State on disk

dotagent is "stateless" in the sense that the daemon can be killed and restarted without losing any decision context — every consequential write is committed to disk first. The full layout is at reference/paths.md; the high-points:

Two important properties:

  1. audit.log is the source of truth for "did this happen". It is append-only and hash-chained (prev_hash field). Past 32MB it rotates into a sealed audit.log.<stamp> segment, stitched to the new file by a hash seam so the chain never has a gap — and no segment is ever deleted automatically. Operational logs are dense (debug-grade) and disposable; the audit log is sparse and forever.

  2. known_manifests.json is how drift / phantom agents are detected. sha256 of every loaded manifest is cached. On the next load, mismatch → ManifestDriftDetected; new agent not in the cache → PhantomAgentDetected. Both are Critical severity (out-of-band notify).


What the daemon does NOT do

  • Run business logic. Every domain concern (APIs to hit, prompts to draft, files to write) belongs in your agent script. dotagent's surface ends at "spawn the process, watch what came out."

  • Sleep to wait for time. Scheduling math is compute_next_event + tokio::time::sleep. No loop { sleep(1s); check() }. This is why a hundred schedules don't burn CPU.

  • Embed AI / call LLMs. dotagent has zero LLM dependencies. Your agent decides whether and when to invoke claude -p, openai, the mcp CLI, or nothing at all.

  • Own conversation state. The daemon routes and supervises a trigger, but session_id, transcripts, and LLM state belong to the agent process. The local API is a Unix socket, not a public HTTP/TCP conversation service.

  • Replace the mcp CLI. dotagent and mcp are independent projects. Agents that use Roam / Sentry / Grafana / etc. call mcp directly the same way they did before dotagent existed. dotagent serves MCP via dotagent mcp (agents as tools) but has no MCP client of its own — see ../reference/mcp.md.

  • Sandbox the agent. The [security] block in agent.toml is v0 schema-only — doctor reports inconsistency, but the runner doesn't yet enforce allowed_commands / filesystem_writable / network policy. Enforcement (sandbox-exec / bwrap / firejail) lands as a follow-up; see threat-model.md.


Last updated

Was this helpful?