Secrets
dotagent reads a single secrets file at daemon startup so notifier configs (and, eventually, agent env injection) can reference ${VAR} without the operator having to wire env vars into the launchd plist or systemd unit.
If you're here because a notifier config says
bot_token = "${TELEGRAM_BOT_TOKEN}"and you're wondering where that variable should live — this is the page.
Where the file lives
~/.config/dotagent/secrets.env # defaultOverride (in order of precedence):
[secrets] file = "..."in~/.config/dotagent/config.tomlDOTAGENT_SECRETS_FILEenv var (absolute path)The default above
The override is useful when the file is mounted from a secret manager (Vault, AWS Secrets Manager, sops-nix, kubernetes secret volume) into somewhere like /run/secrets/dotagent.env.
File format
# ~/.config/dotagent/secrets.env
# Comments start with `#`. Blank lines are fine.
TELEGRAM_BOT_TOKEN=1234:abcdef...
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."
PUSHOVER_TOKEN='literal token with $no expansion'
export DIRENV_STYLE=ok
# 1Password CLI reference — resolved at daemon startup
TELEGRAM_BOT_TOKEN_2=op://Personal/dotagent/telegram-tokenRoughly dotenv-compatible:
One
KEY=VALUEper line. The first=is the separator; subsequent=characters are part of the value.Lines may start with
export— the prefix is stripped (handy for copy-paste from a.envrc).Double-quoted values strip the quotes and process the escapes
\n,\r,\t,\\,\". Anything else after\is an error, on purpose, so typos don't pass silently.Single-quoted values strip the quotes and are otherwise literal (no escapes, no
${VAR}) — useful for tokens with backslashes or dollar signs.Unquoted values: leading/trailing whitespace trimmed, interior preserved. No shell expansion:
$HOMEstays the literal string.Keys must match
[A-Za-z_][A-Za-z0-9_]*.
Secret references (1Password)
A value matching op://vault/item[/section]/field is recognised as a 1Password CLI reference. At daemon startup the loader shells out to op read --no-newline <ref> and stores the plaintext result in memory. The literal op://… string is never kept — if op fails, the key is removed from the store so any notifier needing it fails loud (env var ${…} is unset) instead of sending the placeholder over the wire.
Requirements:
op(the 1Password CLI) onPATH.An active 1Password session — desktop app biometric unlock or
op signinbefore starting the daemon. The loader does not prompt.
Why CLI, not the SDK? Issue #34 explicitly framed remote secret managers as out-of-scope; the CLI sits in the same posture as populating the file from
vault read, just with the lookup happening at startup instead of being a separate pre-step. The 1Password Service Account SDK could replace the fork later (issue not yet filed) but it requires a Business-plan service account token, which most laptop-dev users don't have.
Other secret managers
For anything that isn't op, populate the file before starting the daemon. The file is the single source of truth at startup time.
Wire it into your launchd plist / systemd unit's ExecStartPre and you get the same posture as Kubernetes secret volumes: a normal file on disk, refreshed by an out-of-band tool.
Permission posture
The file must be mode 0600 (read/write owner-only). Any group or world bits and the daemon refuses to load it — same posture as ssh refusing to use a world-readable private key.
Refusal is non-fatal: the daemon still starts so scheduled runs keep firing. But notifiers that depended on the rejected file will fail loudly (env var ${TELEGRAM_BOT_TOKEN} is unset) — there is no silent fall-through to "send the literal placeholder". Run dotagent doctor to see the file's state in one place.
How values get used
At send time, every notifier config string that contains ${VAR} walks through this resolver:
Secrets store — the in-memory copy of the file loaded at daemon startup.
std::env::var— fallback for operators who already wired the variable into the plist / systemd unit.
If both miss, the notifier fails with a clear error rather than sending a request authenticated as the literal string ${…}.
Which fields honor ${VAR}
Every credential-bearing field on every built-in HTTP driver:
slack.webhook_url
The URL is the credential — anyone holding it can post.
ntfy.token
Bearer token for authenticated topics.
ntfy.base_url
A self-hosted URL can legitimately embed HTTP basic credentials.
ntfy.topic
On public ntfy.sh the topic name is the only thing standing between your alert stream and anyone who guesses it.
pushover.token
Application token.
pushover.user
User/group key.
telegram.bot_token
Full control of the bot.
Two fields are deliberately not expanded: telegram.chat_id and imessage.to. They are addresses, not secrets — a phone number or a channel id identifies where the alert goes, and putting them through a resolver would turn a typo into a confusing "env var unset" instead of a message that lands in the wrong place. Keep them literal.
Expansion happens at send time, not at load time, so rotating secrets.env and sending SIGHUP is enough — no manifest edit.
What an unresolved reference does
It fails the send. There is no fallback to the literal "${SLACK_WEBHOOK_URL}": that string is not a degraded credential, it is a request authenticated as a placeholder, which answers 404 and reads like an outage rather than a typo.
The error names the field and the variable and nothing else — slack.webhook_url: env var ${SLACK_WEBHOOK_URL} is unset. It never carries a resolved value, because the input is a credential template and the store holds sibling credentials; echoing either would turn a config typo into a disclosure in ~/.config/dotagent/logs/.
Reload
The file is read once at daemon startup. To pick up changes:
dotagent reload(sendsSIGHUP— preferred), orrestart the unit (
launchctl kickstart/systemctl --user restart).
There is no automatic file-watch reload. The reasoning is the same as for sshd: secrets rotation is rare, surprises are expensive, and a deliberate signal is a feature.
What happens when reload fails
If the file becomes unreadable between startup and SIGHUP (insecure mode, parse error, file deleted), the daemon drops the previously loaded store rather than keeping it as a fallback. Rationale: an operator who just ran op signin && op item rotate <id> would be worse off if dotagent kept serving the old token. Once dropped:
Notifier
${VAR}lookups fall through tostd::env::var.Anything that has no env fallback fails loud with
env var ${…} is unsetrather than silently using a revoked credential.The audit log records
secrets_refusedwith reason ending in; previous store dropped, so the chain explains itself.
Restore the file (fix permissions, re-decrypt, etc) and SIGHUP again to re-populate the store.
What the audit log shows
dotagent's append-only audit.log records the load outcome — never the values themselves.
secrets_loaded
Notice
path, key_count, unresolved_references
secrets_refused
Critical
path, reason (e.g., "insecure permissions … mode 640; previous store dropped")
What never leaks vs. what may appear
Values never appear anywhere — not in
audit.log, not intracingoutput, not inDebugimpls (SecretsStore'sDebugredacts tolen+source).Key names may appear in operational
tracingwarnings — duplicate-key warnings and notifierenv var ${KEY} is unseterrors both include the key name on purpose, because they're unactionable without it. Key names also appear in failedop://...warnings (along with the reference path), since the vault/item identifiers are operator-visible metadata, not the secret itself.The audit log itself never includes key names — only counts.
If you treat key names as sensitive, route the daemon's tracing output to a sink you trust (the daemon's logs/daemon/*.log inherits the umask of whoever started it — set umask 077 in the launchd / systemd unit's environment if you want owner-only).
Agent subprocess isolation
The secrets file is read by the daemon only. Agent subprocesses do not get the whole file dumped into their env — they only see what their own manifest explicitly opts into.
v0 ships with notifier-side resolution working. The opt-in mechanism for
agent.toml([env].from_secrets = ["KEY1", ...]) will land in a follow-up issue.
Non-goals
GPG / age decryption. Tracked separately.
Remote secret managers (Vault, AWS Secrets Manager, Doppler). Out of scope. Tools like
vault read -format=tableoraws secretsmanager get-secret-valuecan populate the file from upstream.Automatic file-watch reload. SIGHUP / restart only.
Troubleshooting
env var ${TELEGRAM_BOT_TOKEN} is unset
Add the key to secrets.env, then dotagent reload.
dotagent doctor flags insecure permissions
chmod 600 ~/.config/dotagent/secrets.env.
dotagent doctor says (not present)
Create the file. Missing is OK — only an issue if a notifier needs ${VAR}.
Edited file, change didn't take effect
dotagent reload. The daemon caches the file in memory.
See also
docs/concepts/notifications.md— notifier drivers and where${VAR}matters today.docs/reference/agent-spec.md— manifest schema.docs/security/threat-model.md— overall posture.
Last updated
Was this helpful?