A terminal agent that shows you what it is allowed to do, what it is spending, and what it refused — while it works.
io is an interface. The agent loop, the providers, the tools, the sandbox, the
permission boundary and the session store are all
io-harness, and none of them are
reimplemented here. A test asserts that: tests/dependencies.rs fails the build
if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox.
- Install · First run · While it works
- Keys · Commands · Configuration
- The fleet · Pictures · Background jobs
- Reading it without seeing it · Headless
- What this release is not · Platform support · Stability
| What it gives you | |
|---|---|
| A session you can read | Every finished line in the terminal's own scrollback, designed rather than defaulted: a tool call as a verb and a path, a thought as a thought, an answer that ends the turn |
| A working view | Two sticky rows while a turn runs — a word for the turn with its clock and spend, and a line under it saying what is happening now |
| The boundary, visible | The posture on the footer, a refusal that names the act, the target, the rule and the layer, and Shift+Tab to change it from the next turn |
| Approvals in place | A write stops the run and shows the diff it proposes; y, a or n, answered where it was asked |
| A fan-out you can watch | Contained turns spawn children under one shared ceiling; Ctrl+F shows the tree and what it is costing |
| Your file, in force | Every section of io.toml bounds a session turn as it bounds io exec; the budgets in force are on the status line with what is left of them, and /status commits the whole state — policy layers, sandbox backend, caps, budgets, connections — into the scrollback |
| Undo | Esc Esc at an empty prompt rewinds the last turn — its files, its memory and the conversation head |
| Conversations that survive | /resume reopens an earlier session, /fork continues from an earlier turn, /clear starts fresh without leaving |
| Headless | io exec runs one goal to completion with documented exit codes and --json |
| Readable without seeing it | --plain animates nothing and commits every state change as text, for a screen reader, a braille display or a log |
| Markdown, rendered | Headings, bullets, code and emphasis drawn as themselves rather than printed as notation |
io does not enter the alternate screen and does not capture the mouse, in any
mode, behind any flag. Every finished message, tool call and system line is
committed into the terminal's own scrollback; eight rows at the bottom hold the
live row, the activity line, two rows of composer and a three-row footer, and
only those repaint.
So when the session ends the whole conversation is still there. Your terminal's search finds it, tmux copy-mode scrolls it, and a mouse drag selects it — none of which is implemented here. It works because it was never taken away.
That is a property, not an intention. tests/structure.rs captures every byte
io writes to the terminal over a scripted session and fails if the
alternate-screen or mouse-capture sequences appear, so no later release can
reintroduce fullscreen without turning a named test red.
macOS and Linux:
curl -fsSL https://raw.githubusercontent.com/initorigin/io-cli/main/install.sh | shWindows, in PowerShell:
irm https://raw.githubusercontent.com/initorigin/io-cli/main/install.ps1 | iexBoth scripts pick the right build for your machine, verify it against the
published SHA256SUMS before unpacking it, and install into a directory you
own — ~/.local/bin, or %LOCALAPPDATA%\io\bin on Windows. Neither needs
administrator rights and neither edits your shell profile: if the directory is
not on your PATH, the script prints the line to add.
Re-running the script is how you update. There is no auto-update and no version check: a terminal tool that contacts a server you did not ask it to contact is one of this product's stated non-goals.
Set IO_VERSION to install a specific version, and IO_INSTALL_DIR to install
somewhere else.
What the checksum is and is not. It defends against a truncated download and
a tampered asset. It does not defend against a compromised repository — piping a
script from the internet into a shell is a trust-the-publisher model however the
script is written, and this is the honest description of it. Read
install.sh first if you would rather not take that on trust.
There is no crates.io publish and cargo install io-cli is not a path. To build
from source, clone this repository and run cargo build --release; the binary is
target/release/io.
Run io in a repository. With no configuration it walks you through a provider,
a key it verifies against the live endpoint before continuing, a model from that
provider's catalogue, a theme with the sample re-rendering as you move, and a
default permission posture. It shows exactly what it will write and where, and
writes nothing until you say so. The file is io-harness's own, at mode 0600.
Run it again at any time with io setup.
Your key never appears on screen, in the scrollback, or in a log line. If the provider's environment variable is already set, the wizard offers to use it and writes no key to disk at all.
Two rows sit above the composer for exactly as long as a turn is in flight, and neither is there before the first one or after the last one ends.
The top row is the live row: what is happening right now, in this order — the run is waiting on you, or a tool call is open and this is the verb and the path, or the model is thinking, or it is the tail of the answer as it streams. Waiting on a person outranks everything, because every other thing that row can say is about work going on without you. It sits directly under the transcript, which is what it is the continuation of.
Under it, past a row of air, is the activity line: a word for the turn, chosen once per step so it moves when the work does rather than on a timer of its own, the elapsed clock, and the token count the run has been billed for. On a narrow terminal it drops the count and then the clock, which is the rule the status line under it already follows.
These two swapped places in 0.13.1. Through 0.13.0 the work was drawn under the line describing it, so the newest words the agent had written read as a footnote to a spinner.
From 0.14.0 the status line also carries the ceilings actually in force, each
beside what is left of it — left 17/20 steps, left 12.4k/200.0k tok,
left 4m30s/10m00s — for the step, token and duration budgets your [run] table
sets. A budget you did not set draws no field at all, so a session that
configured nothing looks exactly as it did: io-cli's own step floor is
scaffolding rather than a number you chose, and reporting it back to you as a
budget would be noise on every line. They appear once a turn has been built,
because the contract is the one place the order of precedence is already
resolved, and they are read off it rather than composed a second time from the
file. A turn that ends on a budget now says which budget, in the vocabulary
of a ceiling reached rather than of an error — step_cap_reached,
time_budget_exceeded and cost_budget_exceeded were reported through the error
path until this release, so what an operator met under a half-finished answer was
error: step_cap_reached. All four are successful calls in io-harness and always
were. The word stays the harness's; what changed is the weight it is said in.
What lands in the scrollback is designed rather than defaulted. A tool call reads
as a verb and a workspace-relative path — Read src/lib.rs, not read_file and
an absolute path — and a tool this release has never seen keeps the name
io-harness sent, because a verb invented for it would mean nothing. A turn ends
on its answer: the run's step and token counts are on the status line beside the
provider, which is where every other number in this interface lives.
Three more things reach the transcript in 0.14.0, all of them facts io-harness has been emitting into every ordinary session and this interface has been discarding:
- Every outbound connection a contained command dialled, with the host as the
command asked for it, the port, and whether the policy permitted it —
dialled api.github.com:443 · permitted, and a refusal drawn as a refusal rather than as an error, because nothing broke when a boundary worked. The host is never a resolved address: the policy's patterns are written against names, so a row showing140.82.121.4would not match the rule that decided it. An absent dial line is not evidence of no egress — the event has one emit site behind three conditions, and a permissive or all-or-nothing policy names no host and emits none of these ever. - Each sandbox created, capped or destroyed, with the backend that isolated it where the event carries one — io-harness sets it on creation and on a command and not on the other two, and nothing here invents one. A cap reached is drawn as a limit reached and not as a failure: the sandbox did exactly what its configuration told it to.
- A stalled agent, while it is stalling, naming the step it stopped on and how
long it has been there. This needed no configuration to fire — a workspace turn
carries io-harness's default stall policy — and until now it reached you as a
session that had gone quiet, and then, once the run was over, as the word
stalledon the outcome line.
From 0.13.0 every turn carries a system prompt io wrote. Before it, every turn
ran io-harness's built-in description, which names the tools and says nothing
about how to answer — so an ordinary question came back from a model with a tool
catalogue and no idea what it was.
What the prompt sets is small and deliberate: what io is, that the person
reading is at a terminal in a pane a few rows tall, that the answer comes first
and briefly, that work is reported in the past tense once it is done rather than
narrated in advance, and that the output is monospaced text about eighty columns
wide — fenced code, no wide tables, no markup that expects a browser.
It is appended, not substituted. io-harness composes it into the prompt it
was already building, between its own tool catalogue and the boundary section, so
the harness keeps its framing, its catalogue, the sentence that decides how a turn
ends, and everything it says about what this run may do. io adds a manner; it
does not stand in for the description of the request.
It names no model and no vendor, because the model is yours to choose and there are hundreds of them. It claims no tool, no skill and no permission, because what the agent may reach is decided by the contract this turn was given — a prompt that promised a browser would be lying on every session that has not configured one.
Per-repository voice belongs in the repository. io-harness discovers what
[instructions] points at — AGENTS.md by default — and composes it into the
same prompt as a clearly attributed section. That is where "in this codebase, do
it this way" goes. There is no [app.io-cli] key for the prompt itself: a second
place the agent's manner is decided is a second thing to keep true.
| Key | Does |
|---|---|
Enter |
send the prompt |
Shift+Enter |
new line — or Alt+Enter, Ctrl+J, or end the line with \ |
| paste again | the same block again: shows it, then collapses it back |
Up / Down |
walk prompt history |
Ctrl+C |
stop the turn; again to stop it now; twice at an empty prompt, exit |
Ctrl+D |
exit, on an empty prompt |
Shift+Tab |
cycle the permission posture, from the next turn |
Ctrl+L |
clear the viewport, never the scrollback |
Esc Esc |
at an empty prompt, undo the last turn — its files and all |
Ctrl+T |
put the whole conversation back into the scrollback |
Ctrl+F |
show the fleet: the children this turn has spawned |
y / a / n |
answer an approval: allow once, allow this session, deny |
Esc |
stop the running turn, or close a picker without choosing |
/ |
at an empty prompt, open the command palette |
@ |
after a space, complete a path from the workspace |
! |
run the rest of the line in your shell; the agent never sees it |
Shift+Enter works where the terminal reports it. io negotiates the Kitty
keyboard protocol on terminals that advertise it, asking for one flag —
DISAMBIGUATE_ESCAPE_CODES — because without it a terminal sends the same byte
for Enter and for Shift+Enter and the newline binding is unreachable. What is
pushed is popped again on every path out of the process, a panic included. The
trailing-backslash fallback still works everywhere, and on a terminal that does
not advertise the protocol nothing is written at all.
And from 0.13.0 io tells you which one that is. The table above is the
shipped naming, and a README is read on a machine other than the one it
describes — so /help and the wizard's closing screen name the key this
terminal can report. On one that cannot report Shift+Enter they name
Alt+Enter and the trailing backslash, and say the key is unreportable here
rather than leaving you to press it and watch a half-written prompt go to the
model. Nothing about the composer changed: Shift+Enter, Alt+Enter, Ctrl+J
and a trailing \ all still work wherever the terminal can distinguish them.
The keys the session itself owns can be rebound in [app.io-cli.keys], by action
name:
[app.io-cli.keys]
clear = "ctrl+k"
rewind = "ctrl+r ctrl+r"| Action | Default |
|---|---|
exit |
Ctrl+D |
posture |
Shift+Tab |
clear |
Ctrl+L |
transcript |
Ctrl+T |
rewind |
Esc Esc |
fleet |
Ctrl+F |
A binding is a chord, or two chords separated by a space. Modifiers are ctrl,
alt and shift, joined to the key with +, in any order and any case; a key is
a single character, a named key — esc, enter, tab, backtab, space, the
four arrows, home, end, pageup, pagedown, backspace, delete, insert
— or f1 through f12. Because + is the join, + itself cannot be bound; that
is a real limit of the syntax and stating it beats a rule that quietly works for
plus and not for the character anyone would type. This spelling is public
contract from 0.6.0 on: it is the one VS Code, Zed and helix already write.
The rest of the table is not rebindable, because those keys belong to whatever
owns the keyboard while it is up — the composer, an approval, a picker — and an
approval's y, a and n are the words of the answer rather than shortcuts
for it.
Ctrl+C is fixed, and it is the only one that is. It interrupts a running
turn and leaves io, so a configuration file able to take it away is one able to
lock you inside a running agent. Both spellings of that mistake are refused out
loud with the reason: naming interrupt, and putting any other action onto
ctrl+c.
Nothing about a bad line is fatal and nothing is silent. A value that cannot be
read leaves its action on the default and names the key it kept; a name that is no
action of ours says which names there are; and every notice is committed into the
scrollback as the session starts, rather than left to be discovered by pressing
something. /help renders the table as the session actually behaves rather than
the defaults that shipped, and marks Ctrl+C as fixed.
| Command | Does |
|---|---|
/help |
this table |
/exit |
leave |
/setup |
run the first-run wizard again |
/theme |
change the theme for this session |
/model |
change the model the next turn is sent to |
/resume |
reopen an earlier session where it stopped |
/fork |
continue from an earlier turn of this conversation |
/expand |
commit the last step's full detail into the scrollback |
/status |
commit the whole session state into the scrollback |
/copy |
put the last answer on the system clipboard |
/copy diff |
put the whole run's patch on the system clipboard |
/contain |
run turns contained, so the agent can fan out: on, off, or ask |
/plan |
make turns propose a plan before they work: on, off, or ask |
/fleet |
show the children this turn has spawned |
/image |
draw an attached image again: /image 1 |
/clear |
start a new conversation; this one stays in /resume |
Everything that shows you more of something writes it into the terminal's own
scrollback. Ctrl+T, /expand and /status do not open a pane: the viewport is a few
rows and this product has no alternate screen, so the place to read something
long is the buffer where the terminal's search, selection and tmux copy-mode
already work. /expand reads the step's full output back out of the run's
durable trace, which is where it went in the first place — the screen is not the
archive.
/expand also holds the part of a long thought that did not fit. The model's
reasoning is committed as a thought — the word, how long the step had been going,
then the text — and a thought longer than ten rows is fitted with the rest kept
for /expand. io-harness neither stores reasoning nor folds it into the next
prompt, so that copy is the only one there is.
/status commits the whole session state, one fact per row: the workspace and
the session id with the turn its head is at, the provider and model, every policy
layer by name with the acts it governs, the containment caps and what has been
drawn against them, the sandbox mode asked for beside the backend that actually
answered on this host, every budget with what is left of it, how full the
context is, and what is connected — MCP servers and language servers as answered
of configured, the browser, the skills directory. Every field on it is a value
io-harness supplied; nothing is io-cli's account of it. It is not a table, because
a table has a column width and the widest cell here is a workspace path: a row too
long for the terminal is folded and never cut, so eighty columns is a supported
size rather than a degraded one. It reads the state and changes none of it — no
plan gate is registered to build the contract it reports on, because registering
one would turn the planning phase on.
/clear starts a new conversation: a new session id, no prior turn sent to the
model, and the run-scoped status fields back to zero. It clears the screen and
nothing else — the conversation it ends is still in the store and still listed by
/resume, and your terminal's scrollback is still your terminal's. It is refused
while a turn is running.
/copy uses OSC 52, so it reaches the clipboard of the machine you are sitting
at rather than the one you are ssh'd into. Nothing acknowledges an OSC 52 write:
the line it prints says what was sent and how large it was, never that it
succeeded. Inside tmux it needs set -g set-clipboard on, and some terminals
refuse a large payload without saying so.
/theme and /model change this session only and say so. Making a choice
permanent is io setup.
An agent can break a task into sub-agents and run them over the same workspace. io-cli does not implement any of that — io-harness does — but it is the only terminal interface that can show it, because the facts it draws are ones only that core emits.
It is off until you configure the caps it runs under:
[app.io-cli.containment]
max_total_agents = 12
max_concurrent_agents = 4
max_depth = 2
max_total_tokens = 200000With that table present your turns run contained, and Ctrl+F or /fleet
opens a live view over the prompt: one row per child with its own state and what
it has drawn, a per-tier count of what is working, waiting and finished, and the
tree's remaining budget on the status line beside everything else. A refused
spawn says which cap refused it and that the agent carries on with what it has. A
report collected from a child lands in the transcript where it arrives.
A waiting child is a number and not a row, because until a concurrency slot frees it has no run of its own to name. A fleet that is queueing and a fleet that is stuck look identical without that count, which is why it is there.
And from 0.12.0 that is all it costs you. Ctrl+C still ends the turn, at the
next point where no child is in flight, and the interface tells you that is what
it is waiting for rather than appearing to have missed the key. /contain off
gives the next turn back; /contain on takes it again.
Through 0.11.0 this switch carried more than the fan-out. io-harness's contained entry point was then the only session entry point that took a task contract, so turning containment on was also how you got skills, MCP servers, language servers, a browser, an answer to the agent's questions and a plan gate — and turning it off took all of them away. 0.11.0 gave the ordinary turn a contract too. Every one of those capabilities is on every turn now, and containment means what its name says.
/plan on makes the next turn propose a plan before it does anything. While the
planning phase is on, io-harness denies every write and every command until you
approve, so reading a proposal costs nothing and cancelling is not an undo —
there is nothing to undo yet. Enter on an empty prompt approves, typing a
correction sends it back, Esc cancels. The status line says planning for as
long as the phase is on, because it outlives the turn you set it on.
/plan off gives you back a turn that starts working immediately, and that is the
default. Bare /plan says which one you are in and changes nothing.
This moved in 0.12.0. Through 0.11.0 the plan gate rode
[app.io-cli.containment], so configuring a fan-out silently made every turn stop
and propose first. If that is what you wanted, /plan on is where it lives now.
Drag a picture onto the prompt, or copy it and paste. That is the whole of
it — there is no command. What lands is [Image #1], and the picture rides the
next turn and only the next turn. Paste the same file again to toggle between
the marker and the path it stands for; backspace takes the marker off in one
press, whichever backspace you use. /image 1 draws the picture itself, at the
bottom, when you want to look at it — a committed row belongs to your terminal's
scrollback, so it cannot be opened in place.
/attach was removed in 0.13.1, alias and all. It was a command you had to
be told about before you could use the feature, and dropping a picture into the
window is what everyone already does. Typing it is answered the way any other
word that is not a command is.
A path inside the workspace is read through io-harness's own workspace, under the same policy as everything else — its documentation is explicit that this is the same gate a source read passes and not a second one — so an image the session may not read is refused exactly the way a file it may not read already is.
A path outside the workspace is read directly, and that is deliberate: the
file you point at is almost never inside the repository, and every absolute path
was refused before — which made this unusable for the one thing most people
attach. This is the only read in the product that is not the agent's, and it is
the boundary ! already crosses when it runs your own shell line. What may be
sent is io-harness's decision too: bmp, tiff, ico, tga and pnm are converted to
PNG on the way in, jpeg, png, gif and webp go as they are, and svg, heic and avif
are refused by name, because a refusal that says which format it was is one
you can act on. A provider that does not accept images at all is refused at the
door rather than after you have typed the prompt.
The agent can look at images in the workspace from this release, using
io-harness's own view_image tool, which enabling its media feature switches
on. It is bounded by the same policy as any other read. When it looks, the same
picture goes into your scrollback at that point in the conversation, so you are
reading what it read rather than a path you would have to open yourself.
A picture is drawn from half blocks — ▀ splits a cell into two halves that are
each about square — fitted to your terminal's width and bounded in height. On
kitty, ghostty, WezTerm and Konsole a PNG is drawn as the real image instead,
and on iTerm2 so is a png, jpeg or gif — it decodes the file itself, so it is not
limited to the one format Kitty's transfer takes. Inside tmux or screen it is
always half blocks: passing a graphics protocol through a multiplexer needs
configuration that is off by default, and an escape the terminal cannot read is
unreadable bytes written permanently into your scrollback.
Under --plain, under NO_COLOR, and with the ASCII glyph set there is no
picture at all — one line naming the file, its format and its size. A half-block
picture is colour carrying the entire meaning, which is the one thing this
interface will not do.
An agent can start something that outlives the step that started it — a dev server, a watcher, a long build. That is the point of it and it is also the problem: a run waiting on a background process looks exactly like a run that has hung.
So the command is named when it starts, the status line grows a bg 2 field
counting what is still alive, and each job says how it ended: exited with a
status, killed, or left running by a run that finished before it did. The
field is absent when nothing is running rather than showing 0 — a session that
has started no background work has not started zero jobs.
io --plain runs the session without animation: nothing turns, nothing moves,
the ASCII glyph set is forced, and each state the session enters — working,
ready — is committed into the terminal's own scrollback as a line of text. In
the default interface that one state is carried by a word that only ever repaints
and an indicator that only ever moves, which makes it the single thing a reader
who cannot see the viewport cannot follow; everything else a run does already
writes a line. For a screen reader, a braille display, a serial console and a
captured log.
The flag is global, so io --plain, io --plain exec "…" and io exec --plain "…" are all accepted. [app.io-cli] plain = true is the same switch for every
session. The flag wins over the file, and there is deliberately no --no-plain:
accessibility is something switched on on purpose, and a mode that can be lost to
a stray flag is not one to rely on. It reaches an interactive session and stops
there — io exec constructs no theme, draws nothing and animates nothing already.
Four properties this product keeps, whether or not that flag is set:
- Colour is never the only thing carrying a meaning. Every refusal, error and
warning also carries a word, a diff's additions and removals are marked as well
as coloured, and an approval says what is being asked for in words before it
says it in colour.
NO_COLORis honoured on presence, whatever its value, and now survives the first-run wizard and/theme: choosing a theme with the variable set records the preference and leaves the session uncoloured, and says so. - Every mark has an ASCII form. The separator, the tool bullet, the selection
marker, the ellipsis, the elision, the dash, the transcript rule, the quotes,
the credential mask and the working indicator each exist in two sets, and each
ASCII form carries its counterpart's meaning rather than merely standing in
the same column.
[app.io-cli] glyphsnames a set —unicodeorascii— and an absent key asks the locale:LC_ALL, thenLC_CTYPE, thenLANG, the first one present deciding. The set is an axis of its own, in both directions:NO_COLORkeeps the Unicode marks, and the ASCII set arrives at a fully coloured theme. The IO CLI wordmark is the one exception, and it is suppressed rather than transliterated — a wordmark redrawn in#is a different and worse image wearing its name. - The cursor sits where input is expected, on every frame that accepts any: the composer, including at a width too narrow to draw it; the approval overlay; the selected row of a picker; and every step of the wizard. It is the focus indicator a screen reader follows, and a frame that leaves it hidden reports no focus at all.
- A frame whose content did not change is not drawn. An idle session writes no bytes to the terminal, so nothing announces itself twice for having merely repainted.
io exec "<goal>" runs one goal to completion with no terminal, prints the
agent's reply on stdout, and exits with a status that says how the run ended.
It is the same session layer, the same policy, the same store and the same
events as the interactive product — a second consumer of io-harness rather than
a second program.
io exec "add a test for the parser and run it"
io exec --json "fix the failing test" | jq -r 'select(.event=="step") | .decision'
ANTHROPIC_API_KEY=… io exec --provider anthropic -m claude-sonnet-4 "tidy the imports"| Flag | Does |
|---|---|
--json |
write the run's events to stdout as newline-delimited JSON instead of the reply |
--sandbox <mode> |
read-only, workspace-write or full-access — where a command this run executes may write |
--policy <posture> |
workspace or read-only — what the agent may attempt at all |
--provider <name> |
openrouter, anthropic or openai — take the credential and model from the environment instead of a file |
--sandbox and --policy are different axes and share the word read-only.
One is where the sandbox lets a command write; the other is what the policy
permits the agent to try.
--policy ask-writes is refused. Nothing in a headless run can answer an
approval, so honouring it would turn ask before writes into deny writes
without saying so. Every approval in a headless run is declined, and the
refusal is fed back to the agent as an observation it can adapt to — which is
what it already does with a policy refusal.
| Code | Means |
|---|---|
0 |
the run ended of its own accord |
1 |
it never got that far — no provider, a bad credential, an unreadable configuration, a usage error |
2 |
a boundary said no: denied, refused, or a rejected plan |
3 |
a ceiling was reached: steps, time, tokens, or the tree's shared budget |
4 |
the run stopped needing a human: it asked a question, or proposed a plan |
5 |
it ended without finishing: stalled, escalated, or cancelled |
A ceiling is 3 and not 0 because io-harness returns one as a successful
call whose outcome says a limit was hit; a status read off the result alone
would call a truncated run a finished one.
Give the goal an end condition. How a run ends is the agent's behaviour, not
this interface's: a goal with no clear stopping point can keep the agent working
after the useful part is done, until io-harness's stall policy ends the run — and
that is 5, even though the work happened. The same goal on the same model
reached Finished on one run and Stalled on another while io exec was being
tested. io reports what the harness decided and never relabels it, so
"…, then stop" in the goal, or a max_steps in [run], is worth more than
retrying.
A headless run takes io-cli's own step floor of a thousand from 0.14.0, where
it used to take io-harness's twelve. Twelve steps is not a turn — a run that reads
a repository and writes a file spends them easily — so what an unattended job
produced was error: step_cap_reached over half-finished work with nobody
watching, which is the defect the floor exists to fix and is not made better by
the run being unattended. A [run] max_steps you wrote still beats the floor, in
either direction.
One object per line, and nothing else on stdout, so it pipes straight into a
reader. The objects are io_harness::RunEvent serialized by io-harness's own
derive — the same shape its [[hook]] writer appends to a file and its store
keeps in run_events.json:
{"run_id":41,"step":2,"depth":0,"event":"step","decision":"wrote src/lib.rs","tool_call":"write_file","tokens":812,"changed":true}The variant's fields sit beside the envelope's rather than under a key of their
own. Because io-cli forwards the harness's type rather than modelling one of its
own, every event kind reaches the stream — including the thirty-nine the
interactive renderer has no way to draw. There is no timestamp: RunEvent
does not carry one, and inventing an envelope to add one would make this a
format io-cli owns rather than one it passes through.
Progress, warnings and the closing summary go to stderr, so redirecting it leaves the data alone.
io-cli has no configuration parser. io-harness owns discovery and layering, and
io-cli's own settings live in the [app.io-cli] section that io-harness
deliberately does not validate. See docs/config.example.toml.
Six keys live there, and five tables:
| Key | Is |
|---|---|
theme |
dark or light. Absent detects from the terminal background. |
diff |
unified — the default, and what an absent key means — or minimal, the changed lines and the @@ header without the context, for reviewing by file rather than by hunk. |
glyphs |
unicode or ascii. Absent asks the locale. |
plain |
true runs every session in plain mode. The same switch as --plain, which wins over it. |
skills |
a directory of skills for the agent. They appear in the / palette by name, and the agent reads them itself. |
max_steps |
how many steps one turn may take. Deprecated in 0.14.0 and removed in 0.16.0: [run] max_steps is where the number moves to. It still wins over [run] until then, and a file carrying it is told so once at session start. |
[app.io-cli.keys] |
the session's keys, by action name. See Moving a key. |
[app.io-cli.containment] |
the caps a fan-out runs under. Absent, a session cannot decompose anything. See The fleet. |
[[app.io-cli.mcp]] |
MCP servers for the turn, in io-harness's own shape. Merged with the top-level [[mcp]], and wins a collision of ids. |
[[app.io-cli.lsp]] |
language servers for this workspace. Merged with the top-level [[lsp]], and wins a collision of ids. |
[app.io-cli.browser] |
a browser the agent may drive. Never downloaded — it is one you already have. |
Because the section is unvalidated by design, an unrecognised value reads as the default rather than stopping a session from starting. A section io-harness cannot parse at all is a different case and is no longer silent: through 0.5.0 that reverted the theme, the diff style and everything else in the section at once with nothing said about it, and the session now starts on the defaults carrying io-harness's own message — which names the key that broke — in its scrollback.
The file is found in this order: $IO_CONFIG, else $IO_CONFIG_HOME/io.toml,
else $XDG_CONFIG_HOME/io/io.toml or ~/.config/io/io.toml, and
%APPDATA%\io\io.toml on Windows. A project's own io.toml and a gitignored
io.local.toml layer on top of it.
One thing worth knowing: a project file may narrow the permission boundary and may never widen it, because a repository you cloned must not be able to grant itself permission. The wizard therefore writes the user-scope file, which is where widening is your own decision.
The policy's own defaults are what Shift+Tab cycles; a posture chosen with the
key lasts for the session and is not written back, because a keystroke that
rewrites a permission boundary on disk is the opposite of what that key is for.
The whole file reaches a session turn from 0.14.0, and it reaches io exec
from the same call. [sandbox] limits, [run] budgets, [run.commit_identity],
[[agent]], [web], [memory], [instructions], [[mcp]], [[lsp]] and
[browser] are all applied to a turn's contract, in your terminal exactly as in
CI. There is no longer a section of this file that a session reads past.
The layers run weakest to strongest, and that order is asserted rather than
described: io-harness's own defaults, then io-cli's step floor, then everything
io-harness's own sections say, then [sandbox], then [app.io-cli]. So a
[run] max_steps you actually wrote beats io-cli's floor — a file that lowers
the cap is honoured, not only one that raises it — and an [app.io-cli] server
of the same id beats a top-level one. The two server lists are merged rather than
replaced, and the session names any id it dropped.
Nothing rides [app.io-cli.containment] but the fan-out. Through 0.11.0 the
contained turn was the only session entry point io-harness let a caller hand a
task contract to, so the responder, the plan gate, MCP servers, language servers,
the browser and the skills directory all arrived on that one switch. 0.11.0 gave
the ordinary turn a contract too. Every one of those has been on every turn since,
contained or not, and no session turn takes a steer inbox — so containment costs
no steering and grants no capability. It is the caps a fan-out runs under and
nothing else.
What changes for a file you already have. No key is added, removed or renamed, and a 0.13.1 file is a valid 0.14.0 file. What changes is what it does:
- A
[run]block written forio execnow bounds your terminal.max_steps = 20is a reasonable thing to have set for an unattended CI run and an unreasonable cap on a conversation. The status line carries each budget in force with what is left of it and/statuslists them all, so a turn that will stop at a ceiling says which one before it gets there. If you want[run]for CI only, move it to a project file or narrow it by scope. io execnow takes io-cli's own step floor of a thousand instead of io-harness's twelve. A headless run used to enderror: step_cap_reachedunder half-finished work with nobody watching. A[run] max_stepsin the file still beats the floor.
And [web] is a capability, not a preference. Reaching a session turn, it
gives the model the provider's own search and fetch — and it is the vendor that
dials the URL, so the net rule in your policy is not what governs it. That rule
decides what this machine may reach. A [web] table that did nothing in your
terminal yesterday turns something on in it today, which is why the session says
so at start in its own words rather than folding it into a list.
[browser] is refused in a project-scoped file, by io-harness rather than by
io-cli: it names a program to execute, and a project's io.toml arrives with a
git clone. Write it in the user-scope file — the one io setup writes — where
widening the boundary is your own decision. There is no project-scope route to a
browser at all. io-cli's own [app.io-cli.browser] is read from either scope.
NO_COLOR is read from the environment rather than from this file, and so is the
locale behind glyphs. See Reading it without seeing
it.
0.14.0 is the release where the configuration file reaches your terminal: every
section of it bounds a session turn as it already bounded io exec, /status
commits the whole picture into the scrollback, and the ceilings in force are on
the status line beside what has been drawn against them.
Four sections of the file are still not applied, and each has a reason.
[[hook]] and capability bundles reach a contract through their own builders and
need a surface that reports what loaded and what was dropped; [prices] is not
part of a contract at all and belongs with the release that reads the
provider-call rows; there is no [verify] section to apply, and giving a session
verification gates needs its own surface; and run.templates is the thirteenth
[run] key, reachable only through its own accessor. None of them is a silent
omission any more — this is where they are named.
There is no way to change a key from inside the session. /status reads the
state and never writes it, and editing configuration is 0.16.0 — a surface for
changing a key is worth building once changing the key does something, which is
what this release is.
Sixel is still absent, because encoding it means palette quantisation and another dependency, for terminals that either speak one of the two protocols already here or draw half blocks correctly. The Kitty path covers PNG rather than all four wire formats, because Kitty's own transfer format is PNG and the only base64 this program has is the one io-harness already computed — a screenshot is a PNG everywhere that takes one.
A text-only model plus an image is a failed run, and it fails at the wire. Whether a provider takes image input is asked before an attachment is accepted, but that is a question about the provider — with OpenRouter in front of four hundred models, the answer is yes while the particular model you have chosen may still be text-only. What you get then is the provider's own refusal, mid-run:
error: provider error (Request, HTTP 404): {"error":{"message":"No endpoints
found that support image input","code":404}}
The step and its tokens are already spent when it arrives. This also reaches you
without attaching anything, because enabling images gave the agent view_image
and the agent may decide to use it on a model that cannot see — and io-cli cannot
take a tool out of io-harness's own workspace tool set. Checking the model rather
than the provider would mean reading the live catalogue on every attach, and it
would still not close the door the agent opens. If you work with images, choose
a model that accepts them.
An image the agent was given rather than asked for is not shown. A picture returned by an MCP tool, and a browser screenshot, both become images inside io-harness — but through private plumbing and with no event of any kind, so nothing reaches this program to draw.
A skill is listed by name and never pasted. A template is expanded by io-cli
into prompt text, so nothing but this program is involved; a skill is read by the
model, through a tool, under the run's own policy. Choosing one from the
palette puts use the <name> skill: in your prompt and stops there. io-cli
parses no skill file and keeps no copy of one.
io exec runs one goal and stops, and a run that pauses stays paused. An
agent that asks a question about what you meant, or proposes a plan, ends the run
at exit 4 with the question persisted in the store. That is io-harness's
behaviour and it is the right one — a machine answering a question about intent
on your behalf sends the agent down a path nobody chose — but there is no io resume in this release to answer it and carry on, so the run is parked rather
than lost. The closing line names its id. Approvals are the one pause that cannot
happen, because they are declined rather than deferred.
There are no --max-steps, --timeout or --max-tokens flags either: [run]
in the configuration file expresses all three, and a CI job's limits belong with
the project rather than in every invocation.
A rewind does not check whether you edited a file yourself since the turn. It
puts each file back to the state before that turn first wrote it, and it does not
compare that against what is on disk now — so a hand edit made afterwards is
overwritten. io cannot detect this, because the snapshot it restores from is not
readable from outside io-harness; what it does instead is tell you, in the prompt,
before the second keystroke. This is what git checkout -- <path> does too, and
it is said here rather than left to be discovered. Making it preventable is a
change to io-harness, not to this interface.
A rewind undoes one turn, the last one. /resume lists every session the
walk found — the twenty-row cap is gone, because it existed only to keep a list
short that nobody could filter. One bound is left, on how many runs the walk will
look at, and the list still says so when it has cut rather than quietly showing
you a subset.
Two more things are absent for reasons worth stating rather than hiding. A diff
cannot be expanded beyond
the context the harness stored — three lines either side, which is what
diff -u has always carried; more than that is not in the trace, and reading it
off disk would be reading a version of the file that no longer exists. And
there is no split view: this renderer commits into the terminal's own
scrollback at its real width, a two-column comparison doubles the horizontal
budget for every line, and word-level emphasis inside a unified diff already
answers the question split view answers.
One ceiling worth knowing about: a hunk is a fragment of a file, and each of its lines is highlighted from a clean parse. A block comment or a multi-line string that was opened above the hunk is not known here, so those lines read as code.
| Platform | Build | Containment |
|---|---|---|
| macOS, Apple silicon | aarch64-apple-darwin |
io-harness's own: sandbox-exec |
| macOS, Intel | x86_64-apple-darwin |
as above |
| Linux | x86_64-unknown-linux-musl, statically linked |
io-harness's chain: Landlock, bwrap, namespaces, floor |
| Windows | x86_64-pc-windows-msvc |
Job Object, with AppContainer opt-in |
The four artifacts and their SHA256SUMS are attached to every GitHub Release,
and the full test suite runs on Ubuntu, macOS and Windows in CI. What confines
a command is io-harness's, not this product's — io shows you which backend
actually answered on this host, in the footer, because the mode asked for and the
backend that applied are not the same fact.
Rust 1.95 or later to build from source. There is no crates.io publish: the
distribution channel is the GitHub Release, and publish = false makes an
accidental one impossible rather than merely discouraged.
Pre-1.0 and staying there until the owner says otherwise. A minor release may
change what a session looks like — 0.11.0 rewrote the transcript's vocabulary,
and the release before it moved where a question is answered. What you can rely
on is that every one of those is in CHANGELOG.md, said plainly,
and that a configuration file written for an older release keeps working: no key
has been removed or renamed since 0.1.0. One key is on its way out —
[app.io-cli] max_steps, deprecated in 0.14.0 and removed in 0.16.0 — and it
keeps working, and keeps winning, until then. A section that was ignored may
start being read, which 0.14.0 did to eleven of them, and that is a behaviour
change for a file that already carried one; it is the migration note in
Configuration and in the changelog rather than something to
find out from a turn.
Report vulnerabilities per SECURITY.md. Your provider key is never printed, never committed to the scrollback, and never written to disk by the wizard when the provider's own environment variable is already set.
See CONTRIBUTING.md. develop is the working branch; nothing
reaches it or main except through a pull request.
Apache-2.0. Copyright 2026 Aakash Pawar (InitOrigin). See LICENSE and NOTICE.
