Agents need credentials all the time, and when one needs a key, it asks me to paste it into chat. Anything pasted into chat is sent to the model provider and saved in the session log, and I don't want API keys in either place.

creel is a small tool that captures the key somewhere else. When the agent needs a secret, it opens a masked popup in tmux, I paste the key there, and creel writes it into a local .env. The agent gets back a status saying whether the save worked, but never the value.

request_secret

On the agent side, creel is a tool called request_secret provided by the pi-creel extension for pi, the coding agent I use. It takes the name of the environment variable and a destination file, which defaults to .env in the working directory:

request_secret(name, dest = ".env")

The tool description tells the model to call it any time it is about to ask for a key, instead of asking in chat:

ALWAYS use this instead of asking the user to paste a
secret into chat: any time you need a credential, or are
about to say 'paste your ... key/token', call
request_secret first.

The flow

sequenceDiagram
    participant A as Agent
    participant P as pi-creel
    participant C as creel popup
    participant E as .env
    A->>P: request_secret(OPENAI_API_KEY)
    P->>C: tmux display-popup
    Note over C: I paste the key
    C->>E: write OPENAI_API_KEY=…
    C->>P: status file: "added"
    P->>A: Added OPENAI_API_KEY to .env

The extension opens a tmux display-popup over the agent's pane running the creel binary. display-popup returns immediately, so the extension polls a temp file every 150ms until creel writes a status token to it, and waits for the trailing newline so it never reads a half-written token:

if (existsSync(statusPath)) {
  const raw = readFileSync(statusPath, "utf-8");
  if (raw.endsWith("\n")) return raw.trim();
}

The extension passes creel a name, a destination, and the path of the status file:

creel 'OPENAI_API_KEY' --dest '.env' \
  --status-file '/tmp/creel-status-…'

creel writes back one of added, updated, cancelled, or error:<reason>. The value stays in creel's memory until it is written to the .env, and the extension never reads it.

The popup

creel is a small Go program with a Bubble Tea TUI. The input for the value uses password echo mode, so a paste shows up as a row of dots. Under the input it shows a hint based on the key's prefix, so I can tell I pasted the right thing without seeing it:

case strings.HasPrefix(value, "sk-ant-"):
    return "looks like an Anthropic key"
case strings.HasPrefix(value, "ghp_"),
    strings.HasPrefix(value, "github_pat_"):
    return "looks like a GitHub token"

The hint is advisory only, and creel accepts a key that doesn't match any known prefix.

The popup opens in pi's working directory (display-popup -d), so a relative .env resolves to the project the agent is working in.

Writing the .env

creel replaces an existing NAME= line in place or appends a new one, and leaves every other line alone. It writes to a temp file in the same directory with mode 0600 and renames it over the original:

tmp, err := os.CreateTemp(dir, ".creel-*.tmp")
// ...
if err := tmp.Chmod(mode); err != nil {
    return cleanup(err)
}

If the directory has a .gitignore that doesn't already list the file, creel adds it. If there is no .gitignore, creel doesn't create one.

When the agent calls request_secret, the destination arrives as a --dest flag and is confined to the working directory. ResolveDest rejects anything that resolves outside cwd, so the model can't point a capture at ~/.ssh/config or another project's .env. When I open creel from the keybinding described below, there is no --dest flag, so creel asks me for the destination and I can pick any folder, including with a file picker on Tab.

Using the key with creel exec

The pi process's environment is set when it launches, so a key written to .env partway through a session isn't in process.env for anything pi starts. The model could cat .env, but that puts the value in the context.

creel exec hands the key to a single command instead, the same way aws-vault exec and sops exec-env do. Everything after -- is the command to run. In this example, node run.mjs stands in for whatever the agent needs the key for, whether that's a script it wrote, a test suite, or a CLI like curl or aws:

creel exec OPENAI_API_KEY -- node run.mjs

It reads each named value from the .env, merges them over the current environment, and replaces itself with the command using syscall.Exec. The value is passed only through the child's environment, not argv or output, and a missing key is an error that names only the key:

if !ok {
    return nil, fmt.Errorf("%s not found in %s", name, dest)
}

Because creel replaces itself with the command, stdio, signals, the tty, and the exit code all pass straight through. The reply from request_secret tells the model to use creel exec and not to read the .env itself:

Added OPENAI_API_KEY to .env (chmod 600). The value was
not shown here. To use it, run `creel exec OPENAI_API_KEY
-- <command>` ... Do not read .env yourself.

prefix S

Sometimes I know a key is needed before the agent asks. For that there is a tmux binding, prefix S, that opens the same popup in any pane. Run bare, creel prompts for the name, the destination, and then the value.

The keybinding passes --event-file so the agent finds out a key was saved. On a successful save creel writes the name, destination, and action to that file, without the value:

{"name":"OPENAI_API_KEY","dest":"/…/.env","action":"added"}

pi-creel watches for that file and sends the agent a note saying which key was saved and how to reference it. The parser refuses any event that contains a value field, so a malformed or tampered file can't push a secret into the context that way.

Event files live under ~/.pi/agent/creel-events/<socket>/<session>.json. I run one tmux server per project, and tmux session ids ($0, $1) are only unique within a server, so events are keyed by socket name and then session id, and each pi session only hears saves from its own tmux session.

Source

creel is in tackle and installs with curl -fsSL https://tackle.tools/install.sh | sh -s creel. The pi extension is pi-creel in pi-extensions.