Per-agent credentials
Bind each agent to specific WhatsApp / Telegram / Google accounts so outbound traffic originates from the right number, bot, or mailbox — never from a shared pool.
Mental model
Three layers:
- Plugin instance — a labelled WhatsApp session or Telegram bot in
config/plugins/{whatsapp,telegram}.yaml. Each instance owns its own token / session_dir and an optionalallow_agentslist. - Google account — an entry in the optional
config/plugins/google-auth.yaml. Each account is 1:1 with anagent_id. - Agent binding — in
config/agents.d/<agent>.yaml, thecredentials:block pins the agent to the instance / account it may use for outbound tool calls.
The runtime runs a boot-time gauntlet that cross-checks all three layers before any plugin boots. Every invariant violation surfaces in a single report so you can fix the full YAML in one edit.
Config schemas
config/agents.d/ana.yaml
agents:
- id: ana
credentials:
whatsapp: personal # must match whatsapp.yaml instance
telegram: ana_bot # must match telegram.yaml instance
google: ana@gmail.com # must match google-auth.yaml accounts[].id
# Opt-out for the symmetric-binding warning when inbound bot and
# outbound bot are intentionally different:
# telegram_asymmetric: true
inbound_bindings:
- { plugin: whatsapp, instance: personal }
- { plugin: telegram, instance: ana_bot }
config/plugins/whatsapp.yaml
whatsapp:
- instance: personal
session_dir: ./data/workspace/ana/whatsapp/personal
media_dir: ./data/media/whatsapp/personal
allow_agents: [ana] # defense-in-depth ACL
- instance: work
session_dir: ./data/workspace/kate/whatsapp/work
media_dir: ./data/media/whatsapp/work
allow_agents: [kate]
config/plugins/telegram.yaml
telegram:
- instance: ana_bot
token: ${file:./secrets/telegram/ana_token.txt}
allow_agents: [ana]
allowlist:
chat_ids: [1194292426]
- instance: kate_bot
token: ${file:./secrets/telegram/kate_token.txt}
allow_agents: [kate]
config/plugins/google-auth.yaml
google_auth:
accounts:
- id: ana@gmail.com
agent_id: ana # 1:1 — the gauntlet enforces it
client_id_path: ./secrets/google/ana_client_id.txt
client_secret_path: ./secrets/google/ana_client_secret.txt
token_path: ./secrets/google/ana_token.json
scopes:
- https://www.googleapis.com/auth/gmail.modify
Agents that still declare the legacy inline google_auth block are
auto-migrated into this store on boot (a warning tells you to migrate).
What the gauntlet validates
| Check | Lenient | Strict |
|---|---|---|
Duplicate session_dir across instances | error | error |
session_dir that is a parent of another | error | error |
| Credential file with lax permissions (linux 0o077) | error | error |
credentials.<ch> points to an instance that does not exist | error | error |
Agent listens on >1 instance without declaring credentials.<ch> | error | error |
Instance allow_agents excludes a binding agent | error | error |
Inbound instance ≠ outbound instance (no <ch>_asymmetric) | warn | error |
Inline agents.<id>.google_auth without matching google-auth.yaml | warn | warn |
Linux permission check is skipped for /run/secrets/* (Docker secrets)
and can be disabled entirely with CHAT_AUTH_SKIP_PERM_CHECK=1.
Topics
Outbound tool calls land on instance-suffixed topics when the resolver has a binding:
plugin.outbound.whatsapp.<instance>
plugin.outbound.telegram.<instance>
Unlabelled (instance: None) plugin entries keep publishing to the
legacy bare topic plugin.outbound.whatsapp / plugin.outbound.telegram
for full back-compat.
CLI gate
# Run the full gauntlet without booting the daemon. Exits 0 clean,
# 1 on errors, 2 on warnings-only.
agent --config ./config --check-config
# Promote warnings to errors (CI lane).
agent --config ./config --check-config --strict
The gate scans agents.yaml, every agents.d/*.yaml,
whatsapp.yaml, telegram.yaml, and google-auth.yaml. Sample
failure:
credentials: FAILED with 1 error(s):
1. agent 'ana_per_binding_example' binds credentials.telegram='ana_tg' but no such telegram instance exists (available: [])
Secrets in logs
The credential layer never logs a raw account id. Every reference is
via an 8-byte sha256(account_id) fingerprint rendered as hex:
2025-04-24T16:03:42Z INFO credentials.audit agent="ana" channel="whatsapp" fp=a3f2…7c direction=outbound
The fingerprint is pinned — switching the algorithm is an explicit
breaking change tracked by crates/auth/tests/fingerprint_stability.rs.
Observability
Nine Prometheus series land at /metrics:
| Series | Type | Labels |
|---|---|---|
credentials_accounts_total | gauge | channel |
credentials_bindings_total | gauge | agent, channel |
channel_account_usage_total | counter | agent, channel, direction, instance |
channel_acl_denied_total | counter | agent, channel, instance |
credentials_resolve_errors_total | counter | channel, reason |
credentials_breaker_state | gauge | channel, instance |
credentials_boot_validation_errors_total | counter | kind |
credentials_insecure_paths_total | gauge | — |
credentials_google_token_refresh_total | counter | account_fp, outcome |
Back-compat
- Configs without a
credentials:block keep working — the resolver infers outbound from the singleinbound_bindingsentry when it is unambiguous; otherwise outbound tools are marked unbound and fall back to the legacy bare topic. - Plugin entries with
instance: Nonestay on the legacy bare topic. agents.<id>.google_authstill registersgoogle_*tools for that agent;google-auth.yamlis preferred going forward.
Hot-reload (no daemon restart)
Edit agents.d/*.yaml, plugins/whatsapp.yaml, plugins/telegram.yaml,
or plugins/google-auth.yaml, then trigger a reload via the loopback
admin endpoint:
curl -fsSX POST http://127.0.0.1:9091/admin/credentials/reload | jq
{
"accounts_wa": 2,
"accounts_tg": 2,
"accounts_google": 1,
"warnings": [],
"version": 4
}
The resolver runs the gauntlet against the fresh files, then atomically
swaps bindings in place. Plugin tools holding Arc<…> references see
the new state on their next call. Failure mode: gauntlet errors
return HTTP 400 with the error list; the previous bindings stay
active so a typo in YAML does not knock out the runtime.
CredentialHandles already issued to in-flight tool calls keep
working — handles are by-value clones; the resolver only mediates
lookup of future calls.
What the reload does NOT cover
- Adding a brand-new WhatsApp / Telegram instance still requires a
restart for the plugin (each instance owns its own session_dir
- websocket). The resolver picks up the new account but the plugin side stays as-was until next boot.
- Removing an account leaks its breaker entry in
BreakerRegistryuntil restart. No correctness impact.
Google client_id / client_secret rotation
Rewriting the secret files (./secrets/<agent>_google_client_id.txt,
..._client_secret.txt) is picked up automatically on the next
google_* tool call — GoogleAuthClient checks file mtime before
each network hop and re-reads when it advanced. No reload call
required for that case. Audit log line:
INFO credentials.audit event="google_secrets_refreshed" \
google_*: re-read client_id/client_secret after on-disk rotation
Strict mode
agent --check-config --strict promotes warnings to errors. Two
checks behave differently under strict:
| Condition | Lenient | Strict |
|---|---|---|
Inline agents.<id>.google_auth block (legacy) | warn + auto-migrate | BuildError::LegacyInlineGoogleAuth, fail boot |
Asymmetric inbound ≠ outbound (no <ch>_asymmetric: true) | warn | error |
Run --strict in CI to gate PRs that touch credential YAML.
Migrating
- Add
instance:+allow_agents:to each entry inwhatsapp.yaml/telegram.yaml. - Create
config/plugins/google-auth.yamlwith oneaccounts[]per agent that needs Gmail. - Add
credentials:to eachagents.d/*.yaml. - Run
agent --check-config --strict. Fix every listed error. - Commit.