Skip to main content
Watchfire
Back to blog

Where Watchfire keeps your secrets

By Nuno Coração11 min read
On this page

There are two questions about secrets that any developer is going to ask in the first hour of using Watchfire. The first is mundane and practical: how do I tell the agent about my staging database URL without pasting it into a task prompt that will sit in a YAML file forever? The second is one they probably will not ask out loud, but will notice the moment they open the GUI's integrations panel: where does the Slack bot token live when the UI happily accepts a paste but pointedly refuses to show me what is already there?

These two questions sound related, but they are actually two different stories. They live in different parts of the daemon, they solve different problems, and they fall back to different floors when the obvious approach is unavailable. The thing that holds them together is a single principle: the daemon should be the smallest possible surface area for credentials, and the wire between the daemon and any client should never leak a value the operator has already trusted to the OS.

This post is the missing pillar of the architecture series — after Wildfire, worktrees, the sandbox, Bring Your Own Agent, and Beacon. It is the one about where the daemon keeps the bytes you do not want to write down.

Per-project secrets: a Markdown file, not a vault

The first story is the smaller of the two and the one operators interact with most often. Every project has a file at .watchfire/secrets/instructions.md. When an agent session starts, the daemon reads that file and prepends it to the agent's system prompt under a ## Secrets & Setup Instructions heading. That is the entire mechanism. There is no schema, no per-key API, no encryption layer, no database. The file is a Markdown document, and the prompt composer in internal/daemon/agent/prompts/compose.go does exactly what you would do by hand if you were assembling the system prompt manually: it appends the contents under a heading and hands the whole thing to the agent.

The choice of "plain Markdown file" instead of "structured secrets schema" deserves a paragraph of its own, because it looks lazy until you sit with it. Agents already think in Markdown — that is the lingua franca for system prompts across every modern coding agent. A YAML schema would force the operator to think in keys and values and then force the composer to render those keys and values into prose anyway. The operator already knows how to write the prose. Let them.

What the file actually contains is up to the operator. Typical content looks like "the Firebase CLI is authenticated, use firebase deploy directly," or "the staging database URL is in $DATABASE_URL," or "use the gh CLI that is already on PATH rather than hitting the GitHub API directly." The point is not to store the credentials. The point is to tell the agent how the surrounding system has already been set up so the agent can use the credentials that are already in the environment, the CLI tools that are already authenticated, and the variables that are already exported. The credentials themselves live wherever they already live. The Markdown file is the briefing document.

This shape has a nice second-order benefit. Because the file lives at .watchfire/secrets/instructions.md inside the project, the worktree machinery duplicates it for free. Every per-task worktree inherits the same briefing. There is no "where does the agent look it up from" question for the operator to answer — it travels with the project, in the same place every time, and the daemon reads it at session start. The full reference for everyday use lives at /docs/concepts/secrets.

It is worth being explicit about what this file is not. It is not a vault. It is not encrypted. It is not access-controlled past the filesystem permissions on the directory it sits in. It is a system-prompt injection point with a .md extension, and the operator is responsible for what they choose to put in it. The .watchfire/ directory is gitignored by default, which means the file does not enter version control unless the operator goes out of their way to remove the gitignore line. That is the floor. The sandbox is the ceiling — even if a future agent decided to misbehave with the instructions it was given, the sandbox is the second line of defense, blocking access to ~/.ssh, ~/.aws, .env files anywhere on disk, and other paths the briefing did not authorize. The whole point of the briefing model is that the value being briefed about is not on disk inside the worktree.

Per-daemon secrets: the keyring contract

The second story is bigger and lives one layer deeper. Every integration the daemon talks to — a Slack workspace, a Discord guild, a GitHub Enterprise host, a webhook receiver — needs some kind of credential. A Slack incoming-webhook URL. A Discord application's bot token. A signing secret for an outbound webhook. A bot token for an OAuth-installed Slack bot. These are not project-scoped; they are daemon-scoped. The Slack workspace token works for every project the daemon manages. Putting any of these in .watchfire/secrets/instructions.md would be the wrong shape — they do not belong in any project's system prompt, and they do not belong in any file the worktree machinery is going to duplicate sixteen times.

These secrets live in the OS keyring. The implementation is internal/config/keyring.go, a thin wrapper around the zalando/go-keyring library that maps to the macOS Keychain on Darwin, libsecret on Linux desktops, and Windows Credential Manager on Windows. Every entry uses a single stable service name — "watchfire" — so an operator who wants to see what the daemon has stored can open Keychain Access, filter for watchfire, and see the entire set. The keyring keys themselves follow a canonical shape: watchfire.integration.<id>.<field>, derived from SecretKeyForIntegration in internal/config/integrations.go. The integration YAML at ~/.watchfire/integrations.yaml carries only the reference — the keyring key — while the actual byte string never touches that file.

The keyring is the right floor for this. It already enforces per-user access control. It already locks on screensaver in most configurations. On macOS it integrates with the hardware-backed key store on supported hardware. We are not in the business of re-implementing any of that. We are in the business of asking the OS to do what the OS is already good at, and accepting the answer.

There is a fallback for hosts where the keyring is not reachable — a headless Linux box with no dbus session, a Windows service running without an interactive desktop, a CI container. The constructor in internal/config/keyring.go probes the keyring with a no-op set/get/delete cycle and, if any of those fail, falls back to a file-backed store rooted at ~/.watchfire/.secrets/, with one file per key at 0600 permissions. The file store is not as good as the keyring — it is a plain directory, gated only by filesystem permissions — but it is the same shape, the same interface, and it lets the daemon run in environments where there is no other option. The daemon logs exactly one WARN when this fallback kicks in, deliberately not spamming the log for every subsequent secret operation. The operator finds out once; the daemon stays quiet after that.

Write-only on the wire

The most interesting piece of the design is not where the secrets sit. It is the contract between the daemon and the clients that configure them. The gRPC service that owns integration configuration is IntegrationsService in proto/watchfire.proto. Its SaveIntegration RPC accepts a WebhookIntegration, SlackIntegration, DiscordIntegration, or GitHubIntegration payload. Three of those four carry secret fields: the URL on Slack and Discord (the URL is the secret in their incoming-webhook auth model), and the explicit secret field on webhook. All three are explicitly marked write-only on the wire.

The shape is symmetric across every secret-bearing field in the schema. There is a value field — url, secret, slack_bot_token, github_secret — that the client populates when saving. There is a companion boolean — url_set, secret_set, slack_bot_token_set, github_secret_set — that the server populates when listing. ListIntegrations returns the boolean. It does not return the value. The bytes the keyring is holding never travel back across the wire.

This is a small choice with a load-bearing consequence. A client — the GUI, the TUI, anything else that connects to the daemon — can save a secret. It can replace a secret with a new value. It can ask whether a slot is currently populated. It cannot read what is in the slot. If a remote attacker manages to talk to the daemon's gRPC socket, they cannot list integrations and walk away with every bot token the operator has configured. The list endpoint simply does not carry the bytes. The GUI works fine without them — the field renders as a placeholder showing "configured" with a Replace button, and the UX is no worse for the absence — but the wire is fundamentally narrower than it would have been if the same field had been read/write.

The same pattern holds for the inbound side. InboundConfig declares the same shape for every signing secret it has to hold — github_secret, slack_secret, discord_public_key, slack_bot_token, gitlab_secret, bitbucket_secret, and the rest. Every one is paired with a *_set boolean. Every one is marked write-only in the field comment.

There is a complementary half to the same idea: an empty secret on an update means "leave the existing keyring entry alone." Partial updates are the common case in the GUI — the operator opens the integration to change a label or an enabled-events bitmask, and the secret field is empty because the GUI never had the value to show. The save path treats empty as "no change," not as "clear." The keyring entry survives. The operator does not have to re-paste the token every time they tweak a setting. The convention shows up in SaveIntegrations in internal/config/integrations.go for Slack and Discord URLs, and in PutIntegrationSecret / DeleteIntegrationSecret for webhook secrets.

The write-only wire is not just a UI affordance. It is the API surface being honest about what the operator has trusted to the OS. The bytes are in the keyring. The keyring is the right place for them. There is no good reason for an RPC client to be able to read them back, and several reasons for it not to be able to.

What we deliberately did not build

The shape this story has taken — Markdown for project briefing, OS keyring for integration tokens, write-only wire for the RPC contract — is the result of declining several alternatives that look attractive until you sit with them.

We did not build an in-daemon encrypted store with a master password. That re-implements the OS keyring badly. The OS keyring is already there, already integrated with the platform's session-lock model, already audited by people whose full-time job is to audit that code. Building a parallel mechanism on top would force the operator to manage one more password, one more lock screen, one more backup story — and would give them no security property the OS does not already provide.

We did not build a per-project secret manager that integrates with cloud KMS APIs. That couples the daemon to AWS Secrets Manager, Google Secret Manager, Vault, and every other provider an operator might want, and it doubles the auth story — the daemon would need its own credentials to fetch the operator's credentials, and now there is a chicken-and-egg problem about where those live. The Markdown briefing model sidesteps this entirely by saying: the credentials are wherever they already are, just tell the agent how to find them.

We did not build "secrets in YAML." A YAML schema with staging_db_url: "postgres://..." would be technically simple and operationally catastrophic. Every committed file becomes a credential leak waiting to happen, every code-review for a project change is suddenly a credential-handling exercise, and git log --all -p | grep -i password becomes a daily ritual. The Markdown briefing model is not a place to store values; it is a place to describe how the surrounding environment has been set up. That distinction is the entire point.

The underlying principle across all three rejections is the same: the daemon should never become a richer credential store than the OS already provides, and the task file should never need a secret in it.

Closing

Two stories, one principle. The project-level injection model trusts the operator to compose the system prompt — to decide what the agent gets told about the environment it is working in. The daemon-level keyring model trusts the OS to hold the bytes — to enforce the access control the daemon would otherwise have to invent. The write-only wire makes that trust auditable, by guaranteeing that a credential the operator has handed to the OS does not flow back out through the RPC surface every time the GUI redraws.

None of these mechanisms are novel. The keyring has been a platform feature for decades. Markdown system prompts are the default shape for every coding agent worth using. Write-only API fields are an old pattern. What is worth noticing is the deliberate refusal to invent anything richer when the well-understood mechanism is already the right one. Watchfire's job is to be a thin, honest layer over capabilities the operating system, the agent, and the git toolchain already provide. Where the secrets live is one of the places where that posture matters most.

More posts

Firestorm 9.0: Watchfire becomes a factory agents can drive

7 min

For nine majors Watchfire drove coding agents. Firestorm inverts that: watchfire mcp serve exposes the whole orchestrator to any MCP-capable client as an 18-tool factory, so an outer agent plans and reviews while Watchfire manufactures the code in sandboxed, worktree-isolated runs. It is the fourth thin client, it holds no orchestration logic of its own, and it is local-only by construction.

Inferno 8.0: one window per project

6 min

Inferno is the first feature-forward major since Beacon, and it's built around a single idea — supervising many projects at once. The Electron GUI goes multi-window, the Project View flips to chat-primary, wildfire becomes a GUI control, the dashboard becomes mission control, and a new analytics layer measures what the agents actually shipped, not just how many tasks closed.