Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Runners, agents and configuration

Part of the decision log: MCP servers per agent, credentials, the prompt on stdin, presets, models and effort, and the CLI's home.

Why MCP servers are declared per agent rather than left to each CLI. A telemetry agent needs a Kusto endpoint and a publisher needs an issue tracker, and configuring those in each CLI's own settings puts them somewhere the route map cannot see, nothing validates, and no reviewer reads. Declaring them in layover.toml makes an agent's reach part of its definition. The cost is that config now sits next to credentials, which is why env and env_from are separate: env is for values safe in a committed file, env_from names variables forwarded from the Tower's own environment, and validation refuses a literal whose name looks like a credential. A warning would not do — the failure is a key in git history, which is not undone by noticing later.

Why a declared server's credential is named in the run's configuration rather than written. Declaring servers in layover.toml only helps if they reach the run, and the one channel into a CLI's MCP setup is the configuration file the Tower hands it — which lives in the run's Hangar under .layover/. Writing an env_from value there would move the secret from git history to a directory anything on the machine can read. The Tower already puts the value in the child's environment, and every supported dialect can say "read it from there": ${NAME} in the JSON that Claude Code and Copilot CLI read, env_vars = ["NAME"] in Codex's TOML. So the file names the variable and never holds it. Verified against Copilot CLI 1.0.88, which also passes its whole environment to the stdio servers it starts; Codex passes only an allow-list, which is why naming the variable is not optional there.

Why the prompt is written to stdin and never onto the command line. The runner template originally inlined it as command = ["claude", "-p", "{prompt}", ...]. Windows caps a command line at 32,767 characters, and real agent prompts are nowhere near small enough: measured against an earlier agent factory's prompt trees, its review agent composes to roughly 98 KB — three times over the limit — and its ordinary developer agent to 34 KB. Two of that project's six agents would not have started. It is a failure mode that passes every test written against a small fixture and appears on the first agent worth running, which is the worst shape a bug can have. That project had already found it the hard way; its runner carries the comment "never -p / a shell pipe". {prompt} now substitutes the path to the composed instructions, for CLIs that accept one.

Why the model an agent runs on is read from its command line. The route map and GET /agents show each agent's model, reasoning effort and context tier. The obvious source was agent.model, and it was wrong for the factories where the question matters most: those that fix the model in the runner command, one runner per model and permission set, and so declare no model at all. A context or effort field that named the flag was rejected for the reason {model} exists instead of a model_flag field — every CLI spells these differently, and the runner command is already the one place that knows how to invoke a given CLI; a second place would disagree with it the first time somebody edited one. (The values later became agent fields, carried by placeholders exactly as the model is; see below.) Reading the command line with the agent's values substituted is also the only honest answer: a declared model whose runner has no {model} placeholder never reaches the CLI. Only flags whose meaning is certain are read — --model, and Copilot CLI's --reasoning-effort and --context — because a display that guessed at unknown flags would state a wrong model with the same confidence as a right one.

Why an agent declares its effort and context, and the runner only carries them. With the values fixed in runner commands, a runner was a model setting times a permission set: tuning one agent's effort meant a new runner and a copied deny list. A real factory did it three times in two days, ended with two byte-identical runners nobody noticed, and lost two deny rules from every runner for a week in one of those copy-and-edit rewrites. A runner should say what an agent may do; how hard it thinks and how much it can read belong to the agent, like its model. So effort and context are agent keys carried by {effort} and {context} the way {model} carries a model, and passed through as written — Copilot CLI refuses an effort a model does not support, and a catalog in Layover would be a second, staler copy of what only the CLI knows. [defaults] gives both a fallback because a factory usually wants one context tier everywhere; it does not give one for model, which was not asked for and would change what an agent with no model has always run on.

Why an unset value takes its flag with it. Effort and context are optional far more often than a model, so a placeholder with nothing to fill it had to disappear cleanly. It used not to: a bare "--model", "{model}" with no model left --model in front of the next flag, which Copilot CLI refuses outright, and a joined --model={model} reached the CLI as that literal text. The rule now is one rule for all three: an argument carrying an unset placeholder is left out whole, and when it is the value of the option before it — the preceding argument starts with - and carries no = — the option goes too. Refusing the separate form at validation was the alternative, and was rejected: it would force every agent on a shared runner to set every value, and it cannot cover Codex's -c model_reasoning_effort={effort}, where the joined form does not exist. The one shape the pairing can misread — a positional placeholder right after a boolean flag — is not one any supported CLI uses, and the documentation says to put such a placeholder first. Every factory this changes was handing its CLI a flag without a value; none of them worked before. A written-out runner that leaves an agent's effort or context unset is warned about, and a missing model is not, because factories have long relied on a joined --model={model} dropping out and a new warning would fail their validate --strict.

Why Layover supplies a known CLI's invocation, and only the half that grants. With effort and context on the agent, a real factory's five Copilot runners were still eleven identical arguments and a list of denials each — --no-ask-user, --output-format json, the three placeholders, the three --allow-all-* — and the identical half was where copy-and-edit had already dropped rules. Those eleven are not choices: an unattended run cannot answer a question or a permission prompt, a cost is read from one output only, and the MCP flag is Layover's own wiring. So cli = "copilot" supplies them and args carry what is a choice, which for that factory is everything it denies. Granting the --allow-all-* by default was decided deliberately (the maintainer, 2026-10-08): an unattended Copilot run without them stops at the first tool it needs, Copilot's deny rules take precedence over them, and a runner that wants a narrow allow-list instead writes its command out. Nothing a preset supplies ever takes something away, because restrictions are what has to be written where it can be reviewed. Claude Code's preset supplies the plumbing and no permission mode: what bypassPermissions grants alongside --disallowedTools has not been confirmed against a real Claude Code, and a preset that guessed wrong would grant more than anybody wrote. Codex has no preset, because a preset has to wire MCP and how Codex is handed MCP servers is still open (below). Agents differ from their runner by args that are only ever added, so a runner stays the permission floor for every agent on it. An agent that leaves a preset's effort or context unset is not warned about, as it would be on a written-out command: the warning exists for a placeholder somebody placed meaning it to be filled, and nobody placed a preset's — unset there asks for the CLI's own default, which is exactly what an agent that says nothing wants.

Why a length of time has two names rather than a new one. Schedules were written "45m" while every other length of time was a bare number in a unit its key's name carried, so a four-hour timeout read 14400 and a reader did the arithmetic (raised by the maintainer, 2026-10-12). Renaming the keys would break every factory, so each gained a name without its unit — timeout, window, keep_lapsed — and both names take both forms, a number being read in the original unit. Naming both is the duplicate-key error TOML would give for any other key, rather than one silently winning. A key that counts hours or days refuses a duration that is not a whole number of them, because rounding "90m" to one or two hours would change what somebody wrote on purpose.

Why http_addr is honoured and state_dir is only warned about. Both were accepted and did nothing, and the configuration page said so — a key that is silently ignored is the failure this project keeps finding, documented or not. http_addr had an obvious meaning and nowhere better to live: the address a factory is served on belongs with the factory, and autostart registers serve with no flags, so it was the one way a factory started with the computer could be served anywhere but 7878. --addr still wins, for one start. state_dir has no such meaning to give it: moving the state directory would move history, the queue, Hangars and live-run records together, and a Tower that read it while an older one did not would split one factory's state in two. So it stays unhonoured, and validate warns where it is set rather than refusing — refusing would stop a factory that has always loaded.

Why a separate CLI home is recommended rather than made. A run keeps HOME and USERPROFILE because without them an agent CLI cannot find the sign-in it already has — so by default every run also writes its session into the user's own CLI home and loads the user's own plugins and MCP servers. On the maintainer's machine, by 20 October 2026, the factory's separate home held 1,711 sessions and 2.4 GB that would otherwise sit in the list the maintainer resumes from; and a personal MCP server that posts as its owner is a tool layover.toml never granted. That factory already runs Copilot CLI under COPILOT_HOME, set up with only the sign-in copied across, no plugins, and no global MCP servers. Layover does not create such a home itself: one it made would start signed out, and how each CLI signs in, where it keeps its token and which of its settings are safe to copy are the CLI's business, change between its releases, and are not something a supervisor can check. Nor does a preset set the variable, which would sign every existing factory out at once. So it is a recommendation, in the configuration reference, the install guide and both starting examples, with env_from as the enforcement: a factory that names the variable refuses a run when it is missing, rather than falling back to the user's home without saying so.