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

ModuleRole
Managoat.RuntimesThe 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.ACPThe 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.LayoutThe 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.InstructionsThe 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.SkillsInline 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.ModelThe 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.QuirksEvery 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.SessionStoreThe 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.FakeRuntimeA 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:

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.

callbackclaudecodexgeminiopencode
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.