Authoring guide
This is the end-to-end guide to adding a runnable to a project: the runspec.toml
section, the code stub, the entry point, validation, and a test. It is the same
guidance the add-runnable Claude Code skill follows, so a human and an agent
build runnables the same way.
If you are migrating an existing argparse script, read
Migrating from argparse first — it maps each add_argument onto
its TOML equivalent. This page assumes you are starting fresh.
Use it as an agent skill
The raw skill file is published here too — fetch it on any machine, no repo access needed, and drop it in as a Claude Code skill:
curl -O https://runspec.app/skills/add-runnable.md
Hard rules — do not deviate
runspec.tomllives inside the package directory (mypkg/runspec.toml), never at the project root. The package directory is the one that holds__init__.py(Python) orindex.ts/index.js(Node).- Each runnable is a top-level TOML section —
[name]. Never[runnables.name]or[scripts.name]. [config]is reserved — never use it as a runnable name.- The entry point name must exactly match the runnable name.
runspec localdiscovers runnables by matching an installed script name to a TOML section name; a mismatch makes the runnable invisible. - Reinstall after editing
pyproject.toml/package.json—pip install -e .(Python) ornpm install(Node). - Autonomy defaults to
"confirm"— safe by default. Opt a runnable up to"autonomous"only when it is read-only or otherwise safe for an agent to run unattended.
Fast path: runspec init
If runspec is installed, scaffold the skeleton rather than hand-writing every
file. From inside the package directory:
runspec init --name <name> --lang python # also: typescript, javascript
This writes the [name] TOML section, a code stub, and — with --write-project
— a pyproject.toml whose [project.scripts] and dev = ["pytest", "ruff"]
extra are already wired. It also drops a placeholder typing stub and the
runspec stubs wiring (mypy path + pre-commit hook), so your
editor types arguments precisely from the first run. Then edit the args and
main() to taste.
The sections below are the manual equivalent — use them to add a runnable to an existing spec, or when you want full control.
File location
mypkg/
__init__.py
runspec.toml ← here, not at the project root
myrule.py ← the runnable's code
When you create a runspec.toml from scratch, put the schema comment on the
first line so editors validate and autocomplete it:
#:schema https://runspec.app/runspec.schema.json
[myrule]
description = "Do the thing"
Arg types
| Type | Notes |
|---|---|
"str" |
Text |
"int" |
Integer |
"float" |
Float |
"bool" |
Boolean (true/false) — distinct from "flag" |
"flag" |
Boolean switch; presence = true; default = false |
"path" |
File/dir path coerced to pathlib.Path (Python); always required unless given a default |
"choice" |
Requires options = [...] |
"password" |
Secret; refused on the command line, read from env / prompt, omitted from agent schemas, redacted from logs |
"rest" |
List of strings captured after a literal --; at most one per runnable; never required |
Inference (so you can often omit type): a default integer infers "int",
a float "float", a string "str", true/false infers "flag", and
options = [...] infers "choice". No default (and not flag/rest) →
required = true; type = "path" with no default → required.
Optional arg fields (all on the inline table): description, default,
required, options, range = [min, max], pattern (full-match regex, str
only), min-length / max-length (str only), multiple = true, delimiter,
short = "-x", env, position (1-based → positional), deprecated, hint,
meta (a pass-through dict runspec never reads). The complete list lives in the
Format Reference.
Inline-table style for args:
[myrule.args]
directory = {type = "path", description = "Directory to process", default = "."}
dry_run = {type = "flag", description = "Preview only", default = false}
format = {type = "choice", description = "Output format", options = ["text", "json"], default = "text"}
Reading argument values: always .value
parse() returns a RunSpec. Each args.<name> is an Arg, not the raw
value — read args.<name>.value for the coerced native value:
from runspec import parse
def main():
args = parse()
root = args.directory.value # pathlib.Path
if args.dry_run.value: # bool
...
print(args.format.value) # str
Since 0.38.0 the Arg is not a transparent proxy. The two cases that used to
be silently wrong without .value now raise a guiding TypeError:
if args.dry_run: # TypeError — did you mean args.dry_run.value?
if args.format == "json": # TypeError — compare args.format.value instead
Arithmetic, int(), iteration, indexing, os.fspath, and attribute delegation
are absent too — there is no path to a silently-wrong read.
For args whose presence depends on the active subcommand, use name in args or
args.get(name, default) (which always returns an Arg) — no up-front guard
needed:
if "force" in args and args.force.value:
...
limit = args.get("limit", default=None).value
Node: parse() returns bare coerced values (args.directory is the
string/number/array) — there is no .value and no per-arg metadata wrapper.
The Arg also carries metadata (Python)
Beyond .value, each Arg is self-describing — it carries its full spec plus
where the value came from, so a runnable can introspect its own inputs
without re-deriving anything:
args.workers.type # 'int' — declared type
args.workers.default # 4 — spec default
args.format.options # ['text', 'json']
args.api_key.env # 'PIPELINE_API_KEY'
args.workers.source # provenance — see below
source is the value's provenance — one of "cli", "env",
"runspec_env", "spec_default", or "not_set" (resolution order
cli → env → spec_default → not_set). Use it to react to how a value arrived:
# Refuse a secret typed on the command line (leaks into shell history / ps):
if args.api_key.source == "cli":
raise SystemExit("✗ Pass the key via PIPELINE_API_KEY, not the command line")
# Tell 'caller chose this' apart from 'fell back to the default':
if args.workers.source == "spec_default":
logger.info("workers not set — using default %d", args.workers.value)
Full field list and the provenance table: Python Library → Arg. (Per-arg metadata is Python-only; Node returns bare values.)
Typed parse() with runspec stubs
Arg.value is typed Any by default, so a typo (args.wrokers.value) sails
through the type checker. Run runspec stubs once your package
is installed and parse("myrule") becomes precisely typed at the call site —
no annotation, no cast:
args = parse("deploy")
args.workers.value # int
args.env.value # Literal["dev", "prod"]
args.wrokers # error: "_DeployArgs" has no attribute "wrokers"
runspec stubs # generate types from the installed runnables
runspec stubs --check # CI / pre-commit: fail if missing or stale
It writes one committed artifact — typings/runspec/__init__.pyi — regenerated
whenever a runspec.toml changes. pyright / Pylance read typings/
automatically; for mypy add mypy_path = "typings". A stale stub never affects
runtime (parse() reads the runspec.toml, not the stub) — the worst it does is
show an out-of-date editor hint. Full guide: Typed args.
Subcommands (optional)
A runnable can branch into subcommands under [name.commands.<sub>]. Args
declared at the runnable level are shared by every subcommand; args under a
subcommand are local to it. Set require-command = true to force the caller to
pick a subcommand (no bare invocation).
[db]
description = "Database operations"
require-command = true
[db.commands.migrate]
description = "Run pending migrations"
[db.commands.migrate.args]
steps = {type = "int", description = "How many migrations to apply", default = 1}
Inside main(), args.runspec_command is the chosen subcommand name.
Autonomy
| Level | Use for |
|---|---|
"confirm" (default) |
Destructive ops — the agent presents intent, awaits human approval |
"autonomous" |
Read-only / safe ops — the agent runs freely |
"supervised" |
The agent runs, a human reviews output before acting on it |
"manual" |
Human only — the agent cannot invoke it |
For a destructive flag on an otherwise-safe runnable, gate it in code so an agent can't trigger it without declared autonomy (a human typing the flag on the CLI is their own confirmation):
if args.runspec_agent and args.runspec_autonomy != "autonomous":
raise SystemExit("✗ --delete requires autonomy='autonomous' for agent invocation")
Output: return what the caller needs
When an agent runs a runnable, all of stdout goes into the model's context, and it is sent again on every later step of that conversation. The console saves output over 32 KB to a file and gives the agent a preview, but anything smaller goes in whole. Shape the output at the source:
- Compact by default, more on request. Return the fields a caller usually
needs. Add bulky parts (comments, history, attachments) through an
includearg (amultiplechoice withdelimiter = ","), and keep the untouched API response behind arawflag. - One arg, not a verb per field. Prefer
--include commentsto a separate verb. Every verb is another tool the agent has to choose between. - Bound every list. Use a
limitwith a small default, and returncount,total,truncatedand the offset of the next page. - Preview long text, drop empties and noise. Keep counts even at zero, so the agent knows whether asking for more is worth a call.
Pick the format by who's reading:
| Caller | How to tell | |
|---|---|---|
Agent (console, runspec serve) |
args.runspec_agent |
compact JSON |
| Person in a terminal | sys.stdout.isatty() |
readable text |
| Anything else (pipe, console Forms tab, Rundeck, Ansible, cron) | neither | JSON |
The Forms tab parses JSON to draw a table, so only a terminal gets text.
runspec-jira is the worked example: get-issue takes --include and --raw,
and search returns compact rows with truncated / next_start.
Code stub
Keep main() thin: parse() → a plain function that does the work → render the
result. The plain function is directly unit-testable with ordinary values (see
Testing runnables).
from runspec import parse
def do_work(directory, *, dry_run=False): # plain values — testable
...
return {"ok": True}
def main():
args = parse()
result = do_work(args.directory.value, dry_run=args.dry_run.value)
print(result)
pyproject.toml entry — the script name must match the runnable name:
[project.scripts]
myrule = "mypkg.myrule:main"
import { parse } from "runspec-node";
const args = parse();
console.log(args.directory); // bare coerced value — Node has no .value
package.json entry:
"bin": { "myrule": "dist/myrule.js" }
Error handling — let it propagate, don't wrap
Do not wrap parse() or the body of main() in a blanket try/except that
prints the error and calls sys.exit(1). runspec already does this, better:
- Argument / config errors are handled by
parse()itself — always, with or without[config.logging]. Bad args, missing required args, bad choices, unknown runnable, missing config: runspec prints a clean human message and exits 1. Wrappingparse()adds nothing. - Uncaught runtime exceptions in your own logic are handled too. Logging is
on by default (runspec 0.71.0+), so runspec installs a process-level handler
(Python
sys.excepthook, NodeuncaughtException/unhandledRejection) that writes a structured run record plus a concise stderr line (ERROR: ValueError: ...), or a full traceback under--debug. Just let the exception propagate.
from runspec import parse
def main():
args = parse() # arg/config errors already exit cleanly
result = do_the_work(directory=args.directory.value) # let any exception propagate — runspec logs + renders it
print(result)
The few legitimate reasons to raise or exit explicitly:
- Intentional control flow with a clear message — e.g. the destructive-flag
autonomy gate above (
raise SystemExit("✗ --delete requires …")). - Catching one specific, expected exception to add a remediation hint, then
re-raising or
raise SystemExit(msg). Catch the narrow type (except ConnectionError), never bareexcept Exception.
A blanket try/except Exception: print(e); sys.exit(1) throws away the structured
run record and the traceback, hides the exception type, and duplicates what
runspec does. See Logging.
Logging and audit events
Logging is on by default (runspec 0.71.0+, Python): every run gets a JSON
log file under {venv}/logs/, a run record (who ran it, which args with secrets
left out, exit code, duration), INFO → stdout and WARNING+ → stderr, and a
--debug flag. In code, just use a logger — no basicConfig(), no handlers:
import logging
logger = logging.getLogger(__name__)
logger.info("Rotated %d files", count)
A package that brings its own logging sets [config.logging] enabled = false
(audit-only: runspec leaves logging alone but still writes the run record). See
Logging.
The run record says that the runnable ran. When an action inside the run must be on the record on its own (a refund issued, a host rebooted, an account unlocked), record an audit event with one call. It goes to the venv's append-only, hash-chained ledger, the OS log, and the run's log:
import runspec
runspec.audit("account.unlock", "succeeded", target={"user": user},
detail={"ticket": ticket})
Use a dotted lowercase noun.verb name, keep secrets out of target/detail,
and pass strict=True before an action that must not happen unrecorded (it
raises runspec.AuditError if nothing could record it). From a shell:
runspec audit record account.unlock --outcome succeeded --target user=alice.
Don't hand-roll an actions log. See Audit events.
Who it runs as — run_as
A runnable without run_as runs as the account that owns its venv
("@owner", never root; runspec 0.68.0+). In a personal venv that's you, so
nothing changes. In a shared venv owned by a service account, the console /
runspec serve / runspec exec escalate to that account, and a direct run by
anyone else is refused with the sudo -u <owner> … command to use. Set
run_as = "<account>" only when the tool must run as someone specific, or
[config] run_as = "" to run as the invoker with no check. Don't add your own
--user/--sudo args. See run_as.
Rundeck and Ansible
runspec emit --rundeck / --ansible turn each runnable into a Rundeck job or
an Ansible module from the same runspec.toml. What the scheduler needs that the
interface doesn't say (venv, job group, node filter, schedule, notifications)
goes in a meta table the emitters read, so write it next to the runnable:
[rotate-logs.meta.rundeck]
venv = "/opt/app/.venv"
run_as = "svc" # runs: sudo -n -u svc /opt/app/.venv/bin/rotate-logs
node_filter = "tags: web"
schedule = "0 0 2 * * ?" # Quartz crontab, not Unix cron
[rotate-logs.meta.ansible]
venv = "/opt/app/.venv"
Project-wide defaults go under [config.meta.rundeck] / [config.meta.ansible].
See Rundeck jobs and Ansible modules.
Testing the runnable
runspec ships a stdlib-only test harness that pulls in no test framework, so
importing it is free. parse() already accepts argv= and config_path=, so
you never monkeypatch sys.argv or mock the parser. There are two layers worth
testing:
- Wiring / parsing — no mocks. Does each (sub)command accept its args,
coerce types, and enforce required args /
require-command? - The actual work — mock only the I/O boundary. Keep
main()thin, then test the plain function directly and patch its single external call.
ParseHarness covers layer 1:
# tests/test_myrule.py — lives in the package's tests/ dir
from pathlib import Path
from runspec.testing import ParseHarness
SPEC = Path(__file__).parent.parent / "runspec.toml" # adjust to your layout
def test_wiring():
h = ParseHarness(script_name="myrule", config_path=SPEC)
spec = h.expect_ok(["--directory", "."])
assert spec.directory.value == "."
h.expect_exit(["--format", "nope"], contains="--format") # bad choice rejected
h.smoke(values={"directory": "."}) # walk the whole tree
h.close()
expect_ok(argv)returns the parsedRunSpec; fails the test if it exits.expect_exit(argv, code=1, contains=...)asserts a clean error exit;containschecks the message names the offending flag. Usecode=0for--help.smoke(values={...})auto-covers the whole command tree:--helpexits 0,require-commandis enforced, omitting required args exits 1, and a fully-populated invocation parses.
A test that calls your runnable's main() directly runs the real parse(): set
RUNSPEC_AUDIT=off for it (tests and CI only) so it doesn't write run records
or audit events into your venv, and wrap the call in no_run_as_check().
Pass an inline spec with toml="..." instead of config_path= for small cases.
Node: import { ParseHarness } from "runspec-node/testing" — same API
(expectOk, expectExit({ code, contains }), smoke({ values })).
The full testing guide — Layer 2 mocking, the runspec test CI gate, and the
no_run_as_check() escape hatch for driving main() directly — is on
Testing runnables.
runspec test — the CI gate
runspec test smoke-tests every installed runnable: an in-process spec smoke
(the ParseHarness.smoke checks above) plus a subprocess that runs each
installed entry point with --help — proving the runnable's own code imports and
is wired to the right spec. It exits 1 if any runnable fails, so it drops
straight into CI.
runspec test # all runnables
runspec test --runnable myrule
runspec test --format json
Steps
- Find the package directory — locate
runspec.tomlor the source dir with__init__.py/index.ts. (Or runrunspec initfrom inside it for the skeleton.) - Add the runnable section to
runspec.toml. If creating the file fresh, add the schema comment first:#:schema https://runspec.app/runspec.schema.json. - Create the code stub in the package directory. Keep
main()thin — delegate the real work to a plain function so it's directly testable. Log withlogging.getLogger(__name__), and add arunspec.audit(...)call for each action that must be on the record. - Wire the entry point in
pyproject.tomlorpackage.json— name must match the runnable. - Add a test under the package's
tests/dir usingParseHarness(at least asmoke()call; assert on key arg coercion / validation). - Reinstall —
pip install -e .ornpm install. - Generate stubs (Python) —
runspec stubs, then committypings/. - Validate — run
runspec local(confirm the runnable appears and is callable), thenrunspec test(confirm it passes the wiring gate), thenpytestfor your own tests.
Next: Format reference — every field and type, for when you need to look one up.