Config reference (config.toml)
Schema for
~/.config/dotagent/config.toml. Every field is optional — dotagent ships with sensible defaults out of the box.
$DOTAGENT_HOME/config.toml # default: ~/.config/dotagent/config.tomlIf this file is missing, dotagent uses the baked-in defaults (spelled out below). You only need a config.toml to:
Bump verbosity, change log retention
Export traces to Honeycomb / Tempo / Datadog
Keep secrets somewhere other than the default path
Talk to your agents from Telegram
Move or disable long-term memory
Retime the daily health summary, or send it somewhere other than the desktop
Stop agents from running on battery
Two of those differ in kind. [logging], [telemetry] and [memory] tune something that already works; [telegram] turns on a path that does not exist otherwise, and it is the one section that changes the threat model — inbound messages mean untrusted input from the internet can cause a local process to run.
There is no required field. Anything you don't write falls back to the default.
After editing, run dotagent reload — the daemon picks up changes on the next tick.
Full example (every field)
# ~/.config/dotagent/config.toml
[logging]
level = "info" # off | error | warn | info | debug | trace
format = "json" # json | pretty | compact
retention_days = 30 # daemon logs older than this are deleted
per_agent_retention_days = 14 # agent logs (noisier; shorter horizon)
compress_after_days = 1 # rotated files older than N days → gzip
[state]
window_retention_days = 30 # state/windows/ files older than this are deleted
[telemetry]
otlp_endpoint = "" # empty = OTel disabled (default)
protocol = "grpc" # grpc | http
service_name = "dotagent"
[telemetry.headers]
# Vendor-specific auth headers. Sent on every OTLP request.
# OTEL_EXPORTER_OTLP_HEADERS env var wins over this table.
"x-honeycomb-team" = "your-api-key"
[telemetry.resource]
# Resource attributes attached to every span/log.
"deployment.environment" = "production"
"host.name" = "workstation-01"
[secrets]
file = "" # empty = default path or DOTAGENT_SECRETS_FILE
[telegram]
bot_token = "${TELEGRAM_BOT_TOKEN}" # empty = inbound Telegram off (default)
allowed_user_ids = [123456789] # numeric ids; empty = nobody
dispatcher_agent = "telegram-assistant"
poll_timeout_seconds = 30 # long-poll hold, capped at 50 by Telegram
rate_limit_per_minute = 10 # accepted messages per sender
[memory]
enabled = true # default; false removes the memory tools
workspace = "" # empty = $DOTAGENT_HOME/outl
[skills]
enabled = true # default; false removes the skill tools
claude_skills = true # default; also read ~/.claude/skills
paths = [] # extra roots, searched before the defaults
[commands]
enabled = true # default; false removes the menu and tools
claude_commands = false # default; opt in to ~/.claude/commands
paths = [] # extra roots, searched before the defaults
[power]
on_battery = "run" # default; run | defer
min_battery_percent = 0 # default; 0 = charge is never consulted
[daily_summary]
enabled = true # default; governs the daemon's fire only
time = "22:45" # default; local HH:MM or HH:MM:SS
grace_minutes = 30 # default; how late a wake-up still delivers
[[daily_summary.notifiers]]
# Same shape as a manifest's [[notifiers]]. None declared = desktop.
driver = "telegram"
bot_token = "${TELEGRAM_BOT_TOKEN}"
chat_id = "123456789"[telegram]
Inbound Telegram. Off unless configured — dotagent opens no inbound path you did not ask for. Outbound notifications are unrelated and live in the manifest's [[notifiers]]; see Notifications.
bot_token
""
Bot API token. Accepts ${VAR}, resolved against the secrets store at poll time.
allowed_user_ids
[]
Numeric Telegram user ids allowed to trigger runs.
open_chat_ids
[]
Group chats where any member may talk to the dispatcher. Direct messages and !/!! typed commands stay restricted to allowed_user_ids.
dispatcher_agent
"telegram-assistant"
Agent every accepted message is handed to.
poll_timeout_seconds
30
Seconds to hold getUpdates open. Telegram caps this at 50.
rate_limit_per_minute
10
Accepted messages per sender per minute.
The ingress starts only when bot_token and at least one entry in allowed_user_ids are present. A token with an empty allowlist stays off and says so in the log: reading empty as "no restriction" would turn one forgotten line into an open remote-execution endpoint.
This section is daemon-level rather than per-manifest because Telegram allows exactly one getUpdates consumer per bot token. N manifests each polling would compete for the same offset and silently drop each other's messages.
Enabling this changes the threat model — a message from the public internet can cause a local process to run. Read V8 in the threat model before turning it on, and Telegram for the full setup.
[memory]
Long-term memory for agents, stored in an embedded outl workspace. On by default.
enabled
true
Expose the memory-* tools from dotagent mcp.
workspace
""
Workspace path. Empty resolves to $DOTAGENT_HOME/outl.
The default workspace is scaffolded when the daemon starts, so memory works without writing any config. A configured path is never scaffolded: a typo there must fail loudly rather than create an empty workspace nobody will look at. dotagent doctor reports which case you are in.
Pointing this at a workspace you already use puts what an agent remembers next to your own notes, synced to your peers. Convenient, and also means an agent writes where you write.
Not the same
[memory]asagent.toml. This section says where the workspace lives and whether memory exists at all. The per-agent section of that name says whether that agent writes to it — see[memory]in the agent spec.
Full behavior in Memory.
[skills]
Procedures exposed to MCP clients as skill-* tools. On by default, with an empty catalog that costs nothing.
enabled
true
Expose skill-*, skill-read and skill-run from dotagent mcp.
claude_skills
true
Also search ~/.claude/skills/ and $CWD/.claude/skills/.
paths
[]
Extra roots, each holding one subdirectory per skill. Searched first, so a name declared here overrides one found later.
claude_skills defaults to on because the skills worth exposing are usually already written for Claude Code, and requiring a copy would mean two versions drifting apart. Turn it off when that catalog is large and mostly irrelevant to an assistant that has no shell.
[commands]
Procedures you invoke by name, published as a Telegram menu and resolved through command-get. On by default, with an empty catalog that costs nothing. See Commands.
enabled
true
Register the / menu and expose command-get / command-list.
claude_commands
false
Also search ~/.claude/commands/ and $CWD/.claude/commands/.
paths
[]
Extra roots, each holding one .md file per command. Searched first, so a name declared here overrides one found later.
claude_commands defaults to off, unlike claude_skills. A skill costs a line in a list until a model judges it relevant; a command is published as a menu, and a Claude Code catalog is typically full of things that assume a shell and a working directory. Menu entries that cannot work are worse than absent ones. Turn it on when the catalog was written for an assistant rather than a terminal.
Use paths for a bundle that keeps its skills one level down — discovery does not walk recursively:
dotagent doctor reports how many skills were found, which failed to parse, and any two names that collapse to the same tool name.
Full behavior in Skills.
[daily_summary]
The end-of-day health roll-up: one line per unhealthy (agent, schedule), healthy ones collapsed into a count. On by default — nothing here is required to receive it.
enabled
true
Deliver on the daemon's schedule. false stops the nightly fire and the wake-up it schedules.
time
"22:45"
Local time of day, HH:MM or HH:MM:SS.
grace_minutes
30
How long after time a delivery still counts. Clamped to [1, 1440].
notifiers
[]
Array of tables, same shape as a manifest's [[notifiers]]. Empty = the desktop driver.
Declaring more than one entry delivers to all of them, driver = "plugin" included. See Notifications for every driver and its fields.
Why the defaults are what they are
No notifier means desktop, not silence. Every other driver needs a chat id, a phone number or a webhook, and there is no universal default for those. desktop is the only one with nothing to fill in and nothing to leak — no credential, no network, nothing leaving the machine. Delivering only when configured is how this feature spent its early life: it fired nightly at a constant nobody owned and left no trace when the message went nowhere.
[telegram] is not used as a destination. That section is ingress — the bot that accepts your messages. Wiring it to egress would send a nightly report to anyone who set up a bot to talk to their agents and never asked for one.
A time that does not parse falls back to 22:45 rather than disabling delivery. A typo should cost you the wrong hour, not a silent month. grace_minutes = 0 becomes 1 for the same reason: a zero-width window is an empty one.
An events filter inside [[daily_summary.notifiers]] is ignored. The list is already scoped to a single event, so a filter there could only subtract. Entries get copied out of manifests, and events = ["given_up"] riding along would match nothing and drop the summary without a word.
When it actually fires
The daemon schedules a wake-up for time, so delivery does not depend on some other schedule happening to be due nearby. grace_minutes covers the case where the wake-up could not happen at all — machine asleep, machine off, a tick that overran its own sleep budget. It fires once per window; re-entering it (a dotagent reload, say) does not double-send.
enabled = false does not block dotagent daily-summary typed by hand — that flag governs the daemon, and someone who ran the command asked for that one. Each delivery is audited as plugin_invoked with plugin: "notifier:<driver>", failures included.
Full command reference in cli.md.
[secrets]
Override the path to the daemon-loaded secrets file. The default (empty file) resolves to $DOTAGENT_HOME/secrets.env, with the DOTAGENT_SECRETS_FILE env var as second-tier override.
file
string
""
Absolute path to the KEY=VALUE secrets file. Must be mode 0600.
See concepts/secrets.md for the file format, posture, and which notifier configs honor ${VAR} today.
[logging]
Controls dotagent's own operational logs — the daemon's tracing output under logs/daemon/dotagent.log and the per-agent rotated files under logs/agents/<name>/<name>.log.
level
string
"info"
off, error, warn, info, debug, trace
format
string
"json"
json, pretty, compact. File output is always JSON regardless — this controls the stderr stream the daemon writes for launchd/systemd to capture.
retention_days
uint
30
Days to keep daemon logs (logs/daemon/).
per_agent_retention_days
uint
14
Days to keep per-agent logs (logs/agents/<name>/).
compress_after_days
uint
1
Rotated files older than N days are gzipped in-place.
level semantics
Same as the RUST_LOG env-var grammar — but here you set a single filter that applies to all targets. Per-target tuning is only available via env var.
Override transiently:
RUST_LOG wins when both are set.
Retention behavior
A daily sweep at 03:00 local time (single-shot per day):
Walks
logs/daemon/and everylogs/agents/<name>/.Rotated files older than
compress_after_days→ gzipped in-place.Rotated files older than
retention_days(daemon) orper_agent_retention_days(agents) → deleted.
Both horizons apply to rotated files only. The active log is never compressed and never deleted, whatever its age: launchd and systemd hold an open fd on it, so unlinking it would strand every subsequent write.
The same 03:00 pass also sweeps state/windows/ — see [state].
The audit log (audit.log) is never swept regardless of these settings — by design. See observability.md.
[state]
Retention for what dotagent writes under state/. Nothing to configure — the default already bounds the only directory that grows without limit.
window_retention_days
uint
30
Days to keep state/windows/. 0 disables the sweep entirely.
A schedule writes one window file per fired window and never revisits it, so an agent on a 15-minute interval leaves ~96 files a day behind — a .json plus the .lock next to it. Left alone, that directory grows forever.
The nightly sweep deletes each aged-out window together with its .lock, and skips any window a writer currently holds the lock on. Windows are deleted, never gzipped: the daemon reads them as JSON.
Why 30 days
The horizon has to clear the oldest window the daemon might still consult. A window stops being actionable once it is older than the schedule's stale_after_minutes (default 120), which 30 days exceeds by ~360×. Even a wildly permissive stale_after_minutes of a full week still leaves four days of headroom.
Erring high costs a few MB. Erring low deletes retry state under a running daemon, which resets attempts and re-fires an alert someone already gave up on — so widen it freely, narrow it carefully:
Heartbeats are deliberately not covered: there is exactly one per (agent, schedule) and it is rewritten in place, so state/agents/ is bounded by how many schedules exist.
[power]
Whether a due run happens while the machine is on battery. Off by default — dotagent dispatches exactly as it always did until you opt in, and never probes the power source when the settings can't defer anything.
on_battery
string
run
run dispatches regardless. defer holds runs until mains power.
min_battery_percent
uint
0
Defer below this charge whatever on_battery says. 0 disables.
The problem it solves is specific to laptops. A daemon that sleeps between events costs nothing, but the agents it wakes up to run are not free: a 15-minute interval agent fires 96 times a day whether the machine is plugged in at a desk or in a bag at 12%.
The two rules are independent. min_battery_percent is the common case on its own: agents are welcome to run on battery in general, just not when there is nearly none left.
Deferring does not queue
A deferred run is not stored and replayed. Both schedule kinds resolve to the current window rather than a backlog, so an agent deferred across four hours of battery runs once when the charger goes in — not sixteen times. This is the behavior that makes defer safe to set on an aggressive interval.
The check sits after the staleness check, so a window that ages past stale_after_minutes while on battery is dropped rather than run hours late. That is the same call staleness always makes; if you want a deferred agent to survive a long unplugged stretch, widen its stale_after_minutes.
Per-schedule override
[power] is the default. Any schedule can override it, because the cost is per-schedule — an agent can keep a cheap hourly check running on battery while its expensive every-15-minutes sync waits for a charger:
See agent-spec.md for the field in context. min_battery_percent is deliberately not overridable: "the battery is nearly empty" is a fact about the machine, not about one schedule's appetite.
Detection
macOS
pmset -g batt
Linux
/sys/class/power_supply/* (type, online, capacity)
Other
undetectable
An undetectable power source is treated as mains power: a machine whose battery cannot be read must keep running its agents. Failing to run is the worse failure.
dotagent tick honors these settings too — a tick that dispatched what the daemon would have held back would misreport the thing it exists to reproduce.
[telemetry]
Opt-in OpenTelemetry OTLP export. Disabled by default — nothing leaves your machine until you set otlp_endpoint.
otlp_endpoint
string
""
Empty = disabled. e.g., "https://api.honeycomb.io:443".
protocol
string
"grpc"
grpc or http (HTTP/protobuf).
service_name
string
"dotagent"
service.name resource attribute on every span/log.
[telemetry.headers]
Inline TOML table. Keys/values sent verbatim as HTTP/gRPC headers on every OTLP request.
The OTEL_EXPORTER_OTLP_HEADERS env var (comma-separated k=v) wins when both are set — useful for keeping secrets out of the config file.
[telemetry.resource]
Inline TOML table of OpenTelemetry resource attributes attached to every span and log record. Vendor-agnostic.
Standard OTel semantic conventions apply — deployment.environment, service.namespace, host.name, service.version, etc.
What gets exported
Today the OTel pipeline exports spans:
daemon— root span for the daemon process lifetimetick— one per scheduler tickagent_run— one per agent invocationplugin_invoke— one per plugin call (preflight / sink / notify-via-plugin)
Logs are NOT yet exported via OTLP — that bridge is on the roadmap. For now, ship logs via a sidecar (fluent-bit, vector, promtail) reading the JSON file directly.
See observability.md for per-vendor recipes (Honeycomb, Tempo, Jaeger, Datadog).
[os]
Installed binaries an assistant may run. Off by default, and empty by default even when on — the only section here that starts closed.
enabled
false
Expose the os-run / os-list tools
allow
[]
What may run. Empty runs nothing, even when enabled
timeout_seconds
60
Wall-clock ceiling for one invocation
Naming a binary so the model knows it exists
os-run makes every allowed binary reachable. Reachable is not discoverable: a model has to already know outl exists and guess what it is for. An [[os.tool]] entry publishes one under its own name, with a description:
That publishes os-outl and os-kubectl-get. The description is the whole point — a name without one is what os-run already offers.
args fixes the leading arguments. The model appends to them and cannot replace them, so kubectl get is a read-only view of a binary that can also delete: asking that tool for delete pods runs kubectl get delete pods, which fails as it should. Two entries for one binary with different fixed arguments get distinct names and never collide.
Keep the list short. A normal machine has around a thousand executables on PATH; a tool each would bury the catalog and push the useful ones behind tool search. Name the ones that come up by name in conversation and let the rest fall through to os-run.
Named tools obey the same policy as everything else: deny refuses them, and a confirm-class binary is refused with a note to have the person type it. doctor reports an entry whose binary allow does not admit, one with an empty description, and two entries that would resolve to the same name.
Asking before it acts
allow says what may run. Two more lists say how:
deny
[]
Refused always. Beats allow, including *
confirm
(see below)
Runs only after a !! reply
confirm_ttl_seconds
120
How long a parked command stays answerable
confirm does not default to empty. With allow = ["*"] an empty one would mean a chat message can repartition a disk with nothing in between, so the default covers the destructive classics — rm, rmdir, dd, mkfs, shred, diskutil, fdisk, parted, shutdown, reboot, halt — and the shells: sh, bash, zsh, fish, dash, ksh.
The shells are the entry that makes the rest mean anything. A guard on rm that lets sh -c 'rm -rf /' through guards nothing. Set confirm = [] to opt out deliberately.
The flow:
One slot per conversation, so !! can only ever release the last thing that chat parked. Pending confirmations live in memory: a daemon restart forgets them, which fails in the safe direction.
A model cannot confirm. When os-run asks for something on the confirm list, it is refused and told to have the person type the line. A tool that could both ask and agree would be a confirmation in name only.
Matching is per binary, not per pattern, which is what makes it hold: confirm = ["rm"] catches rm -rf /, rm -r -f /, rm -fr / and rm --recursive --force / alike, where a textual "rm -rf" would catch one of the four.
Opening the whole machine
A single entry * allows every binary on PATH, a shell included:
Prefer this over enumerating PATH. An enumerated list goes stale the next time something is installed, and reading four hundred names suggests a decision was made about each one — * says what is true.
What it means concretely: anyone who can send a message can run what the daemon's user can run. Whatever guards the inbound channel (the Telegram allowed_user_ids, the socket's uid check) is now guarding the machine rather than the agent catalog. doctor prints a warning on every run while this is set, and each invocation is still audited with its full argument list.
A path is refused even here: /bin/sh is not a name, and sh is what runs.
Granularity is per entry
An entry is a binary name, optionally followed by the leading arguments that must match:
rgallows the binary and every argument it takes.kubectl getallows that subcommand only.kubectl deleteis refused by the catalog, not by the cluster.
Matching is on whole tokens, so kubectl get never admits kubectl getsecrets. Choose bare for binaries that only read, and pin the subcommand for anything that can change something you care about.
Two ways to reach it
The allowlist is one policy with two doors:
os-run, where an assistant decided a binary would help.!in a message, where you typed the command yourself.!rg foo srcruns it directly: no model, no session, nothing stored, and errors come back raw. Works over Telegram and over the local socket (dotagent api).
The prefix is read after the Telegram allowlist and rate limit, and obeys the same allow list — a stolen session types ! as easily as you do.
Quotes group an argument (!rg "hello world"). Nothing else from a shell applies: !ls; rm x looks for a binary named ls;.
What it does not do
The allowlist bounds which programs run, not what each one is capable of. A binary listed bare is trusted with everything it can do, and some read-ish commands can still execute things through their own flags. Listing kubectl bare on a machine holding production credentials means a chat message reaches production.
Arguments never touch a shell. os-run spawns the program with an argument list, so |, && and $(…) are literal characters. A path is refused where a name is expected, so /bin/sh cannot stand in for a listed sh.
Every invocation is audited as os_command_invoked at Critical, recording the binary and the full argument list. See security/threat-model.md V17.
What's NOT in config.toml
Per-agent retry policy
[defaults] in the agent's own agent.toml
Per-agent notifications
[[notifiers]] in the agent's own agent.toml
Per-agent security policy
[security] in the agent's own agent.toml
Notifier defaults across agents
(Not yet supported — declare per-agent for now.)
Daemon binary path / unit file content
Generated by dotagent install from the running binary. No override knob.
Migrating partial configs
config.toml is partial-overlay: missing fields keep their defaults. The minimal "I want debug logs" config:
Everything else (format, retention_days, [telemetry], …) stays default.
You don't need to write empty tables for sections you don't customize.
Reloading
config.toml is re-read on:
Daemon startup
The next tick after a SIGHUP (
dotagent reload)
Changes that need a full restart (not just reload):
Switching
[logging].formatbetweenjson/pretty/compactfor the stderr stream — the subscriber is initialized once at boot.Changing OTel
protocol(gRPC ↔ HTTP) — the exporter is built once.
For those, use:
Verifying your config
To confirm OTel went live:
You should see a "telemetry initialized" or similar message after the next reload/restart.
Related
observability.md— logging architecture + OTel vendor recipesenv-vars.md—RUST_LOGandOTEL_EXPORTER_OTLP_HEADERSoverridespaths.md— where logs land on diskagent-spec.md— per-agent config (manifest)
Last updated
Was this helpful?