roba -- a sharp, focused sugaring of claude -p

snapshot @ v0.5.0 -- 2026-06-10

roba

A sharp, focused sugaring of `claude -p` --
pipeable, composable, safe-by-default, session-re-enterable.

roba (Venetian): "stuff, things." roba returns the stuff you asked about --
roba is the bag, the answer the contents.

arrows to advance -- down dives into a chapter

man roba

## One invocation, one answer

- Sugar over the one binary -- not a platform, orchestrator, daemon, or skills framework
- Point it at a quick question, a CI step, or an unattended worker
- stdout is the answer, stderr is everything else
- Built on `claude-wrapper`; Claude-only by design

    $ roba "summarize the rust ownership model in 3 bullets"
       Rust's ownership model rests on three rules:
         - Each value has a single owner.
         - When the owner goes out of scope, the value is dropped.
         - Borrows are either many immutable or one mutable.

    tokens 1.2k/450 . $0.0042 . 2.0s . session abc12345

roba --help | diff claude-p -

## vs. `claude -p` directly

Adds | How
--- | ---
Composable input | `-f` file, piped stdin, `-e` editor, `--prepend/--append`, `--attach` globs, `--git-diff/--git-log/--git-status`, `--var`
Pipe-clean output | stdout = answer; footer, spinner, tool lines, warnings all on stderr
TTY rendering | markdown, spinner, color while it runs -- gone when the answer lands
Session re-entry | `-c` continue, `--fork`, `--pick`, `--session-id` mints a caller-chosen id
Read-only inspection | `roba show` (`--metrics`, `--wait`), `worktree list`, `history --worktree`
A stable scripting ABI | typed exit codes, versioned `--json` envelope, clean stream split

For interactive multi-turn work: use claude itself. That's the point.

roba --show-permissions

## Safe by default

Starts read-only: `Read`, `Glob`, `Grep` and nothing else. Everything beyond is an explicit opt-in:

    roba "explain this"                      # read-only (default)
    roba --writable "rename foo to bar"      # add Edit + Write
    roba --allow-tool "Bash(git:*)" "..."    # one specific pattern
    roba --deny-tool WebFetch "..."          # block one (deny wins)
    roba --add-dir ../shared "..."           # widen file scope
    roba --full-auto "..."                   # bypass all (sandbox only)

the two axes, with receipts

roba --show-permissions --profile worker

## Two axes, with receipts

- Allow-list axis -- which tools exist at all: `--readonly / --writable / --allow-tool / --deny-tool`; deny always wins
- Approval axis -- claude's own gate, orthogonal: `--permission-mode` (`plan`, `acceptEdits`, ...)
- `--full-auto` is the exception: bypasses both, on purpose, loudly documented

    $ roba --show-permissions --profile worker
    all tools allowed (--full-auto from profile.worker)

Resolved set + provenance ([default] / [env] / [profile.NAME]), then exit. Zero cost, no call -- the receipt for every permission question.

roba history

## Sessions, without living in one

    # dip back into a thread
    roba -c -p "now add a test for that"

    # scripted multi-turn: mint an id once, reuse it
    # (bare `claude -p --continue` no-ops in print mode; this is the fix)
    uuid=$(uuidgen)
    roba --session-id "$uuid" "start a refactor plan"
    roba -c="$uuid" "now do step 1"

inspection + named handles

roba show 2fca1a5d --metrics --wait

## Inspection: read-only by construction

- `roba show <id>` -- a stored run's result, reconstructed from its JSONL: `duration_ms` null, turns/cost derived, honestly labeled
- `--wait [--timeout]` -- poll until the last assistant turn's `stop_reason` goes terminal (best-effort heuristic over the log, never a hang)
- `roba worktree list` -- the repo's git worktrees (a superset of claude's); list-only
- `roba history --worktree NAME` -- find a dispatched runner's session to resume

The hard line: roba never writes .claude/ -- sessions, worktrees, credentials are claude's domain. Inspect and report, only.

roba --session meta -p "weekly groom"

## Named handles: config, not state

    [session]                 # in roba.toml -- machine-local
    meta   = "8b273dbb-50d1-4cfa-9e2f-1c30a9b7e441"
    ci-bot = "f12e9f36-2a01-47bb-bd58-77c2b41d09aa"

- `--session NAME` / `ROBA_SESSION=NAME` resolve the handle through the pool
- Mint the uuid yourself (`--session-id`), then bind it -- names exist before first run
- Parsed exactly like [profile] / [alias]: roba owns zero runtime state, so there's no ledger to corrupt

roba --json | jq

## The agent ABI: deterministic bookends

    success: { "version": 1, "result": { ... }, "refusal": bool }   (stdout)
    failure: { "version": 1, "error": { kind, message, exit_code, chain } }   (stderr)

Exit | Meaning
--- | ---
0 | ok -- refusals included; detect via `refusal`, not the code
1 | generic failure, incl. `--max-turns` / `--max-budget-usd` cap hits
2 / 3 / 4 | auth / budget / timeout

observability

tail -f trace.jsonl

## Watching a run

- `--trace PATH` -- the spawned session's events as JSONL, live, on every path (even under `--json`)
- Every `--json` output carries `version: 1` -- ask, cost, history, doctor, show, worktree list. One parser for everything
- The answer lives at `.result.result`; metrics nest under `.result`
- `roba show <id> --wait` -- poll a stored run until it completes, then render

    roba --json "..." | jq -r '.result.result'

jq survival

jq -r '.result.result'

## jq survival

You want | The path
--- | ---
the answer | `.result.result` -- not `.result` (that's the whole object)
metrics | `.result.duration_ms`, `.result.num_turns`, `.result.total_cost_usd` (a serde rename of `cost_usd`); top-level paths return null
"did it refuse?" | `.refusal`, top-level -- a refusal still exits 0
what broke | `.error.kind` in the stderr envelope; `see_also` is omitted when empty

    # capture with printf, not echo -- zsh's echo interprets the
    # JSON's backslash escapes and corrupts it
    out=$(roba --json "..."); printf '%s' "$out" | jq -r '.result.result'

UNSUNG HERO 01

# Profiles

Named bundles of flag defaults. Nothing magical --
and that's exactly why they're powerful.

six slides down: resolution, the pool, the surface, the theory

roba profile show worker

## Profiles are just defaults

A profile is a TOML alias for flags you'd otherwise type every time. CLI always wins.

    [profile.review]
    readonly = true
    git_diff = true

    roba --profile review "is this safe to merge?"
    # identical to: roba --readonly --git-diff "is this safe to merge?"

A profile named `default` auto-applies. ROBA_PROFILE=name selects by env. --no-default-profile kills auto-apply.

roba --show-permissions --profile review

## The resolution ladder

Every knob resolves the same way, highest layer wins:

1. CLI flag -- you typed it, it wins

2. ROBA_* env var -- lists comma-separated; truthy bools only enable

3. active [profile.NAME] -- the overlay

4. top-level roba.toml keys -- the floor you set

5. roba's built-in defaults -- read-only, fresh session

6. claude's defaults

Deterministic and inspectable: --show-permissions prints the resolved set with provenance ([default] / [env] / [profile.NAME]) and exits. No call, no guessing.

roba profile path

## The pool: config that composes

- `~/.config/roba.toml` + every `roba.toml` walking up from cwd to the git root
- Closer-to-cwd wins per-key -- a repo can sharpen your global defaults
- Lists concatenate across files; `[vars]` merge per-key
- `[session]` binds `NAME = "uuid"` handles for `--session NAME` -- config, not state

roba owns zero runtime state. The pool is a pure function of the files; parse errors are loud (unknown keys are hard errors, listing every valid key).

grep -c '=' roba-config.sample.toml

## The surface: 40+ keys, every dimension of a run

Dimension | Keys
--- | ---
Posture | readonly, writable, full_auto, allow_tool, deny_tool, permission_mode
Context | prepend, append, attach, git_diff, git_log, git_status, vars
Identity | model, effort, fallback_model, agent, system_prompt, append_system_prompt
Session | continue, session_id, worktree, no_session_persistence
Output | json, json_schema, quiet, plain, stream, echo, show_thinking, trace, no_dollars, rates_file
Rails | max_turns, max_budget_usd, no_retry, bare, no_agent_check
Tools | mcp_config, strict_mcp_config, add_dir

The annotated sample (roba profile init) documents every key and is parse-tested in CI -- config docs that cannot go stale.

: the theory

## Enforcement vs. compliance

A "review skill" asks the model to stay read-only and look at the diff.
Model compliance is a coin flip -- that lesson is paid for.

`[profile.review]` doesn't ask:

- permissions blocked by machinery, whether or not the model agrees
- the diff is in the prompt because roba put it there
- `json_schema` output is validated, not requested
- the spend cap fires regardless of cooperation

: the theory, part two

## Deterministic bookends, probabilistic middle

A profile pins both ends of a run -- the setup (posture, context, model, rails) and the finish line (the output contract). Only the reasoning in between belongs to the model.

That's most of what people want from a lightweight agent -- with zero prose to drift and zero interpretation risk.

Where it tops out: procedure. Sequence and judgment are irreducibly prose -- that's the skill layer's job, and it lives outside the binary.

    # an enforced-read-only,
    # diff-fed, schema-validated
    # reviewer. nine lines. no
    # skill. nothing to ignore.
    [profile.review]
    readonly = true
    git_diff = true
    append_system_prompt = """
    Review for correctness.
    Cite file:line. No nits."""
    json_schema = "findings.json"
    max_turns = 10

cat ~/.config/roba.toml

## A working set

    # the proven unattended-worker shape, wearing its rails
    [profile.worker]
    full_auto = true
    max_turns = 80          # a true spiral trips them,
    max_budget_usd = 10.0   # a heavy 20-edit run clears them

    # long-horizon: fable primary, opus on overload
    [profile.fable]
    model = "claude-fable-5"
    fallback_model = "claude-opus-4-8"

    # cheap fast one-shots
    [profile.quick]
    model = "claude-haiku-4-5"

Plus review / explain / commit-msg / fix-build on the human side. The dispatch line is now: roba --profile worker -C repo -f task.md --trace t.jsonl

UNSUNG HERO 02

# Aliases

New verbs. `roba review 42` expands a prompt template + flags
and dispatches like a normal call.

five slides down: the schema, the template language, a worked verb

roba alias show review

## An alias is a verb definition

    [alias.review]
    description = "Review a PR by number"
    agent = "reviewer"          # pin a claude-code subagent
    args = ["pr"]               # positional 1 -> ${pr}
    flags = ["--readonly"]      # merged BEFORE your CLI flags
    template = """
    Review PR #${pr} in this repo.

    Diff:
    $(gh pr diff ${pr})
    """

description, agent, args, flags, template -- that's the whole schema.

man roba-template

## The template language

Form | Means
--- | ---
`${1} ${2} ...` | positional args after the verb (1-based)
`${@}` | all positional args, space-joined
`${name}` | named arg, resolved via the `args` schema
`$$` | a literal `$` -- dollar amounts and shell vars survive
`$(command)` | shell substitution: runs in your shell, stdout interpolated

$(...) is NOT sandboxed -- it's your shell, before claude ever runs. Orthogonal to claude's permissions. Don't template a command you wouldn't type.

roba review 42

## What one verb buys you

    roba review 42
      |
      |  1. lookup: [alias.review] in the pool (built-ins win; shadowing warns)
      |  2. args:   42 -> ${pr}
      |  3. shell:  $(gh pr diff 42) runs NOW, in your shell
      |  4. flags:  --readonly merged in (your CLI flags still win)
      |  5. agent:  reviewer pinned
      v
    roba --readonly --agent reviewer "Review PR #42 ... [the actual diff]"

The diff is IN the prompt before claude starts -- mechanically, not because a skill asked the model to go fetch it.

roba r "look at the auth module"

## Template-less = a flag shortcut

    [alias.r]
    description = "Quick read-only review preset"
    agent = "reviewer"
    flags = ["--readonly"]      # no template: args become the prompt

- `flags` merge before CLI flags -- your CLI always wins
- Built-in names (cost, history, profile, ...) cannot be shadowed
- Inspect everything: `roba alias {list, show, path}`

Your domain knowledge lives in your aliases, not the binary.

roba alias list

## A working set

    NAME    DESCRIPTION
    cm      Conventional-commit message from the staged diff
    issue   Summarize an issue and propose an approach
    review  Review a PR by number
    wtf     Diagnose piped failure output

    roba review 244                      # gh pr diff, in the prompt
    roba issue 242                       # gh issue view, summarized
    git add -p && roba cm                # staged diff -> commit message
    cargo build 2>&1 | roba wtf          # piped failure, diagnosed

Adding review/issue surfaced a real bug the same day: ${arg} wasn't substituted inside $(...) -- fixed within the hour (#247). The heroes earn their keep by being used.

: the stack

## The determinism gradient

Layer | Carries | Character
--- | --- | ---
Profile | params: posture, context, model, rails, output contract | fully deterministic -- enforced by machinery
Alias | params + a prompt template + live shell context | deterministic setup, parameterized prose
Skill | procedure: sequence, judgment, lifecycle | prose a model interprets -- lives outside roba

Each step up adds expressiveness and sheds determinism.
Push everything parametric down the stack -- reserve prose for what only prose can do.

roba --profile worker

## Unattended workers

The proven dispatch loop: orchestrator owns branch + PR, roba does the code work. ~16 consecutive dispatched PRs shipped v0.5.0 itself -- zero spirals.

    roba --profile worker -C ~/code/repo -f /tmp/task.md --trace /tmp/t.jsonl

- `--json-schema` for validated structured output
- `--mcp-config` for per-run tools (a pass-through, not a daemon)
- piped stdin becomes context when a prompt is present: `cat err.log | roba "what's wrong here?"`

exit codes as recovery signals

echo $?

## Exit codes are recovery signals

Signal | The orchestrator's move
--- | ---
`2` auth | halt the whole fleet and re-auth -- nothing else will succeed
`4` timeout / transient `1` | re-fire the same task (`--no-retry` hands the retry decision to you)
`1` at a rail | under-scoped: raise `--max-turns` / `--max-budget-usd`, or split the task
0 + `refusal: true` | not success -- branch on the field, never the code alone

And the real verdict is downstream anyway: the worker is graded on its artifact -- a commit, a PR, green CI -- not its exit code.

roba doctor

## Install

Source | How
--- | ---
crates.io | `cargo install roba`
Homebrew | `brew install joshrotenberg/brew/roba`
Binaries | macOS arm64/x86_64, Linux arm64/x86_64, Windows -- shell + PowerShell installers on the latest release

- v0.5.0 -- CLI surface intended stable across 0.x
- ~430 unit + 110 mechanical tests; daily live CI against the real API
- Docs: README + `roba --help` + parse-tested config sample. The documentation is the skill.

github.com/joshrotenberg/roba -- MIT OR Apache-2.0