Why I Made Keylight Drivable By A Coding Agent
I wrote the Keylight CLI — Automate Your Licensing Operations to be a normal command-line tool: flags, --json, an exit code. It turned out that building it to that ordinary a standard is what made it possible to hand to a coding agent, and I want to lay out the four decisions that did it, because none of them were made with an agent specifically in mind. Each one has a cost. I’d rather name the cost than pretend the design is free.
No command ever asks a question
Every input to the CLI is a flag. Nothing waits on stdin, nothing prints “are you sure? [y/N]” and blocks. licenses create takes --customer-email and --product up front; products create takes --display-name, --key-prefix, and --support-email. You either passed what the command needs or it fails immediately and tells you what’s missing.
This is a small, almost boring rule, and it’s the one the rest of the design depends on. A command that pauses for interactive confirmation works fine at your fingertips and hangs forever under anything else — a cron line, a CI job, a coding agent running commands on your behalf. None of those have a human sitting at the terminal ready to type “y”. If the CLI ever blocked on a prompt, it would be a tool you could only use by hand, and everything I actually wanted from it — running the same command in a scheduled job, handing it to an agent, not being the one who has to remember to run it — would be off the table.
The cost: removing interactive prompts also removed the easiest place to catch a mistake. “Are you sure you want to revoke this license?” is a real safety net, and I gave it up. That’s not a detail I’m glossing over — it’s the reason the next two decisions exist. A destructive command that can’t ask “are you sure” still needs an answer to that question from somewhere. I just couldn’t let the answer come from blocking on a keypress.
Secrets come from the environment, never an argument
The CLI’s own --help says it directly, on the one command that would be most tempting to shortcut:
Store an API token, read from stdin (never a CLI argument — argv is visible
in shell history and to other users via `ps`).
auth set-token reads the token from stdin. Day to day, you don’t even do that — you export KEYLIGHT_API_TOKEN and every command picks it up from the environment. Nothing secret is ever typed as an argument, anywhere in the CLI, including integrations connect, which takes your provider’s API key, secret, and webhook signing secret as named environment variables, not flags.
The reasoning is ordinary and still worth spelling out, because it’s easy to skip when you’re the one typing the command and in a hurry: an argument lands in your shell history in plaintext, and on a shared or monitored machine, ps shows the full command line of every running process to anyone else on the box — a leaked history file or a ps aux at the wrong moment hands over the token same as if you’d posted it. An environment variable set for a session isn’t in either place. This is why the token an agent uses to run these commands on your behalf is exactly as exposed as the token you use yourself — no more, because there’s no separate, looser channel for automation. Same rule, same token, same environment variable.
The cost: there isn’t much of one here, which is rare enough to be worth saying. Reading from the environment instead of an argument doesn’t make the CLI harder to use — export KEYLIGHT_API_TOKEN=... once per session is less typing than passing a flag on every call. This decision is the one design choice in this post I’d make again without hesitation.
Exit code 2 is broken out on its own
Every other CLI failure — a malformed flag, a network error, a license that doesn’t exist — exits 1. Not-authenticated is its own code, 2, separated from that entire category on purpose:
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Everything else |
| `2` | Not authenticated, or the token lacks the required scope. Re-run `keylight login`. |
| `3` | A confirmation was denied or expired |
Code 2 covers two related situations: you aren’t signed in at all, or you’re signed in with a token that doesn’t carry the scope the command needs. Both boil down to the same instruction — go get the right credential — and both are things an agent can act on without a human. Every other failure needs a person to look at what actually happened: a license ID that’s wrong, a network that’s down, a flag that’s malformed. Exit 1 doesn’t tell you which, and it shouldn’t have to — that’s what stderr is for. But “you’re not authorized” is different in kind, not just severity, and it gets its own number so a script — or an agent — can branch on it without parsing error text: if exit_code == 2, re-authenticate; otherwise, stop and report.
The cost: none I’ve found, but that’s exactly why it doesn’t carry the rest of this post. It’s a single reserved integer. The interesting trade-offs are elsewhere.
Confirm gates on seven operations
This is where the cost of the first decision gets paid back. Seven operations block on browser approval before they run: revoke a license, export licenses, delete a key type, rotate an integration secret, read the SDK key, rotate the SDK key, and set the webhook. Run any of them and the CLI prints a URL, opens it if it can, and waits. Someone has to approve it in your browser before the command completes.
These seven are the direct answer to the problem the no-prompts rule created. I took away the interactive “are you sure?” because it doesn’t work for a scheduler or an agent — but the operations that prompt used to guard didn’t get any less dangerous. Revoking a license cuts off a paying customer. Exporting the license table walks every key out of the account at once. Rotating the SDK key breaks every shipped build still holding the old one until you release an update. Those needed a safety mechanism that survives the absence of a keypress, so the gate moved from “confirm before this call runs” to “this call doesn’t finish until a person approves it in their browser” — and the second version works whether the caller is you, a cron job, or a coding agent running the command with your token in its environment. A leaked token can list, read, and create. It cannot revoke a customer’s license, export the license table, or rotate the SDK key out from under a shipped build — those seven still need a person, every time, no matter who or what is holding the token.
The cost, and I’ll close on it: these gates block instead of failing fast. For a script, blocking is close to the worst possible behavior — it means a job that’s supposed to finish in a second can now sit open for however long it takes a human to notice a browser tab and click approve. I could have made the same seven operations return an error instantly instead, something like “this needs approval, run it again after you’ve approved it out of band” — faster, more script-friendly, and arguably more composable. I didn’t, because failing fast on a destructive operation just means the automation moves faster too, and the one property I actually wanted was that a leaked token, a bad script, or a confused agent still cannot revoke a customer’s license or export your license table without a person seeing it happen first. Blocking is worse for scripts and right for people. I’d take that trade again.
If you want to see what these decisions look like as actual working commands instead of design reasoning, the companion post is Four Licensing Jobs You Can Stop Doing By Hand — four real workflows, checked against the CLI’s own --help output, including exactly where the seven gated operations show up and what happens when a scheduled job runs into one.
Frequently asked
Why does no command ask a question?+
Because anything that waits on input hangs when nobody is watching. Every input is a flag instead, which makes the same command safe in a scheduled job and under an agent.
Why is not-signed-in its own exit code?+
It is the only failure an agent can resolve by itself. Separating it from every other error means an agent can tell "I need to authenticate" apart from "this genuinely failed" without parsing text.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free