Filesystem layout
Every file dotagent reads or writes, where it lives, and who owns it.
dotagent picks convergence over XDG: everything lives under a single root. The trade-off is documented in crates/dotagent-state/src/paths.rs: finding / inspecting / wiping is easier when one $DOTAGENT_HOME holds state, logs, config, and the audit log together. If you religiously follow the XDG Base Directory Spec, set DOTAGENT_HOME to your preferred path.
The root
$DOTAGENT_HOME/ # default: ~/.config/dotagentResolution order:
$DOTAGENT_HOMEenv var (if set, absolute path)$HOME/.config/dotagent./.dotagent(last-resort sentinel if$HOMEisn't set)
Everything below is relative to this root.
Top-level
$DOTAGENT_HOME/
├── agents/ # YOUR manifests (or symlinks to them)
├── skills/ # YOUR procedures (optional; also read from ~/.claude/skills)
├── commands/ # YOUR commands (optional; one .md each)
├── plugins/ # YOUR custom plugin binaries (optional)
├── config.toml # global config (optional)
├── secrets.env # daemon-loaded KEY=VALUE secrets (optional, 0600)
├── api.sock # local client API socket (daemon, 0600)
├── state/ # daemon state (read-write, machine-managed)
├── logs/ # operational logs (rotated)
├── audit.log # append-only hash-chained event log
└── audit.log.20260806T101500 # rotated segment (kept forever)agents/
you
daemon, CLI
Manifests OR symlinks to manifests living elsewhere (e.g., dotfiles).
skills/
you (optional)
dotagent mcp
One directory per procedure. ~/.claude/skills/ is searched too, so a skill written for Claude Code needs no copy. See concepts/skills.md.
commands/
you (optional)
daemon, dotagent mcp
One .md per command, subdirectories namespaced with :. Published as the Telegram menu. ~/.claude/commands/ is not searched unless [commands] claude_commands = true. See concepts/commands.md.
plugins/
you
PluginClient
Per-user plugin binaries. Skip if you install plugins via brew / cargo.
secrets.env
you (optional)
daemon
KEY=VALUE secrets for ${VAR} interpolation in notifier configs. Must be mode 0600. See concepts/secrets.md.
api.sock
daemon
local clients
Unix-socket JSON-lines API, mode 0600. Created only when telegram.dispatcher_agent resolves to a discovered agent. See local-api.md.
state/
daemon, runner
daemon, CLI
Don't edit by hand.
logs/
daemon
you (via dotagent logs / tail)
Rotated daily, gzipped after 1d, deleted after retention horizon.
audit.log
daemon
you (tail, jq)
Append-only, hash-chained. Rotates at 32MB into audit.log.<stamp> segments, which are never deleted automatically.
agents/<name>/
Each direct subdirectory containing an agent.toml is an agent.
Discovery roots (a manifest is picked up from the first match):
Every directory in
$DOTAGENT_ROOT(colon-separated; for one-off overrides / CI).$DOTAGENT_HOME/agents/← typical production$CWD/agents/$CWD
Each direct subdirectory of a search root that has an agent.toml becomes one agent. dotagent indexes by agent.name from the manifest, not by directory name — duplicates resolve to first-found.
skills/<name>/
Each direct subdirectory containing a SKILL.md is a skill — a procedure an assistant loads on demand, not something that runs on a schedule.
Discovery roots (first match wins a name):
[skills] pathsfromconfig.toml$DOTAGENT_ROOTand$DOTAGENT_ROOT/skills$DOTAGENT_HOME/skills/← typical~/.claude/skills/and$CWD/.claude/skills/— unless[skills] claude_skills = false$CWD/skills/
Not scaffolded: an empty catalog is a valid state, and step 4 means a skill you already wrote for Claude Code is reachable without being copied. Symlinks work here the same way they do under agents/. See concepts/skills.md.
commands/
Each .md file is one command — a prompt you invoke by name from the Telegram menu. Not a directory, unlike a skill: a command is text and nothing else, so there is nothing to package alongside it.
Discovery roots (first match wins a name):
[commands] pathsfromconfig.toml$DOTAGENT_ROOT/commands$DOTAGENT_HOME/commands/← typical~/.claude/commands/and$CWD/.claude/commands/— only when[commands] claude_commands = true$CWD/commands/
Step 4 is opt-in, the reverse of skills: a command becomes a published menu entry, and a Claude Code catalog usually assumes a shell it will not have.
Nesting goes three levels deep. Not scaffolded, same as skills/. See concepts/commands.md.
plugins/
Custom plugin binaries you install yourself (vs. brew / cargo install, which drop binaries into $PATH already).
Discovery order (dotagent-plugin-<name> is resolved against, in sequence):
Every directory in
$DOTAGENT_PLUGIN_PATH(colon-separated)$DOTAGENT_HOME/plugins//usr/local/lib/dotagent/plugins/$PATH
First match wins. See plugin-protocol.md.
config.toml
Optional global config. dotagent works with zero config — defaults are baked into the binary.
When present, fields you set override defaults; missing fields fall back. Full schema: config-reference.md.
state/
state/supervisor.json
Snapshot of every subprocess the daemon is supervising right now (agents
plugin invocations). Out-of-process consumers (
dotagent status,dotagent doctor) read it to surface live processes with age vs deadline. Cleared on daemon exit.
The daemon refreshes it every 2s while something is supervised. Once the registry drains it publishes the empty snapshot once and then stops writing until the next spawn — so an unchanging mtime is the normal resting state, not a sign of trouble.
pgid and command are not for display — dotagent status shows neither. They exist so a later daemon can prove that a pid it read off disk is still the process that was recorded, before it signals anything. Both are optional on read: a snapshot written by an older build has neither, and such a record must deserialize into one that is refused rather than fail the whole file.
Missing file ⇒ daemon not running. File age is not a liveness signal: an idle daemon deliberately stops rewriting it, so an old mtime says nothing about whether the daemon is alive. What the file's mere existence means is that the previous daemon did not run its shutdown path (SIGKILL, panic, launchctl kickstart -k), because that path deletes this file. The next daemon treats the leftover as a record of possible orphans: it reaps the entries whose identity it can confirm, leaves every other entry alone, and then removes the file. An unreadable or unparseable snapshot signals nothing and is left on disk as evidence. See Boot orphan reap.
Scope:
supervisor.jsonreflects ONLY the daemon's supervisor.dotagent run-now/dotagent runinvoked standalone instantiate their own short-lived supervisor and do NOT publish to this file — they aren't visible indotagent status. Follow-up to issue #36 will either emit per-PID snapshot files (supervisor-<pid>.json) or move to a real IPC channel.
state/agents/<name>/<slug>.heartbeat.json
One file per (agent, slug) pair. Written before AND after each non-dry-run execution. The shape is intentionally compatible with the legacy Fish framework's heartbeat (see migrating-from-fish.md).
Slug derivation (from the schedule's args):
args
slug
[]
default
["--period", "dia-anterior"]
period_dia-anterior
["--mode", "unsubscribe"]
mode_unsubscribe
Rules: strip leading dashes, lowercase, non-alphanumeric → _, collapse _, trim trailing _. Empty → default.
state/windows/<name>-<slug>-<YYYY-MM-DD-HHMM>.json
One file per (agent, schedule, expected_at), plus a .lock beside it. Tracks whether the expected window has been satisfied — drives the retry policy and health computation.
The only state directory with a retention horizon: a schedule never revisits a window once it passes, so the directory would otherwise grow forever. The daily 03:00 sweep deletes each pair older than [state] window_retention_days (default 30).
attempts counts dispatches, not retries — the one that finally succeeded is in there too. So a window that worked on the first try reads attempts: 1 with last_attempt_exit_code: 0, and that is ok, not degraded.
state/plugins/<plugin>/<key>.json
Per-plugin scratch. Format is plugin-defined — dotagent doesn't read this directory itself.
Convention from the in-tree plugins (e.g., sink-roam): <key> is a stable identifier the plugin picks (slug, hash, etc.).
state/notify/<driver>/<slug>.json
Built-in notifier rate-limit state. Each driver decides what to write here. The imessage driver, for example, persists the last-send timestamp per (agent, slug) so rate_limit_minutes works across daemon restarts.
<any state file>.lock
Empty file beside every JSON state file, held with flock while the file is being changed.
Most are held for the write alone, because the write is the whole update. notify/alerts.lock and audit.log.lock are held longer — across the entire read-modify-write cycle — because both files are read, edited, and written back, and two processes really do run those cycles (the daemon and dotagent tick). A lock scoped to the write alone would let one of them save a table that never saw the other's change.
It is not removed afterwards, on purpose. Unlinking it while still holding the lock lets the next writer open a fresh inode and take its own "exclusive" lock, so two processes end up writing the same file believing they have it to themselves. Safe to delete when nothing is running; it reappears on the next write.
outl/
Long-term agent memory, as an outl workspace. Scaffolded when the daemon starts.
A normal outl workspace, on purpose: open it in the desktop app, read what an agent remembered, fix a wrong memory, delete a page. Relocate with [memory] workspace in config.toml. See Memory.
state/<agent>/
Whatever an agent needs to keep between runs, under a directory named after it. dotagent writes nothing here; the agent owns it.
examples/telegram-assistant uses state/telegram-assistant/ for which claude session belongs to which chat, and how many times that session has been retired for length:
An agent that keeps state here should declare the path in [security] filesystem_writable, so doctor can audit what it writes.
state/notify/alerts.json
When each ongoing alert last spoke, so a condition that stays true does not notify on every tick.
Keyed agent/schedule/event. An episode starts at the first notification and is deleted when that schedule succeeds again, so the next failure alerts immediately instead of inheriting the previous episode's spacing. Re-notify ladder: on entry, then 1h, 6h, and daily thereafter.
Losing the file costs at most one duplicate alert — the opposite failure (losing the alert) is the expensive one, so a missing or corrupt table is read as "nothing has been notified yet". See notifications.
state/notify/telegram/sent.json
What each outbound notification was about, so a reply to one resolves to the run that caused it.
The key is scoped by Telegram chat, because message_id is only unique within that chat. Older entries may keep the numeric key and omit chat_id; they stay available to legacy callers but are not used for scoped Telegram correlation.
Capped at the most recent 500, oldest dropped first. It is a lookup table for alerts you might still answer, not a history — and losing it costs correlation on old messages, never delivery.
state/notify/telegram/threads.json
Which conversation each recent group message belongs to, so a Telegram reply inherits the thread it answers instead of mixing subjects into one session.
Both ends of an exchange are bound: the inbound message when it is screened, and the bot's reply when it is delivered — so replying to either continues the thread. The key is scoped by chat, like sent.json, because Telegram message ids are only unique within a chat.
Capped at the most recent 1000, oldest dropped first. Losing an entry costs thread continuity on that one reply (it roots a new conversation), never a delivered message. Direct chats never touch this file — their session is the chat id. See Telegram.
state/notify/telegram/offset.json
Last acknowledged Telegram update_id, written tmp-then-rename.
Telegram redelivers every update until a higher offset acknowledges it. Without this file a daemon restart replays the backlog — and for a bot that runs agents, replay means re-running whatever the last messages asked for. Delivery is at-most-once on purpose. See Telegram.
state/known_manifests.json
Cache of sha256(agent.toml) for every loaded manifest. Drives manifest drift detection:
On each daemon load:
New name in
agents/not in the cache →PhantomAgentDetected(critical, notify).Existing name but mismatched sha →
ManifestDriftDetected(critical, notify).
state/daemon.pid
The running daemon's PID. Used by dotagent reload (sends SIGHUP) and removed on graceful exit via a Drop guard. Stale pidfile (no live process at that PID) means the daemon crashed without cleanup — restart it.
logs/
daemon/dotagent.log
NDJSON (tracing)
daily
30 days
daemon/run.avelino.dotagent.log
Raw text
(launchd / systemd appends)
(managed by OS)
daemon/run.avelino.dotagent-error.log
Raw text
(launchd / systemd appends)
(managed by OS)
agents/<name>/<name>.log
Raw stdout+stderr
daily
14 days
Compression: rotated files older than compress_after_days (default 1) get gzipped in-place. Deletion: files older than the retention horizon are removed by the 03:00 sweep. The active (non-rotated) file is never deleted, whatever the horizon says: launchd and systemd hold an open fd on it, so unlinking it would strand every subsequent write in a file with no name.
The stderr file is a crash channel, not an activity log
run.avelino.dotagent-error.log receives panics and pre-logging startup failures. It does not receive the daemon's tracing stream.
The daemon installs its stderr mirror layer only when stderr is a terminal. Under launchd / systemd stderr is an appended plain file that no rotation policy covers, so mirroring there would duplicate dotagent.log into a file that grows without bound. On a healthy daemon this file stays empty — that is the intended state, not a symptom.
DOTAGENT_LOG_STDERR
Effect
unset (default)
mirror on only if stderr is a TTY
1 / true / yes / on
force the mirror on — for units rewired to journald, which does rotate
0 / false / no / off
force it off, even in a terminal
anything else
ignored; falls back to the TTY default
ANSI colour follows the same rule and additionally honours NO_COLOR: set it to any value and escapes are suppressed in the daemon and in every subcommand.
The full schema + jq examples are in guides/observability.md.
audit.log
Hash-chained, append-only. Rotates by size; segments are never deleted automatically.
One JSON object per line. Each line carries prev_hash = sha256(previous line's full JSON). The very first line ever written has prev_hash = "GENESIS". On startup the daemon verifies the chain; if it breaks, an AuditChainBroken entry is appended (which itself becomes a chained entry — anchoring the new chain to the broken position).
Rotation and the seam
Past 32MB the live file is renamed to audit.log.<YYYYMMDDTHHMMSS> and a fresh audit.log opens with a seam as its first line:
The seam's prev_hash is the rotated segment's tail hash, so the chain has no gap at the rename. And because the seam names what left, deleting an old segment is legible: verification reports "intact since first_ts; 38513 earlier entries lived in audit.log.20260806T101500, which is gone" rather than "chain broken". Cutting lines off the head of the live file takes the seam with them, and the orphaned prev_hash that remains is accounted for by nothing — which is the case that fires audit_chain_broken.
Segments are never removed by dotagent. The log rotation sweeper in [logs] does not touch them; this is the only forensic artifact dotagent keeps and pruning it is the operator's call. rm them when you mean to.
Verification has two scopes:
current segment
the live audit.log only
daemon at boot, and dotagent audit verify — the live file is the only one that changes
full
follows seams backwards through every segment still present
dotagent audit verify --full, when you want the guarantee to reach GENESIS
dotagent audit verify prints the verdict and exits non-zero on a break or an unexplained truncation; --json emits one machine-parseable line. See cli.md.
Example line (pretty-printed):
Audit events emitted by dotagent:
event_type
When
Severity
daemon_started
Daemon process boots
info
daemon_stopped
Daemon receives SIGTERM / SIGINT
info
tick_started
No longer emitted. Parsed so existing logs stay readable — see below
info
tick_completed
No longer emitted. Parsed so existing logs stay readable — see below
info
agent_run
An agent completed (success or failure)
info / critical
agent_recovered
A previously-failing schedule passed
notice
agent_given_up
All retries exhausted for a window
critical
preflight_failed
A preflight plugin blocked the run
critical
plugin_invoked
Any plugin / notifier invocation
info / notice
manifest_loaded
Manifest read on daemon start / SIGHUP
info
manifest_drift_detected
sha256(manifest) doesn't match cache
critical
phantom_agent_detected
Discovered agent not in known_manifests.json
critical
audit_chain_broken
Hash chain verification failed at line N, or the head of the log was removed with nothing accounting for it
critical
audit_log_rotated
The log passed 32MB; the seam naming the segment it came from and that segment's tail hash
notice
config_reloaded
SIGHUP picked up changes to config.toml
notice
secrets_loaded
Daemon read secrets.env; payload has path, key_count, unresolved_references (no values, no op:// paths)
notice
secrets_refused
Daemon rejected secrets.env (insecure mode, parse, or IO)
critical
manifest_invalid
An agent.toml exists but failed to parse or validate; that agent is skipped, the rest keep running
critical
trigger_received
Inbound message accepted; records actor and reply_to, never the text
notice
trigger_rejected
Inbound message refused (allowlist, rate limit) before anything ran
critical
agent_triggered
A run started from a trigger rather than a schedule window
notice
command_dispatched
A /name invocation was resolved; records the name and whether it was known, never the arguments
notice
skill_invoked
A scripts/ executable inside a skill ran — code outside any manifest, which agent_run would not record
notice
skill_invalid
A SKILL.md exists but failed to parse; that procedure is missing from the catalog, the rest keep working
notice
remediation_invoked
A declared [[preflight]] remediation ran. The one event where a chat message changed the machine
critical
orphan_reaped
A process a previous daemon left running was killed at boot, after its identity was confirmed against the snapshot
critical
Critical severity drives out-of-band notifier dispatch. Defined in crates/dotagent-core/src/audit.rs.
Retired: tick_started / tick_completed
The daemon no longer writes these. A tick is telemetry, not an auditable event — "woke up and looked at 17 agents" tells a forensic reader nothing, and it dominated the file: on one real 38,510-entry log the two variants were 64% of every line. Because each append re-reads the log to find the tail hash, that noise also made every event worth recording more expensive to write. The daemon emits tracing at debug level instead, which already rotates — see observability.
The enum variants are kept so existing logs stay parseable. Removing them would make dotagent audit verify and chain verification fail the moment they reached a historic tick entry, and a chain you can no longer verify is worse than one carrying dead weight.
Platform-specific paths (outside $DOTAGENT_HOME)
These are written by dotagent install, not by the daemon at runtime:
macOS
Rendered properties:
Label=run.avelino.dotagentProgramArguments=["<dotagent binary>", "daemon"]RunAtLoad= trueKeepAlive= trueProcessType=BackgroundThrottleInterval= 10StandardOutPath=$DOTAGENT_HOME/logs/daemon/run.avelino.dotagent.logStandardErrorPath=$DOTAGENT_HOME/logs/daemon/run.avelino.dotagent-error.log
Linux
Rendered properties:
[Service] Type=simpleExecStart=<dotagent binary> daemonRestart=alwaysRestartSec=10StandardOutput=append:<logs/daemon/run.avelino.dotagent.log>StandardError=append:<logs/daemon/run.avelino.dotagent-error.log>[Install] WantedBy=default.target
Permissions
dotagent runs as your user (no daemon root, no setuid). Files are written with your umask (0644 for regular files, 0755 for directories on a typical user shell). Override by setting umask before launching the daemon:
macOS Full Disk Access: if your agent or sink-file writes into ~/Documents / ~/Downloads / ~/Desktop, the daemon binary needs the Full Disk Access entitlement under System Settings → Privacy & Security.
What dotagent does NOT put under $DOTAGENT_HOME
launchd plist / systemd unit (platform-specific, see above).
The
dotagentbinary itself (lives in~/.cargo/bin/,/opt/homebrew/bin/,/usr/local/bin/, etc.).Plugin binaries installed via
cargo install/brew(those go to~/.cargo/bin// Homebrew prefix).Your agent scripts' working data, unless your script writes there deliberately. dotagent gives each run a fresh
$AGENT_TMPDIRthat auto-cleans on exit.
Related
env-vars.md—DOTAGENT_HOME,DOTAGENT_ROOT,DOTAGENT_PLUGIN_PATH, etc.config-reference.md—config.tomlschemaobservability.md— log format + jq recipesthreat-model.md— audit log's forensic role
Last updated
Was this helpful?