CLI Agents
fast-rlm can drive a coding agent through its own non-interactive mode —
claude -p, codex exec, opencode run — as the model behind a run.
This is the recommended way to use Claude Code, Codex, or opencode with fast-rlm. The ACP route still works and is still supported.
Why this exists
The ACP route puts three independently-versioned pieces between fast-rlm and the agent: a pinned ACP provider, a third-party bridge package, and the ACP protocol itself. Each can lag behind a CLI that ships almost daily, and fast-rlm then has to cut a release before you can catch up.
The direct route has nothing in between:
Those --json modes are maintained by the same teams that ship the agents, so
there is no bridge to fall out of date. Concretely, compared to acp::
acp: |
cli: |
|
|---|---|---|
| Install step | fast-rlm acp install |
none |
| Needs Node/npx | yes (bridge packages) | no |
| Startup | downloads/launches a bridge | spawns the agent directly |
| Token & cost budgets | inert (no usage reported) | work normally |
| Subprocess permission | blanket --allow-run |
scoped to the named binaries |
Quick start
Nothing to install — you only need the agent's own CLI, already logged in.
A model is required for every cli: agent — see Choosing a model.
from fast_rlm import run, RLMConfig
run("What is 2+2?", config=RLMConfig(primary_agent="cli:opencode?model=anthropic/claude-sonnet-5"))
Built-in presets
cli: name |
Launches | Isolation |
|---|---|---|
cli:claude-code |
claude -p --output-format json |
--disallowedTools + temp cwd |
cli:codex |
codex exec --json --sandbox read-only |
read-only sandbox + temp cwd |
cli:opencode |
opencode run --format json --pure |
temp cwd |
Prerequisites: the agent's CLI installed and authenticated in its own right
(claude — subscription login or ANTHROPIC_API_KEY; codex login;
opencode auth login). No Node, no npx, no fast-rlm install step.
How it works
Identical in shape to every other backend:
- fast-rlm sends the agent its system prompt and the message history, flattened into a single prompt on stdin.
- The agent replies with a
```replblock. - fast-rlm executes that block in its own Pyodide sandbox and feeds the output back on the next turn.
The agent never executes the code and never writes files — fast-rlm does.
Two details that matter:
- The system prompt is replaced, not appended. For Claude Code,
--system-promptswaps out its coding-agent prompt entirely rather than stacking fast-rlm's on top. Appending would leave the whole agent harness prompt and its tool definitions in context — and fast-rlm would then have to argue the model out of using tools it can still see. - The prompt goes on stdin, never as an argument.
--disallowedToolsis variadic and silently swallows a trailing positional prompt; stdin also avoids argv size limits, which RLM prompts routinely exceed.
Agents with no system-prompt flag (codex, opencode) get fast-rlm's system prompt prepended to the prompt text instead.
Choosing a model — required
Every CLI agent must state its model. A run that omits one fails before anything is spawned:
This is deliberate. Left unset, the model is whatever that vendor's CLI happens to default to: it changes between releases, it appears nowhere in your run's config, and for Claude Code it is Opus — the most expensive option, at roughly $0.17/step. An unstated model is a silent cost and a silent reproducibility hole, so fast-rlm refuses to guess.
?model= is passed straight through to the CLI's own flag. Aliases and exact
ids both work — whatever that CLI accepts:
primary_agent: "cli:claude-code?model=sonnet" # alias — always latest Sonnet
primary_agent: "cli:claude-code?model=claude-sonnet-5" # exact id — pinned (preferred)
primary_agent: "cli:claude-code?model=opus"
primary_agent: "cli:codex?model=gpt-5.5-codex"
primary_agent: "cli:opencode?model=anthropic/claude-sonnet-5" # provider/model
| agent | flag | accepts |
|---|---|---|
cli:claude-code |
--model |
alias (sonnet, opus, fable) or full id (claude-sonnet-5) |
cli:codex |
-m |
model id (gpt-5.5-codex) |
cli:opencode |
-m |
provider/model |
To set a default for a project rather than per call, re-declare the preset:
cli_agents:
claude-code:
command: claude
args: ["-p", "--output-format", "json", "--strict-mcp-config",
"--disallowedTools", "Bash Read Write Edit MultiEdit WebFetch WebSearch Task Glob Grep NotebookEdit"]
model_flag: "--model"
model: "claude-sonnet-5" # exact id instead of the alias
system_prompt_flag: "--system-prompt"
extract: { kind: json, path: result }
Alias or exact id?
An alias (sonnet) follows the latest release of that model — convenient,
but what runs underneath can change without you noticing. An exact id
(claude-sonnet-5) pins it, at the cost of eventually being retired.
For benchmarks or anything you need to reproduce months later, pin the exact id and record it alongside the results.
Budgets and cost
cli:claude-code refuses to run while ANTHROPIC_API_KEY is set
claude silently prefers that key over your Pro/Max login, so a run you
believe is using your plan is billed per token instead — with no error
and no warning fast-rlm can surface. Rather than let that happen quietly,
the run fails immediately, before any process is spawned:
'cli:claude-code' refuses to run while ANTHROPIC_API_KEY is set.
Use your subscription: unset ANTHROPIC_API_KEY
Or accept API billing: RLMConfig(cli_allow_api_key=True), or --cli-allow-api-key
cli_minimal implies it: that mode cannot read a subscription login.
Set cli_allow_api_key: true if you genuinely want metered billing — for
instance if you have no Claude subscription at all. cli_minimal implies
it, since --bare cannot read a subscription login.
Custom agents can declare the same protection with forbid_env.
No model is chosen for you
Claude Code's own default is Opus, which at ~25k prompt tokens per step costs around $0.17 — far more than an RLM step needs. The preset therefore so fast-rlm requires you to name one rather than inheriting that default. Sonnet passes the full verification suite at roughly a fifth the cost per token:
Unlike ACP, these CLIs report token usage — so max_money_spent,
max_completion_tokens, and max_prompt_tokens all work.
Claude splits prompt tokens across uncached, cache-read, and cache-creation
fields; fast-rlm sums them, so prompt_tokens means the same thing here as on
every other backend.
Cost under subscription auth
Claude Code reports total_cost_usd even when you are logged in with a
subscription rather than an API key. That figure is API-equivalent cost,
not money billed to you. It remains a useful max_money_spent guardrail;
just don't read it as a bill. Codex reports no cost at all (tokens only).
cli_minimal — cheaper steps, API key required
Claude Code's --bare skips hooks, CLAUDE.md discovery, plugins, LSP and
auto-memory. Measured on a real fast-rlm step:
| prompt tokens/step | cost/step | |
|---|---|---|
| default | 25,680 | $0.161 |
cli_minimal: true |
8,465 | $0.055 |
The ~17k difference is Claude Code harness context that an RLM run has no use for. Multiply by dozens of steps and recursive sub-agents and it is substantial.
The catch: --bare never reads OAuth or the keychain, so it only works with
ANTHROPIC_API_KEY set. That is why it is off by default — subscription logins
must keep working. Enabling it without a key fails immediately, saying so.
primary_agent: "cli:claude-code?model=claude-sonnet-5"
cli_minimal: true # requires ANTHROPIC_API_KEY
Registering your own agent
Any agent with a JSON-emitting non-interactive mode works — no code changes.
Register it under cli_agents and select it by name. A registered name
overrides a built-in preset, which is how you repair a preset locally if a CLI
changes a flag, rather than waiting for a fast-rlm release.
primary_agent: "cli:myagent?model=some-model" # or set `model:` below
cli_agents:
myagent:
command: myagent
args: ["run", "--json"]
model_flag: "-m" # declaring this makes a model required
system_prompt_flag: "--system" # omit to inline the system prompt
prompt_via: stdin # or "arg"
extract:
kind: json # json | jsonl | text
path: reply
usage:
kind: json
input: usage.prompt_tokens
output: usage.completion_tokens
| Field | Required | Meaning |
|---|---|---|
command |
yes | Executable to spawn. |
args |
no | Static arguments. |
extract |
yes | Where the reply text is — see below. |
model_flag |
no | Flag that selects the model. Without it, ?model= has no effect. |
system_prompt_flag |
no | Flag that sets the system prompt. Omitted → prepended to the prompt text. |
prompt_via |
no | stdin (default) or arg. |
usage |
no | Where token counts live. Omitted → usage reports zero and token budgets go inert, as with ACP. |
error_flag |
no | {path, message_path?} for CLIs that exit 0 and signal failure in the payload. |
minimal_args / minimal_requires_env |
no | Extra flags under cli_minimal, and env vars that mode then needs. |
forbid_env |
no | Env vars that must not be set, because they would silently redirect billing away from the agent's own login. Refused unless cli_allow_api_key. |
model |
no | Default model id for this agent, overridable per call via ?model=. Required in one place or the other whenever model_flag is set. |
env |
no | Extra environment variables for the agent process. |
config_files |
no | Relative path → JSON content, written into the temp cwd before launch. |
extract
kind |
Shape | Fields |
|---|---|---|
json |
Whole stdout is one JSON object | path (dotted) |
jsonl |
Stdout is JSONL | match (dotted path → exact value), path, join |
text |
Raw stdout is the reply | — |
For jsonl, the last matching event wins unless join: true, which
concatenates every match in order (needed when a reply arrives in several
streamed parts). Lines that are not valid JSON — progress chatter, partial
lines — are skipped rather than treated as fatal.
usage
Same kind/match selection, plus dotted paths: input, output, cached,
reasoning, cost. Missing fields report zero. input also accepts a list
of paths, which are summed — for CLIs like Claude that split prompt tokens
across several fields.
iterations handles CLIs that run several model iterations inside one turn and
report usage summed across them. Summing is right for billing but wrong for
prompt_tokens, which max_prompt_tokens reads as "how large did this
context get": the same cached prefix gets counted once per iteration. Observed
on claude -p: 563,963 reported input tokens for a context of roughly 15k,
which tripped the 200k per-call budget. With iterations set, prompt_tokens
is the largest single iteration instead.
Keeping work in the REPL
The premise of an RLM run is that all computation is observable in the REPL. A coding agent with its own shell breaks that: it can compute internally and return a hardcoded answer, which looks identical to REPL work from the outside.
Each preset was tested by planting a canary file in the cwd and asking the agent to read it and to run a shell command:
| preset | mechanism | canary read | shell ran |
|---|---|---|---|
cli:claude-code |
--disallowedTools |
no | no |
cli:opencode |
injected opencode.json denials |
no | no |
cli:codex |
(cannot be disabled) | yes | yes |
Three things this surfaced, all now handled:
--puredoes not remove opencode's tools — it only skips external plugins. The injectedopencode.jsonis what denies them.- Denying opencode's
bash/readis not enough: itstasktool spawns a subagent that does not inherit the denials, and the model will delegate "read this file for me" to it.taskandskillare denied too. - Codex cannot be locked down.
--sandbox read-onlyblocks writes but still permits reads and command execution, andsandbox_permissions=[]changes nothing. There is no flag that removes its shell.
Detection, where prevention is impossible
Because codex cannot be prevented, its bypasses are detected. Presets
declare native_tool_events — output-stream signatures meaning the agent used
its own tools — and the run fails with a clear error when one appears:
"codex" used its own tools (command_execution) instead of the fast-rlm REPL.
Results computed inside the agent's harness are invisible to the REPL loop and
may be fabricated.
Control it with cli_native_tools: "error" (default), "warn", or "off".
A blocked attempt is not a violation — opencode reports a denied call as tool
invalid, which means the guard worked.
Verifying it yourself
scripts/verify_agents.py runs a real query whose answers cannot be guessed
(random data plus a canary token buried in the context) and then audits the
run's transcript:
python scripts/verify_agents.py --all
python scripts/verify_agents.py cli:codex --seed 4242 # reproducible
It checks correctness against locally-computed ground truth, that the answer
contains the canary (proving the agent actually read the context it was given),
that the transcript contains steps indexing into context, that no answer
appears as a literal in non-computing code, and that no native tool use was
detected. Exit code is non-zero on any failure.
Safety
- Isolated cwd. Every agent runs in a throwaway temp directory, removed
afterwards, so a stray write lands there rather than in your project. The
child's
PWDis set to it as well as its working directory — agents that discover project config fromPWD(opencode does) otherwise read your project and silently ignore the permission config written for them. - Scoped subprocess permission. The Deno host is granted
--allow-run=claude,codex— only the binaries the run can actually spawn — rather than the blanket--allow-runthat ACP requires fornpx. - Registered agents are launched as written. fast-rlm adds no sandbox flags
of its own; put the agent's own read-only flags in
args.
Limitations
- No streaming. One reply per turn, as with ACP. fast-rlm consumes whole turns anyway.
- Each call is a fresh process. History is re-sent every step, so there is no
provider-side cache reuse across steps. Both CLIs support session resume
(
claude --resume,codex exec resume) — a possible future optimisation. - Version drift. Presets are verified against
claude 2.1.x,codex-cli 0.149.x, and current opencode. If a flag changes, override the preset undercli_agents— you do not need a fast-rlm release.