Skip to content

Latest commit

 

History

History
532 lines (460 loc) · 35.8 KB

configuration.md

File metadata and controls

532 lines (460 loc) · 35.8 KB

Configuration

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.

Without leaving the session

/config shows every key with the value in force and the file that decided it — user, project, local, or default where no file decided it. A key no file named names no file rather than being blamed on the lowest-precedence one: io-harness reports an empty origin for it, and that is its own default speaking.

From 0.28.0 a value is chosen rather than typed. Choosing a row used to put its key in the prompt and leave the value to you — so setting policy.defaults.write meant guessing a word out of a set the pinned dependency has made public, and there was no way to tell a typo from an option that does not exist. A row now descends into its own values: the three effects and the three sandbox modes come from io-harness's own types rather than from a list here, a model comes from the [prices.models] already in your file, a path comes from the same workspace reader the composer's @ opens, and a number comes from a ladder. Nothing on that screen reaches the network, and there is no per-key table of options to go stale.

A number descends into a one-two-five ladder built around the value in force. 1, 2, 5 at each magnitude, ordered outwards from where you are, with the value the file currently says always present as a rung whether or not it sits on the ladder — because a list that quietly omits your own setting is a list you cannot find it in. The anchor is the value in force and not a default, because there is no default to anchor on: max_tokens and max_duration_secs are absent in both of io-harness's contract constructors and max_steps is 8 in one and 12 in the other, so "the default" is not a thing this surface could read. A key no file names ladders from 1. app.io-cli.gates.expect_exit is the one signed key and its ladder runs through zero into the negatives, because a process may legitimately be expected to exit on one.

A horizontal arrow opens a row's values, with the marker on the value in force. Left and Right do what Enter does, and they open on where you already are so you can see it before you move; Enter on a value is the confirmation, and the write goes into the file the descent names.

No arrow key writes a configuration file, and until 0.33.0 one did. Left and Right on a boolean or a closed set of words used to step to the next value and write it into a scope file on the keystroke — one press, one write, nothing asked. It was the only unconfirmed write in this product reachable from a bare arrow key, and it is the reason /config could not be opened while a turn was running at all. Removing the write rather than guarding it is what made the bare list safe enough to report mid-turn; see Keys for the commands that now answer while the agent works.

The price refresh is one descent below prices.as_of, and it used to be the last row of the bare list. There it made a keystroke on /config read the network, write a scope file and reassign the configuration a running turn was holding — which is a list that acts, on a surface whose whole job is saying what is in force. It is now where the act belongs: choose prices.as_of, the date the refresh writes, and the descent offers leave it at the top and the re-read under it, with the date it last read beside it. Row 0 declines and nothing happens, which is what row 0 does on every confirmation in this product. What the refresh then shows you before it writes is unchanged — see What it costs.

prices.as_of is still the one key you cannot type a value into. A date typed by hand is a claim about a fetch that never happened, so /config prices.as_of <value>, io config set prices.as_of and every other typing door refuse it — and they refuse it in the same words, because a key one door writes and another will not is the asymmetry this surface exists to remove. The descent offers the fetch instead of the date.

Every row also offers unset it, which removes the key rather than writing a default's text into a file. The distinction is the one this whole surface opens with: after an unset the origin column says default and names no path, which is io-harness's own default speaking. Writing the default's value instead would attribute a crate default to a file you never wrote it in, and that is a lie a reader has no way to detect.

A write goes into the file already deciding the key, and the confirmation says so before you choose. Asking every time would cost more than the change did — the value was chosen in one keystroke — and answering "your own file" every time is worse than asking: it silently shadows a committed project setting with a personal one, which is the change you are least able to see afterwards. A key no file names has nothing to inherit and goes to your own file, and the title says that too. write it to another file… is the row for moving a key between the three scopes, and it carries the current value along so a move does not also ask you to retype what you had.

What is still typed is only what no menu can hold — a substring, a rubric, a URL, a command — and each of those now says what shape it wants and shows a worked example before the composer opens. Nothing opens a bare prompt with a key in it and no candidates any more.

Three ways in, and they differ in one thing. /config opens the list. /config <key> <value> is the shorthand this surface has always had and asks which of the three files to write to. /config set <key> <value> — and /config unset <key>, and io config set and io config unset — inherit the deciding file the way the picker does, and take --scope user|project|local to override it. The change is in force from the next turn.

Your file survives it. The comments, the blank lines, the order you chose and every section io-cli has no type for come back byte for byte — one value's bytes are replaced and the rest is copied through. The write is staged in a temporary file and renamed over the original, so a failure cannot truncate a configuration, and the mode is preserved. That now holds for a removal as well: through 0.21.0, removing or moving an entry took the next section's comment block away with it, and moving one into the last position of a file with no trailing newline concatenated it onto the final value. A key whose name carries a dot — a model id, an MCP server id — is addressed correctly too; it could only be written quoted, and the path splitter cut it in half, which surfaced as an unexplainable "the edit would have produced a file that does not parse".

A change that would widen the boundary is refused, in io-harness's own words, from any file inside the workspace — the project's io.toml and io.local.toml alike. The rule is about which file, not which value, and since io-harness 0.74.0 the local file is on the wrong side of it too: it sits in a root the run's own agent can write to, so a single write_file of it would declare an argv the next discovery runs, outside the policy and outside the sandbox. The user scope is the one file no workspace can reach. io-cli keeps no copy of those rules: it writes, asks io-harness to read the file back, and restores it exactly when the answer is no.

From 0.28.0 the row says so before it writes, and that is worth a second mechanism rather than being left to the round trip, because the cost is not one key. There are thirteen (key, value) pairs across twelve keys that a file in the workspace may not carry — the four policy.defaults.* acts set to "allow", sandbox.allow_network = true, sandbox.force_floor = false, sandbox.mode set to either "full-access" or "workspace-write", and each of the five sandbox.limits.* set to 0, which is what "no cap" means. io-harness's check runs before the file is deserialized, so choosing one of them in a workspace file does not get you a rejected setting: it gets you a configuration that no longer parses. The write is still verified by io-harness reading it back, and still rolled back to the exact bytes that were there. What the row adds is that the file is not written at all, and that the refusal says the whole file is what would have been refused. On config set it goes further and names the scope that will take the value: --scope user, for yourself. From 0.40.0 the /config scope picker offers only that scope for such a key, rather than offering three and letting the write refuse two of them.

/mcp shows what is configured, which servers answered this session, how many tools each announced, how many distinct ones this session has asked for, and the last failure. A server the session has not reached says so and is not shown as broken. From 0.21.0 it edits and removes [[mcp]] entries, through the same write /config uses: staged, read back by io-harness, and rolled back whole when the answer is no. From 0.28.0 it declares them too — /mcp add <id> -- <command> [args…] for a server io starts, /mcp add <id> --url <URL> for one it dials, with --env, --header, --timeout-secs and --scope, read by the same parse io mcp add uses so the two doors write the same bytes. That is the verb this panel was missing: servers::add existed, was tested, and was called from nothing, so a list you could prune was a list you could not grow. See Managing the configuration without a session for the grammar, which is one grammar.

Adding one reports whether the policy will let it start. io-harness denies net by default and every posture io setup writes says so, so an HTTP server will report will not start for almost everyone until a [[policy.layers]] rule names its host — which is not a bug and is the first thing to check. The report is a disclosure and never a veto: the entry is written either way. It is where a server is added, rather than at the run that first needs one, because a refused entry looks exactly as valid as a working one and the alternative is finding out a turn later. The same paragraph in Managing the configuration without a session has the rule to write.

It switches a server off and back on, which through io-harness 0.69.0 it could not: McpServer carried no key for it, and an enabled = false invented here would have been accepted by the file and ignored by the harness. io-harness 0.70.0 made enabled a real field, honoured before the server is spawned, dialled or even checked against the policy, so 0.29.0 drew the state without a writer for it and 0.30.0 adds both halves — a surface that can switch a server off has to be able to switch it back on. io mcp disable and io mcp enable are the same edit from a shell. There is still no reconnect: servers are attached per turn, so the next turn is what picks up your edit.

/provider shows the [[provider]] array as what it is: the order a turn tries them. From 0.21.0 you can arrange it — promote an entry, demote one, remove one — which is the fallback chain io-harness has supported since its 0.27.0 and that this interface has drawn an event for without ever being able to cause one. Reordering moves an entry with its own comments and its own keys rather than rebuilding the array, because a chain rebuilt from io-cli's model would silently drop whatever io-cli does not model.

From 0.28.0 it also adds a link, and changes the model on one it already has. Add a provider offers the presets, reads the model catalogue that endpoint actually serves and offers you one from it, then verifies the credential — in that order, and the order is the guarantee rather than a preference: the check happens before a single byte is written, so a rejected credential leaves your configuration exactly as it was, and a catalogue that cannot be read is a reason to send you to io setup rather than to refuse you for being offline. A new link is appended, so it is a fallback and not the provider in force, and the line that confirms it says which position it landed in — promote it if you meant it to answer the next turn. Nothing writes a key into the file: a vendor entry is written with no api_key line at all, which is what io-harness reads as "use my own environment variable".

Change the model is the only key this panel edits, from the same catalogue, and it is deliberately narrow. kind, preset and base_url are the link's identity — an entry pointed at a different vendor is a different link, and remove-then-add says so in words rather than leaving behind the both-bases entry that a preset written over a base_url would be. A model change is not a claim about a credential, so nothing is verified for one: pinging an endpoint to rename a field would spend your money answering a question nobody asked.

Add only offers a preset whose API-key variable is already set in your shell — OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY — and names the variable beside each, never anything about its contents. That is a decision and not a shortfall. A credential that has to be typed has one flow in this product: io setup, which asks for it, verifies it against the endpoint and writes it. A second credential prompt grown inside the session loop would be a second thing to keep correct, a second place a key can be pasted, and a second answer to "where did my key end up". So this surface offers exactly the case that needs no typing, and with no variable set it says so and sends you to the flow that already exists. Export one and the row appears.

From 0.26.0 the chain is what runs. Since 0.21.0 that panel has drawn the whole order while the product only ever asked its head, so a second [[provider]] entry was a row on a screen and nothing else. It answers now: when the first provider fails in a way another vendor might survive — a transport error, a timeout, a rate limit, a 5xx — the next entry is asked, the fall-through is committed to the scrollback as it happens, and the status line's provider field moves to whoever actually answered. A failure that will fail identically everywhere does not fall through, a bad API key above all, so a wrong credential on the primary can never start spending at the secondary. That predicate is io-harness's own — the same one its Fallback and its in-run retry ask — and io-cli holds no opinion here and must not grow one. This is a behaviour change for a file that already has more than one entry: the second one starts being used.

--provider on io exec and io resume replaces the whole chain rather than heading it. Naming a provider on the command line is saying which endpoint this run uses, and keeping the file's fallbacks underneath it would let a run you scoped to one vendor spend at another.

Both of those panels could only list until 0.21.0. The writers were there and tested and called from nothing, while three places in this documentation said they "add, edit, disable and remove". They genuinely write now, and the word disable is gone from that sentence because /mcp does not offer it.

Half of that was still true until 0.28.0, and this page said otherwise. What 0.21.0 actually reached was edit, promote, demote and remove. servers::add and providers::add stayed exactly where the paragraph above found their siblings — written, tested, and called from nothing — so both panels could shorten a list neither of them could lengthen, and this page listed "add an entry" among /provider's verbs for seven releases over a row that was never drawn. 0.28.0 is the release that makes it true, and this is the second time the same mistake has had to be written down: a writer with no caller reads exactly like a feature to whoever is documenting it.

No verb here takes a row number. An entry is addressed by finding its id in the file's own bytes, because a row on screen and a position in a file's array are different numbers the moment anything sorts or filters, and getting that wrong does not fail loudly — it removes a server you never named, or bills the next turn to a vendor you did not choose.

/plugin shows the capability bundles a [[plugin]] entry declared: what each one contributed, by name, and every bundle that was declared and dropped with io-harness's own reason beside it. It has been able to remove one since 0.20.0; from 0.28.0 it declares one as well. The add row does not ask you to type a path — it walks up to three directories below the workspace root, skipping target, node_modules and anything dotted, and offers every directory that carries a plugin.toml. A path typed from memory is a path that gets mistyped, and io-harness's plugin loader has no error path: an entry naming a directory with no manifest is dropped, recorded and otherwise silently absent, which is a bundle you believe is loaded and is not. So existence is checked before the entry is written and again on the keystroke that writes it, because a candidate can lose its manifest between the row being drawn and the row being chosen. A directory below the root is declared by its relative path, which is what makes a bundle vendored into a repository work for everyone who clones it; one kept elsewhere is written absolute. A bundle deeper than three directories, or outside the root entirely, is named outright — /plugin add <path>, or io plugin add <path> from a shell — and is refused by the same check rather than by a shallower one. See Capability bundles.

From 0.29.0 the same verb also takes a name. /plugin add ultraship installs a bundle out of a marketplace you have added, and ultraship@zeroonething/ultraship says which one where two carry that name — a bare name two marketplaces carry is refused rather than resolved, because taking the first match installs code you did not choose. install is accepted as the same verb. A word is a path if it resolves to a directory carrying a manifest, and a name otherwise: the rule asks the disk rather than the spelling, so one word cannot mean a directory on a machine that has one and a marketplace bundle on a machine that does not.

Installing by name declares the bundle switched off and shows you what it would bring before it brings it — see Marketplaces. Installing by path does not: that directory is one you already have.

/profile switches to a named [profile.<name>] for the session, and --profile <name> picks one for a single run without writing anything.

Eleven keys live there, and eight 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.
escalate false turns off asking. Absent means on: a refusal that came from a default raises the approval overlay instead of refusing, so a permission is granted once per class of thing rather than ending the work. A refusal that came from a [[policy.layers]] rule you wrote is never escalated, never drawn and never asked — see the limits.
skills a directory of skills for the agent. They appear in the / palette by name, and the agent reads them itself. Absent, it is skills/ under the home in force — ~/.io-cli/skills unless $IO_CONFIG or $IO_CONFIG_HOME moved it. A leading ~ is your home directory — io-cli expands it before io-harness sees the path, because io-harness substitutes ${env:…}, ${file:…} and ${cmd:…}, and a tilde is none of the three.
max_parallel_reads how many read-only tool calls one turn may run at once. Absent, it is io-harness's own 10; 0 is clamped to 1 rather than meaning none. A TaskContract field with no io-harness configuration key of its own, which is why it is named here.
spawn_background_after_secs how long a spawned child may run before it is backgrounded. Absent, a child is waited for however long it takes.
detached_spawns whether a spawn may detach at all. Absent, it may. false buys a trace with every child's whole life in it, which a detached child gives up.
conversational whether a prompt that is only a question may be answered in one completion, with no steps and no tools. Absent leaves io-harness's own classification where it is, which is what every release before 0.26.0 did; false opens a full run for every prompt. See Answered without opening a run.
[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.
[app.io-cli.gates] what "done" means for this repository: one of command (with expect_exit), file (with contains), or rubric (with reviewer, and allow_self_review if the judge may be the model that did the work), plus retries, which defaults to 1 and is report-only at 0. Naming none of the three, or more than one, is refused rather than resolved by precedence. See Verification gates.
[app.io-cli.routing] when a run should change models, and to which: escalate_after with failures and model, downshift_under with bytes and model, each a sub-table and both optional. Absent, a run asks one model from the first token to the last. The rules do not fire under [app.io-cli.containment], which the session says at start, on /config, and when /contain on is typed. A rule that cannot be obeyed — half a rule, a threshold of zero, or an empty model — is refused by name and leaves the run unrouted. See Which model a run asks.
[app.io-cli.prices] where the rates in [prices] came from: source_url names a catalogue to read instead of io-harness's default, and source and models record what the last read was and how many models it priced. The last two are written by a fetch rather than by hand. See Where a price comes from.
reference_catalogue whether io may read a model catalogue before the first step, so the context ceiling is the model's real window rather than an assumption. Absent means yes, which is a decision this release takes on your behalf: it contacts one more host per process, and an egress policy that denies that host refuses the run rather than skipping the lookup — the refusal names this key. false leaves the Anthropic and OpenAI providers assuming, which is what every release before 0.39.0 did. Which catalogue is [app.io-cli.prices] source_url's question, not this one. See What fills the window.

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.

Letting a turn write a program

Since 0.39.0 a turn can write one contained program instead of a chain of tool calls. It is io-harness's CodeAct, reached through io-harness's own [codeact] section:

[codeact]
interpreter = "python3"
max_callbacks = 64
timeout_secs = 120

Absent, nothing changes. The tool catalogue a turn is sent is byte for byte what it was before this release: io-harness offers run_program only where a contract carries this section, so enabling the feature grants the agent nothing on its own. That is worth saying plainly, because turning on media in 0.9.0 did give every run a new tool as a side effect of a flag.

Like [otel], this belongs in your user-scope file — a repository may not decide that whoever clones it runs programs.

A host with no interpreter is a supported host. io-harness looks for python3 then python, needs 3.8 or newer, and where it finds neither the turn composes, sends and steps exactly as it would with the section absent. io says so once, in io-harness's own words naming what it tried, rather than leaving you watching twelve round trips where you expected one program.

A program that ran draws one row — the interpreter, how many calls it made, and how it ended — and the tool cells underneath it are what it did. Each callback re-enters the same dispatch every other tool call goes through, so the same policy gate sees it and the same transcript draws it. What the row adds is that those acts belong to one program rather than to the model calling tools one at a time, which is worth knowing because they are not separately approved.

The program's own source is not in the transcript. io-harness's event carries the interpreter, a callback count and an outcome, and no program text; the durable trace is where the detail lives.

Sending a run to a collector

Since 0.39.0 io can export every run as OpenTelemetry spans. It is io-harness's exporter, reached through io-harness's own [otel] section, and io adds no key of its own:

[otel]
endpoint = "http://localhost:4318"
service_name = "io"
timeout_secs = 10
max_queue = 512

It has to go in your user-scope file, not in a repository's io.toml. io-harness refuses a collector declared inside a workspace for the reason it refuses a provider there: the collector is a host every span of every run is posted to, reached with whatever credential the same table names, and an io.toml arrives with a git clone. Without that rule a repository could quietly forward every run of everyone who cloned it.

The session says what it configured at startup, and io exec prints the same line on stderr. Read it literally:

spans go to http://localhost:4318/v1/traces as io — io reports what it configured and cannot report what arrived

io cannot tell you a span was delivered, and will never say it was. io-harness accounts for a refused or dropped batch in a log rather than in a value — nothing is returned, no event is emitted, and the export runs on a task nobody awaits — so reading that account would take a logging subscriber io does not have. If your collector is empty, the thing to check is the collector and the network between you, because this process knows no more than the address it was given. A section that will not open at all is a different case and does say so.

Prompts, replies, tool arguments and tool output are never sent. That is io-harness's decision and its own documentation states it.

io acp does not export. The session, io exec and io resume do; the editor door has never composed observers at all — it takes no [[hook]] either — so a run driven from Zed or a JetBrains IDE produces no spans. If your collector has traces from the terminal and none from your editor, that is why.

Where io keeps your things

One directory: ~/.io-cli, or %USERPROFILE%\.io-cli on Windows. The configuration file is in it, and so is the run store runs.db with the -wal and -shm SQLite keeps beside it — which is where the agent's durable memory lives too, because that is rows inside the store rather than a file of its own — and the skills directory, which is ~/.io-cli/skills when skills names none. That directory is the skills/ of the home in force — the same home your io.toml and IO.md are in — so $IO_CONFIG or $IO_CONFIG_HOME moves all three together, and io reads and writes skills in whichever one is in force. ~/.io-cli/IO.md is in it as well: the guidance you want in every project, which /remember writes when you pick that scope. That is one directory to copy to a new machine, and one path to put in a bug report — as long as you have named no location of your own, which splits it in two, as the resolution order below says.

Two more paths arrive with the shipped skills. ~/.io-cli/skills/disabled/ holds the ones that are turned off, which is a directory rather than a setting — see Skills. And ~/.io-cli/.skills-manifest is where io-cli records the bytes it last wrote for each shipped skill, so an upgrade can tell an untouched file from one you edited. It sits in the home and deliberately not in the skills directory, because every markdown file in there is offered to the model and a state file is not a skill.

io also records in the home that it has offered to bring your setup across from another agent tool, so that offer is made once on a first run and never again however many times you start a session. Opening it deliberately is /import — see Bringing your setup across.

The file is found in this order, which is io-harness's and is unchanged: $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. What 0.15.0 changed is that io-cli sets IO_CONFIG_HOME to its own home before io-harness resolves anything, so the second rung is the one that answers when you have named no location yourself. Set IO_CONFIG or IO_CONFIG_HOME and io-cli sets nothing and moves nothing — that location is yours. A project's own io.toml and a gitignored io.local.toml layer on top of whichever file was found.

That is also where the home splits, and the split is deliberate. io.toml, the run store beside it and IO.md follow the variable, because all three are resolved from the configuration path in force. The skills directory, .skills-manifest, marketplaces/ with the staging directory a clone is assembled in, and the session lock are built from ~/.io-cli whatever the variables say: a marketplace clone is io-cli's own cache of other people's repositories rather than part of your configuration, and following the variable there would leave every clone already fetched invisible. So under a home you named yourself there are two directories rather than one, and both go in the copy and in the bug report.

io-cli sets IO_CONFIG_HOME in its own process environment, which every child a session starts inherits: a ! shell line, a spawned agent, a nested io. For a nested io that is the answer you want, since it reads the same home as the session that started it. For anything else it is one more variable in the environment that nothing reads.

On the first 0.15.0 run an existing install is moved into the home — the configuration file and the store together, each file named on screen as it moves. Nothing is deleted, and nothing is overwritten: where the home already holds a file of that name, both are left where they are and the session says which one is in force. To keep the location you have, set IO_CONFIG_HOME to it before the first 0.15.0 run.

One thing worth knowing: a file inside the workspace may narrow the permission boundary and may never widen it, because a repository you cloned must not be able to grant itself permission — and io.local.toml counts, since io-harness 0.74.0, because the agent can write to it. 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 — and from 0.20.0 so are [[plugin]] and [[hook]], which are the last two that reached nothing. 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 since 0.17.0 every session turn carries a steer inbox as well, so a contained turn can be steered mid-flight exactly as an ordinary one can. Containment grants no capability and costs no steering. 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 for io exec now bounds your terminal. max_steps = 20 is 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 /status lists 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 exec now takes io-cli's own step floor of a thousand instead of io-harness's twelve. A headless run used to end error: step_cap_reached under half-finished work with nobody watching. A [run] max_steps in 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 any file inside the workspace, by io-harness rather than by io-cli: it names a program to execute, a project's io.toml arrives with a git clone, and io.local.toml is a path the agent can write. Write it in the user-scope file — the one io setup writes — where widening the boundary is your own decision. There is no workspace-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.


README · All guides · What you may depend on