Managoat.Runtimes
How a coding agent CLI — claude, codex, gemini or opencode — gets into a
sandbox and comes up speaking the Agent Client Protocol.
The protocol session itself is managoat_acp;
the sandbox it runs in is managoat_sandbox.
This package is what has to have happened on the sandbox before a peer can
open a session: which adapter to install and at what version, where the
runtime keeps its files, the instructions file that carries the agent's
system prompt, the credential env vars, the skills tree, and the per-runtime
workarounds with the condition for deleting each one.
alias Managoat.Runtimes
alias Managoat.Runtimes.{ACP, Instructions, Skills}
{:ok, handle} = Managoat.Sandbox.create("my-agent", provider: :sprites)
{:ok, runtime} = Runtimes.for_runtime("claude")
agent = %{
name: "reviewer",
model: "anthropic/claude-sonnet-4-6",
system: "You review pull requests. Be brief.",
mcp_servers: %{"gh" => %{"command" => "gh-mcp", "env" => %{"GITHUB_TOKEN" => token}}}
}
# 1. The credential env for this runtime, from the credentials the host holds.
# Three of the callbacks are optional, so they are called through the
# dispatchers rather than on the module — see "Calling the optional
# callbacks" below, and never guard them with function_exported?/3.
env = Runtimes.default_env(runtime, agent, %{anthropic_api_key: key})
# 2. Files: the runtime's own config (claude's .mcp.json), the system prompt,
# the skills.
:ok = Runtimes.write_config(runtime, handle, agent)
:ok = Instructions.write(handle, "claude", agent)
:ok = Skills.install(handle, [%{"name" => "house-style", "content" => skill_md}], runtime: "claude")
# 3. The ACP adapter, pinned, then whatever bootstrap the runtime needs.
:ok = ACP.install(handle, "claude", env)
:ok = Runtimes.prepare_sandbox(runtime, handle, agent, env)
# 4. Spawn it and hand the process to Managoat.ACP.Peer.
{bin, args} = ACP.command("claude")
{:ok, command} = Managoat.Sandbox.spawn(handle, bin, args, env: env, dir: ACP.cwd("claude"), stdin: true)
{:ok, peer} =
Managoat.ACP.Peer.start(
owner: self(),
writer: &Managoat.Sandbox.write_stdin(command, &1),
ref: command.ref,
prompt: "review PR 42",
mode: :run,
session_id: nil,
cwd: ACP.cwd("claude"),
mcp_servers: ACP.mcp_servers(agent),
model: Managoat.Runtimes.Model.acp_model("claude", agent.model)
)
The pieces
| Module | Role |
|---|---|
Managoat.Runtimes | The behaviour (default_env/2, write_config/2, prepare_sandbox/3, skills_root/0, skills_sh_agent/0, and an optional build_command/5 for a runtime that cannot speak ACP), for_runtime/1, the dispatcher from a runtime name to its module, and default_env/3, write_config/3, prepare_sandbox/4, implements?/3, the safe way to call the optional ones. The agent is read as a plain map, t:Managoat.Runtimes.agent/0, so the host's own record satisfies it. |
Managoat.Runtimes.ACP | The adapter table: which package and pinned version reach ACP for each runtime (@agentclientprotocol/claude-agent-acp, @agentclientprotocol/codex-acp; gemini and opencode are native), install/3, command/1, cwd/1, concurrency/1 (how many turns one sandbox takes for the runtime), asks_permission?/1 (measured, not assumed), mcp_servers/1 in the shape session/new takes, and initialize_params/1. |
Managoat.Runtimes.{Claude, Codex, Gemini, OpenCode} | One module per runtime: credentials in, env and files out. Two credential shapes, not four: an env var, or a login exec that consumes the key on stdin (codex). |
Managoat.Runtimes.Layout | The one table every path derives from: <home>/<config_dir>/<leaf> per runtime. gemini and opencode run with HOME=/tmp, and deriving the HOME export and the file paths from the same row is what keeps a system prompt from being written where the CLI never looks. |
Managoat.Runtimes.Instructions | The user-level instructions file each runtime reads at session start (CLAUDE.md, AGENTS.md, GEMINI.md), which is where the agent's system prompt is delivered. |
Managoat.Runtimes.Skills | Inline SKILL.md writes under the runtime's skills root and skills.sh installs for github sources, behind a shell allow-list. The list is the host's to assemble. |
Managoat.Runtimes.Model | The provider/model_id parser: which provider a runtime reaches, the bare id a single-provider CLI wants, the canonical id opencode wants. Which models to suggest is the host's product data and is not here. |
Managoat.Runtimes.Quirks | Every workaround carried on a runtime's behalf, as a registry: the defect, the upstream issue, how to re-probe it, what to delete when it is fixed, and the function that implements it (a test asserts that function still exists). |
Managoat.Runtimes.Gemini.SessionStore | The largest of those workarounds: gemini's own session store erases a session in the act of loading it, so this consolidates the store after every turn. Goes when google-gemini/gemini-cli#28775 lands. |
Managoat.Runtimes.Testing.FakeRuntime | A runtime for tests that reports every callback to an observer, plus two that fail on purpose. Ships in lib/ so a host's tests can drive their turn machinery without a CLI. |
What the host still does
This package writes files into a sandbox and tells you what to spawn. It does not spawn, does not hold the protocol session, and does not know the tenant. The host:
- decrypts the tenant's inference credentials and passes them to
default_env/2as%{anthropic_api_key: ..., openai_api_key: ..., gemini_api_key: ..., claude_code_oauth_token: ...}; - decides the skills list (Fountain prepends its own API skill to the agent's);
- reads its own configuration for how long a held permission waits for a
human, and passes it to
Managoat.ACP.Permissions.ask_timeout_ms/1; - applies any network policy to the sandbox, and calls
ACP.install/3andprepare_sandbox/3after it, with the npm registry allowed if the policy restricts egress; - calls
Gemini.SessionStore.consolidate/2at the end of every gemini turn.
Calling the optional callbacks
Four of the callbacks are optional and the matrix is genuinely sparse — every runtime is missing at least one — so a host has to guard the call. The obvious guard is wrong:
# WRONG: silently no-ops in an escript or a release
if function_exported?(mod, :default_env, 2), do: mod.default_env(agent, creds), else: []
function_exported?/3 answers false for a module that is merely not
loaded yet, which is the normal state of any module nobody has called under
an escript or a release. That drops the callback without an error: one host
lost its whole inference credential env this way, and saw a provisioning run
report every stage green and then fail minutes later on authentication. The
callback that vanished was default_env/2, which all four runtimes
implement — so no amount of checking the matrix saves you.
Dispatch through Managoat.Runtimes instead. Each function loads the module
first and falls back to the documented no-op:
env = Managoat.Runtimes.default_env(mod, agent, credentials) # or []
:ok = Managoat.Runtimes.write_config(mod, handle, agent) # or :ok
:ok = Managoat.Runtimes.prepare_sandbox(mod, handle, agent, env) # or :ok
build_command/5 has no default to fall back to — there is no argv to
invent — so ask Managoat.Runtimes.implements?/3 and decide what an
unimplemented runtime means on your legacy spawn path. skills_root/0 and
skills_sh_agent/0 are required callbacks; call them on the module.
| callback | claude | codex | gemini | opencode |
|---|---|---|---|---|
default_env/2 | ✓ | ✓ | ✓ | ✓ |
write_config/2 | ✓ | — | ✓ | — |
prepare_sandbox/3 | — | ✓ | ✓ | ✓ |
build_command/5 | — | — | — | — |
The adapter is pinned, and that is load-bearing
An unpinned adapter can stop advertising sessionCapabilities.resume in a
point release and silently downgrade every conversation to a full history
replay per turn. So the versions in Managoat.Runtimes.ACP are exact, the
install is idempotent on that exact version (an image carrying a different
one is corrected, not accepted), and a pin moves in a commit that says why.
Licence
Apache-2.0. Extracted from Fountain under its decision record 0037; the issue numbers in the source are that repository's.