Skip to content

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

  1. runspec.toml lives inside the package directory (mypkg/runspec.toml), never at the project root. The package directory is the one that holds __init__.py (Python) or index.ts / index.js (Node).
  2. Each runnable is a top-level TOML section — [name]. Never [runnables.name] or [scripts.name].
  3. [config] is reserved — never use it as a runnable name.
  4. The entry point name must exactly match the runnable name. runspec local discovers runnables by matching an installed script name to a TOML section name; a mismatch makes the runnable invisible.
  5. Reinstall after editing pyproject.toml / package.json — pip install -e . (Python) or npm install (Node).
  6. 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 include arg (a multiple choice with delimiter = ","), and keep the untouched API response behind a raw flag.
  • One arg, not a verb per field. Prefer --include comments to a separate verb. Every verb is another tool the agent has to choose between.
  • Bound every list. Use a limit with a small default, and return count, total, truncated and 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 Print
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. Wrapping parse() 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, Node uncaughtException / 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 bare except 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:

  1. Wiring / parsing — no mocks. Does each (sub)command accept its args, coerce types, and enforce required args / require-command?
  2. 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 parsed RunSpec; fails the test if it exits.
  • expect_exit(argv, code=1, contains=...) asserts a clean error exit; contains checks the message names the offending flag. Use code=0 for --help.
  • smoke(values={...}) auto-covers the whole command tree: --help exits 0, require-command is 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

  1. Find the package directory — locate runspec.toml or the source dir with __init__.py / index.ts. (Or run runspec init from inside it for the skeleton.)
  2. Add the runnable section to runspec.toml. If creating the file fresh, add the schema comment first: #:schema https://runspec.app/runspec.schema.json.
  3. 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 with logging.getLogger(__name__), and add a runspec.audit(...) call for each action that must be on the record.
  4. Wire the entry point in pyproject.toml or package.json — name must match the runnable.
  5. Add a test under the package's tests/ dir using ParseHarness (at least a smoke() call; assert on key arg coercion / validation).
  6. Reinstall — pip install -e . or npm install.
  7. Generate stubs (Python) — runspec stubs, then commit typings/.
  8. Validate — run runspec local (confirm the runnable appears and is callable), then runspec test (confirm it passes the wiring gate), then pytest for your own tests.

Next: Format reference — every field and type, for when you need to look one up.