What you may depend on, what may change, and what does not work today.
A library's contract is its API. A command-line tool's contract is what a script may rely on: the argv it accepts, the codes it exits with, the JSON it emits, the keys it reads out of a configuration file, and the paths it writes to. That is what this page enumerates.
The product is pre-1.0 and stays pre-1.0 until its owner says otherwise. Within 0.x, a
breaking change is a minor bump; there is no other room for one.
io [-C DIR] [-m MODEL] [--profile NAME] [--plain] [--full-access] [<subcommand>]
All five flags are global, which means they are accepted on either side of a subcommand:
io -C dir exec "…" and io exec -C dir "…" are the same command. A flag whose acceptance
depends on which side of a word it is typed is a flag that works only on its author's machine,
and 0.5.0 shipped that defect once.
With no subcommand, io opens an interactive session.
| Subcommand | What it does |
|---|---|
io setup |
The first-run wizard: provider, credential, model, permission posture, theme |
io exec "<goal>" |
Runs one goal to completion with no terminal interaction |
io resume |
Carries on a run that stopped for a question, a plan or an interrupted call; --list shows what is waiting |
io mcp … |
Manage MCP servers without opening a session; io mcp serve makes io one, over stdio |
io plugin … |
Manage capability bundles and marketplaces — the verbs are add, install, list, search, remove and marketplace. io plugin marketplace add <owner>/<repo> clones an index, io plugin marketplace list and remove are its other two, and io plugin add <name> installs a bundle out of one |
io config … |
Read and write configuration keys |
io skill … |
Add, list and remove skills |
io acp |
Serves the Agent Client Protocol (ACP) on stdin and stdout, for an editor — an ACP client such as Zed or a JetBrains IDE spawns it; see From an editor |
io upgrade |
Prints the command that updates this binary, for the way it was installed: brew upgrade io from the tap this repository is, scoop update io from the bucket, and the installer line from a script install. It prints and does not run |
--plain reaches an interactive session and stops there. io exec builds no theme, draws
nothing and animates nothing already, so there is no second thing for the flag to switch off.
--full-access runs unconfined (0.41.0): every tier default becomes allow and the turn
runs under io-harness's ExecMode::FullAccess, which is what reaches a call the sandbox refuses
structurally rather than by policy. What you may depend on about it: it is not in the
Shift+Tab cycle and there are still exactly three postures; it is marked on every frame it
is in force; it is never written to any file, so it lasts the session and no longer; it
cannot be requested by the agent; and it does not unlock a [[policy.layers]] rule — .env
stays denied under it. See the limits for the three sources a refusal can come
from and which one asks.
-C on a path that is not an existing directory is refused before any run opens, with exit
1, and creates nothing. Through 0.40.0 it created the directory and ran inside it.
Two more things a script may depend on, both new in 0.41.0 and both exit codes rather than text:
io config get <key>exits1when there is no such key and0otherwise. The line it prints is unchanged. A key the catalogue names that no file sets is a successful read that saysdefault— "nothing set it" and "there is no such key" are different answers, and only the second is an error.io config seton a list key refuses a single quoted command line, with exit1and a message naming the form that works. The two list keys areapp.io-cli.gates.commandandapp.io-cli.browser.args, and their words go after--:io config set app.io-cli.gates.command -- python3 --version. After--a single element containing a space is taken at its word, so a program path with a space stays expressible.
io exec --json ends with a cost line (0.41.0). It is a JSON object like every other line
in the stream, keyed by "event": "cost", and it carries total_cost_usd, unpriced_calls,
calls_without_usage, calls and run_id. total_cost_usd is the same figure /cost reports
for that run, computed by the same two calls over the same rows rather than by a second
implementation.
Read unpriced_calls beside the money or do not read the money. A run with unpriced calls in
it is reporting a floor, not a total — no model was recorded, or no price is entered for the
one that was. calls_without_usage counts calls that reported no token usage at all.
There is deliberately no per-step cost_usd. EventKind::StepUsage carries no
server_tool_requests, which pricing charges for, so a figure derived from that event would
under-report any step that used a provider's own server tool — and a knowingly-low money figure
is worse than none. The accurate total is what ships.
io exec additionally takes --json, --sandbox <read-only|workspace-write|full-access>,
--policy <workspace|read-only> and --provider <openrouter|anthropic|openai>.
--policy ask-writes is refused, because nothing headless can answer an approval.
io acp takes no arguments of its own — the client speaks the protocol, not the command line —
and the global flags reach it, which is how an editor points io at the workspace it has open.
It is not run by hand: an ACP client spawns it and speaks newline-delimited JSON-RPC 2.0 at its
stdin. stdout is the protocol, so nothing else is written there and diagnostics go to stderr.
It always exits 0 when the client closes the pipe, because the protocol reports a run's outcome
in its own stopReason and a process exit code would be a second, disagreeing answer.
io mcp serve (0.39.0) is the mirror of that door: it makes io an MCP server, offering this
install's own tools to another agent over newline-delimited JSON-RPC on stdio, under the policy
this install resolved. It takes no arguments — the workspace is -C's and the policy is the
configuration's, because either as an argument would be a way to serve a workspace under a posture
the operator's own file does not grant. stdout is the protocol on this door too: what it serves,
what it does not, and under which posture are written to stderr before the loop starts. It ends
when the client closes stdin, and exits 0. Eleven of io-harness's tools are deliberately not
served, and it names them.
These are the contract a CI job depends on. A code never changes meaning under a dependency
bump — when io-harness 0.70.0 split VerificationFailed away from StepCapReached, mapping it
to the existing 3 would have moved exactly the runs 6 was invented for.
| Code | Name | Means |
|---|---|---|
0 |
OK | The run finished |
1 |
FAILED | The run failed, or the command line was not understood |
2 |
REFUSED | Denied, refused, or the plan was rejected |
3 |
CEILING | A step, time, cost or budget ceiling was reached, and nothing judged the work |
4 |
PAUSED | The run is waiting on a question, a plan, an approval or an interrupted call — resumable with io resume, except an approval |
5 |
UNFINISHED | The run ended in a state this table does not name |
6 |
UNVERIFIED | The work was judged and did not hold up — a verification gate that failed, or a run that never satisfied its output schema |
Two different failures reach 6, and a caller that has to tell them apart reads stderr rather
than the code. A verification gate is [app.io-cli.gates], this crate's own key, and a failing
one is io-harness's VerificationFailed. The schema contract is io-harness's [run] output_schema — io-cli defines none and sets none, and Config::apply_to applies the operator's
to a session turn and to both headless doors alike — and a run that never produces the shape it
asks for is RunOutcome::SchemaUnsatisfied. Both mean the same thing, which is why they share a
code: the work produced an answer, something checked it, and it did not hold up. The line
io exec writes to stderr is what separates them, and the schema one says the run never produced
the shape its output schema asked for. Neither is a ceiling — reporting either as 3 would send
an operator to raise [run] max_steps, which buys more attempts at the same failure. Over ACP
both are max_turn_requests, because such a run exhausted the attempts it was allowed rather than
refusing anything.
Exit 4 names the question_id, plan_id or attempt_id that io resume needs — for three of
the four pauses. An approval is the fourth and it names no invocation, because there is none:
io-harness publishes no resume entry point that takes an approval, and one is answered by the
person the run asked, at the terminal it asked from. That pause names its request_id and says
that instead, rather than printing a command a script would find does not exist. io does not
produce that pause today at all — it declines every approval rather than deferring one, in a
session and headless alike — so the carve-out is what would be printed if it ever did, and is
written down because the code that would print it exists and is reached by nothing.
That sentence is true of io exec. It stopped being true of io acp in 0.38.0. An ACP
session is the first surface with a person behind it that is not a terminal, so it is the first
that can raise an approval and be answered — and it does. An approval becomes a
session/request_permission request naming the tool call, its act and its target, offering three
options: allow once, allow for this session, and deny.
The answer decides the call. allow-session remembers exactly that act on exactly that target
for the rest of the run, which is the same rule the terminal's own approval overlay writes —
both surfaces build it in one place, so they cannot come to mean different things by the same
word. An option id that was never offered, a cancellation, and a JSON-RPC error are all
denials: there is one safe direction to be wrong in.
A client that disconnects mid-question denies rather than hangs, and there is no timeout. When the reader stops, every outstanding request is abandoned and each waiter becomes a denial. A timeout would have been a number invented here — a minute is too short for someone reading a diff, and an hour is indistinguishable from a hang. The connection ending is the real event.
Where nobody answered, the model is told the interface could not route the approval, never that the operator denied it: the operator was not asked. Where the operator was asked and said no, the model is told they refused, because that is what happened.
io exec is unchanged and declines every approval for its own reason: an unattended run has
nobody to ask, and an approver that blocked there would hang forever.
Every boundary refusal reaches 2, from either headless door. A run whose provider endpoint
lands in the ask tier is refused by io-harness before the run's first step, and io resume
re-authorizes that same endpoint before it carries anything on, so the refusal reaches both doors.
Through 0.34.1 both exited 1 — the typed refusal was flattened to a message before an exit code
was chosen — which that release documented as a known defect rather than fixing, because the
repair changes a published status. It is reachable only through an explicit
act = "net", effect = "ask" rule matching the provider host — see
the headless guide, which describes it where an operator meets it.
The file is io-harness's io.toml, and its resolution is io-harness's, not this crate's:
$IO_CONFIG names a file outright and wins over everything; otherwise $IO_CONFIG_HOME names
the directory; otherwise the platform default. io-cli's only contribution is to set
$IO_CONFIG_HOME to its own home when the operator has set neither.
[app.io-cli] is the one section io-harness deliberately does not validate, so it is this
crate's own and this page is its contract. It carries nineteen keys:
| Key | Shape |
|---|---|
theme |
string; absent means "detect from the terminal background" |
diff |
unified or minimal; absent means unified |
glyphs |
unicode or ascii; absent means "ask the locale" |
plain |
bool; the same switch as --plain, and the flag wins |
escalate |
bool; absent means on. A deny that came from policy.defaults asks instead of refusing. A deny that came from a [[policy.layers]] rule never does, and neither does a path outside the workspace root. false restores 0.40.0 |
keys |
a table of action name to chord |
containment |
the caps a fan-out runs under — this key is what turns the fleet on |
mcp |
MCP servers, merged with io-harness's own by id |
lsp |
LSP servers, merged the same way |
browser |
io-harness's BrowserConfig |
skills |
a path |
max_parallel_reads |
integer |
spawn_background_after_secs |
integer |
detached_spawns |
bool |
prices |
a price table |
gates |
the verification criterion |
conversational |
bool |
routing |
which model answers, and what happens when a provider stops |
reference_catalogue |
bool — absent is true, and false stops io reading a model catalogue before the first step |
A sub-table's fields are optional and refused by name, never required. A required field is a
deserialization failure that takes the whole [app.io-cli] section down with it — the theme,
the keys, the ceilings and the verification gate — over one misspelt line.
[app.io-cli] max_steps was removed. The notice survives it, read from the raw section,
because the removal would otherwise be silent.
Every key is documented with a worked example in config.example.toml.
One directory: ~/.io-cli on Unix, %USERPROFILE%\.io-cli on Windows, created 0700.
| Path | Holds |
|---|---|
io.toml |
The user-scope configuration |
runs.db (+ -wal, -shm) |
The session store |
skills/ |
Skills, with skills/disabled/ for the ones switched off. Read from and written to the home in force, so $IO_CONFIG/$IO_CONFIG_HOME moves it with io.toml and IO.md. io does not move files you already own; it says so at startup if the old directory still holds any |
marketplaces/ |
Cloned marketplaces, with marketplaces/.entries/ for a repository an index pointed at |
adapters/<owner>/<repo>/<name>/ |
The plugin.toml io generates for a Claude Code or Codex bundle |
.fetching/ |
Staging for a clone in flight, renamed into place on success |
IO.md |
The user-scope memory file |
.import-offered |
Marks that the import offer was made, so declining is remembered |
An install found elsewhere is moved in, never onto a file that already exists, and nothing is
deleted that was not already copied. This does nothing at all when $IO_CONFIG or
$IO_CONFIG_HOME is already set.
A gated headless run with no budget stops at forty steps and exits 3. A [app.io-cli.gates]
criterion takes away every bound io-cli's own floor of a thousand is safe because of — io-harness
hands a failed criterion back as a reason to keep going, a run with no budget is measured against
nothing, and headless has no operator — so a run that set no max_steps, max_duration_secs or
max_tokens is bounded at forty. Anything you wrote beats it, including max_steps = 1000,
which through 0.39.0 was indistinguishable from having written nothing.
A marketplace, and an entry inside one, resolve against github.com and no other host. A
marketplace is named <owner>/<repo>; an index entry naming a repository elsewhere has its url
re-derived to that shape, and a url that is not two ordinary path segments on that host is refused
and listed with its reason. The only string io hands git is one io built — a url somebody else
wrote reaching a clone is how ext::sh -c … becomes a remote shell. All 238 remote entries of
anthropics/claude-plugins-official are on that host.
A Claude Code or Codex plugin's hooks are read, shown, and translated into nothing.
io-harness's hooks are argv against its own event tags and never a shell string, and it refuses
${env:}, ${file:} and ${cmd:} inside a manifest in every scope. This is an impossibility, not
a deferral: the author adds a plugin.toml if they want hooks under io.
A turn the operator interrupted cannot be resumed. It is reported as ended, with /fork
offered instead.
An imported allowlist is read, shown, and translated into nothing. io-harness's Act::Exec
matches a binary name and nothing else, so the nearest faithful import of bun install is a
blanket allow on bun — wider than the operator granted. This is an impossibility, not a
deferral.
servers::edit is reachable from no keystroke. /mcp adds and removes; editing an existing
entry in place is not wired to a key.
Routing never reaches a contained turn. io-harness applies routing in the flat loop only; a tree takes each agent's model from its own roster entry. The session discloses this at all three points an operator can enter the state.
Every HTTP MCP server is refused by default, because the default network effect is Deny. A
bare-host rule opens it.
Eleven commands run while a turn is in flight, and the rest are refused. /status,
/context, /cost, /stats, /help, /theme, /copy, /expand, /fleet, /image and
/config are admitted while the agent works; / and @ open the palette and path completion;
/compact and /steer reach the turn through their own arms as they always have. Every other
command keeps its refusal, in the same sentence, and the rule is what a command does rather than
how harmless it looks: anything that reassigns the session or the provider, writes the store or a
configuration file, or submits a turn of its own is refused.
Ten of the eleven only report. /context withhold <tool> is the exception and it changes
something — the session's tool mask — which is admitted because the moment you want a tool
withheld is the moment you watch the agent reach for it. It passes the rule above rather than
bending it: no file is written, nothing is reassigned, and the mask reaches the next turn
because this one's contract was built before the keystroke.
The mask changes what the agent may call and not what a turn costs. io-harness sends the same
catalogue either way and io adds one sentence naming what is withheld, so a masked request is the
longer of the two. /context allow <tool> takes one name back and /context allow on its own
clears the mask; a name that matches nothing the last request carried is kept, because io-harness
keeps an unknown name deliberately so a mask stays portable between builds, and io says so on the
line.
/config is admitted bare and refused the moment it carries a word. Through 0.32.0 it was
refused in every form, because the bare list carried a row that re-read the provider's catalogue,
wrote the prices into a scope file and reassigned the configuration the running turn was holding —
and because a horizontal arrow on a row wrote the scope file where it stood. 0.33.0 removed both
acts rather than guarding them: the refresh is one descent below prices.as_of, the arrows open a
setting's values, and the bare list reports and nothing more. /config <key> and
/config <key> <value> descend toward a write and keep their refusal, which is the first time the
run-state guard has read past a command's first word. The whole-command refusal still applies to
/plugin, /mcp, /provider, /skills, /memory and /store.
A mid-stream token figure is an estimate and is drawn as one. EventKind::Token carries text
and no count, because providers do not bill per chunk, so the figure that moves while a step is
streaming is derived from the delta text and written ~1.2k tok. It is replaced by the provider's
own number the moment the step commits, and the settled form has no tilde. Nothing adds the two
together.
A bundle's contributions are displayed with a colon and addressed with io-harness's separator.
ultraship:brainstorm is what you read; ultraship__brainstorm is what the model was shown and
what read_skill resolves. io exec, io plugin and io skill report the wire name, because a
script addresses the wire. From 0.34.0 the separator reaches no operator-facing surface at all —
including an MCP tool call, drawn Call github:create_issue — and a gate walks six of those
surfaces and fails on io_harness::NAMESPACE appearing in any of them. Invoking a bundle's skill
as a command is the one place the two forms are side by side on purpose: the scrollback echoes the
line you typed, /ultraship:brainstorm …, and the prompt behind it carries ultraship__brainstorm.
A bundle's [[bin]] is resolvable inside a run and nowhere else. io-harness 0.74.0 accepts the
key, resolves the path against the bundle root and places nothing; io-cli appends the directory
holding that file to its own process PATH, which every command a run spawns inherits. Appended
and never prepended, so nothing a bundle ships can shadow a system command. No file is written and
nothing is linked, so the program resolves under the name it already has on disk — a [[bin]]
whose name is not that name is reported at startup and does not resolve. Your shell's PATH is
untouched, and a bundle switched off contributes no entry.
Everything not named above. In particular: the layout of what is drawn on the terminal, the
wording of any line, the set of slash commands and which group each sits in, and the internal
module structure of the crate. The library target (io_cli) exists so that tests can link
against it and is not a supported API — src/main.rs is not linkable from tests/ at all, which
is why any behaviour that needs a gate lives in the library rather than the driver.