Cole Foster

Giving a coding agent an API key without pasting it into chat

Coding agents tell you not to paste keys into chat but give you nowhere else to put them. Keyrelay is a one-time local page for that handoff.

Every coding agent I use has the same reflex. A command needs an API key, and the agent tells me, correctly, not to paste it into the conversation. Then it stops. The key has to get from my password manager into the environment of a process the agent is about to start, and the agent has just ruled out the only channel we share.

The usual workaround, another terminal and an export, breaks down when the agent is headless or on another box.

So I wrote Keyrelay. The agent runs the command through it, Keyrelay prints a one-time local link, and I type the key into a page in my browser instead of into the chat.

A coding agent needs OPENAI_API_KEY, runs Keyrelay, and shows a local link. A key is entered on the one-time page, the command receives it, and the agent sees [REDACTED] in the output.

The recording above is a real claude -p session. It got the README’s agent instruction plus a note on running Keyrelay in the background, since it was headless. Its replies are unscripted. The key is fake, and the port and request id are masked. The agent’s closing remark that it can’t confirm the key is valid is accurate: the stand-in script only reports the key’s length.

What happens when the agent runs it

The agent’s side is one command:

npx keyrelay run --env OPENAI_API_KEY -- node check-key.js

Flow diagram: the agent starts keyrelay run, which prints a link. You open the link in a browser on the same machine and send the key once. The runner claims it with a bearer token, starts your command with the key in an environment variable or stdin, and returns output with exact echoes replaced by [REDACTED].

  1. Keyrelay starts a small HTTP broker bound to 127.0.0.1 on a port the OS picks, and registers one request for this command.
  2. It prints Open http://127.0.0.1:<port>/r/<id> and polls every two seconds. The agent passes the link along.
  3. I open it. The page shows the variable name and the exact command that will receive the value, so I can see that OPENAI_API_KEY is going to node check-key.js and not something else.
  4. I paste the key and press Send once. The broker accepts one submission per request.
  5. The runner claims the secret with a bearer token that only it holds. The link id alone can’t read it back. The broker deletes the entry on claim.
  6. The command runs with the key in its environment, or on stdin with --stdin. When it exits, the runner shuts the broker down.

A request expires after five minutes. Nothing is written to disk and the broker doesn’t log requests.

The redaction trade-off

The hard part is the output. Plenty of CLIs echo their configuration, and a debug flag can print a key in full. If that output goes straight back into the agent’s context, the key ends up in the transcript anyway, and keeping it out was the whole reason to do this.

So the runner captures stdout and stderr completely, replaces every exact byte-for-byte copy of the secret with [REDACTED], and only then prints them. In the GIF, check-key.js prints the key on purpose and the agent reads OPENAI_API_KEY=[REDACTED].

Capturing everything has costs I decided to accept:

  • Output arrives when the command finishes, not as it runs. A long build looks silent until it’s done.
  • Interactive commands that need a terminal don’t work.
  • Only exact copies are caught. An encoded key, or one the command sends somewhere on purpose, passes through.

Streaming with redaction is possible, but a secret split across two reads makes it fiddly to get right. Taking the whole buffer was the version I could be confident in.

One bug from this part was about signals. If the runner got SIGTERM while waiting on the command, the child could be orphaned holding the key. Now the handler raises inside subprocess.run, which kills and reaps the child, and the exit status follows the shell convention of 128 plus the signal number.

What it doesn’t protect against

Keyrelay is for an agent you already trust. The command gets the real key and can do anything with it. The agent chose the command, so a hostile agent could simply choose a hostile one. Other local users and programs that can reach 127.0.0.1 are inside the trust boundary too. What Keyrelay removes is the copy of the key in the chat transcript and the agent’s context. That copy was the thing that bothered me.

The broker does a couple of browser-facing checks. It requires the exact Host header 127.0.0.1:<port>, which blocks DNS-rebinding pages, and it rejects POSTs whose Origin header doesn’t match. It caps pending requests at 100 and secrets at 16,384 characters. None of that makes it a sandbox.

Other ways to do this

If the key is already in 1Password, op run -- <command> does this well, and other password manager CLIs like bw can be wired in similarly. Keyrelay is for when the key isn’t in a vault the agent’s machine can reach. You could also run the command yourself, but then you’ve taken over from the agent. Secret managers solve a different problem, storing and rotating keys. Keyrelay stores nothing. It covers the moment where a person has a key and a waiting process needs it once.

There’s also an optional mode for when the agent runs on another machine. A persistent broker sits behind Tailscale Serve, restricted to one Tailscale identity, and I can enter the key from my phone. The README warns against putting it behind Tailscale Funnel or any public proxy, and I’d repeat that here.

Using it

It needs Node 18+ and Python 3.9+. The npm package is a 29-line launcher around a single 387-line Python file, with no dependencies beyond the standard library. You can try it with a made-up value:

npx keyrelay run --env DEMO_TOKEN -- node -e "console.log(process.env.DEMO_TOKEN ? 'Handoff complete.' : 'No value received.')"

To make an agent use it, I put one paragraph in its instructions telling it to run credentialed commands through npx keyrelay run, show me the link, and never ask for a key in chat. The exact text is in the README.

The code is at github.com/colefoster/keyrelay, MIT on GitHub. If you think the security notes in the README overstate or understate anything, I’d like to hear it. I’d also like to know whether one secret per command run is the right granularity. I haven’t settled that.