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

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       # default

Override (in order of precedence):

  1. [secrets] file = "..." in ~/.config/dotagent/config.toml

  2. DOTAGENT_SECRETS_FILE env var (absolute path)

  3. 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-token

Roughly dotenv-compatible:

  • One KEY=VALUE per 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: $HOME stays 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) on PATH.

  • An active 1Password session — desktop app biometric unlock or op signin before 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:

  1. Secrets store — the in-memory copy of the file loaded at daemon startup.

  2. 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:

Field
Why it is a credential

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 (sends SIGHUP — preferred), or

  • restart 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 to std::env::var.

  • Anything that has no env fallback fails loud with env var ${…} is unset rather than silently using a revoked credential.

  • The audit log records secrets_refused with 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.

Event
Severity
Payload

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 in tracing output, not in Debug impls (SecretsStore's Debug redacts to len + source).

  • Key names may appear in operational tracing warnings — duplicate-key warnings and notifier env var ${KEY} is unset errors both include the key name on purpose, because they're unactionable without it. Key names also appear in failed op://... 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=table or aws secretsmanager get-secret-value can populate the file from upstream.

  • Automatic file-watch reload. SIGHUP / restart only.

Troubleshooting

Symptom
Fix

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

Last updated

Was this helpful?