CLI reference
The runspec binary ships with both runspec (Python) and runspec-node.
It scaffolds new projects, validates configs, smoke-tests runnables for CI,
emits agent schemas, runs a live MCP server, and executes tools on remote hosts.
runspec <command> [options]
It's runspec all the way down
Both CLIs define their own command surface in runspec.toml. When you
run runspec --help, you're seeing the same renderer your CLI gets —
the same argument validation, the same examples field, the same
inference. The bundled specs live at:
packages/python/runspec/runspec/runspec.tomlpackages/node/src/runspec.toml
Read either as a worked example of a non-trivial spec: subcommands,
examples, position, rest, choice, short flags, and a [config]
block that suppresses autonomy display on the developer-facing menu.
Top-level options
| Option | Description |
|---|---|
-V, --version |
Print package version and exit |
-h, --help |
Show help (also shown when no command is given) |
runspec init
Scaffold a new runnable: write runspec.toml and a working code stub.
runspec init [--name <name>] [--lang <lang>] [--example] \
[--write-project] [--project-dir <dir>] [--force]
| Flag | Default | Description |
|---|---|---|
-n, --name <name> |
current directory name | Name for the initial runnable |
--lang <lang> |
python (Python CLI) / typescript (Node CLI) |
Code stub language: python, typescript, javascript |
-e, --example |
off | Generate worked example runnables (clean + scan) with confirmation prompts, conditional deletion, autonomy escalation |
-w, --write-project |
off | Python CLI only. Also generate pyproject.toml, __init__.py, .gitignore, and CLAUDE.md in the parent directory |
-d, --project-dir <dir> |
parent directory | Where --write-project lays down its files |
--force |
off | Bypass the cwd safety check (don't refuse if pyproject.toml is already present) |
If runspec.toml already exists, init exits with an error — it will not
overwrite or merge. If a code stub already exists, it is skipped with an
informational message.
What gets written
greet/
runspec.toml
greet.py # or greet.ts / greet.js depending on --lang
sandbox/
runspec.toml # defines clean + scan runnables
clean.py # destructive op with autonomy/confirmation
scan.py # read-only op marked autonomy = "autonomous"
. # parent of cwd, by default
├── pyproject.toml # [project.scripts] wired; [tool.mypy] mypy_path
├── .gitignore
├── .pre-commit-config.yaml # runspec stubs hook (keeps types fresh)
├── CLAUDE.md # project memory for Claude Code
├── typings/
│ └── runspec/__init__.pyi # placeholder — "run runspec stubs"
└── greet/
├── __init__.py
├── runspec.toml
└── greet.py
--lang typescript and --lang javascript generate a .ts / .js stub
respectively. The Node CLI supports the same --lang values but python is
not in its menu (use the Python CLI to scaffold a Python project).
parse() typing is wired from the start. init drops a placeholder
typings/runspec/__init__.pyi, so before you've generated anything your editor
already says run runspec stubs (a typed arg access is a clear error, not a
silent Any). Once the package is installed, the next runspec local
auto-upgrades that placeholder to real types; afterwards runspec local nudges
when the stub is stale, and the --write-project pre-commit hook regenerates it
when a runspec.toml changes. See runspec stubs.
Examples (from the bundled spec)
runspec init # use cwd name as the runnable name
runspec init --name deploy # scaffold a runnable called 'deploy'
runspec init --example # generate worked example (clean + scan)
runspec init --example --write-project # also generate pyproject.toml
runspec init --write-project --project-dir /tmp/myproject # write project files to a specific path
runspec init --name myapp --lang typescript # use TypeScript code stub
runspec local
List all runspec-aware runnables installed in this environment, with inline validation. Also emits tool schemas for agents.
runspec local [--format <fmt>] [--runnable <name>]
| Flag | Default | Description |
|---|---|---|
-f, --format <fmt> |
text |
Output format: text, json, mcp, openai, anthropic |
-r, --runnable <name> |
all | Filter to one runnable by name |
Discovery uses importlib.metadata (Python) or node_modules/.bin/
(Node) — no filesystem walk, no guessing. Runnables must be installed
(pip install -e . / npm install) to appear.
Text output (default)
Shows installed runnables grouped by config file, with autonomy levels and any config issues inline:
runspec local
Found 3 installed runnable(s):
/home/user/project/mypkg/runspec.toml
deploy Deploy the application to an environment [confirm]
backup-logs Back up application logs to S3 [manual]
process Process input files [confirm]
Issues:
ℹ 'process' autonomy not declared — defaulting to 'confirm'
✗ 'process.api-key' is required but has no description
Run 'runspec local --format mcp' to emit MCP tool schemas.
Exits with code 1 if any errors are found — usable as a CI gate:
# .github/workflows/ci.yml
- name: Validate runspec
run: runspec local
Schema output
Emit all installed runnables as tool schemas for agent frameworks:
runspec local --format mcp # MCP tool list (the standard schema format)
runspec local --format openai # OpenAI tool calling format
runspec local --format anthropic # Anthropic tool use format
runspec local --format json # raw discovery JSON for tooling
Target a single runnable:
runspec local --format mcp --runnable deploy
Every emitted tool carries:
| Field | Source |
|---|---|
name |
Runnable name |
description |
description field |
x-autonomy |
Effective autonomy level |
x-autonomy-reason |
autonomy-reason if declared |
x-output |
output field ("text" if not declared) |
inputSchema |
JSON Schema for all args |
An argument that declares its own autonomy additionally carries it as
x-autonomy on its property inside inputSchema, so schema consumers can
apply per-arg escalation (most restrictive level wins when the arg is
provided) without reading the TOML.
runspec stubs
Generate precise per-runnable types for parse(), so your editor and type
checker know each runnable's args. Scans the installed venv (same discovery as
runspec local) and writes a self-contained stub to
typings/runspec/__init__.pyi at the project root.
For a step-by-step walkthrough and the everyday workflow, see Typed args (
runspec stubs).
runspec stubs [--check] [--off]
| Flag | Default | Description |
|---|---|---|
--check |
off | Verify the stub is up to date; write nothing. Exit 1 if missing/stale. |
--off |
off | Remove the stub, restoring the default Any-typed arg access. |
With the stub in place, parse("<name>") is typed precisely at the call site —
no annotation, no cast:
from runspec import parse
args = parse("deploy")
args.workers.value # int
args.format.value # Literal["json", "yaml"]
args.wrokers # error: "_DeployArgs" has no attribute "wrokers"; maybe "workers"?
A typo or an arg you haven't regenerated for is a hard error rather than a
silent Any. The runnable's metadata surface (args.runspec_*, args.get(...),
name in args) is preserved, and subcommand args are exposed as the superset
across the command tree (read presence-dependent ones with args.get(...)).
Editor setup. pyright / Pylance (VS Code) read typings/ by default — no
config. For mypy, point it at the directory:
# pyproject.toml
[tool.mypy]
mypy_path = "typings"
Keeping it fresh. The stub is generated from runspec.toml, so regenerate
when an interface changes — the same reflex as re-locking dependencies. Guard it
in CI or a pre-commit hook so a stale stub can't slip through:
# .github/workflows/ci.yml
- name: Check runspec stubs are current
run: runspec stubs --check
A stale stub never affects runtime — parse() reads the toml, not the stub — so
the worst case is an out-of-date hint until you rerun runspec stubs.
runspec test
A CI smoke gate. Where runspec local validates metadata (descriptions,
autonomy, entry-point registration), runspec test proves each runnable
actually works — it's built for deployment packages that bundle runnables
from different authors, some of whom may not write tests.
runspec test [--format <text|json>] [--runnable <name>]
For every discovered runnable it runs two checks:
- Spec smoke (in-process) — drives
ParseHarness.smoke():--helpworks for the runnable and every subcommand,require-commandis enforced where declared, and omitting a required arg exits non-zero. This proves therunspec.tomlis coherent and the parse wiring is sound. - Entry-point
--help(subprocess) — executes the installed binary with--helpand expects exit 0. This imports and runs the runnable's own code, catching import errors, syntax errors, and a wrong runnable name passed toparse()— failures the spec smoke can't see because it never loads that code. A missing binary or a 30s timeout counts as a failure.
It exits 1 if any runnable fails either check — drop it straight into CI:
# .github/workflows/ci.yml
- name: Smoke-test all runnables
run: runspec test
Output
Text (default) prints a ✓/✗ line per runnable and a final tally; failures
show the entry-point error and the most informative stderr line:
Tested 3 runnable(s):
✓ deploy spec ok, --help exit 0
✗ backup spec ok, --help failed
entry point: exit 1
stderr: ModuleNotFoundError: No module named 'boto3'
✗ scan spec failed
spec: KeyError: runnable 'scan' not in ...
Summary: 1 passed, 2 failed (3 total)
--format json prints a machine-readable object (results[] + summary) to
stdout regardless of pass/fail, then sets the exit code — so CI can both gate
and parse:
runspec test --format json | jq '.summary'
{
"results": [
{"runnable": "deploy", "source": "/abs/runspec.toml", "ok": true,
"checks": {"spec_smoke": {"ok": true, "checks": ["--help", "..."]},
"entry_point": {"ok": true}}}
],
"summary": {"total": 1, "passed": 1, "failed": 0}
}
Discovery scope
The set of runnables tested matches what runspec local discovers:
- Python tests every installed
runspec-dependent package in the venv (importlib.metadata) —pip install(orpip install -e .) the packages you want covered. - Node tests every runnable in the venv-shaped folder's single
runspec.toml. A Node deployment folder is the unit (see Deploying a Node runnable folder); dependency specs undernode_modulesare not runnables and are not tested.
runspec serve
Start a live MCP stdio server for the current environment.
runspec serve
No arguments. Reads the runspec config (walking up to find runspec.toml),
then starts a Model Context Protocol
server over stdin/stdout. Every installed runnable is exposed as an MCP
tool. When an agent calls a tool, serve runs the corresponding script and
streams the output back.
Zero extra dependencies — the protocol is JSON-RPC 2.0 newline-delimited over stdin/stdout (plain stdlib).
Discovery
| Pack | How runnables are found |
|---|---|
| Python | importlib.metadata — installed packages that declare runspec as a dependency |
| Node | Filesystem scan of cwd and subdirectories, plus node_modules/.bin/ |
For Python, pip install -e . is the convention for making a package
visible during development.
Subcommand flattening
Runnables that declare subcommands ([<name>.commands.<sub>]) are
automatically expanded into flat MCP tools with underscore-joined names:
[portal-api.commands.orders.commands.get-list]
description = "List orders"
autonomy = "confirm"
Becomes the MCP tool portal-api_orders_get-list, with command
[portal-api, orders, get-list, …args] assembled at invocation time.
Host filtering
If a runnable declares a hosts field, serve checks the current machine's
hostname at startup. Tools that don't match are excluded from the MCP tool
list. See Jump Hosts for the remote-execution model.
Environment variables on every invocation
Before running a script, serve injects:
RUNSPEC_<RUNNABLE>_ARG_<NAME>=<value> # for every arg declared in the spec
RUNSPEC_AGENT=1 # always, so the runnable can branch
RUNSPEC_CONFIG=/abs/path/to/runspec.toml # so parse() finds the spec in the subprocess
Names are uppercased and hyphens become underscores (runnable backup-logs,
arg dry-run → RUNSPEC_BACKUP_LOGS_ARG_DRY_RUN); flag/bool values are 0 or 1;
multiple = true lists are newline-delimited. Defaults are always set even
when the caller didn't pass the arg.
Connecting to Claude Desktop
{
"mcpServers": {
"analytics-pipeline": {
"command": "/home/user/envs/analytics-pipeline/bin/runspec",
"args": ["serve"],
"cwd": "/home/user/projects/analytics"
}
}
}
On Windows:
{
"mcpServers": {
"analytics-pipeline": {
"command": "C:\\envs\\analytics-pipeline\\Scripts\\runspec.exe",
"args": ["serve"],
"cwd": "C:\\projects\\analytics"
}
}
}
See Agent Integration for autonomy gating, schema fields, and
the RUNSPEC_AGENT pattern.
runspec jump
List tools on a remote host or run a tool via SSH+MCP.
runspec jump [--bin <path>] [--format <fmt>]
<host> [<tool>] [-- <tool-args>...]
| Flag / Argument | Description |
|---|---|
<host> |
SSH connection string — user@host or just host (positional) |
<tool> |
Tool to run on the remote (positional) |
--bin <path> |
Path to runspec binary on the remote. Basename must be runspec. Env fallback: RUNSPEC_JUMP_BIN. Default: runspec (relies on remote PATH). |
-f, --format <fmt> |
Output format for tool listings: text (default) or json |
-- <tool-args>... |
Args forwarded to the remote tool (rest-type) |
Discover what's available
runspec jump user@prod.example.com # list tools available on the remote
runspec jump user@prod.example.com --format json
Run a tool
runspec jump user@prod.example.com deploy -- --env prod
runspec jump user@prod.example.com backup-logs -- --days 14 --dry-run
Everything after -- is forwarded to the tool on the remote.
Specify the remote binary
If runspec isn't on the remote shell's PATH (SSH runs non-login shells):
runspec jump user@prod --bin /opt/venv/bin/runspec deploy -- --env prod
# or
export RUNSPEC_JUMP_BIN=/opt/venv/bin/runspec
runspec jump user@prod deploy -- --env prod
How it works
- SSHes to
<host>withBatchMode=yes(stdin/stdout are the JSON-RPC channel). All connection options (port, key, ProxyJump, etc.) come from~/.ssh/config. - Starts
runspec serveon the remote via the resolvedbinpath. - Speaks MCP JSON-RPC over stdin/stdout.
- Invokes
tools/list(without<tool>) ortools/call(with<tool>and tool args). - Streams the response back to your terminal in real time; stderr from the remote is mirrored live.
- Exits with the remote process's exit code.
See Jump Hosts for ~/.ssh/config examples, the trust model,
and the run_as / become_method / become_flags privilege-escalation matrix.
runspec env
Show the resolved .runspec_env deployment file for a runnable — the path,
where that path was resolved from, and the variables it would inject at run
time. Useful for debugging deployment-time variable injection without running
the tool.
runspec env # the default .runspec_env
runspec env deploy # the file that applies to the 'deploy' runnable
Resolved .runspec_env for 'deploy':
Path: /opt/app/.runspec_env
Source: [config] runspec_env
DEPLOY_SERVER = web-01
DEPLOY_TOKEN = ****
The resolution order (per-runnable runspec_env → [config] runspec_env →
default location) is described under
Environment variable fallbacks and
the .runspec_env section of the format reference.
runspec audit
Record, list or verify audit events — the venv's append-only,
hash-chained ledger ({venv}/logs/audit.jsonl), also copied to syslog / the
Windows event log. The Node pack has the same command (npx runspec audit,
runspec-node 0.48.0+), over {project}/logs/audit.jsonl.
runspec audit record payment.refund --outcome succeeded -t order=A-1043 -d amount=12.50
runspec audit record deploy.start --strict # exit 1 if it couldn't be recorded
runspec audit list # newest 50 events
runspec audit list --json -n 500 # one JSON record per line
runspec audit verify # check the hash chain (exit 1 if broken)
| Option | Description |
|---|---|
record EVENT |
Event name (lowercase, dotted) |
-o, --outcome |
Outcome word (started, succeeded, failed, …) |
-t, --target key=value |
What was acted on (repeatable) |
-d, --detail key=value |
Extra context (repeatable) |
--strict |
Exit 1 if no sink took the event |
-f, --file |
Ledger file to list/verify, or directory to record into |
-n, --limit |
Events to show for list (default 50) |
--json |
JSON output |
runspec mock
Record, list, play back or check mock responses. With
RUNSPEC_MOCK=1 set, a runnable checks its arguments and then prints a
recorded response instead of running its code.
runspec mock record jira -- get-issue --key OPS-101 # run it for real, save the response
runspec mock list # what is recorded, where, how big
runspec mock play jira -- get-issue --key OPS-101 # same as RUNSPEC_MOCK=1 jira get-issue --key OPS-101
runspec mock check # exit 1 if a response no longer fits its runnable
| Option | Description |
|---|---|
record RUNNABLE -- ARGS |
Run the runnable as an agent would and save its output and exit code |
play RUNNABLE -- ARGS |
Play back a recorded response |
list [RUNNABLE] |
List recorded responses (default: every installed runnable) |
check [RUNNABLE] |
Check recorded responses against the current spec |
--to package\|venv\|DIR |
Where record saves (default: the package's mocks/ for a source checkout, else the venv) |
--redact REGEX |
Replace matches with «redacted» before saving (repeatable) |
--note TEXT |
Note saved with the response |
--timeout N |
Seconds record waits for the runnable (default 300) |
--strict |
play: exact argument match only |
--json |
JSON output (list, check) |
runspec deny
Manage the venv denylist — a kill switch that disables runnables installed
in this virtual environment, by name, user, and/or hostname. Edits
{sys.prefix}/runspec_denylist.toml, which parse() reads on every run, so the
effect is uniform across CLI runs, entry points, and runspec serve.
runspec deny add --runnable deploy # disable one runnable (all users/hosts)
runspec deny add -r deploy -u svc-batch -H 'prod-*' # disable deploy for svc-batch on prod hosts
runspec deny all --reason "change freeze" # disable every runnable in this venv
runspec deny list # show the current mode + rules
runspec deny mode warn # warn instead of refusing (enforce|warn|off)
runspec deny remove 2 # remove rule 2 (numbered by `deny list`)
runspec deny remove -r deploy -H 'prod-*' # remove the rule added with these same lists
runspec deny enable # undo `deny all`; keep the rules
runspec deny clear # remove the denylist — re-enable everything
--runnable/--user/--host accept shell-style globs (*) and may be
repeated; an omitted dimension means "all". runspec deny is a core subcommand,
not a runnable, so it can never disable itself — runspec deny clear is the
guaranteed re-enable path on any host. See the denylist guide for
the file format and matching rules.
runspec verify
Check installed packages against the hashes pip recorded when it installed them
(RECORD). Every run already does this for its own package and what it depends
on directly, and records a changed file as modified; verify is the on-demand
version.
runspec verify # every runnable package in this venv + its direct dependencies
runspec verify runspec-linux # one package, or the package that ships a runnable
runspec verify --all # every package in the venv
runspec verify --json # structured result
Prints each package's version, where it came from (index, folder,
archive, vcs, editable) and its status (ok, modified, skipped for an
editable install, unverifiable when pip recorded no hashes), with any changed
or missing files. Exits 1 when anything is modified. Records nothing. See
Installed-code integrity.
The Node CLI's runspec verify checks the project folder and its direct
dependencies instead, and takes --snapshot to retake the first-run snapshot
of packages that ship no hash list. runspec manifest (Node only) writes that
list, runspec-files.json, for a package's prepack script. See
Node runnables.
runspec rebrand
Republish a runnable package wheel under your own distribution name — for a
private index such as Nexus — without changing a line of it. Only packaging
metadata is rewritten (distribution name, *.dist-info directory, re-pointed
dependencies, RECORD hashes); code, runspec.toml, entry points and the
import package stay byte-identical.
runspec rebrand runspec-linux==0.14.0 --name mycorp-linux # download via pip's index config
runspec rebrand runspec_windows-0.9.0-py3-none-any.whl --name mycorp-windows
runspec rebrand runspec-linux --name mycorp-linux \
--rename-dep runspec-linux-core=mycorp-linux-core -o dist/ # re-point a core dependency
| Option | Meaning |
|---|---|
<source> |
A .whl path, or a requirement to pip download (no deps, wheels only) |
--name, -n |
New distribution name |
--rename-dep OLD=NEW |
Re-point a dependency (repeatable; one that doesn't apply is reported and ignored) |
--set-version |
Override the version — normally leave upstream's |
--out, -o |
Output directory (default dist) |
See White-label runnable packages for the full workflow, including a scheduled CI job that mirrors each new upstream release.
runspec logs
View, prune, and compact per-invocation audit logs — the read and maintenance
side of [config.logging] store = "per-run" (see
Logging → Per-invocation files).
In per-run mode each invocation writes its own file with no in-process
rotation, so runspec logs is how you read them as one stream and how you do
retention.
View — many files, one stream
runspec logs deploy # merged, timestamp-sorted, one record per line
runspec logs deploy --follow # live tail across invocations
runspec logs deploy --since 1h # only the last hour
runspec logs deploy --user alice # only runs alice launched
runspec logs deploy --run 3f2a9c1e # one invocation (its id, or a unique prefix)
runspec logs deploy --json # raw JSON lines (for jq)
Each text line shows the first 8 characters of its run id (run=3f2a9c1e);
--run accepts that prefix, or any longer one, as long as it names one run of
that runnable. A prefix shared by several runs is refused with their full ids.
runspec logs with no runnable lists the runnables that have logs.
The view is a plain stdout stream that composes with the usual tools — it's
TTY-aware and SIGPIPE-clean, so piping to grep/head/less just works, and
process substitution makes the files look like a single file to anything that
wants a path:
runspec logs deploy | grep ERROR | less
awk '$2=="WARNING"' <(runspec logs deploy --since 1h)
Status — what's on disk
runspec logs status # per-runnable file count + disk usage
runspec logs status deploy # just one runnable
runspec logs status --json # machine-readable inventory
status is the read-only inventory the runspec-console Logs tab reads to
show per-venv usage before offering compact/prune. The --json shape is
{dirs, runnables:[{runnable, per_run_files, archives, total_bytes, oldest,
newest}], total_bytes, total_files}. Like every runspec logs verb it runs
locally or over SSH, so the console drives one uniform interface against every
venv it manages.
Prune / compact — retention by hand
There is no automatic rotation in per-run mode, so an operator schedules
cleanup. Both verbs default to all runnables in the venv when none is
given, take --dry-run to preview, and never touch a single-mode
{runnable}.log — only per-run files and archives.
runspec logs compact --older-than 7d --gzip # roll runs >7d old into a dated .gz archive
runspec logs prune --older-than 90d # delete files older than 90 days
runspec logs prune deploy --max-files 50 --dry-run # preview keeping newest 50
runspec logs prune --max-total-size 5GB # cap total size (oldest deleted first)
Which directory — --scope. runspec reads logs from two places: {sys.prefix}/logs
(this venv's own dir) and, only when the venv dir isn't writable, ~/logs (the
running user's home fallback, shared by every venv that fell back there). To avoid
one venv's prune deleting another venv's files in that shared ~/logs, the
destructive verbs are venv-scoped by default: prune/compact act on
{sys.prefix}/logs only unless you widen them. status/view still show the
union. Override with --scope:
runspec logs prune --older-than 90d # this venv's dir only (default)
runspec logs prune --older-than 90d --scope home # the shared ~/logs fallback only
runspec logs prune --older-than 90d --scope all # both (the pre-0.56 behaviour)
Add --json to either verb (with or without --dry-run) to get a structured
result object instead of text — what the console parses to render a preview and
then the applied outcome.
An archive is one file per runnable per day the runs finished
({runnable}.archive.{YYYYMMDD}.log[.gz]), and its modification time is the
newest run in it (runspec >= 0.80.0), so prune --older-than removes a day's
runs together once they are old enough.
Retention policy — the venv cleans up after itself
Rather than a cron line, give the venv a policy (runspec >= 0.80.0):
runspec logs retention --compact-after 7d --delete-after 90d # set it
runspec logs retention # show it + the last pass
runspec logs retention --now --dry-run # preview a pass now
runspec logs retention --now # apply it now
runspec logs retention --compact-after off # clear one setting
runspec logs retention --off # remove the policy
The policy lives in {venv}/runspec_logs_retention.toml. The venv owner's own
runs apply it at most once a day, in a detached background process, so a run's
exit is never held up: it deletes per-run files and archives older than
--delete-after, compresses the rest older than --compact-after into gzip
archives, then enforces --max-total-size if set. It only ever touches
{venv}/logs/ and only the files compact/prune manage. Run it as the owner
(root drops to the owner). A venv that only other accounts run applies it at
the owner's next run, the same as the inbox sweep. The runspec console's
Provision venv sets a policy by default. See SPEC → "Log retention policy".
If you'd rather schedule it yourself, a nightly cron line does the same:
30 2 * * * /opt/venv/bin/runspec logs prune --older-than 90d && \
/opt/venv/bin/runspec logs compact --older-than 7d --gzip
Archives stay JSON-lines (gzipped), so runspec logs <runnable> and the
console's History/Analytics keep reading them after compaction.
Set up the logs inbox — runspec logs share --inbox (runspec >= 0.66.1,
POSIX). A shared venv needs this once, run as its owner. It lets every other
account's runs (operators, root, other crontabs) reach the venv's history
instead of falling back to their own ~/logs:
sudo -u <owner> {venv}/bin/runspec logs share --inbox # create logs/inbox (01777, owned by the owner)
runspec logs share --no-inbox # opt this venv out (logs/.no-inbox marker)
runspec logs share --inbox --owner-only # …and make logs/ itself owner-only (0755)
Other accounts drop their per-run files into the inbox, and the owner sweeps
them into the runs index (runspec history sweep, and automatically on the
owner's own runs). Run as root, the command drops to the venv's owner first. It
refuses a non-owner with a one-line message naming the owner. --inbox leaves
logs/'s own mode alone unless --group / --acl-user are also given.
Walkthrough: Shared venvs — the logs inbox.
The group alternative — runspec logs share without --inbox. When more
than one account writes into a venv (a console operator plus the service account
a run_as runnable runs as), they must all be able to write the runs index
(runspec_runs.db) and audit logs into the same dir. share makes that dir
group-writable in one command:
runspec logs share --group consoles # chmod 2775 (setgid) + chgrp to `consoles`
runspec logs share # group-writable + setgid, group unchanged
runspec logs share --group consoles --scope home # share the ~/logs fallback instead
For each targeted dir it ensures the dir exists, chmods it 2775
(group-writable and setgid, so new files inherit the dir's group), optionally
chgrps it to --group, and repairs the existing runspec_runs.db (chgrp +
chmod 664) — a dir chmod never touches a file already inside it. Scoping
matches prune/compact: an unset --scope is this venv's dir only. It is
sudo-free like the rest of the core CLI — it changes only what the invoking
user may change — so if you don't own the dir, run it as the owner yourself:
sudo -u <owner> {venv}/bin/runspec logs share --group consoles. Add the accounts
to the group first, or a non-member still falls back to ~/logs.
Grant a specific user without a group — --acl-user (repeatable). On a host
where you have no root, you often can't add an account to the dir's group (that
needs root) — but the dir's owner can grant write directly with a POSIX ACL:
sudo -u <owner> {venv}/bin/runspec logs share --acl-user alice --acl-user svc
For each user this sets an access ACL (recursive — existing files + the runs
index) and a default ACL (new files inherit), so the named account can write
the dir with no group membership and no root. Best-effort: if setfacl or
filesystem ACLs aren't available the share still succeeds (the --json result's
per-dir acl field reads unsupported: …) and you fall back to the group route.
This is exactly what the runspec-console "Make shared" button passes for your own
login user when it escalates to the owner.
runspec exec
Run one installed runnable and record the whole invocation — including the parts
a runnable can't record about itself. Built for unattended callers (cron, CI,
schedulers), and it's what runspec-scheduler runs for every fire, from its
crontab lines or its daemon.
runspec exec backup -- --target prod # args after -- go to the runnable
runspec exec cron -- list # subcommands pass through too
runspec exec --label nightly --trigger schedule backup
runspec exec --lock ~/.cache/nightly.lock backup # recorded 'skipped' if the lock is held
runspec exec --skip-if-running --timeout 600 report # skip if already running; kill after 10 min
runspec exec --env-file /opt/venvs/ops/runspec_schedules.env backup # add a schedule-only env file
What it adds over running the binary directly:
- A row for every run in the runs index (
runspec history), even when the runnable has no[config.logging]or fails before logging starts (bad args, arun_asrefusal, not installed — exit 127). - The observed exit code. A runnable's own record infers
1on an uncaught exception and0otherwise, sosys.exit(2)would read as success. - Captured output. stdout and stderr are passed through live and their last
--capture-bytes(default 64 KiB) are appended to the run's log file asexec_outputrecords, next to anexec_resultrecord — sorunspec logs view --run <run_id>and the console's History show them, andrunspec logs prunecleans them up like any other log. - Recorded skips. With
--lock PATH(POSIX, race-free) or--skip-if-running(runs-index check), a run that can't start because the previous one is still going is recorded with statusskippedand exits 0. - A schedule-only env file.
--env-file PATHadds a dotenv file's variables to the run's environment: under anything already set (the caller's own environment wins) and over the venv's.runspec_env, which the runnable still loads for the rest.runspec-schedulerpasses{venv}/runspec_schedules.envon every fire, so credentials a schedule needs reach its fires and not the interactive runs in the venv. A missing file adds nothing. A file that isn't a regular file, is writable by group or others, or is owned by another account (not this one or root) is refused: the run is recorded as failed with exit 78 and the runnable isn't started. - Labels.
--labeltags the run; find them withrunspec history list --label <label>. Without--labelthe run takes the caller'sRUNSPEC_LABEL, elsecli, and the runnable gets the same label inRUNSPEC_LABEL. Runs started withoutrunspec execare labelled too: see SPEC → "Run labels".
The wrapper and the runnable share one run id (handed down in RUNSPEC_RUN_ID),
so each run is one row: the runnable's own details (command, args, log file) plus
the wrapper's label, trigger and observed outcome. The exit code is the
runnable's (124 on --timeout). See the design:
docs/design/runspec-exec.md.
runspec history
Query and maintain the per-venv runs index (runspec_runs.db) — a small
SQLite index core writes beside the audit logs ({sys.prefix}/logs, falling back
to ~/logs) at the start and end of every run. It's what makes the console's
History tab load fast and gives a live "what's running" view. It is a derived
index: the JSON-lines audit logs stay the source of truth, so the index can
always be rebuilt from them.
runspec history # recent runs, newest first
runspec history running # what's running now (pid-checked)
runspec history list --runnable deploy --failed # filter; add --json for JSON lines
runspec history list --label sched:nightly # runs recorded by `runspec exec --label`
runspec history stats # index health: rows, size, span, stale rows
runspec history prune --older-than 30d # drop finished rows older than 30 days
runspec history prune --max-rows 10000 # keep only the newest 10k rows
runspec history prune --max-rows-per-runnable 5000 # keep each runnable's newest 5k rows
runspec history reap # crash 'running' rows whose process is gone
runspec history sweep # index finished runs from the logs inbox (venv owner)
runspec history reindex # rebuild the index from the audit logs
You rarely need to run these. Every run's finalize already does the
maintenance automatically: it reaps orphaned running rows, ring-prunes to the
[config.history] bounds (retention_days 90, max_rows_per_runnable 50000,
max_rows 1000000 for the whole venv by default), and
reclaims space incrementally. The verbs are for on-demand repair — shrinking a
retention after the fact, clearing orphans on a quiet venv, or rebuilding an
index that was deleted or damaged.
Who is handling a failure
When a shared job fails, say so on the venv itself, so the next person who looks doesn't start on the same thing and a job someone dealt with doesn't keep reading as broken:
runspec history jobs --failing # failing jobs, and who (if anyone) has them
runspec history claim --label sched:nightly # "I'm on it" (the newest failed run of that job)
runspec history resolve 3f9a1c2e --note "disk full, cleared /var/tmp" # dealt with; a note is required
runspec history release 3f9a1c2e,7b20d4aa # let go of two claims (comma-separated runs)
runspec history notes --label sched:nightly # every note on that job, newest first
A job is a schedule label (sched:<id>) or, for runs without a label, the
runnable. A note covers the job's failures since its last success: one claim
covers a job that keeps failing for the same reason, and the next successful run
ends it. jobs shows each job as clean, failed (nobody has it), handling
or done (dealt with — counts as clean until it next succeeds). list --json
rows carry each run's newest note.
Notes are kept in the runs index (run_notes, schema v8), so only the venv
owner can write them; the console runs these verbs as the owner for you. The
person is recorded as SUDO_USER when sudo switched to the owner, else the
account itself. Each note is also an audit event (run.claim / run.resolve /
run.release) on the venv's ledger.
Scope. Reads (list/running/stats) cover every logs dir by default;
prune/reap/reindex act on this venv's own {sys.prefix}/logs only unless
you pass --scope home|all — so maintaining one venv never touches another's
index in the shared ~/logs.
Permissions. On a shared venv the index belongs to whoever created it (often
a service account). A maintenance verb run by a user who can't write it fails
with a named permission denied … (owned by <user>) error rather than reporting
"0 rows"; run it as the owner (sudo -u <owner> {venv}/bin/runspec history reap).
Other accounts don't need to write the index at all once the venv has a logs
inbox (runspec logs share --inbox).
The logs inbox (0.66.0+). list/running also show runs still waiting in
{venv}/logs/inbox (other accounts' runs, not yet swept). Such a row carries
"pending": true and its kernel-attested os_user, and the index wins a
dedupe. sweep is owner-only; as root it drops to the owner first. It indexes
finished (or confirmed-dead) inbox runs and moves their files into logs/. A run
whose lock is still held is left alone. stats reports the inbox's state
("inbox" in --json). Schema v6 adds os_user, source (direct/inbox)
and exec_uid.
--json. list/running print one row per line; stats/prune/reap/
reindex print a single result object ({"verb", "count", "dirs": [...], "ok"}),
which is what the runspec-console's Logs → Runs index… dialog drives.
Change sequence (0.63.0+). The index (schema v4) stamps every write with a
per-DB change sequence and keeps a short-lived tombstone for every deleted row,
so a reader can pull only what changed since its last read — the
runspec-console's History cache does this over SSH instead of re-reading every
venv's newest rows on each refresh. stats shows it as change seq N (floor F,
T tombstone(s)) ("sync" in --json). It's maintained by SQLite triggers
stored in the DB file, so an older runspec writing the same venv's index still
keeps it correct. Tombstones are kept 30 days. Since 0.64.0 each row also
carries the run's log-event counts by level and its uncaught exception (type +
message), so readers can aggregate from the index rather than the log files.
The Python API is
runspec.runsdb.changes_since(log_dir, db_id=…, seq=…); see
the design.
Bash and shell runnables
Any executable on PATH can be a runspec runnable — bash, Python, Node,
Ruby, Go binary, whatever. runspec serve invokes it with all argument
values pre-exported as RUNSPEC_* environment variables, so the runnable
doesn't need a runspec library:
[backup-logs]
description = "Back up application logs to S3"
autonomy = "confirm"
[backup-logs.args]
env = {type = "choice", options = ["prod", "staging"], description = "Target environment"}
days = {type = "int", default = 7, description = "Days of logs to retain"}
dry-run = {type = "flag", description = "Print what would happen without doing it"}
#!/bin/bash
set -euo pipefail
if [ "$RUNSPEC_DRY_RUN" = "1" ]; then
echo "Would sync $RUNSPEC_DAYS days of $RUNSPEC_ENV logs"
exit 0
fi
aws s3 sync "/var/log/app/$RUNSPEC_ENV" "s3://logs-$RUNSPEC_ENV" \
--delete --exclude "*.tmp"
echo "Backed up $RUNSPEC_DAYS days of $RUNSPEC_ENV logs"
Hyphens in arg names become underscores in the env var name (dry-run →
RUNSPEC_DRY_RUN). RUNSPEC_AGENT=1 is always set so the script can branch
between human and agent output.
The runnable just has to be on PATH and have its section in
runspec.toml. pip install / npm install puts entry points on PATH
automatically; for bare shell scripts, drop them in your venv bin/
(Python) or expose them via a bin entry in package.json (Node).
Usage in agent workflows
# 0. New project? Scaffold the whole thing
runspec init --name myapp --write-project
# 1. Check your config and see what's installed
runspec local
# 2. Preview what schemas an agent will see
runspec local --format mcp
# 3. Start the live MCP server
runspec serve
# 4. From an agent or terminal, run a tool on a jump host
runspec jump prod-app deploy -- --env prod
To wire it into Claude Desktop, point the MCP server config at runspec
serve (see the example above). The agent connects once at startup and
calls tools as needed.
Next: Agent integration (MCP) — expose runnables as agent tools.