Stop Pushing to Find Out: Meet the Latchkey CLI
latchkey run packs your working tree, runs any command on a fresh Ubuntu runner with self-healing built in, streams the logs back, and exits with the command's own exit code. What it does, why we built it, and how to hand it to your coding agent.
Kaveh Alemi
CTO and co-founder, Latchkey
There is a loop every developer knows by heart. The tests pass locally. You push. CI thinks about it for eight minutes. It goes red on a step you did not touch, because the runner has a different Node, or a clean node_modules, or is Linux and your laptop is not. You read the log, guess, push again, and wait another eight minutes to find out whether the guess was right.
The Latchkey CLI exists to delete that loop. One command runs whatever you were about to push, on a fresh Linux machine, from your terminal, and tells you the answer in the only form that matters: the exit code.
npm install -g @latchkeydev/cli
latchkey run 'npm test'
That is the whole pitch. The rest of this post is what actually happens when you run it, the things it deliberately will not do, and the part I am most excited about: it was built from day one to be driven by a coding agent, not only by a person.

What one run looks like
Here is a real session shape, narration and all. Progress goes to stderr, the job's own output goes to stdout verbatim, and the process exits with the job's exit code.

$ latchkey run 'npm ci && npm test'
Packing context from /Users/kaveh/src/api...
Packed 1,284 file(s), 2.1 MB compressed.
Created job cli-6f0e8a3c-6a3e-4a7e-9d5f-0f1c2b3a4d5e.
Uploading context (2.1 MB)...
Context uploaded.
Submitted; state: queued.
state: provisioning
state: running
added 412 packages in 9s
> api@1.4.0 test
> vitest run
✓ src/billing/proration.test.ts (18 tests) 212ms
✓ src/webhooks/github.test.ts (31 tests) 640ms
✓ src/runners/scaleup.test.ts (12 tests) 88ms
Test Files 12 passed (12)
Tests 143 passed (143)
Duration 4.21s
Job cli-6f0e8a3c-6a3e-4a7e-9d5f-0f1c2b3a4d5e succeeded (exit 0).
$ echo $?
0
Five things happened in those few seconds, and each one is a design decision:

- Pack. The directory you are standing in is walked with gitignore semantics and tarred deterministically. Not the enclosing repository, the directory. Run from
services/apiand the job seesservices/api. - Upload. The CLI declares the exact compressed size to the Jobs API, which mints a presigned PUT bound to that size. The tarball never touches our API servers.
- Boot. A fresh Ubuntu 24.04 VM comes up for this job and only this job. It is the same image our managed GitHub Actions runners boot from, so "run it the way CI will" is literal.
- Stream. Stdout and stderr come back as an ordered ledger of chunks. Ctrl-C stops the tail, never the job; the notice tells you how to resume or cancel.
- Verdict. The process exits with the command's own exit code.
0means the suite passed on a clean Linux machine. Nonzero means it did not. Nothing about the log text has to be parsed to know which.
That last point sounds small. It is the entire reason the tool is useful to an agent, and we will get there.
Why "fresh" is the feature
Most "run this remotely" tools optimise for warm state: a persistent box, a cached checkout, a reused node_modules. That is fast and it is also exactly how you end up with a machine that passes for reasons nobody can reproduce.
Every latchkey run job gets a cold-started instance of its own. No filesystem, no cache, no leftovers from your previous job a minute earlier. Anything your .gitignore names (build output, node_modules, .venv, target/) does not ship, so dependency installs run from scratch. If it passes, it passes on a machine that had never seen your project before.
The image is the one our GitHub Actions runners use: several concurrent versions of Node, Python, Go, Java, Rust, Ruby, PHP, .NET, Swift, Kotlin, Haskell and Julia, plus Docker, headless browsers and drivers, and the usual build tooling. Versions move with every image rebuild, so rather than guess, ask the machine:
latchkey run --no-context 'node --version; python3 --version; go version'
latchkey run --no-context 'cat /opt/latchkey/base-manifest.json'
--no-context skips packing entirely and runs the command in an empty workspace. It is the right tool for probes like these and costs you nothing to upload.
Four sizes, chosen per job with --size:
| Size | vCPU | Memory | Disk | Price |
|---|---|---|---|---|
small (default) |
2 | 8 GB | 100 GB | $0.0025/min |
medium |
4 | 16 GB | 100 GB | $0.0050/min |
large |
8 | 32 GB | 100 GB | $0.0100/min |
xlarge |
16 | 64 GB | 200 GB | $0.0200/min |
Each step up doubles everything including the rate, so moving up pays for itself when it at least halves the job. A big TypeScript or Rust build often does; a lint job never will.
latchkey run --size large --timeout 3600 'npm ci && npm run build && npm test'
What ships, and what never does
Uploading a working tree is a trust problem before it is a bandwidth problem. A tree upload will cheerfully ship the .env your .gitignore never mentioned. So the packer applies three passes, and the last pass to express an opinion wins:

- Every
.gitignorein the tree, root first. - A built-in credential deny-list: private keys,
.envfiles, cloud credential files and directories. Matched case-insensitively, so an uppercase.PEMis still a key. Every hold is printed. - Every
.latchkeyignore, the documented escape hatch, which outranks both of the others wherever it sits.
The ordering is the point. A .gitignore three directories down cannot re-include a private key. If you genuinely need to ship one, you say so explicitly and loudly:
# .latchkeyignore
!certs/ca.pem
and the packer prints a warning naming the path and the pattern that brought it back. For secrets the job actually needs, use --env instead:
latchkey run --env DATABASE_URL="$DATABASE_URL" --env NODE_ENV=test 'npm run test:integration'
A few more rules worth knowing before your first run. .git never ships, so pass a SHA through --env if the command needs one. Size ceilings are 200 MB compressed, 1 GiB uncompressed, 250,000 entries, and a breach fails the pack locally with the largest paths named, before anything is uploaded or billed. And if your directory has no .gitignore of its own (a scratch project, or a subdirectory of a monorepo whose ignore file lives at the root), nothing filters node_modules/ or __pycache__/ for you; a .latchkeyignore at the packaging root fixes that in one line each.
Self-healing, even for a one-off job
The command runs under the same self-heal wrapper our CI steps get. Every nonzero exit is sent to an on-runner sidecar for diagnosis, and you see the round-trip in the log stream as [latchkey-bash-wrapper] lines.
Most of the time the diagnosis is a no-op, and that is the shape you will meet most often: two lines, and the original exit code stands.
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=260s ...)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
ok and http=200 describe the HTTP round-trip, not your job. A failing test suite next to those lines was diagnosed and left alone, because a test that catches a real bug is doing its job and we are not in the business of making it green.
The interesting case is when the failure is the environment's fault, not yours:
$ latchkey run 'jq -r .version package.json && npm test'
...
bash: jq: command not found
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=260s ...)
[latchkey-bash-wrapper] installed package: jq
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
1.4.0
> api@1.4.0 test
...
Job cli-2b9d... succeeded (exit 0).
The wrapper named what was missing, installed it, and re-ran the command. The job's exit code reflects the final attempt, so this is a pass. The right follow-up is to fix it at the source (install jq in your own setup step) so the next run does not need the heal. When a heal does not work, the original failure's exit code stands. Nothing is ever quietly swallowed.
Built for agents first
Here is the part I care about most. At Latchkey, most of the code is written by agents, and CI is the quality gate. Daniel wrote up the reasoning behind that; the short version is that a test suite does not get tired at file 40, and an agent that can run the suite on a clean machine before it declares victory is an agent you can trust with more.
So the CLI was designed as a verification loop an agent can close on its own: run the check remotely, read the exit code, fix, re-run. Three things make that work.
The exit code is the verdict, and the contract is written down. succeeded exits with the command's code (normally 0). failed exits with the command's code, or 1 when that is unrecorded. cancelled is 130, expired is 124. A usage error is 2 and an operational error (auth, network, API) is 1. An agent never has to parse prose to know whether the build passed.
--output json turns the stream into NDJSON. One event object per line on stdout and nothing else; the human narration stays on stderr.

$ latchkey run --output json 'npm test'
{"event":"packed","compressed_bytes":184320,"file_count":142,"deny_listed":[]}
{"event":"created","job_id":"cli-6f0e8a3c-6a3e-4a7e-9d5f-0f1c2b3a4d5e"}
{"event":"uploaded"}
{"event":"submitted","job_id":"cli-6f0e8a3c-...","state":"queued"}
{"event":"log","index":0,"content":"\n> app@1.0.0 test\n> vitest run\n..."}
{"event":"state","state":"running"}
{"event":"log","index":1,"content":"Test Files 12 passed (12)\n"}
{"event":"state","state":"succeeded"}
{"event":"log","index":2,"content":"Duration 4.21s\n"}
{"event":"complete","job_id":"cli-6f0e8a3c-...","state":"succeeded","exit_code":0,"failure_reason":null}
Log chunks are dense, zero-based, sent once, and cut at byte boundaries rather than line boundaries, so you concatenate content with no separator. state events are advisory and arrive late; only complete ends the stream. All of this is specified, with the ordering rules and the gotchas, because an agent reading a spec is more reliable than an agent guessing.
One gotcha is worth repeating here because everyone hits it once: piping the stream into a parser destroys the verdict, since $? belongs to the parser. Redirect to a file first, or read ${PIPESTATUS[0]} on the very next line.
latchkey run --output json 'npm test' > events.ndjson
verdict=$?
The package ships its own skill file. SKILL.md is in the npm tarball: the full flag lists, the event vocabulary, the exit-code contract, the context rules, and how self-heal shows up in the stream. Point an agent at that one file and it can drive the whole surface. We did not write documentation and then add an agent mode; the agent reference is the documentation.
The other half of the loop: latchkey watch
latchkey run is you asking Latchkey to check something. latchkey watch is Latchkey telling you something broke.
It polls the failures that self-heal diagnosed but could not turn green, across your GitHub Actions runs and your CLI jobs, and hands each new one to a coding agent. The first poll only records what is already failing, so starting it next to a backlog does not launch an agent per historical red build. After that, each new failure is handed off exactly once.

latchkey watch --once # what is failing right now, exit 0
latchkey watch # poll every 15s, hand each new failure to claude
latchkey watch --agent 'claude -p' # or any agent that takes a prompt as its last argument
latchkey watch --no-spawn --output json # just the events, start nothing
What the agent receives is deliberately tiny: a prompt naming the attempt_id and telling it to call get_failure_bundle on the Latchkey MCP server. The failure text itself never rides in the prompt. Log-derived prose from a fork's pull request is text a stranger wrote, and it does not belong in an agent's opening instruction. The agent fetches the bundle (root cause, failing file, secret-stripped logs, what self-heal already tried and why it stood down) as data, over MCP, and diagnoses from there.
Connecting the agent is one line; the exact command for your workspace is in the dashboard under Settings:
claude mcp add --transport http latchkey \
https://latchkey.dev/mcp \
--header "Authorization: Bearer lk_live_YOUR_KEY"
Put the two halves together and you get something I did not expect to enjoy this much: a red build appears, an agent picks it up, verifies its fix with latchkey run on a clean machine, and opens the PR. I read the diff and the green check. That is the review.
Patterns that stuck
A few ways we and early users actually use it day to day.
A pre-push gate. Runs the exact CI command before the push leaves your machine, and blocks the push on a nonzero exit.
# .git/hooks/pre-push (or a husky pre-push script)
#!/usr/bin/env bash
exec latchkey run --quiet 'npm ci && npm run lint && npm test'
Reproducing a CI-only failure. A failure that happens in CI and not locally almost always depends on a clean workspace, a fresh install, or Linux. A job gives you all three, and you can iterate on it without a single push.
latchkey run --env CI=true 'npm ci && npx playwright install --with-deps && npm run test:e2e'
Fire and forget, check later. --detach submits and prints the job id; logs --follow picks the verdict up later with the same exit-code mapping.
job=$(latchkey run --detach --size large 'cargo test --release')
# ... do something else ...
latchkey logs "$job" --follow; echo "exit: $?"
latchkey list --limit 5
Batch, do not fan out. Each job boots its own VM, so one job that runs lint, build and test is cheaper and faster than three jobs that each do one.
latchkey run 'npm ci && npm run lint && npm run build && npm test'
Testing on Linux from a Mac. The number of "it is a case-sensitive filesystem" bugs we have caught this way is embarrassing and I am not going to give you the number.
What it costs, honestly
Jobs are billed per minute at the size's rate, rounded up per job, against the free minutes every plan includes: 2,000 a month on Developer, 4,000 on Launch, 6,000 on Scale. The billed window opens when your command starts, not when the VM boots. A short test run on a plan with free minutes left costs nothing.
Two things to budget for. A fresh VM boots for every job, so expect some provisioning time before your command starts. And a failing run costs more wall clock than a passing one, because every nonzero exit buys a self-heal diagnosis round-trip inside the billed window; on measured jobs, a command exiting nonzero took roughly 55 seconds from start to completion where the equivalent passing command took about one. The round-trip is bounded, but for a tool whose whole purpose is iterating on failures, that is the cost that matters, and I would rather you read it here than on the invoice.
Job creation is limited to 120 per hour per organisation, and declared context bytes count against a rolling 3-day budget of 20 GiB.
What it is not
Saying this up front saves everyone a support ticket.
- Not interactive. No stdin, no TTY. Anything that prompts hangs until the timeout kills it.
- Not for things that never exit. A dev server or a watch mode runs until the timeout fires, billing the whole way.
- Not on your network. The runner cannot reach your local database, your running containers, or your private VPC. It is a fresh VM on the public internet with a standard CI toolchain.
- Not incremental. Every job uploads its own context and starts cold. That is the feature.
- Not a git client.
.gitnever ships. Pass the SHA.
Get started
- Install it:
npm install -g @latchkeydev/cli(Node 20.18.1 or newer), ornpx @latchkeydev/cli run 'npm test'with no install. Standalone Linux and macOS binaries are on the GitHub releases page. Install the scoped name: the unscopedlatchkeypackage on npm is an unrelated project. - Mint an API key in the dashboard under Settings, API keys, with the
jobs:runscope. latchkey login(orexport LATCHKEY_TOKEN=lk_live_...for an agent).latchkey run 'npm test'from the directory you want the job to see.
The CLI documentation has the full reference, and latchkey help <command> has it offline. If you build something with it, I would genuinely like to hear about it: kaveh@latchkey.dev, or open an issue on the repo.
Stop pushing to find out.