Run your tests on a fresh runner with latchkey run
latchkey run executes one command on a fresh Latchkey runner: it packs the current directory, uploads it, runs the command on a clean Ubuntu machine, streams the log to your terminal and exits with the command's own exit code. It is the answer to "does this pass in CI" without spending a commit, a branch or a GitHub Actions workflow run to find out.


The usual way to find out whether a change passes CI is to commit it, push it, and wait. That loop costs a few minutes and a run of your pipeline, and it leaves a commit behind whether or not the answer was yes. It is also the wrong shape for a coding agent, which wants a verdict it can branch on rather than a web page to open.
This page is the CLI: how to install it, what one run actually does, which flags exist, what comes back, and when running a command remotely beats pushing a branch. Every flag named here is in the latchkey help run output from version 0.3.0, run on 2026-09-20.
Install it and sign in
The CLI is an npm package and needs Node 20.18.1 or newer. The package name is scoped, the command is not: install @latchkeydev/cli and you get a latchkey executable on your PATH. The unscoped latchkey package on npm belongs to an unrelated project, so the scope matters.
latchkey login validates an API key and saves it for the machine. Keys are minted in the Latchkey dashboard under Settings, API keys, and a key needs the jobs:run scope to create and run jobs. In a script or an agent session the environment variable is usually the better choice: it touches nothing outside the process, and latchkey run authenticates before the tarball is uploaded, so a bad key costs no quota and no compute.
npm install -g @latchkeydev/cli
latchkey login
# or, without writing a config file:
export LATCHKEY_TOKEN=lk_live_...
latchkey run 'npm test'What one run actually does
The flow is fixed: pack the current directory into a tarball, create the job with the exact tarball size, upload through a presigned PUT, submit, tail the logs, exit with the job's mapped code. The packaging root is the directory you point at rather than the enclosing git repository, so running from a subdirectory ships that subdirectory.
What gets uploaded is the working tree minus three passes of exclusions: every .gitignore, then a built-in credential deny-list, then every .latchkeyignore, with the last opinion winning. That means node_modules, build output and caches named by your own ignore files do not ship, so the dependency install on the runner runs from scratch, which is the state CI is in. Files matching credential shapes are held back by default and every hold is printed, so a .env your repository never ignored does not travel with the tree.
The command tokens are joined with single spaces into one bash line and executed with -e -o pipefail. A multi-step check therefore travels best as a single quoted argument: latchkey run "pytest -k 'a or b'" rather than a series of loose tokens, because the quoting your local shell already resolved is not re-applied on the runner.
latchkey run 'npm ci && npm test'
latchkey run --env NODE_ENV=test 'npm test'
latchkey run --no-context 'node --version; python3 --version; go version'The flags, and only the flags that exist
Flags come before the command: the first token that is not one of run's own flags, or everything after --, starts the remote command line.
| Flag | What it does |
|---|---|
--size | Runner size: small (default), medium, large, xlarge |
--env KEY=VALUE | One job environment variable, repeatable, maximum 64 |
--timeout <s> | Job timeout in seconds, 30 to 7200, server default 1800 |
--detach | Submit and exit 0 without tailing; prints the job id |
--no-context | Skip packing and uploading; the command runs in an empty workspace |
--quiet | Silence progress narration on stderr; warnings and the job id still print |
--output json | NDJSON on stdout: packed, created, warning, uploaded, submitted, state, log, complete. A warning immediately before complete means log events are missing from the stream |
What comes back
Three things: the log, the exit code, and a job id you can come back to. In text mode the log streams to stdout as it is produced and progress narration goes to stderr, so a pipe carries the job output and nothing else. In json mode stdout is NDJSON, one event per line, and the complete event is the only terminal one.
The exit code is the contract. A job that ran exits with the command's own code, a cancelled job exits 130, an expired one 124, a usage error 2 and an operational error, such as a rejected key or a network failure, 1. That separation is what lets a script tell "your tests failed" apart from "the job never ran", without reading a single line of log.
Interrupting with Ctrl-C stops the tail, not the job. The job keeps running remotely, and the interrupt notice prints the commands to resume or stop it: latchkey logs <job-id> --follow and latchkey cancel <job-id>. latchkey list finds a job id that scrolled out of the terminal.
latchkey run --output json 'npm test' \
| jq -r 'select(.event == "complete") | "\(.state) \(.exit_code)"'
verdict=${PIPESTATUS[0]} # the pipe hid it; this line is the only place it survives
latchkey list --limit 5
latchkey logs cli-6f0e... --followThe machine the job lands on
A run is one job on one machine, and the machine is destroyed afterwards. The recording below is content/repro/run-tests-on-a-fresh-runner-from-your-terminal.sh on a latchkey-small runner on 2026-09-20, printing what the job sees before running a two-test fixture.
Two details in it are worth reading carefully. The workspace held zero entries because the harness passed --no-context; with packing on, that is where your tree lands. And free -g reports 7 GB on a size documented as 8 GB, because it counts what the kernel leaves available rather than what the instance is sold with, which is the same gap you see on any Linux host.
user: runner, workspace: /home/runner/.latchkey-job-gYFchJ/workspace
entries in the workspace: 0
os: Ubuntu 24.04.4 LTS, kernel 6.17.0-1019-aws
cpus: 2, memory: 7 GB, disk: 96G 43G
node v20.20.2, git 2.55.0, docker 29.7.2
..
test command exit code: 0When to use it instead of pushing a branch
Use it when the answer you want is about the code or the machine: does the suite pass with a fresh install, does the build work on Linux when you are on macOS, does this failure reproduce on a clean workspace. Use it before a push, so the pull request you open is one that has already passed somewhere clean, and use it from an agent, where an exit code is a better answer than a web page.
Do not use it for interactive work. The runner has no stdin and no TTY, so anything that prompts hangs until the timeout kills it, and a command that never exits, such as a dev server or a watch mode, bills until it is stopped. It also cannot reach your laptop: no local database, no running container, no private network.
The cost is per minute of job time, rounded up per job. At the published rate on 2026-09-20, latchkey-small is $0.0025 per minute, so ten two-minute verification runs come to five cents of runner time, drawn from the same free-minute pool as your GitHub Actions jobs on Latchkey runners.
When the failure is not your code
A remote run has the same failure modes as any CI job, and the runner treats them the same way: a registry timeout, a disk that filled or an out-of-memory kill is diagnosed and repaired on the machine where it happened, and a failing test is left alone. Self-healing CI explained has the full line between the two, and no space left on device in GitHub Actions is one of the worked examples.
A job that self-heal diagnosed but could not turn green is not lost either. latchkey watch polls for exactly that set and hands each new one to a coding agent, which is the other half of this loop: latchkey run is you asking for a check, latchkey watch is Latchkey telling you something broke.
Key takeaways
- One command, one fresh machine, one exit code: that is the whole contract.
- Your
.gitignoredecides what ships, so dependency installs run from scratch, as they do in CI. - 130 means cancelled and 124 means expired, so a script can tell a failed test from a job that never ran.
- No stdin, no TTY, no access to your laptop: anything interactive hangs until the timeout.
Frequently asked questions
Can I SSH into a CI runner to debug a failed build?
latchkey run --no-context 'df -h; free -m; node --version' answers most questions about the machine in one job, and --detach plus latchkey logs <job-id> --follow lets you leave a longer job running and come back to it.Which GitHub Actions services let you run jobs on faster hardware without self-hosting?
runs-on label such as latchkey-medium. latchkey run is a different surface on the same fleet: it is a one-off job you start from your terminal, not a replacement for the runner label in your workflow. Most teams use both, the label for the pipeline and the CLI for the loop before the push.How can I eliminate GitHub Actions queue times?
Is it possible to run a job longer than six hours?
--timeout accepts 30 to 7200 seconds, so two hours is the ceiling and 1800 seconds is the server default. A check that genuinely needs longer belongs in a workflow with a job timeout of its own rather than in a one-off run you are waiting on.