Run shell commands on your own computer from an AI assistant, without opening an inbound port, exposing SSH, or keeping an agent runtime on the machine.
Runlet is deliberately small. A remote MCP client queues a signed command through a Cloudflare Worker; a local runner polls for it, verifies it, runs it, and writes the result back.
assistant ──MCP──▶ Cloudflare Worker ──▶ signed row in D1
▲
your machine ◀──────── runlet.mjs polls, verifies, runs, returns output
The local machine only makes outbound HTTPS requests. It needs no public IP, open port, VPN, or inbound firewall rule.
Warning
Runlet executes shell commands as your user. Anyone who has the secret connector URL can ask the Worker to queue commands for that machine. Treat the URL like a password, and only give it to assistants or clients you trust.
Runlet is a small remote-execution primitive, not an agent framework. It does one job: give an MCP-capable assistant a shell on a machine you control.
That makes it useful on its own, or as a low-level building block underneath larger agent, automation, CI, homelab, and administration workflows.
It works with MCP clients that can connect to a remote server by URL, including Claude clients, ChatGPT custom connectors where available, and other MCP hosts.
Runlet exposes four MCP tools:
| Tool | Purpose |
|---|---|
run_command |
Queue a shell command and optionally wait for its result. |
get_result |
Fetch a queued command later, optionally waiting for completion. |
detach |
Let an already-running command continue without holding the foreground queue. |
cancel |
Stop a pending command, or kill a running command and its process group. |
A short command can usually be handled in one call:
run_command("uname -a")
For a long command, start it in the background and collect it later:
run_command("make test", background=true, wait=2)
get_result(id=42, wait=30)
If a foreground command turns out to be slow, detach lets it keep running while later commands move through the queue. cancel stops a queued command before it starts, or asks the runner to terminate a running process group.
Each command starts in a fresh bash -lc shell. Shell state, including the working directory, does not persist between calls, so use cd in the command when needed.
A shell alone does not tell an assistant what is worth running. List the tools you have set up on the machine, one file per tool, in ~/.config/runlet/skills/:
ln -s ~/projects/agent-mail/skills/agent-mail/SKILL.md ~/.config/runlet/skills/agent-mail.mdEach entry is a Markdown file, a symlink to one, or a directory containing a SKILL.md. Its frontmatter should give a name: and a description:. The run_command description tells the assistant to start with runlet skills. install.sh links runlet into ~/.local/bin, and runlet --help points there too. That command prints each skill's name, its description and the path to read before using it. Where ~/.local/bin is not on the login-shell PATH, "$RUNLET" skills works instead: the runner sets $RUNLET for every command it runs. Nothing is found by scanning the disk: a tool is listed only when you put it in this directory.
- The MCP client calls the Worker through a secret URL.
- The Worker creates a nonce, signs
nonce + "\n" + commandwith HMAC-SHA256, and writes the command to D1. runlet.mjspolls its Worker over HTTPS, with a per-machine bearer token; the Worker is the only thing that touches D1.- The runner verifies the signature and rejects reused nonces.
- The runner atomically claims the row, then executes the command locally.
- Output, exit status, runner identity, and final state are written back to D1.
- The Worker returns the result to the MCP client, or the client retrieves it later with
get_result.
There is no persistent shell session between calls, and no requirement for Claude Code, tmux, SSH, or a local AI runtime.
For the complete walkthrough, see SETUP.md.
The short version for an existing Cloudflare user:
-
Create a Custom API token with these Account permissions:
- Workers Scripts: Edit
- D1: Edit
Those two are the whole of provisioning, and the installer needs them once — nothing is left on the machine afterwards. See Why only two. It no longer leaves a Cloudflare token on the machine at all: the runner reaches its queue through its own Worker with a per-machine bearer token. See Use a separate token per machine for the background.
-
Run the installer:
./install.sh
On macOS, install Homebrew first; the same
./install.shsets up dependencies and a login LaunchAgent.On Windows, run
.\install.ps1from PowerShell. It is a native installer: no WSL2, no Ubuntu. The runner there iswin\runlet.mjs(Node), started by a Scheduled Task at logon. -
Add the printed
https://.../<secret>/mcpURL to your MCP client as a remote/custom connector with no additional authentication.
The installer creates the D1 database, applies the schema, creates the Worker, generates the signing key and URL secret, stores the required Worker secrets, deploys the Worker, runs an end-to-end smoke test, writes the local config, and starts the runner as a systemd user service (Linux), a LaunchAgent (macOS), or a Scheduled Task (Windows). The runner is the same runlet.mjs on all three.
Re-running the installer is safe. Existing IDs and secrets are reused unless you deliberately rotate them.
Several machines can share one Cloudflare account. Each gets its own Worker and D1 database named runlet-<site>; the site name defaults to the hostname. Copy install.conf.example to install.conf if you want to pre-answer the installer prompts.
Runlet is intentionally capability-based and minimal. It does not try to be a multi-user authorization system.
The endpoint is /<secret>/mcp; other paths return 404. The secret is five random words from the bundled EFF short wordlist by default, roughly 52 bits of entropy. The comparison is constant-time.
Use RUNLET_SECRET_WORDS=6 during installation if you want a longer secret. Fewer than four words are refused.
To rotate a leaked connector URL, remove RUNLET_URL_SECRET from ~/.config/runlet/env and run the installer again.
Every queued command is signed with HMAC-SHA256 using a key known only to the Worker and runner. Writing directly to the D1 table is therefore not enough to make the runner execute an arbitrary row.
The runner also records seen nonces and refuses replays.
Commands run as the account that owns the runner service. Runlet has no command allowlist or sandbox of its own. Normal operating-system permissions remain the boundary.
The defaults are:
- command timeout: 600 seconds
- stored output: 60 KB
- foreground concurrency: 1
- background concurrency cap: 4
- progress copy interval: 10 seconds
- finished-row retention: 30 days
A cancellation stops future execution, but it cannot undo side effects a command already caused.
By default Runlet is serial: one foreground command finishes before the next begins. This keeps command order predictable.
Set RUNLET_PARALLEL=4 in ~/.config/runlet/env to allow up to four foreground commands at once. The runner reloads several operational settings while it is running, so many tuning changes do not need a service restart.
A single command can bypass the foreground lane with run_command(..., background=true). Background jobs have their own cap, RUNLET_BACKGROUND_MAX, which defaults to 4.
detach promotes an already-running foreground command out of the lane without killing it. The runner checks for detach and cancel requests every RUNLET_DETACH_CHECK seconds, default 3.
Runlet tries to make ambiguous states visible rather than pretending they did not happen.
- A runner restart marks that runner's in-flight rows as
error, with a note that the command may or may not have completed. - A row that remains
runningwell beyond the command timeout is markederrorby the stale-job sweep. - A command that exceeds
RUNLET_CMD_TIMEOUTbecomestimeout. - A bad signature or reused nonce becomes
rejected. - A cancelled command becomes
cancelled. - Partial output from a running command is copied to D1 periodically, so
get_resultcan show progress before completion. - Finished rows are pruned daily after
RUNLET_KEEP_DAYS; pending and running rows are never pruned.
Each claimed row records the runner name, normally the hostname. This keeps restart and stale-job cleanup scoped correctly when more than one runner uses a database.
The normal local config lives in:
~/.config/runlet/env
~/.config/runlet/relay.key
Useful runtime settings include:
| Variable | Default | Meaning |
|---|---|---|
RUNLET_POLL |
5 |
Seconds between queue polls. |
RUNLET_CMD_TIMEOUT |
600 |
Maximum command runtime in seconds. |
RUNLET_MAX_OUTPUT |
60000 |
Maximum output bytes retained per command. |
RUNLET_PARALLEL |
1 |
Foreground commands allowed to run at once. |
RUNLET_BACKGROUND_MAX |
4 |
Maximum background jobs. |
RUNLET_DETACH_CHECK |
3 |
Seconds between detach/cancel checks. |
RUNLET_PROGRESS_EVERY |
10 |
Seconds between partial-output updates; 0 disables them. |
RUNLET_KEEP_DAYS |
30 |
Days to retain finished rows. |
RUNLET_LOAD_MAX |
0 |
Hold new work above this 1-minute load average; 0 disables the ceiling. |
RUNLET_RUNNER_ID |
hostname | Name written on rows claimed by this runner. |
The Worker also supports RUNLET_WAIT_DEFAULT and RUNLET_WAIT_MAX; waits are capped at 30 seconds by the current Worker implementation so MCP clients are not left silent for too long.
Check recent commands:
runlet status
runlet status 30Follow the runner log:
journalctl --user -u runlet -fInspect the service:
systemctl --user status runletStop or start it:
systemctl --user stop runlet
systemctl --user start runletOn macOS, use launchctl print "gui/$(id -u)/org.runlet.runner" and tail -f "$HOME/Library/Logs/runlet/runner.log". See SETUP.md for stop/start commands. The Mac runs queued work while awake, online, and logged in.
The signing compatibility test keeps the runner's OpenSSL implementation and the Worker's WebCrypto implementation pinned to the same vectors:
./tests/check-signing.shRun it after changing signing code on either side. Portability checks exercise installer routing with mocked external services and real job execution, timeouts, and cancellation:
python3 tests/check-platform.pyThe runner is driven against the real Worker over real SQLite, so both sides are tested against each other: execution, timeout, cancel, detach, signature and replay rejection, the serial lane, env-file reloading, and — as real child processes — that a cancel and a shutdown both reach everything a command started. check-platform.py runs it too; run it alone while working on the runner:
node --experimental-strip-types --experimental-sqlite tests/check-runner.mjs # every case
node --experimental-strip-types --experimental-sqlite tests/check-runner.mjs basic # one case
node --experimental-strip-types --experimental-sqlite tests/check-worker.mjs # the Worker's runner APIThe installer provisions Cloudflare and cannot be exercised offline, but what it decides is pure and is where the bugs have been:
node tests/check-install.mjsThe CI matrix runs these checks on Linux and macOS, including the Mac's system Bash. A real Cloudflare installation is still required to validate the complete setup on a target Mac.
Runlet does not provide OAuth, per-client permissions, command allowlists, audit identity for which assistant queued a row, persistent shell sessions, or an agent runtime on the target machine.
Those omissions are part of the design. If you need richer client identity, session routing, or multi-user policy, see tmux-relay, the larger system from which Runlet was distilled. The two projects use the same command-signing scheme.
| File | Purpose |
|---|---|
worker/src/index.ts |
Remote MCP Worker: four tools, signing, queueing, and result retrieval. |
schema.sql |
D1 schema: one command table and its pending-row index. |
runlet.mjs |
The runner on every platform: poll, verify, execute, monitor, and report. |
install.mjs |
The installer on every platform: provisioning, config, and the service. |
install.sh |
Linux/macOS bootstrap: finds Node, hands over to install.mjs. |
runlet.service |
systemd user-service template. |
install.ps1 |
Windows bootstrap: finds Node, hands over to install.mjs. |
lib/service-systemd.mjs |
The systemd user service. |
lib/service-macos.mjs |
The macOS LaunchAgent. |
lib/service-windows.mjs |
The Windows Scheduled Task. |
install.conf.example |
Optional non-interactive installer configuration. |
SETUP.md |
Start-to-finish setup guide. |
tests/check-signing.sh |
Cross-implementation signing compatibility test. |
tests/check-platform.py |
Installer routing and real job supervision, with external services mocked. |
tests/check-runner.mjs |
The runner driven against the real Worker over real SQLite. |
tests/check-install.mjs |
What the installer decides: names, secrets, the env file, each service definition. |
tests/check-windows-install.ps1 |
The Windows bootstrap: that it finds Node and hands over. |
tests/check-worker.mjs |
The Worker's runner API, executed against real SQLite via tests/fake-d1.mjs. |
MIT. See LICENSE.