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

Introduction

outrig is a command-line tool for running LLM agents against your repository with MCP tools isolated inside a podman-managed container that you describe with a Dockerfile. You stay in control of the environment those tools run in: which language toolchains are available, which MCP servers are wired up, what the agent’s view of the filesystem looks like.

The problem

Modern coding agents are good at running tool calls – reading files, editing them, executing shell commands, hitting external APIs. But “running tool calls” on a developer’s laptop means the agent can do anything the developer can: delete files outside the project, push to remote branches, talk to anything on the network. Most agent harnesses respond to this by gating each tool call behind a human-approval prompt. That works, but it defeats the point of letting the agent work – every prompt is a context switch back to the human.

outrig takes a different trade. Instead of approving each call, you set up a sandbox once: a Dockerfile, a config file, a network policy. The agent loop stays in the host outrig process, but the tool calls it can make go through MCP servers inside that sandbox. The blast radius of a wrong tool call is bounded by the container.

How it fits together

flowchart LR
    you(["you<br/>(terminal)"])
    api(["OpenAI-compatible<br/>HTTPS API"])
    repo[("your repo<br/>on disk")]

    subgraph host["host"]
        direction TB
        outrig["outrig CLI"]
        rig["Rig<br/>(agent loop)"]
        outrig --> rig
    end

    subgraph container["podman container - sleep infinity"]
        direction TB
        fs["fs MCP server"]
        shell["shell MCP server"]
    end

    you -- "prompts (stdin)" --> outrig
    rig -- "assistant text (stdout)" --> you
    rig <-- "chat + tool calls" --> api
    rig -- "tool call" --> fs
    rig -- "tool call" --> shell
    outrig -. "buildah build<br/>podman run" .-> container
    repo == "bind-mounted at /workspace" === container
  • outrig is a Rust CLI. You run it from a shell inside your repo.
  • Rig (rig-core) drives the agent loop: it talks to your configured LLM provider, decides when to call tools, feeds tool results back to the model.
  • podman is the container runtime. We use buildah to build the image from your Dockerfile (buildah and podman share image storage, so anything buildah builds, podman can run).
  • MCP servers (Model Context Protocol) are the actual tools – filesystem access, shell execution, whatever you install in your container. outrig speaks JSON-RPC to each one over podman exec’d stdio and exposes them to Rig as dynamic tools.

What outrig is not

  • Not a hosted service. It’s a CLI you run locally.
  • Not an LLM. You bring the model – any OpenAI-compatible endpoint, plus whatever providers Rig adds.
  • Not opinionated about what the agent can do. You write the Dockerfile, you pick the MCP servers. outrig is just the harness.
  • Not a replacement for human-in-the-loop review of outcomes. The agent has full filesystem access to the workspace you mount; you review what it did via git diff after the session ends, not via approval prompts during it.

What’s next

Quickstart

This walks you from a fresh repo to your first running agent in five minutes, mostly via outrig init (which orchestrates outrig config init and outrig image add under the hood).

Crate names matter: depend on outrig when embedding the library, and install outrig-cli when you want the command-line tool that provides the outrig binary. Both crates publish from this repository.

Prerequisites

  • Linux on x86-64 or AArch64. WSL2 counts; macOS and native Windows are not supported.
  • Rust toolchain (you’ll need it to install outrig itself).
  • podman installed and working in rootless mode. Verify with podman info – it should print without errors.
  • buildah installed alongside podman. They share image storage by default.
  • An API key for an OpenAI-compatible chat endpoint, exported as OPENAI_API_KEY.
$ podman info | grep -E "rootless|graphRoot" | head -3
  graphRoot: /home/you/.local/share/containers/storage
  rootless: true

If podman info errors out, fix that before continuing – outrig delegates everything to it and won’t paper over a broken setup.

Install outrig

Install the CLI package to get the outrig command:

$ cargo install outrig-cli
$ outrig --version
outrig 0.1.0

Try it before configuring anything

You need a provider and a model, and nothing else. outrig config init writes the global config – once per machine, not per repo:

$ outrig config init
$ cd any-repo-you-like
$ OPENAI_API_KEY=sk-... outrig run

With no .agents/outrig/config.toml anywhere, outrig uses the current directory as the workspace and falls back to its built-in default image-config: a Debian container with git and curl, plus fs and shell MCP servers. That session also carries outrig’s own documentation tools, so you can ask the agent things like “how do I pin a Rust toolchain in my image?” and it will answer from the docs shipped with your build.

The first run pulls a couple of images, so give it a minute. This is a real session, not a demo mode – but the built-in default is a starting point, not a destination. When you want a container with your project’s toolchain in it, configure one:

Initialize the configs

In a fresh git repo, run outrig init:

$ mkdir -p hello-outrig && cd hello-outrig
$ git init && echo "hello, world" > HELLO.txt && git add . && git commit -m initial
$ outrig init

init walks you through the global config first (providers and models – skipped if it already exists), then the repo config (workspace, default agent), then offers to call image add for the first image-config. Every prompt shows its default in [default: ...]; press Enter to accept. Type ? and Enter at any prompt for an explanation and the available options.

If the built-in image prompts do not cover the environment you need, still start with outrig init for the provider, workspace, and agent scaffolding. Then use AI-assisted design to have an AI tool generate or refine the Dockerfile and [images.<name>] block. The MCP path gives the AI validators; outrig design prompt is the no-MCP fallback.

[outrig] no global config found at ~/.outrig/config.toml -- let's create one.
? Provider style [default: openai]:
? Provider name [default: openai]:
? Base URL [default: https://api.openai.com/v1]:
? API key environment variable [default: OPENAI_API_KEY]:
? Define a model now? [Y/n]:
? Model name [default: fast]:
? Model identifier [default: gpt-4o-mini]:
? Provider for this model [default: openai]:
? Use this model as default-model? [Y/n]:
[outrig] wrote ~/.outrig/config.toml

[outrig] no repo config at .agents/outrig/config.toml -- let's create one.

Configuring models
[outrig] models available in your global config: fast (default: fast)
? Would you like to configure LLM models specific to this repo? [Y/n]: n
? Set a default-model for this repo? [y/N]:

Configuring your first agent
? Agent name [default: coder]:
? Preamble (one line) [default: You are a careful coding assistant.]:

Configuring your first image
? Image-config name [default: hello-outrig-standard]:
? Workspace host-path [default: .]:
? Workspace container-path [default: /workspace]:

[outrig] wrote .agents/outrig/config.toml

? Base image [default: debian:bookworm-slim]:
? Language toolchains [default: ]: node
? MCP servers [default: fs]:
[outrig] wrote .agents/outrig/images/hello-outrig-standard/Dockerfile
[outrig] added [images.hello-outrig-standard] to .agents/outrig/config.toml

(In the transcript above we accepted most defaults by pressing Enter; only node was typed explicitly for toolchains.)

Two files were written under .agents/outrig/, plus your global config:

~/.outrig/config.toml
hello-outrig/
├── HELLO.txt
└── .agents/
    └── outrig/
        ├── config.toml
        └── images/
            └── hello-outrig-standard/
                └── Dockerfile

The generated MCP table starts with fs. The run transcript below also uses shell execution, so the example config includes a manual shell entry. Replace shell-mcp-command with the shell MCP server you install in the image.

Commit the agent config so it travels with the repo:

$ git add .agents/ && git commit -m "add outrig config"

The global file (with API keys etc.) is not in your repo – it’s per-user and references secrets via env-var substitution.

What got generated

~/.outrig/config.toml:

default-model = "fast"

[providers.openai]
style    = "openai"
base-url = "https://api.openai.com/v1"
api-key  = "${OPENAI_API_KEY}"

[models.fast]
provider   = "openai"
identifier = "gpt-4o-mini"

.agents/outrig/config.toml:

default-image = "hello-outrig-standard"
default-agent     = "coder"

[workspace]
host-path      = "."
container-path = "/workspace"

[agents.coder]
# model omitted -> falls back to default-model = "fast"
preamble = "You are a careful coding assistant."

[images.hello-outrig-standard]
dockerfile = ".agents/outrig/images/hello-outrig-standard/Dockerfile"
context    = ".agents/outrig/images/hello-outrig-standard"

  [images.hello-outrig-standard.mcp]
  fs    = { command = ["mcp-server-filesystem", "/workspace"] }
  shell = ["bash", "-lc", "exec shell-mcp-command"]

.agents/outrig/images/hello-outrig-standard/Dockerfile (excerpt):

FROM docker.io/library/debian:bookworm-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl git nodejs npm \
 && rm -rf /var/lib/apt/lists/* \
 && npm install -g @modelcontextprotocol/server-filesystem

# Add the shell MCP package you chose, matching shell-mcp-command in config.toml.

WORKDIR /workspace
CMD ["sleep", "infinity"]

The Dockerfile is yours to edit – image add produces a known-good starting point, not a finished spec. Note there’s no USER directive and no useradd – not even the package that provides one: outrig writes a user matching your host UID/GID into the container at run time, so the same image works for any host user without rebuilding. See Concepts -> Workspace.

Pre-build the image

The first outrig run would build the image automatically, but doing it explicitly is faster to verify and keeps the first run snappy:

$ outrig build
[outrig] image-config: hello-outrig-standard
[outrig] dockerfile:       .agents/outrig/images/hello-outrig-standard/Dockerfile
[outrig] context:          .agents/outrig/images/hello-outrig-standard
[outrig] image tag:        hello-outrig-standard:8c2a4f7e91d6b5a3
[buildah] STEP 1/N: FROM docker.io/library/debian:bookworm-slim
...
[outrig] image ready: hello-outrig-standard:8c2a4f7e91d6b5a3

A second outrig build is a cache hit:

$ outrig build
[outrig] image ready (cache hit: hello-outrig-standard:8c2a4f7e91d6b5a3)

Run an agent

$ OPENAI_API_KEY=sk-... outrig run

Diagnostics arrive on stderr:

[outrig] agent:             coder (model: fast / provider: openai / gpt-4o-mini)
[outrig] tool-call max:     50
[outrig] tool-result max:   262144 bytes
[outrig] image-config:  hello-outrig-standard
[outrig] image:             hello-outrig-standard:8c2a4f7e91d6b5a3 (cache hit)
[outrig] container started: outrig-20260502T103412-3f2a
[outrig] mcp fs:    initialized (3 tools)
[outrig] mcp shell: initialized (1 tool)
[outrig] tools available: fs__read_file, fs__list_directory, fs__write_file, shell__exec
[outrig] session id: 20260502T103412-3f2a   (Ctrl-D to exit, /help for slash commands)
> 

Anything you type at > is the user turn. Replies (only the assistant text) come back on stdout:

> what's in this repo and what does it do?
[outrig] tool call: fs__list_directory({"path": "/workspace"})
[outrig] tool call: fs__read_file({"path": "/workspace/HELLO.txt"})
This is a tiny git repo with one tracked file: HELLO.txt, which contains the
text "hello, world". There's no source code yet -- just the README-equivalent
and the outrig harness configuration under .agents/outrig/.
> 

Now ask it to do something useful:

> add a second line to HELLO.txt that says "from the agent",
> commit it on a new branch named agent/hello, and tell me
> what the new file looks like.
[outrig] tool call: shell__exec({"cmd": "git checkout -b agent/hello"})
[outrig] tool call: fs__write_file({"path": "/workspace/HELLO.txt",
                                    "contents": "hello, world\nfrom the agent\n"})
[outrig] tool call: shell__exec({"cmd": "git add HELLO.txt && git commit -m 'append agent line'"})
[outrig] tool call: fs__read_file({"path": "/workspace/HELLO.txt"})
Done. I created branch agent/hello, appended "from the agent" to HELLO.txt,
and committed the change. The file now reads:

    hello, world
    from the agent
> 

Press Ctrl-D to end the session. outrig stops the container, finalizes the session record, and exits.

[outrig] session 20260502T103412-3f2a ended (duration 2m18s, exit 0)
$

Review what happened

The agent wrote directly to your repo (we used a direct bind-mount, not a staging copy – see Concepts -> Workspace). Use git to inspect:

$ git status
On branch agent/hello
nothing to commit, working tree clean

$ git log --oneline -2
b41af3a append agent line
12c0a99 add outrig config

$ outrig ls
ID                       STARTED              DURATION  CONTAINER              EXIT
20260502T103412-3f2a    2026-05-02 10:34:12  2m18s     hello-outrig-standard  0

If you don’t like what the agent did, the rollback is whatever you’d normally do with git: git checkout main, git branch -D agent/hello, etc. outrig itself doesn’t try to take that work back from you.

Where to go next

Concepts

This section explains the moving parts an outrig user actually has to think about. If you’ve worked through the Quickstart, you’ve already touched all four – this is where they’re spelled out.

  • Containers – the Dockerfile that defines the agent’s environment, the [images.<name>] config block, named image-configs for switching between toolsets, and the UID/GID convention that keeps file ownership sane.
  • MCP Servers – how outrig discovers and routes tool calls, the <server>__<tool> name prefix, lifecycle and crash behavior, stderr capture.
  • MCP Trust Model – why the container is the MCP trust boundary and why tools can be configured liberally inside it.
  • Workspace – what the agent can reach on your filesystem, the direct bind-mount model, why outrig doesn’t stage changes, how to review with git.
  • Providers, Models, and Agents – the three-layer LLM config: providers (where), models (what), agents (which preamble + image). Most users keep providers and models in the global config; agents are typically per-repo.
  • In-process LLMs – a feature-gated provider that runs the model inside the outrig process itself, for questions whose content must not leave the host. Plumbing for the future egress filter, tool-use filter, and prompt-injection scanner.
  • Subagents – extra agent loops the agent launches itself, sharing the session’s container and tools. The outrig__ built-in tools, the explicit result inbox they report through, and why a subagent cannot launch subagents.

Containers

The container is the agent’s whole world. It’s where MCP servers run, where shell commands execute, where files get read and written. You define it with a Dockerfile you commit to your repo, and outrig builds it with buildah and runs it with podman.

Default location

outrig image add writes image files under .agents/outrig/images/<name>/:

.agents/outrig/
├── config.toml
└── images/
    └── coding/
        ├── Dockerfile
        └── (any other files referenced by the Dockerfile)

Putting them under .agents/outrig/ keeps outrig-specific build context separate from your project’s own Dockerfiles (which often live at the repo root or under containers/, docker/, etc. for unrelated purposes). You can override the default by editing [images.<name>].dockerfile and .context to point anywhere relative to the repo root – or absolutely anywhere, with an absolute path.

Dockerfile conventions

outrig expects a few things from your Dockerfile.

Don’t set an ENTRYPOINT

The container’s job is to stay running while the agent works. Every real process – MCP servers, shell commands the agent runs – is launched via podman exec from outrig on the host, so the container’s own process only has to stay alive.

outrig arranges that itself: it appends sleep infinity after the image reference on the podman run, and trailing arguments there are the container’s command. Your CMD is overridden and does not matter. Setting one is harmless – outrig’s own templates still end with CMD ["sleep", "infinity"], which keeps the image behaving the same way under a plain podman run <image> – but nothing depends on it.

ENTRYPOINT is the one that matters, because podman appends trailing arguments to an exec-form ENTRYPOINT rather than replacing it. An image that sets one therefore runs <entrypoint> sleep infinity, which is rarely what either side intended. Leave ENTRYPOINT unset on an image you mean to use as a primary.

The same goes for a sidecar image whose servers are podman exec’d into it – those containers start exactly the way the primary does. An entrypoint-stdio sidecar is the opposite case: there the image’s ENTRYPOINT is the server, outrig appends no command of its own, and args supply its arguments. See MCP Servers for which entries are which.

The one thing the image has to supply is a sleep that accepts infinity. GNU coreutils has always understood it, and busybox has since 1.30 – Alpine 3.10, June 2019 – so any current base is fine. An older busybox exits immediately with sleep: invalid number 'infinity', and an image with no sleep at all (FROM scratch, some distroless bases) has nothing to run.

When either of these bites, the container exits moments after starting and the failure surfaces one step later, as a bootstrap error reporting that the container has no running namespaces. podman logs outrig-<sid> has the real cause.

Don’t set up a user in the Dockerfile

outrig handles user identity entirely at run time – see Workspace. Don’t useradd a hard-coded UID in your Dockerfile, and don’t set a USER directive. The build is intentionally generic so the same image works for any host user (yours, a teammate’s, CI’s) without rebuilding.

The image needs no user tooling for this. outrig writes the matching /etc/passwd and /etc/group entries into the container itself, from the host, so an image with no useradd, groupadd, or getent – an unadorned FROM docker.io/library/alpine, or a distroless base – works unchanged. What it does need is the two files to already exist and a writable /etc; a base so minimal that it ships neither cannot be bootstrapped, and neither can one without the sleep the section above describes.

Install MCP servers

The image needs to contain the binaries and dependencies for every MCP server you reference in the matching [images.<name>.mcp] config. There’s no other place to put them – outrig doesn’t fetch tools at run time.

RUN npm install -g @modelcontextprotocol/server-filesystem

If multiple MCP servers need different language toolchains (one needs Node, one needs Python), install both in the same image. The agent’s whole MCP set runs in one container per session.

The [images.<name>] config block

A typical config has at least one [images.<name>] block plus a top-level default-image:

default-image = "coding"

[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"   # relative to repo root
context    = ".agents/outrig/images/coding"              # relative to repo root
build-args = { NODE_VERSION = "20" }                     # extra Dockerfile ARGs

  [images.coding.mcp]
  fs    = { command = ["mcp-server-filesystem", "/workspace"] }
  shell = ["bash", "-lc", "exec shell-mcp-command"]

An image-config can come from three places, lowest precedence first: outrig’s built-in default, the global config, and the repo config. The built-in only appears when nothing else names an image at all, and any block you declare under one of its reserved names shadows it entirely.

dockerfile and context are relative to the directory of the config file that declared the block. For this repo config that is the repo root (the directory containing .agents/outrig/). Declare the same block in ~/.outrig/config.toml and the paths are relative to ~/.outrig/ instead, so a build-from-Dockerfile image can live in your global config and be used from every repo on the machine. See Config -> path resolution.

build-args are extra Dockerfile ARGs – whatever your Dockerfile needs parameterized at build time.

The [images.<name>.mcp] map is covered in MCP Servers.

Capability profiles

By default, outrig preserves podman’s default Linux capability set. That keeps existing toolchains and MCP servers working while still applying --security-opt=no-new-privileges, which stays on unless an image-config turns it off (see Devices and privilege escalation). When a container can run with less privilege, add a security block:

[images.coding.security]
capability-profile = "no-net-raw"

The supported profiles are:

  • default: keep podman’s default capability set.
  • no-net-raw: drop NET_RAW, which blocks raw sockets without breaking most development tooling.
  • drop-all: start from --cap-drop=ALL.

You can combine a profile with explicit overrides:

[images.web.security]
capability-profile = "drop-all"
cap-add = ["NET_BIND_SERVICE"]

Explicit cap-add values are rendered last, so a container can start from drop-all and add back one narrow capability. Capability names may include or omit the CAP_ prefix.

Devices and privilege escalation

Two further keys in the same block cover what capabilities cannot express. A device node is not a capability, and no_new_privs is a separate process flag, so each gets its own key:

[images.coding.security]
no-new-privileges = false          # default true
devices           = ["/dev/fuse"]  # default []

no-new-privileges = false weakens the container boundary. Under no_new_privs the kernel ignores the setuid bit and file capabilities on every execve; clearing the flag puts them back, so a process that finds a setuid-root binary in the image can use it to become root inside the container. Treat this as a deliberate tradeoff rather than a neutral knob.

Two things bound the damage. The key is opt-in per image-config and defaults to true, so a config that never mentions it keeps today’s protection. And the container is still an unprivileged rootless podman container in a user namespace – clearing the flag grants the container’s own namespace-local root, not root on the host.

devices passes host device nodes through, one --device=<path> per entry, in declaration order. This is the sharper of the two in the general case: /dev/kvm or a raw block device hands out real hardware access. It is explicit per path and per image-config, and outrig does not police which paths you may ask for.

The motivating case for both is a nested container runtime – an agent whose job is to build an image or run a throwaway container. A nested rootless podman needs newuidmap to map its subordinate UID range, and newuidmap is setuid-root, so no_new_privs breaks it. The session container’s rootfs is overlayfs and the kernel refuses overlay-on-overlay, so the nested runtime also needs fuse-overlayfs, which needs /dev/fuse. outrig supplies the two primitives; assembling them into a working nested runtime (and installing the tools in your Dockerfile) is yours to do.

Using a pre-built image

If you already have an image (from a registry, CI pipeline, or local build), set image-name instead of dockerfile + context:

[images.scratch]
image-name = "docker.io/library/ubuntu:24.04"

  [images.scratch.mcp]
  fs = { command = ["mcp-server-filesystem", "/workspace"] }

Exactly one of these two shapes must be set on each block:

  • dockerfile + context (with optional build-args) – build path.
  • image-name – use-existing-image path.

Setting both, neither, or image-name alongside build-args is a config-validation error.

outrig build --image scratch pulls the image if not already local:

$ outrig build --image scratch
[outrig] image-config: scratch
[outrig] image:            docker.io/library/ubuntu:24.04
[outrig] image ready: docker.io/library/ubuntu:24.04

On subsequent runs when the image is already present:

$ outrig build --image scratch
[outrig] image ready (already pulled: docker.io/library/ubuntu:24.04)

outrig run --image scratch starts the container directly – no buildah invocation.

A stock distribution image works here precisely because outrig supplies the command itself: it needs no CMD, and ubuntu, debian, fedora, and alpine all set no ENTRYPOINT and bake in no user. What it does not have is any MCP server, and outrig installs nothing at run time, so an off-the-shelf base is useful only with servers placed in sidecars – see MCP Servers -> Sidecar placement. That is exactly how outrig’s own built-in default is put together.

Named image-configs

You can declare multiple image-configs for the same repo and switch between them with --image:

default-image = "coding"

[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"
context    = ".agents/outrig/images/coding"

  [images.coding.mcp]
  fs    = { command = ["mcp-server-filesystem", "/workspace"] }
  shell = ["bash", "-lc", "exec shell-mcp-command"]

[images.planning]
dockerfile = ".agents/outrig/images/planning/Dockerfile"
context    = ".agents/outrig/images/planning"

  [images.planning.mcp]
  fs       = { command = ["mcp-server-filesystem", "/workspace"] }
  research = { command = ["mcp-research-tools"] }
$ outrig run                       # uses default-image = "coding"
$ outrig run --image planning      # different Dockerfile, different MCPs

This is useful when you want lighter-weight environments for different kinds of work – e.g. a planning image that has no compiler, no shell, and only research-oriented MCPs; a coding image with the full toolchain. Agents can also pin their own default image via agents.<name>.image; see Providers, Models, and Agents.

Every image-config is built and cached independently. Switching between them is fast after the first build.

Image caching

outrig tags built images as <image-config-name>:<hash>, where the name is the [images.<name>] block key and the hash is a content-addressed cache key combining the contents of the Dockerfile, the build-args, the OutRig labels derived from [images.<name>.mcp], and the content of the build context (gitignore-aware when the context is in a git repo, otherwise a tarball hash). So [images.outrig-standard] builds to outrig-standard:<hash>, which podman shows as localhost/outrig-standard.

Repo-local build images carry an org.outrig.mcp label too. On a cache miss, outrig builds a temporary image, reads any inherited/Dockerfile MCP label, overlays [images.<name>.mcp], and commits the final cache tag with the merged label. This keeps outrig image inspect <image-config-name>:<hash> aligned with the declared servers that startup will use.

Because the name is the image’s repository, give image-configs repo-specific, lowercase names (e.g. outrig-standard, not standard) so podman images makes clear which repo an image came from. Build-image names must be valid container image repository components – lowercase alphanumeric separated by ., _, or - – and outrig rejects invalid names at config load.

A change to the Dockerfile, any file in the context, build args, or [images.<name>.mcp] causes a rebuild on the next outrig run or outrig build. Otherwise the cache hit is immediate. To force a rebuild without changing files, run outrig build --no-cache.

Image-name configs use podman’s local image store directly; there is no <name>:<hash> tag in that path. --no-cache on an image-name config re-runs podman pull even when the image is already present locally. (The library Outrig::launch API, which builds from a raw Dockerfile with no image-config name, falls back to the outrig-cache:<hash> repository.)

Sidecar containers

A session can own more than one container. Sidecars are extra podman containers declared under the top-level [sidecars.<sc>] (or implied by an inline image key on an MCP entry) that host MCP servers away from the workspace container; see MCP Servers -> Sidecar placement for the config surface.

Blocks are declared once and shared by any number of image-configs. A session starts the ones its image-config’s [mcp] entries name, so a block nothing references costs nothing.

[sidecars.tools]
image      = "mcp-tools"        # sibling [images.mcp-tools] block first, else raw podman ref
workspace  = "ro"               # "none" (default) | "ro" | "rw"
start      = "auto"             # "auto" (default)  | "manual"
on-failure = "abort"            # "abort" (default) | "warn"

[[sidecars.tools.mounts]]
host-path      = "~/.cache/example"
container-path = "/cache"
access         = "read-write"   # "read-only" (default) | "read-write"

The image key resolves exactly like --image: an [images.<name>] config name first (Dockerfile-built sidecars get content-hash caching for free), then a raw podman ref, which must be present locally. An optional [sidecars.<sc>.security] block reuses the whole security surface of the primary – capability keys, no-new-privileges, and devices alike – and each sidecar’s block stands on its own, so opting one out of no-new-privileges leaves the others hardened. start = "manual" declares a sidecar that does not start with the session (a later release adds the surfaces that start one mid-session; until then its servers are skipped with a notice).

Naming and labels. Sidecar containers are named outrig-<sid>-<sc>; an anonymous sidecar uses its server’s name as <sc>. Every session container – primary included – carries the podman label org.outrig.session=<session-id>, and sidecars additionally carry org.outrig.sidecar=<sc>. The session record lists sidecar container names next to container_name.

(A library caller declares the same containers with SidecarSpec, which mirrors this block minus start and on-failure – a spec starts when Outrig::add_sidecar is called, and launch-time sidecars are abort-only. Naming, labels, and lifecycle coupling are identical, so outrig clean sweeps a library session’s strays the same way.)

Lifecycle coupling is entirely outrig-managed (no pods, no --requires): sidecars start after the primary and stop before it, and the same three cleanup layers – explicit stop, Drop, and the panic-hook sweep – cover every container. In addition, sessions with sidecars run a podman wait watcher on the primary: if the primary dies out from under outrig (manual podman kill, OOM), the watcher reaps all sidecars and ends the session with an error. A stray that survives even that (say, a SIGKILLed outrig) is caught by outrig clean, which sweeps stopped, record-less containers carrying org.outrig.session; see Sessions -> outrig clean.

Session [network] policy applies to every container: the network interceptor attaches to each sidecar the same way it attaches to the primary, before any MCP server connects.

What outrig sets in the run

outrig adds --userns=keep-id, the primary workspace bind-mount, any configured extra workspace mounts, and the runtime user-mapping bootstrap (see Workspace). It also supplies the container’s command: sleep infinity, appended after the image reference, which overrides the image’s CMD (see Don’t set an ENTRYPOINT). The bootstrap runs from the host: a forked child joins the container’s user namespace, becomes its root, joins its mount namespace, and appends the missing /etc/passwd and /etc/group entries before creating /home/<user>. No podman exec is involved, and nothing is written when podman’s keep-id mapping already planted the entries. A view = "primary" sidecar is the one exception to keep-id: it runs --userns=container:<primary> to join the primary’s user namespace, plus --cap-add=SYS_ADMIN/SYS_PTRACE, the primary’s /proc/<pid>/ns directory, and the outrig-enter launcher as its --entrypoint (see MCP Servers). It keeps its own PID namespace, so the launcher mounts a fresh proc over the primary’s before the exec – /proc/self in the primary’s procfs would name nothing. The launcher’s argv carries --uid/--gid with the session user’s ids: it holds those capabilities only until the graft is in place, then becomes that user for the exec, so the server runs unprivileged even though the container was created privileged. --security-opt=no-new-privileges goes on too unless the selected image-config sets no-new-privileges = false. Capability flags are emitted only when that image-config opts into a capability profile or explicit cap-drop / cap-add entries, and --device=<path> flags only when it declares devices. Session containers additionally carry the org.outrig.session label (and sidecars org.outrig.sidecar).

outrig does not configure seccomp profiles, AppArmor policy, SELinux policy, read-only root filesystems, or network egress policy in this container launch path. Network audit/filter mode is a separate session-level interceptor; see Workspace.

What outrig sets in an exec

Most real processes – an exec-hosted MCP server, a command a library caller runs – reach the container through podman exec, and that invocation is assembled separately from the run. For those, outrig always passes -i so stdin stays open for a stdio-speaking server, --user=<uid>:<gid> with the session’s ids, and --env HOME=/home/<user> naming the home directory the runtime user bootstrap created. Each entry a caller supplies becomes one further --env K=V, emitted in sorted key order so the same inputs always produce the same command line.

An entrypoint-stdio server is the exception, and gets none of that. It is the image’s own ENTRYPOINT, started by podman create plus podman start --attach --interactive rather than exec’d, so there is no --user and no HOME from this path – it runs as whatever user its image expects, and its environment has to be baked in at create time. Such a container also skips the runtime user bootstrap unless something else about it needs identity (a workspace, a mount, or a co-hosted exec server). Do not assume the mapped ids for one. The single exception to the exception is a view = "primary" sidecar, whose launcher is handed the session’s ids explicitly and drops to them before the payload runs.

The working directory is the one exec flag that is conditional. Supply one and outrig emits --workdir <path>; leave it unset and no flag is emitted at all, so the exec lands wherever the container already is. That is the image’s WORKDIR only when nothing overrode it – a workspace-backed session sets -w to the workspace’s container path on the run, so an unset exec runs in the workspace, on your mounted checkout. Set the directory explicitly when a relative or destructive command must not land there. The path is the container’s, not the host’s, and outrig does not check that it exists before running: podman reports a missing directory itself, naming the path, and probing for it in advance would cost an extra exec on every call. That failure arrives the way any failing command does – a non-zero exit status with the message on stderr – rather than as a distinct kind of error.

Setting the directory this way is what lets the argv form stay usable on an image with no shell. The alternative, wrapping the command in sh -c 'cd ... && ...', needs a shell in the image and correct quoting from the caller; setting PWD instead changes the variable without moving the process, so anything calling getcwd never notices. A library caller sets it with ExecOptions::with_workdir; see the crate docs for Outrig::exec_stdio and Outrig::exec_capture.

See also

MCP Servers

MCP – Model Context Protocol – is the wire format outrig uses to talk to tools. Each MCP server is a child process that runs inside a session container and speaks JSON-RPC over its stdio. outrig connects to each one by podman exec -i’ing into the container, hands the resulting stdio pair to the rmcp client, and treats every tool the server advertises as a Rig dynamic tool.

By default a server runs in the session’s primary workspace container. An entry can instead opt into a sidecar – an extra container owned by the session – so the server’s runtime dependencies stay out of the workspace image and the server sees only what its container is granted. See Sidecar placement below and Containers for the container-side details.

The same [images.<name>.mcp] table is consumed by both outrig run and outrig mcp. outrig run registers those tools with its built-in agent; outrig mcp republishes them as one stdio MCP server for an external client. See Usage -> outrig mcp for client setup and transport rules. When outrig mcp --attach points at an existing container, it still starts its own MCP child processes inside that container; it does not share or proxy the host session’s existing MCP protocol state.

Declaring servers

MCP servers are configured per-image, as a map keyed by the server’s local name:

[images.coding.mcp]
fs    = { command = ["mcp-server-filesystem", "/workspace"] }
shell = ["bash", "-lc", "exec shell-mcp-command"]
build = { command = ["cargo-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }

shell-mcp-command is a placeholder for the shell MCP package you choose and install in the image. OutRig supports arbitrary MCP commands; outrig image add only renders package recipes for the MCP servers it can install without more input.

Each entry is one of:

  • A bare array (short form): ["bin", "arg1", ...]. Equivalent to { command = ["bin", "arg1", ...] } with no extra env.
  • A table (full form): { command = [...], env = { KEY = "value", ... } }. The env map is added to the podman exec invocation for this server.

Server names must match ^[a-zA-Z][a-zA-Z0-9_-]*$ and must be unique within an image-config. Names are how you reference servers elsewhere – in outrig logs <session> <server>, in tool-call traces, in the prefix that gets attached to every tool the server exposes.

Sidecar placement

A full-form entry can name the container it runs in:

[sidecars.tools]
image     = "mcp-tools"        # sibling [images.mcp-tools] block first, else raw podman ref
workspace = "ro"               # "none" (default) | "ro" | "rw"

[sidecars.serve]
image     = "docker.io/mcp/filesystem:latest"
args      = ["/workspace"]     # argv for the image's ENTRYPOINT
workspace = "ro"

[images.dev.mcp]
# Runs in the primary container, exactly as before.
local = ["mcp-local", "--stdio"]
# exec-stdio in the named sidecar "tools".
fs    = { command = ["mcp-fs", "/workspace"], sidecar = "tools" }
# exec-stdio in a dedicated anonymous sidecar built just for this server.
grep  = { command = ["mcp-grep"], image = "ghcr.io/example/mcp-grep:1" }
# entrypoint-stdio: no command; the image ENTRYPOINT is the server.
fetch = { image = "ghcr.io/example/mcp-fetch:2", env = { TOKEN = "${FETCH_TOKEN}" } }
# entrypoint-stdio in the named sidecar "serve", which supplies the argv.
ws    = { sidecar = "serve" }

The placement shapes:

  • Primary (default). No placement key. The short form (bare array) always runs in the primary.
  • Named sidecar: sidecar = "<sc>" names a top-level [sidecars.<sc>] block; with a command, the server is podman exec’d in that container. Naming it is also what starts it – blocks are shared, so a session only runs the ones its [mcp] entries reference.
  • Anonymous sidecar: image = "<ref>" plus command gives this one server a dedicated container with all defaults (no workspace, no mounts, on-failure = "abort"). Anything fancier – workspace access, mounts, security – requires promoting to a named block. sidecar and image are mutually exclusive.
  • Entrypoint sidecar: an entry without a command runs its container’s ENTRYPOINT as the server over piped stdio – the off-the-shelf MCP image pattern, zero repo-side command knowledge. Container lifetime equals server lifetime: the server exiting removes the container, surfacing exactly like a mid-session sidecar death (tools error, session survives). Two ways to write it:
    • image = "<ref>" with no command – the one-liner, all defaults.
    • sidecar = "<sc>" with no command – the named block becomes the entrypoint host, which is how such a server gets a workspace view, mounts, or its own security policy.

Every one of these is reachable from the library API as well as from a config file. A SidecarSpec carries the same workspace, view, mounts, and security a [sidecars.<sc>] block does, and hosts either exec-stdio servers (with_server) or a single entrypoint-stdio one (with_entrypoint_server, whose args become the container argv). LaunchSpec::from_config lowers a parsed config into those same specs, so an embedding program and outrig run reach the same placements and produce the same containers.

An entrypoint server’s arguments come from args, on the entry or on its sidecar block – never both. Without it, docker.io/mcp/filesystem and most real MCP images cannot be reached at all, since they take their served directories positionally:

[images.dev.mcp]
serve = { image = "docker.io/mcp/filesystem:latest", args = ["/workspace"] }

args is for images whose server is an ENTRYPOINT. podman appends trailing arguments to an exec-form ENTRYPOINT, so ["node", "/app/dist/index.js"] plus args = ["/workspace"] runs node /app/dist/index.js /workspace. But it replaces CMD – an image that puts its server in CMD instead loses it, and nothing starts.

Because the container process is the server, an entrypoint host serves exactly one server and cannot be start = "manual". It also skips the in-container user bootstrap: that needs a running container, and an entrypoint host runs its server as the first process, so there is no window for it between the container’s creation and the attach that runs the entrypoint. The image’s own USER therefore applies to any workspace or mounts the block declares. view = "primary" is the exception – see below: its payload runs as the session user whatever the image says, because OutRig’s own launcher is the process that starts it.

Entrypoint sidecars never race session network policy: the container is created and initialized with its entrypoint held un-executed, audit/filter interception attaches to its network namespace, and only then does the entrypoint run. Its first packet is already subject to policy.

Primary filesystem view

An entrypoint sidecar can go one step further and run against the primary container’s filesystem view with view = "primary" – the primary’s rootfs and every mount, at the primary’s paths, with the sidecar image supplying its own runtime:

[images.dev.mcp]
# The quickstart one-liner: an off-the-shelf filesystem server over the primary's view.
fs = { image = "docker.io/mcp/filesystem:latest", view = "primary", args = ["/workspace"] }

The sidecar’s outrig-enter entrypoint joins the primary’s mount namespace and grafts the sidecar’s own rootfs aside at /mnt, then execs the image’s server. This is what lets an unmodified docker.io/mcp/filesystem (Alpine) index a Debian project’s tree – neither image knows about the other. view = "primary" is entrypoint-stdio only, and mutually exclusive with workspace (the view already contains it).

A ref written inline like that is resolved the way --image is: an [images.<name>] block first, else a raw podman ref that must already be present locally – OutRig will not pull it, so that a typo cannot reach a registry. To have OutRig pull the image, name it in a block of its own and point the entry at that:

[images.mcp-filesystem]
image-name = "docker.io/mcp/filesystem:latest"

  [images.dev.mcp]
  fs = { image = "mcp-filesystem", view = "primary", args = ["/workspace"] }

The entrypoint program must be an ELF binary. outrig-enter opens it before joining the primary’s namespace and classifies it; a #! script is refused, because its interpreter line would resolve against the primary’s rootfs and quietly run something other than the image’s own. Published MCP images often ship a console script – docker.io/mcp/git’s ENTRYPOINT is one – and the fix is a two-line image of your own that names the interpreter and passes the script to it:

FROM docker.io/library/python:3.13-slim
RUN pip install --no-cache-dir mcp-server-git==2026.7.10 mcp==1.29.0
ENTRYPOINT ["/usr/local/bin/python3", "/usr/local/bin/mcp-server-git"]

The argument asymmetry. Because OutRig rebuilds the server’s command line, two kinds of path in it mean different things:

  • Elements from the sidecar image – its ENTRYPOINT and CMD – name files in the sidecar’s own rootfs, now under the graft. OutRig prefixes absolute ones with /mnt. The program itself is the exception: outrig-enter opens it before joining the primary’s namespace, while the sidecar’s own rootfs is still at /, so it stays bare and the helper applies the graft itself when handing the path to the loader.
  • Elements you wrote in args name paths in the primary’s view. They are passed bare.

So args = ["/workspace"] against an image whose entrypoint is /usr/local/bin/node /app/dist/index.js runs /usr/local/bin/node /mnt/app/dist/index.js /workspace – the tool from the sidecar, the directory from the primary.

HOME is the primary user’s. The payload runs as the session user, but the sidecar image sets HOME for whoever it expects to run as – usually root, whose /root is 0700. So the container gets --env HOME=<primary's home>, the same directory every exec-stdio server is given, and an env entry of your own for HOME still wins. Without it the breakage is oblique rather than obvious: git cannot read core.excludesFile under an unreadable $HOME and fails rather than skipping it, so cargo reports a fingerprint error – and only a tool that writes to $HOME would have looked guilty.

/proc is the payload’s own. The launcher joins the primary’s mount namespace and nothing else, so the primary’s /proc – an instance of the primary’s PID namespace – has no entry for the payload, and /proc/self there resolves to nothing. The launcher mounts a fresh proc over it in the private namespace it already holds, so /proc/self means the payload, which is what a program asking about itself intends. Programs that read it are more common than they look: rustup’s cargo shim resolves its own toolchain that way. The trade is that the primary’s process list is not visible through the view – it is a filesystem view.

A program named without a /ENTRYPOINT ["node", "/app/dist/index.js"], which is what the quickstart image above declares – is searched along the sidecar image’s PATH, in that same pre-join instant and for the same reason: the program is one of the sidecar’s own files, so the sidecar’s PATH is the one that can find it. Only absolute PATH entries are searched, since a relative hit could not be named under the graft afterwards. When nothing matches, startup fails with a message naming the program and the PATH that was walked.

view = "primary" is a real posture change: the container starts with CAP_SYS_ADMIN and CAP_SYS_PTRACE in the primary’s user namespace, and the server can read the primary’s whole filesystem. See MCP Trust Model and SECURITY.md. It needs the outrig-enter helper. The helper is supported on Linux x86-64 and AArch64 and is compiled when the matching <arch>-unknown-linux-musl Rust target is installed; without it a view = "primary" session fails at start with a message naming the missing artifact and the specific build-time reason. The helper is always a Linux binary because it runs inside the container, not in the host process. macOS and native Windows are unsupported; WSL2 is Linux and uses the normal path.

The capabilities belong to the launcher, not to the server. outrig-enter needs them for the namespace join and the graft, and gives them up the moment that work is done: it becomes the session’s uid/gid immediately before exec’ing the payload, which clears its capability sets with the uid transition. The server – and anything it shells out to – therefore runs as the same user every other OutRig-launched server runs as, so what it writes into the workspace is yours rather than a subuid you cannot chown back, and a root-owned path in the primary is out of reach. The image’s USER does not enter into it.

That posture is identical from the library. SidecarSpec::with_view(SidecarView::Primary) takes the same capabilities in the same namespace, drops to the same ids, and a build without the helper fails the same way, before any container is created. The helper is materialized into the session’s log directory, which is the one writable location a LaunchSpec names.

Exec-stdio named sidecars honor their image’s org.outrig.mcp label with the usual semantics, scoped to that sidecar: label-declared servers materialize as exec-stdio servers in that sidecar, and repo config overrides by server name (the whole entry, placement included). The label adds servers to a sidecar an [mcp] entry already started; it cannot start one itself. Labels are inert on anonymous sidecars and on entrypoint hosts – exactly the one declaring server runs there. Labels may not carry args for the same reason they may not carry placement keys: they describe exec-stdio servers in the image that carries them, whose arguments belong in command.

The server-name namespace stays flat per session, across the primary and every sidecar. If two sidecar images both advertise the same name and neither is overridden, session start fails with an error naming both hosts; add an override by name in [images.<name>.mcp] to pick one.

A sidecar that fails to start, bootstrap, or connect any of its servers is handled per its on-failure key: abort (the default) fails the session start; warn logs to stderr, skips the sidecar and every server it hosts, and continues with a reduced tool set. Primary-container and primary-hosted-server failures always fail fast.

Embedding MCP config in the image

An image can also carry its MCP declarations in its org.outrig.mcp OCI label. You author them as a [mcp] table in the project’s image.toml, which outrig image build serializes into the label:

# image.toml
[mcp]
fs    = { command = ["mcp-server-filesystem", "/workspace"] }
shell = ["bash", "-lc", "exec shell-mcp-command"]
build = { command = ["cargo-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }

The [mcp] table uses the same short and full entry shapes as [images.<name>.mcp]. At session startup, outrig reads the org.outrig.mcp label off the image, then overlays entries from config.toml. If both sources define the same server name, the config.toml entry replaces the image entry in full; fields are not deep-merged. Servers that appear in only one source remain in the merged set.

For build-from-Dockerfile repo images, outrig build also stamps the cache image with the same merged org.outrig.mcp label it would use at startup. The startup overlay still runs, but is idempotent for those repo-local entries. This means outrig image inspect <name>:<hash> can show the declared servers without starting a container.

Use embedded MCP config when a shared image owns the tool binaries and their default commands. Use config.toml for repo-local additions or overrides. A repo that wants to delegate completely to the image can omit [images.<name>.mcp].

The org.outrig.mcp label is not required; this allows a shared image to be used with different configurations via config.toml. Malformed JSON, invalid server names, and empty command arrays are startup errors because they mean the image metadata is broken.

To inspect what will actually start, run:

outrig mcp show-merged --image coding

The command starts the selected container, reads the org.outrig.mcp label off the primary image and every named sidecar image, applies config.toml overrides, prints the effective [mcp] table to stdout (without launching sidecar containers), and then stops the container. Each server carries a comment naming its placement and where it was declared:

[mcp]
# fs: primary (image label org.outrig.mcp)
fs = ["mcp-server-filesystem", "/workspace"]
# search: sidecar "tools" (config.toml)
search = { command = ["mcp-search"], sidecar = "tools" }

Lifecycle

When outrig run starts, the primary container comes up first, then every start = "auto" sidecar, then network interception attaches to each container, and only then do MCP servers connect – primary-hosted and sidecar-hosted alike, in name order. The sequence per MCP server is:

  1. podman exec -i <container> <command> – outrig spawns the server as a child process inside its placement’s running container, with stdin/stdout piped back to outrig.
  2. Initialize handshake – outrig sends the MCP initialize request and reads the server’s capabilities.
  3. Discover tools – outrig calls tools/list and receives the list of advertised tools, each with a name, description, and JSON Schema for its inputs.
  4. Register with Rig – each discovered tool becomes a McpToolAdapter that implements Rig’s dynamic-tool trait. The agent now has access to it.

All servers come up before the REPL accepts any input. If any server fails to initialize, outrig run reports the error on stderr and exits before the REPL starts – you don’t get partial sandboxes.

When the REPL terminates (Ctrl-D, Ctrl-C, or LLM error), outrig closes each server’s stdin in turn, waits up to 5 seconds for the process to exit, then stops the containers – sidecars first, primary last. Servers don’t see SIGTERM directly; they see EOF on stdin, which the MCP spec defines as the normal shutdown signal.

In attach mode, outrig mcp --attach borrows a container that something else owns. It shuts down only the MCP children it started and leaves the borrowed container running. If the owner stops the container while the attacher is live, the attacher exits instead of trying to relaunch it.

Dynamic addition

Sidecars can also join a session after it starts. The primitive is the library API: Outrig::add_sidecar(SidecarSpec) accepts an arbitrary spec – a raw podman image ref, a workspace view, a filesystem view, mounts, a security block, and either exec-stdio servers or an entrypoint-stdio one – starts the container with session labels and keep-id, attaches the session’s network interceptor, connects the servers, and extends Outrig::tools. LaunchSpec::with_sidecar(SidecarSpec) declares the same thing at launch time.

The two transports differ in what “starts the container” means. An exec-stdio sidecar is started and then exec’d into, so it outlives any one server. An entrypoint-stdio sidecar is created with its server’s environment baked in and only runs when the server does, so adding one mid-session gives the session a container whose lifetime is that server’s – the same coupling a config-declared entrypoint host has.

The REPL surface is narrower by design: /sidecar add <name> starts a sidecar the image config declared with start = "manual", and nothing else – unknown names and already-running sidecars are errors, and there is no /sidecar stop. New tools become available to the agent on the next turn. /sidecar list shows every declared sidecar with its status (running, not started, exited) and the servers it hosts.

Dynamic-add failures are reported only to the caller – the REPL prints the error, the library returns Err – and the session stays healthy: anything the failed add started (container, interceptor attachment, connected servers) is torn down, and the existing tool set is untouched. This is deliberately different from the session-start on-failure policy, which decides whether a launch-time sidecar failure aborts the session.

There is intentionally no agent-invocable tool for adding sidecars: the agent must not grow its own environment. Only the human at the REPL or the embedding program can.

Tool name prefixing

Two different MCP servers can have a tool with the same name (e.g. both fs and archive might define read_file). To avoid collisions and keep the LLM’s tool-name space predictable, outrig always prefixes every tool with its server name:

fs__list_directory
fs__read_file
fs__write_file
shell__exec
build__cargo_check

The separator is __ (double underscore). The combined name is sanitized to fit OpenAI’s ^[a-zA-Z0-9_-]{1,64}$ constraint – non-matching characters become _, and over-long names get truncated with a stable hash suffix.

The LLM sees fs__write_file in its tool list and emits tool calls under that name. outrig’s router strips the prefix and dispatches to the correct MCP client with the original tool name.

You can list every tool currently registered with the agent from inside the REPL:

> /tools
[outrig] tools available (4):
  fs__list_directory   List the contents of a directory.
  fs__read_file        Read the contents of a file.
  fs__write_file       Write contents to a file (overwrites).
  shell__exec          Run a shell command and return its stdout/stderr.
>

What if a server crashes mid-session?

A crashed MCP server is surfaced as a tool-call error to the LLM, which usually causes the model to stop calling that tool and tell you about the failure. outrig does not auto-restart MCP servers in v0 – the server is gone for the rest of the session.

A sidecar container that dies mid-session behaves the same way, uniformly and regardless of its on-failure setting: outrig logs the death to stderr, every tool the sidecar hosted returns errors, and nothing restarts. If the primary container dies out from under outrig (a manual podman kill, the OOM killer), outrig reaps every sidecar and ends the session with an error.

TODO: Incomplete – auto-restart and per-server health-checking are deferred.

The server’s stderr, captured to <session_dir>/logs/<server>.stderr, usually has the actual error. See Sessions for how to view it.

Attach mode can run more than one copy of the same MCP server in one container. Prefer servers that are reentrant-safe: no fixed listening port, global pidfile, or exclusive lock unless the server is explicitly designed to coordinate multiple copies. If a server cannot run twice, the second copy should fail clearly during startup and its stderr log will show the underlying conflict.

Picking which servers to include

Two practical guidelines:

  • Match servers to the image-config’s purpose. A planning image probably doesn’t need shell. A coding image almost certainly needs both fs and shell.
  • Fewer servers is better when it’s enough. Every server is another initialize cost at startup, another tool list cluttering the LLM’s prompt, another process to monitor. If a single MCP server covers your needs, use one.

See also

  • Containers – the Dockerfile that has to install the server binaries.
  • MCP Trust Model – the container boundary that makes broad MCP tools practical.
  • AI-assisted design – use outrig mcp self to design custom MCP-enabled image-configs.
  • Sessionsoutrig logs <session> <server> for stderr.
  • Reference -> Config – full schema for the [images.<name>.mcp] block.

MCP Trust Model

OutRig treats the container as the trust boundary for MCP servers. The tools run inside the container, see the container filesystem, and execute with the user mapping OutRig creates at session startup. The host stays outside that boundary except for the workspace mount and the runtime services OutRig explicitly connects.

That means an MCP server inside the container does not need a second application-level sandbox just to be safe for normal agent work. The container is already the place where the agent is allowed to inspect files, run commands, and coordinate tools.

Filesystem tools

Filesystem MCP servers can be pointed at broad container paths, including /, when that is the right shape for the container. “The agent can read everything” means everything in the container, not everything on the host.

For a coding container, /workspace is usually the right default because it matches the mounted repo. For a purpose-built operations container, a broader root can be useful if the image includes reference files, generated config, local SDKs, or test fixtures outside the workspace.

Least-privilege placement with sidecars

The container boundary does not have to be one container. Placing an MCP server in a sidecar narrows what that server can see to what its container is granted: no workspace unless workspace = "ro"/"rw" is set, no extra paths unless mounted, its own image’s toolchain rather than the workspace’s. An off-the-shelf MCP image can run tools for the agent without ever seeing the repo.

Two properties keep the boundary deliberate. First, placement is repo-config-only: an image’s org.outrig.mcp label can declare servers for the image that carries it, but cannot direct a server into some other container. Second, the agent cannot grow its own environment – there is no agent-invocable tool that starts sidecars; new containers come from the config or the operator. Session network policy covers every container, so a sidecar is not a way around audit or filter mode.

Subagents do not bend this. An agent can launch one with outrig__subagent, but a subagent runs in the container that is already there, over the MCP connections that are already open. It starts no container, connects no server, and can call nothing the operator did not already grant the session – it is a second consumer of a fixed environment, not a way to widen one.

The one placement that widens rather than narrows is view = "primary". Such a sidecar joins the primary’s mount namespace and can read the primary’s entire filesystem – which is the point, but it means the sidecar image is now inside the primary’s trust boundary, as trusted as the primary image itself, not beside it. What still bounds it: the CAP_SYS_ADMIN/CAP_SYS_PTRACE the join needs belong to OutRig’s outrig-enter launcher, scoped to the rootless user namespace (not host root) the primary already runs in, and the server is exec’d only after the launcher becomes the session’s uid/gid – which clears those capabilities, so the server owns what it writes and can pass no privilege to anything it spawns; it is opt-in and defaults to "none"; it is still a container, so cgroups, seccomp, network policy, and no-new-privileges all still apply; and only the mount namespace is joined – PID, network, and cgroup stay the sidecar’s own. Grant it only to a sidecar image you trust with the primary’s files: it reads them all, even though it writes as you.

Shell tools

Shell MCP servers do not need command allowlists inside a normal OutRig container. Arbitrary code execution inside the container is the point of giving an agent a development environment. If a tool needs bash, package managers, compilers, or project-specific CLIs, install them in the image and let the MCP server expose them.

Keep secrets and host-only material out of the container unless the agent should be able to use them. Environment variables passed to MCP servers are part of the container tool boundary.

Network tools

Network-capable MCP servers are bounded by the container network namespace and by the host network policy around the container runtime. Future network interception can add host-level allowlists, but MCP server config should still describe what the tool needs to do rather than pretending the server is harmless.

Practical posture

Configure MCPs liberally inside the container and keep the container boundary deliberate. Install the tools the agent needs, expose useful paths, and rely on OutRig’s host/container split instead of building a narrow MCP config that prevents useful work.

See also

  • Containers – Dockerfile conventions and runtime user mapping.
  • MCP Servers – declaring the tools that run inside the container.
  • AI-assisted design – using outrig mcp self to design image-configs with an external AI tool.

Workspace

The workspace is what the agent’s tools get to read and write. By default, your repository is mounted live into the container at /workspace. You can also mount extra resource directories, read-only by default, when the agent needs supporting material outside the repo. Read this page before you run outrig on anything you can’t easily roll back.

Direct bind-mount, no staging

outrig mounts the primary host workspace directly:

podman run -v <repo>:/workspace:rw --userns=keep-id ...

That means changes made by tools inside the container appear on your host filesystem immediately for any read-write bind mount. There is no staging directory, no overlay, no per-session shadow copy. If the agent runs rm -rf /workspace/*, your repo is gone – recoverable only via git or whatever backup you have. Extra mounts default to read-only, but an extra access = "read-write" mount has the same immediate-write behavior.

This is intentional. The alternative – staging changes in a sandbox and asking you to “apply” them after the session – has a few real costs:

  • Upfront copy time, every session, on potentially large repos.
  • A second source of truth between session-end and apply-time that can drift.
  • Friction every time you want to actually use the agent’s output.
  • An apply step that may merge surprisingly with files you’ve edited concurrently.

For an autonomous-agent workflow, the better safety net is git, not a staging step:

  • Run outrig on a feature branch.
  • Commit (or stash) your work-in-progress before starting.
  • After the session, use git diff, git status, git checkout -- <path>, git reset --hard, etc. to review and roll back as you would any change.
  • If the agent really destroyed something, that’s what git reflog is for.

If you find yourself wanting a “review before apply” step, that’s a signal you don’t yet trust the agent’s environment – usually the right fix is to tighten the Dockerfile or the MCP server choices, not to layer a staging mechanism over the top.

UID/GID: runtime user mapping

By default, rootless podman maps the container’s UID 0 (root) to your host UID. Anything the container does as root therefore appears on the host as files you own – but anything done as a non-root in-container UID maps to some scrambled subuid number that’s awkward to clean up.

outrig sidesteps this in two parts:

  1. --userns=keep-id on the run command. With this, your host UID/GID map straight onto the same UID/GID inside the container’s user namespace – so an in-container process running as UID 1000 produces files owned by host UID 1000 on the bind-mount.
  2. A startup bootstrap that creates a matching user inside the container at run time, so tools that call getpwuid() (some shells, npm postinstall scripts, etc.) don’t fail on a missing /etc/passwd entry.

After podman run -d brings the container up, but before any MCP server starts, outrig writes those entries itself – from the host, without running anything inside the container. A forked child joins the container’s user namespace (which makes it the container’s root), then its mount namespace, and hands the two open files back; outrig reads them and appends what’s missing:

# Group: reuse if a group with this GID already exists, else append one.
# Name collisions get a `_` suffix, retried up to ten times.
tgockel:x:1000:

# User: same dance. The home directory is named here but created separately,
# exactly as `useradd` without `-m` behaves.
tgockel:x:1000:1000::/home/tgockel:/bin/sh

# Some tools assume $HOME exists, and it must belong to you, not to root.
mkdir -p /home/tgockel && chown 1000:1000 /home/tgockel

Appending, rather than rewriting the files, is deliberate: it keeps their owner and mode, and it is safe against a concurrent writer. /etc/shadow gets no entry – nothing in outrig authenticates as this user, and the entry useradd would write is a locked password.

After bootstrap, every podman exec outrig issues – to start MCP servers, to run anything else – uses --user=$(id -u):$(id -g) and HOME=/home/<user>. Files written under /workspace therefore appear with your UID/GID on the host. The image itself needs no user setup and no user tooling: any base image works, including one with no useradd, groupadd, or getent at all.

The collision dance handles the case where the image already has a group or user at your UID/GID (common for 1000:1000 – the typical first non-root user in many distros). When that happens, outrig reuses the existing entry rather than creating a duplicate.

What’s mounted, what isn’t

The [workspace] block controls what host directories the container sees:

[workspace]
host-path      = "."          # relative to the repo root containing .agents/outrig/
container-path = "/workspace"

[[workspace.mounts]]
host-path      = "../shared-docs"   # relative to the file that declared it
container-path = "/resources/shared-docs"

[[workspace.mounts]]
host-path      = "/var/tmp/outrig-cache"
container-path = "/resources/cache"
access         = "read-write"

host-path = "." (the default) mounts your whole repo. You can narrow this – e.g. host-path = "src" mounts only the source dir. The primary workspace is always read-write and becomes the container workdir.

Extra workspace.mounts entries are for supporting directories: sibling repos, generated docs, SDK checkouts, model artifacts, or caches. Relative extra host paths resolve against the repo root, just like the primary workspace. Their container paths must be absolute, cannot be /, and must not duplicate the primary workspace or another extra mount. Exact duplicates fail during config validation instead of relying on podman mount ordering.

Extra mounts default to access = "read-only". Use access = "read-write" only when the agent really should mutate that host directory, such as a scratch cache under /var/tmp.

Files outside the declared bind-mounts are not reachable from the container under any circumstances:

  • ~/.ssh – not mounted, agent can’t read your keys.
  • ~/.config – not mounted.
  • /etc/passwd on the host – not mounted; the container has its own.

The container has its own /etc, /home, /tmp, etc. coming from the image. The only windows onto the host filesystem are the primary workspace and the extra mounts you declare.

Sidecar containers see nothing by default

MCP sidecar containers (see Containers) get their own, stricter defaults: workspace = "none" – a sidecar sees no host directory at all unless its block asks. workspace = "ro" mounts the session workspace read-only at the primary’s container path (and makes it the working directory for the sidecar’s servers); workspace = "rw" mounts it read-write. Extra mounts entries on a sidecar use the same shape and validation as [[workspace.mounts]], including path resolution: a relative host-path resolves against the directory of the file that declared the sidecar block.

Sidecars run with --userns=keep-id like the primary. The in-container user bootstrap runs only where identity matters: the sidecar hosts at least one exec-stdio server, sees the workspace, or declares mounts. A mount-less sidecar keeps the image’s own USER untouched – as does an entrypoint host, mounts or not, since the bootstrap needs a running container and that container’s first process is already the server.

Network is not part of the workspace

By default, outrig grants the container full outbound network access. This means an agent with shell access can run curl, git push, npm publish, etc. – anything that talks to the outside world.

Network audit and filter modes are opt-in. Set one in config, or override one fresh session with --network audit or --network filter, to put a host-side interceptor in the path of outbound container traffic:

[network]
mode = "audit"

Audit mode writes one Zeek conn.log-style JSON object per connection to <session_dir>/logs/network.jsonl. Audit mode is allow-and-log only: every connection is still allowed, but records include the best known host, destination IP and port, transport, service, byte counts, and duration. HTTPS remains opaque except for TLS SNI. URL, method, status, and body inspection are deferred.

Filter mode uses the same interceptor and audit log, then applies global host/port policy before opening upstream TCP connections:

[network]
mode    = "filter"
default = "deny"
allow   = ["github.com:443", "*.npmjs.org"]
deny    = ["*:22"]

Policy is global-only because it belongs to the machine running the agent. Repo config may choose network.mode, but cannot set default, allow, or deny. Deny entries win over allow entries, and unmatched connections use default. A denied connection is closed immediately and still writes a network.jsonl record with outrig.action = "deny" and zero byte counts, so the audit log is the place to diagnose network policy failures.

When network mode is default, outrig leaves Podman’s configured default networking in place, does not install nftables rules, does not rewrite container DNS, and does not create network.jsonl. Systems without nftables or namespace support therefore keep running normal sessions. If audit or filter mode is explicitly enabled and the host cannot install the interceptor, startup fails before MCP servers launch.

See also

  • Containers – Dockerfile conventions, including the UID/GID setup.
  • MCP Servers – the layer that bounds what the agent can actually do with the workspace.
  • Reference -> Config – full schema for the [workspace] block.

Providers, Models, and Agents

outrig delegates LLM calls to the Rig crate, but configures the LLM stack in three layers so the same providers and models can be reused across many repos without copy-pasting:

  • Provider – where to talk to (base-url, api-key, wire format).
  • Model – a named identifier living on one provider (e.g. gpt-4o-mini on openai).
  • Agent – a runnable unit: model + system preamble (+ optional default image).

Most users keep providers and models in the global config (~/.outrig/config.toml) since those depend on the user’s accounts and preferences. Agents typically live in the repo config because their preambles and image choices are project-specific.

flowchart LR
    user[("user / API key")]
    subgraph global["~/.outrig/config.toml"]
        prov["[providers.openai]<br/>base-url<br/>api-key"]
        m1["[models.fast]<br/>identifier=gpt-4o-mini"]
        m2["[models.smart]<br/>identifier=gpt-4o"]
    end
    subgraph repo[".agents/outrig/config.toml"]
        a1["[agents.coding]<br/>preamble"]
        a2["[agents.review]<br/>preamble"]
        cont["[images.coding]"]
    end
    user --> prov
    m1 --> prov
    m2 --> prov
    a1 --> m1
    a2 --> m2
    a1 -. "image" .-> cont

[providers.<name>]

A provider is a wire-format + endpoint + API key.

[providers.openai]
style    = "openai"
base-url = "https://api.openai.com/v1"
api-key  = "${OPENAI_API_KEY}"

style is the protocol. v0 wires "openai" for any OpenAI-compatible endpoint and "anthropic" for Anthropic’s native Messages API (see Native Anthropic below), and recognizes "mistralrs" for in-process LLMs (gated behind a Cargo feature – see In-process providers below). base-url is the HTTPS endpoint. api-key must be the ${ENV_VAR} form – outrig resolves it at run time, never reads a key from disk. See Reference -> Config for the exact rules.

You can declare as many providers as you want – one per account, one per local Ollama install, one per OpenAI-compatible aggregator. Names you pick (e.g. openai, local-ollama, work-account) become labels you reference from models.

[models.<name>]

A model picks a specific identifier on a specific provider.

[models.fast]
provider   = "openai"
identifier = "gpt-4o-mini"

[models.smart]
provider   = "openai"
identifier = "gpt-4o"

provider references one of the names you defined under [providers.<name>]. identifier is whatever string the provider expects in its API request’s model field.

The model layer exists so that agents can refer to a stable name (fast, smart) and swap the underlying API model without touching every agent. If OpenAI renames a model, you edit one identifier; every agent using that name picks up the change.

[agents.<name>]

An agent ties a model to a system preamble and (optionally) a default image. Agents are optional: outrig run with neither --agent nor default-agent runs the model directly, with no preamble and no agent-level knobs.

[agents.coding]
# model omitted -> falls back to top-level default-model
image       = "coding"
preamble    = "You are a careful coding assistant. Repo is at /workspace."
temperature = 0.2
tool-call-max = 300
tool-result-max = 1048576

[agents.review]
model    = "smart"      # explicit override of default-model
preamble = "You are a meticulous code reviewer. Be specific about line numbers."

model is optional. If set, it must reference one of the names you defined under [models.<name>]; if omitted, the agent inherits the top-level default-model (typically declared in ~/.outrig/config.toml). image is optional too: if set, outrig run --agent <name> defaults to that image-config. preamble is the system prompt the agent operates under – the place to encode role, scope, voice. It is optional as well: an agent with no preamble runs with no system prompt.

temperature and max-tokens live on the agent because the same underlying model is often used with different sampling for different tasks (e.g. low temperature for code, higher for brainstorming). max-tokens may also be set on the model, which covers every agent pointed at it; the agent’s value wins where both are set. That matters for Anthropic models, whose API requires a ceiling on every request – see Native Anthropic.

tool-call-max also lives on the agent when a role needs longer tool loops. If unset, the agent uses the top-level tool-call-max, then the compiled-in default of 50. The max is per user turn, so typing a follow-up prompt starts a fresh count while keeping conversation history. tool-result-max works the same way for oversized MCP output: an agent can inherit the top-level max or set its own byte max when a role regularly reads larger files or logs.

default-model at the top level

Most users have one preferred model and reuse it across repos. Set it once globally:

# ~/.outrig/config.toml
default-model = "fast"

Now any agent that omits model picks it up automatically. Per-repo overrides still work – write default-model = "smart" at the top of a repo config and that repo’s agents fall back to "smart" instead. Per-agent overrides still work too: agents.<a>.model = "smart" wins over both defaults.

Pointing at OpenAI-compatible endpoints

Anything that speaks the OpenAI Chat Completions wire format works as a style = "openai" provider:

[providers.together]
style    = "openai"
base-url = "https://api.together.xyz/v1"
api-key  = "${TOGETHER_API_KEY}"

[providers.openrouter]
style    = "openai"
base-url = "https://openrouter.ai/api/v1"
api-key  = "${OPENROUTER_API_KEY}"

[providers.local-ollama]
style    = "openai"
base-url = "http://localhost:11434/v1"
api-key  = "${OLLAMA_API_KEY}"   # set to anything; some servers ignore the header
[models.tg-llama]
provider   = "together"
identifier = "meta-llama/Llama-3.3-70B-Instruct-Turbo"

[models.or-claude]
provider   = "openrouter"
identifier = "anthropic/claude-sonnet-4-6"

The agent loop is unchanged – it’s still tool calls in OpenAI’s format, just routed somewhere else.

Native Anthropic (style = "anthropic")

style = "anthropic" talks to Anthropic’s own Messages API rather than to an OpenAI-compatible translation of it. Requests go to {base-url}/v1/messages and carry the x-api-key header; tools are advertised in Anthropic’s input_schema shape, the model answers with tool_use blocks, and outrig sends each result back as a tool_result block.

[providers.anthropic]
style    = "anthropic"
base-url = "https://api.anthropic.com"
api-key  = "${ANTHROPIC_API_KEY}"

[models.sonnet]
provider   = "anthropic"
identifier = "claude-sonnet-4-6"
max-tokens = 16384

base-url is the API root; outrig appends /v1/messages itself. Both this endpoint and an OpenAI-compatible bridge to Claude (the openrouter provider above, with an anthropic/claude-* identifier) work, and they are genuinely different paths: the bridge translates to and from chat-completions on someone else’s server, while this one is the native protocol end to end. Prefer the native style when you hold an Anthropic key.

The one thing it asks of you is max-tokens. The Messages API requires an output-token ceiling on every request, so outrig always sends one: yours if you set it on the model (covering every agent that uses it) or on the agent, otherwise the published ceiling for a Claude identifier it recognizes, otherwise a fallback of 32768. That last case – an older model, a proxy’s own naming, or simply a model newer than the pinned rig release – says so once on stderr rather than picking a number quietly. Where outrig knows the published ceiling, it is a cap as well as a default: a larger value of your own is lowered to it, because the API refuses an over-ceiling request outright. See max-tokens in the config reference for the tiers in full.

Prefer setting the ceiling yourself. The fallback keeps a first run working; it does not know your model. It errs high deliberately, because a model whose real limit is lower rejects the request and names that limit, whereas a ceiling set too low truncates replies mid-sentence with nothing logged.

Everything else is shared with the other remote styles: request-timeout-secs, retry-budget-secs, tool-call limits, tool-result truncation, conversation history, and subagents all behave identically. Turns are non-streaming, as for openai.

Transient failures

A rate limit is not a bug, and neither is a gateway that briefly falls over. Both are routine on a shared endpoint, so outrig treats them as a wait rather than a failure.

An LLM call that comes back 408, 425, 429, or a 5xx – or that never comes back at all, timing out or losing its connection – is retried until it succeeds or the retry budget runs out. The budget is retry-budget-secs on the provider, falling back to the top-level value and then to ten minutes. Everything else, including the rest of the 4xx family, is final on the first try: a 401 will not become a 200 on the second attempt.

When the server says how long to wait, outrig waits that long. This is the reason the retry lives in outrig’s own HTTP client rather than around the model call: a Retry-After header is gone by the time a failure has become a provider error, and a rate limiter’s own number is better than any curve we could guess. Absent one, the wait is exponential backoff from a second, doubling to a 30-second ceiling and jittered so that several agents sharing an endpoint do not all come back at the same instant.

A provider can also fail while appearing to succeed: a 200 OK whose body carries no usable content. There is nothing for outrig to say and nothing to act on, so that response is retried too – twice, then given up on. This retry cannot live in the HTTP client, which sees a 200 and calls it a success; it wraps the model call instead. It shares the budget, so retry-budget-secs = 0 switches off both layers, but it is capped by that count as well: an unusable response comes back in milliseconds, so the budget alone would spend ten minutes on dozens of tries where a hiccup wants two.

Both retries happen beneath a single model call, which is what keeps them safe. A turn is a model -> tool -> model loop, and retrying the turn would re-run container tool calls that already happened. Tools run between model calls, never inside one, so replaying either an HTTP request or a model call replays exactly that and nothing observable.

If the budget or the attempts do run out, the turn ends and the REPL prompts again with the conversation untouched – see outrig run. The session, and its containers, stay up.

Other Rig provider styles

TODO: Incomplete – v0 wires "openai" and "anthropic". The other styles Rig ships adapters for (Cohere, Gemini, and friends) are not exposed yet, and neither are the Anthropic-specific extras – prompt caching, citations, and configurable API versions.

In-process providers (mistralrs)

An in-process provider runs the model in the outrig process itself, with no socket and no serialization. The use case is questions whose content must not leave the host – the eventual egress filter, tool-use filter, and prompt-injection scanner all want this. See In-process LLMs for the full picture.

A mistralrs provider table is bare – it just declares “this is the in-process runtime.” Each set of weights goes on a [models.<name>] row referencing the provider:

[providers.local]
style = "mistralrs"

# Auto-download from HuggingFace on first use:
[models.phi3-fast]
provider   = "local"
model-id   = "microsoft/Phi-3-mini-4k-instruct-gguf"
model-file = "Phi-3-mini-4k-instruct-q4.gguf"

# Or point at a GGUF you placed on disk yourself:
[models.llama-local]
provider   = "local"
model-path = "/var/cache/outrig/models/llama-3-8b-instruct.q4.gguf"

The provider takes no base-url and no api-key. Each mistralrs model must set exactly one of model-id / model-path. One provider can back many models.

The backend is gated behind cargo build --features local-llm. A build without the feature still parses and validates style = "mistralrs" blocks cleanly; the error fires only when an agent tries to actually use one of those models, with a message that names the missing feature flag. This keeps configs portable across builds.

For the full schema (including revision, context-length, and the top-level model-cache-root key) see Reference -> Config. For why this exists at all – and why “localhost LLM” isn’t the same thing – see In-process LLMs.

Tool calling

outrig’s agent loop relies on the model emitting tool calls in the provider’s native format (e.g. OpenAI’s tool_calls field). Models that don’t support tool calling won’t work – the agent will appear to “see” tools in its prompt but never invoke them.

If you’re using an OpenAI-compatible endpoint, verify the underlying model supports tool/function calling before pointing outrig at it. A quick test: run a one-shot prompt asking the model to “list files in /workspace” and watch for [outrig] tool call: fs__list_directory(...) on stderr. No tool-call line means no tool calling.

API keys are env-var-only

api-key = "${VAR}" is the only accepted form for the API key. outrig refuses to load any other value – a literal key, a missing ${...} wrapper, anything. This guarantees:

  • Configs are safe to commit to source control.
  • Keys never end up in outrig logs output, in tracing diagnostics, or in session metadata on disk.
  • Rotating a key is a shell change, not a file edit.

Different shells (different accounts, different rate limits) just point api-key at different env-var names:

[providers.cheap]
style    = "openai"
base-url = "https://api.together.xyz/v1"
api-key  = "${TOGETHER_API_KEY}"

[providers.expensive]
style    = "openai"
base-url = "https://api.openai.com/v1"
api-key  = "${OPENAI_API_KEY}"

Where things live: global vs repo

LayerGlobal (~/.outrig/config.toml)Repo (.agents/outrig/config.toml)
[providers.<name>]typical homeallowed for repo-only providers
[models.<name>]typical home (reused names)allowed for repo-specific models
[agents.<name>]raretypical home
[workspace]rare (machine-wide paths)typical home
[images.<name>]rare (reused across repos)typical home
default-modeltypical homeoptional override
default-agentrareoptional
default-imagerarerequired for outrig run

If a name is defined in both, the repo wins – override by redefining.

[workspace] is the one block that does not merge by name. Its host-path and container-path merge per key, so a global container-path stays in effect until a repo declares its own, and workspace.mounts from both files are appended rather than replaced – global entries first. A relative path in either file resolves against the directory of the file that declared it. See Reference -> Config.

See also

  • Quickstart – shows outrig init writing the repo config and invoking outrig config init for the global one.
  • Reference -> Config – every key in [providers], [models], [agents].
  • Rig documentation – what each provider style Rig ships supports.

In-process LLMs

An in-process LLM provider runs the model in the same address space as the outrig CLI itself, backed by the mistralrs crate. The model weights load into outrig’s process; questions never cross a socket, never get serialized into JSON, never reach a network.

This is a feature you opt into at build time and at config time. Most users running outrig against a hosted API never need it.

Why you might want this

Three downstream features (none of which ship with the in-process provider itself) need an LLM whose input must not leave the host:

  • Network egress filter. A future CONNECT proxy in front of the container will ask “does this outbound payload match what the user told the agent to do?” Asking a remote LLM that question is self-defeating – the payload becomes the question.
  • Tool-use filter. A wrapper around the agent loop will ask “is this tool call, with these arguments, consistent with the agent’s stated objective?” The arguments may include source code, secrets, or session context the user does not want round-tripped to a third party.
  • Prompt-injection scanner. A pre-filter on incoming tool results will ask “does this content appear to contain instructions targeting the agent?” Tool results from untrusted sources are exactly what you don’t want re-emitted to a remote LLM.

In all three cases the answer is small (often a single token, sometimes a structured verdict). It’s the question that’s sensitive. The right placement for the model is local enough that the question never crosses a process boundary.

TODO: Incomplete – the egress filter, tool-use filter, and prompt-injection scanner are downstream features. v0 ships the in-process provider as plumbing only; nothing in outrig calls it automatically yet.

Why in-process and not localhost

A local LLM server (Ollama on 127.0.0.1, for instance) puts the model in another process under the same user. That’s not the same trust boundary as in-process:

  • Marshaling. Sensitive payloads still cross a socket and get serialized into JSON. Any process with the right uid (or ptrace) can observe the traffic. “Did this question ever get serialized somewhere I can’t see?” becomes harder to answer.
  • Lifecycle skew. A separate daemon has its own start/stop, its own logging, its own crash recovery. The trust property “this question was answered locally” is weaker when it depends on another process’s configuration.

In-process keeps the question, the model weights, and the answer in one address space owned by outrig itself. The boundary is the outrig process, not “the host machine.”

If you want the convenience of a localhost server (Ollama, vLLM, etc.) and don’t need the in-process trust property, the OpenAI-compatible style = "openai" provider with a localhost base-url is the right tool. Use the in-process provider only when content locality is itself the requirement.

Configuring an in-process provider

A style = "mistralrs" provider has no base-url and no api-key. The provider table is bare – it just declares “this is the in-process runtime.” Each set of weights goes on its own [models.<name>] row referencing the provider, so one mistralrs provider can back many models. You tell outrig where to find each model’s weights either by HuggingFace repo id (outrig downloads it) or by local path (you place it on disk).

[providers.local]
style = "mistralrs"
[models.phi3-fast]
provider   = "local"
model-id   = "microsoft/Phi-3-mini-4k-instruct-gguf"
model-file = "Phi-3-mini-4k-instruct-q4.gguf"   # required when the repo has multiple GGUFs
# revision      = "main"   # optional; pin a git ref for reproducibility
# context-length = 4096    # optional; override the model's default context window
# device         = "cuda"  # optional; defaults to "cpu"

On first use, outrig downloads the named GGUF file from https://huggingface.co/<model-id> and caches it under <XDG_CACHE_HOME>/outrig/models/ (override with the top-level model-cache-root config key; see Reference -> Config). Subsequent runs reuse the cached file.

model-file is optional only when the repo ships exactly one .gguf. Repos that publish several quantizations (-q4, -q5_k_m, -f16, etc.) require an explicit pick.

From a local path

[models.llama-local]
provider   = "local"
model-path = "/var/cache/outrig/models/llama-3-8b-instruct.q4.gguf"
# context-length = 4096    # optional
# device         = "metal" # optional; defaults to "cpu"

Use this when you want to pre-stage the model yourself – in CI, in air-gapped environments, or when you want to manage the cache directory by hand. model-path may be absolute or relative to the repo root.

One or the other, not both

Exactly one of model-id and model-path is required on each mistralrs model. Specifying both, or neither, is a config error. model-file and revision are only meaningful with model-id. The [models.<name>].identifier field that openai-style models use is not allowed on mistralrs models – the weights are the model.

GGUF only

v0 supports GGUF model files only. mistralrs can also load raw HuggingFace safetensors directories, but outrig doesn’t expose that path – if you need it, file an issue.

Device selection

By default, mistralrs models run on CPU:

device = "cpu"

GPU builds can opt into CUDA or Metal per model:

device = "cuda"    # CUDA device 0
device = "cuda:1"  # CUDA device 1 as the base device
device = "metal"   # Metal device 0

cuda requires a binary built with --features "local-llm cuda"; metal requires --features "local-llm metal". A config that asks for an unavailable backend fails loudly when the agent is resolved, with a rebuild hint. Enabling cuda or metal without local-llm emits a build warning and has no effect. outrig does not silently fall back to CPU, because that would hide the performance and policy properties the user asked for.

Metal is only usable on macOS targets, and outrig does not build for one today: its container plumbing calls setns(2) and CLONE_NEW* unconditionally, so crates/outrig/src/lib.rs rejects a non-Linux target outright. That makes metal unreachable in practice until plan/next/macos-host-support.md lands. It stays declared so the feature matrix and the macOS-only dependency block in crates/outrig-cli/Cargo.toml do not rot: non-macOS builds can compile with it for that coverage, but instantiating a Metal device fails with a platform error.

For one-off runs, outrig run --device cuda, --device cuda:1, --device metal, or --device cpu overrides the model’s configured device without editing the config file. The override only applies to style = "mistralrs" models.

outrig still passes mistralrs’s automatic device map through to the loader. For cuda:N, N is the selected base device; mistralrs may use other same-kind devices if its auto mapper decides the model needs them. Explicit sharding controls and ROCm/AMD GPU support are not part of this surface yet.

Build flag and the “still parses” rule

The in-process backend is gated behind a Cargo feature:

cargo build --features local-llm
cargo build --features "local-llm cuda"
cargo build --features "local-llm metal"

The metal line is listed for completeness. It compiles anywhere, but the device it selects needs a macOS target that outrig does not build for yet – see above.

A build without --features local-llm still recognizes style = "mistralrs" in config files. Parsing succeeds, cross-reference validation succeeds, outrig will load and display configs that contain mistralrs-style providers and models without complaint. The error fires only when an agent actually tries to use one of those models – at agent-resolve time, when outrig walks agent -> model -> provider and tries to instantiate a client. The message names the missing feature flag so the fix (“rebuild with --features local-llm”, --features cuda, or --features metal) is one shot.

The point of this design is portability: a repo’s .agents/outrig/config.toml can declare both an OpenAI-style provider and a mistralrs-style provider, and the same checked-in config works for teammates whether or not they built with the feature on. The cost – “using a mistralrs model on a build that doesn’t support it errors out at run time” – is paid only by users who actually try to use it.

First-use download stalls

The first request to a model-id provider blocks while the GGUF downloads. Models are typically a few hundred megabytes to a few gigabytes; on a residential connection this can take minutes. There is no progress UI in v0; the run looks idle until the download completes. Pre-warm by running outrig once with a short prompt before relying on it for real work, or use the local-path form and place the file yourself.

Decode streaming

After the model is loaded, assistant replies stream to stdout while mistralrs decodes them. This matters most on CPU, where a long local reply can take minutes if you wait for the full completion. Tool-call traces and prompts remain on stderr, so outrig run > reply.txt still captures only assistant text.

Model lifecycle

Loading a GGUF is expensive (seconds, sometimes tens of seconds, sometimes gigabytes of RAM). outrig holds one loaded engine per model name for the lifetime of the process:

  • The first request to a mistralrs model triggers the load.
  • Subsequent requests against the same model reuse the loaded engine.
  • Two agents that point at the same [models.<name>] block share one in-memory copy.
  • Two [models.<name>] rows that share a provider but specify different weight specs load distinct engines – the cache key is the model name, not the provider.
  • The engine is dropped on outrig process exit. There’s no eviction in v0 – one engine per model, no multi-tenant pressure.

The registry lives in the host outrig process, never inside the sandboxed container. Loading the model in the container would defeat the trust property: the container is the thing being filtered.

flowchart LR
    you(["you<br/>(terminal)"])
    api(["remote LLM<br/>(HTTPS API)"])

    subgraph host["host -- outrig process"]
        direction TB
        outrig["outrig CLI"]
        rig["Rig agent loop"]
        registry["LlmRegistry<br/>(in-process models)"]
        outrig --> rig
        rig --- registry
    end

    subgraph container["podman container"]
        mcp["MCP servers"]
    end

    you -- "prompt" --> outrig
    rig <-- "remote model" --> api
    rig -- "tool call" --> mcp
    registry -. "(future) policy" .-> rig

What this enables (sketch)

The downstream features named under “Why you might want this” will share a small one-shot policy API:

#![allow(unused)]
fn main() {
pub struct PolicyEngine { /* ... */ }

impl PolicyEngine {
    /// Ask a yes/no policy question; returns the verdict.
    pub async fn classify_yes_no(&self, question: &str) -> Result<bool>;

    /// Ask a structured question; returns a JSON value validated against the schema.
    pub async fn classify_json(
        &self,
        question: &str,
        schema: &serde_json::Value,
    ) -> Result<serde_json::Value>;
}
}

The implementation will lean on mistralrs’s constrained-JSON decoding so the returned value is well-formed by construction. The point of mentioning this here, before any of it ships, is so the “what is this provider for?” question has a concrete answer.

See also

  • Providers, Models, and Agents – the three-layer LLM config; the in-process provider plugs into the same [providers.<name>] slot as a remote one.
  • Reference -> Config – field-level schema for the bare style = "mistralrs" provider, the matching mistralrs [models.<name>] rows, and the top-level model-cache-root key.
  • Workspace – the eventual egress filter is the headline consumer of this provider.

Subagents

A subagent is a second agent loop, launched by the agent itself, running against the same container and the same tools. It exists so a big task can be split up without every piece having to fit in one context under one preamble: the parent hands off a scoped job, the subagent works through it with a fresh context, and only its report comes back.

The agent reaches subagents through the outrig__ tools – OutRig’s own built-ins, which appear alongside MCP tools like fs__read_file and shell__exec and are called the same way. The outrig server name is reserved in config so nothing can shadow them.

Subagents are enabled by default. Set subagents = false on an [agents.<name>] block to leave the tools out entirely; see Reference -> Config.

What a subagent is

A subagent is a headless REPL. outrig run drives one agent loop from lines you type; a subagent is the same loop driven by prompts from the parent instead, keeping its own conversation history across them. That is the whole idea – the rest is bookkeeping.

What it inherits, and what it does not:

InheritedNot inherited
The container, and everything in itYour conversation and context
The MCP tools, over the same connectionsThe session preamble – the parent sets it
The tool limits and sampling settingsThe outrig__ launch tools past the limit
The model and provider, unless overriddenmax-tokens, when a model is named
/workspace, at the same paths

Because it borrows the parent’s MCP connections, launching one starts no container and connects no server. That keeps the MCP trust model invariant intact: a subagent can only reach tools the operator already granted the session.

The tools

outrig__subagent({"name": "audit-config", "prompt": "..."})   -> returns immediately
outrig__wait_results({"names": [...], "min_count": 1})        -> which ones have something
outrig__get_result({"name": "audit-config"})                  -> that one's findings
outrig__subagent_send({"name": "audit-config", "prompt": "..."})
outrig__subagent_release({"names": ["audit-config"]})

Inside a subagent there is exactly one:

outrig__set_result({"status": "result", "body": "..."})    // findings
outrig__set_result({"status": "error",  "body": "..."})    // could not finish

name is a short kebab-case handle the parent picks, and is how it refers to that subagent everywhere afterward.

Choosing the subagent’s model

outrig__subagent takes an optional model. Omit it and the subagent runs under the model the launching agent is running under, which is what every call above does.

outrig__subagent({"name": "grep-callers", "prompt": "...", "model": "fast"})

The value is a model name – a key under [models.<name>], like fast or smart – not a provider and not a wire identifier like gpt-4o-mini. The names configured for the running build are listed in the argument’s schema, so an agent never has to guess one.

This is for delegating mechanical work. Grepping a tree, summarizing logs, or checking whether a symbol is still used does not need the parent’s expensive reasoning model, and moving it to a cheap one keeps the parent’s context free for synthesis.

Sampling and the tool limits still come from the launching agent – temperature, tool-call-max, and tool-result-max are unaffected by the model named. max-tokens is not: an output-token ceiling belongs to the model, and a cheap model generally serves fewer output tokens than an expensive one, so a subagent takes the ceiling that applies to the model it actually runs on. That is [agents.<name>].max-tokens where the launching agent sets one, otherwise the named model’s [models.<name>].max-tokens, capped at the model’s published ceiling where outrig knows one – the same order config applies to any other turn.

A subagent’s model is fixed for its lifetime: outrig__subagent_send cannot re-point a live one, since its history was accumulated under the model it started on. Launch a second subagent instead.

Launching is not waiting

outrig__subagent returns as soon as the subagent starts. Two calls in a row give two subagents running at once – that is how fan-out works, and it does not depend on the model emitting parallel tool calls. The parent’s own tool calls stay sequential and ordered.

Fan-out has a ceiling. subagent-width-max (default 8) bounds how many live subagents one agent may hold, and a launch past it is refused rather than queued. Every subagent counts against the budget until it is released – including one that has finished and whose result the parent already collected – so at the limit the remedy is outrig__subagent_release, not a retry. The budget is per launching agent: a parent that is full does not stop its own subagents from launching theirs.

Reporting is explicit

A subagent reports by calling outrig__set_result, not by finishing with a nicely worded message. This matters more than it looks: a model’s last message is whatever it happened to close with, and “Done, let me know if you need anything else” is a poor thing to hand another agent. Publishing explicitly also makes failure honest – a subagent that runs out of tool calls never publishes, so the parent is told it stopped rather than handed a status string dressed up as an answer.

A subagent may call it more than once. The inbox keeps only the latest value, so a later call simply supersedes an earlier one, and calling it does not end the subagent’s round.

Both fields are required, which is deliberate. An earlier shape took {result} or {error} as two optional strings, and models called it as {} constantly – a schema where every field is optional permits the empty call, so the only rejection possible came from the runtime, after the subagent had already spent a tool call. required is the one constraint providers enforce and models reliably attend to, and an exclusive choice between two optional fields cannot use it. Folding the choice into a status enum makes both fields mandatory and the empty call unrepresentable.

When a report does not fit

body carries the whole report, so it is the one argument in the toolset large enough to run into the model’s output-token ceiling. A reply cut off part-way arrives as a set_result call with status present and body missing, because fields generate in schema order.

OutRig recognizes that shape rather than passing a bare “missing field” back: the subagent is told its report was probably truncated and asked to shorten it, which is something it can act on.

That ask is bounded, because the ceiling does not move between attempts. A subagent that could have fit the report in body does it when first asked; past that it regenerates the same oversized body and fails the same way, so retrying only spends model calls:

AttemptWhat the subagent is told
1stThe report was probably cut off – call again with a shorter body.
2ndStop chasing the report; send status: "error" with a one-sentence body.
3rdThe failure has been reported upward already – stop calling.
4th onThe same refusal, unchanged.

The third attempt is what makes this end. OutRig publishes the truncation as that round’s outcome on the subagent’s behalf, so a parent blocked in outrig__get_result wakes with the cause immediately instead of once the subagent’s whole tool-call budget has drained into one call that could not succeed.

Having given up, the round stays given up: it publishes and warns once, and every later truncated call gets the same refusal. That refusal is a tool failure, not a success, so identical repeats keep accumulating against the breaker below – which is what ends the round if the subagent will not take the hint.

The durable fix is a bigger ceiling. max-tokens is unset by default, which leaves the limit to the provider, and that default can be much lower than expected behind a gateway. The first truncated attempt prints one line to stderr naming the agent and the ceiling that was in effect for that subagent, because that is the part a human – not the model – has to act on. Set [agents.<name>].max-tokens explicitly if subagents produce long reports, or, for one launched under a named model, [models.<name>].max-tokens on the model it runs.

Repeating a failing call does not pay

The same shape shows up beyond set_result: a model that cannot act on a tool error tends to re-emit the identical call rather than try something else. Within a subagent round, OutRig counts consecutive failures of the same tool called with identical arguments. The second such failure gets a note appended to the tool result saying that repeating will not change the outcome; the fourth ends the round. Changing the arguments, or any call that succeeds, resets the count – a subagent taking the hint is making progress, not looping.

A round ended this way publishes nothing, so its parent is told the reason it stopped rather than the bare “stopped without calling outrig__set_result”. The same is true of a round that runs out of tool calls.

This applies to subagents only. Nothing about it is specific to reporting – any tool can be looped on – but the primary agent has someone sitting in front of it who can interrupt, while a subagent loops unattended inside its parent’s tool call.

Reading is edge-triggered

Each subagent’s inbox carries a version, and the parent keeps a read position against it. outrig__get_result blocks until there is something newer than what the parent last saw, returns it, and moves the read position past it. Reading twice with nothing new in between blocks rather than returning the same answer again.

outrig__wait_results blocks on the same condition across several subagents and reports names only. Results can be large, so a call that returned three of them at once is exactly the oversized tool result worth avoiding; the parent pulls each one with outrig__get_result and can stop once it has enough. Use min_count to react to whichever finishes first:

outrig__subagent({"name": "audit-config", "prompt": "..."})
outrig__subagent({"name": "audit-mcp",    "prompt": "..."})
outrig__subagent({"name": "audit-net",    "prompt": "..."})

outrig__wait_results({"names": ["audit-config", "audit-mcp", "audit-net"], "min_count": 1})
  -> ["audit-mcp"]

outrig__get_result({"name": "audit-mcp"})
  -> what it found

A subagent that is ready and left uncollected stays ready. Keep passing its name to outrig__wait_results and every call returns it immediately and never blocks for the others – drop names once they have been collected.

Subagents stay addressable

Finishing a round does not end a subagent. It goes idle with its history intact, and outrig__subagent_send reopens it – to follow up on a result, or to redirect one that is still working. A running subagent sees the message at its next step, so the parent never has to know whether it is busy. Idle subagents live until released or until the session ends.

outrig__subagent_release takes the whole list or none of it. If any name in the call is unknown – or named twice – nothing is released and every subagent in that call stays live, with its history and its unread results intact. Releasing is unrecoverable, so a bad list is better retried than half-applied.

Subagents can launch subagents, up to a depth limit

A subagent’s toolset is the session’s MCP tools plus outrig__set_result, and – while there is depth left – the same launch tools the primary has. The primary agent is the root at depth 1; a subagent it launches is at depth 2, one of theirs is at depth 3, and so on. An agent gets the launch tools only while its depth is under subagent-depth-max (default 3), so nesting stops on its own rather than running away.

Each launching agent has its own private view: it sees only the subagents it launched, names them in its own namespace, and collects their results independently. A mid-tree subagent both reports upward with outrig__set_result and collects its own children with outrig__get_result.

Set subagent-depth-max = 1 to switch subagents off entirely, or 2 to allow only the single layer the primary launches – see Reference -> Config. Releasing a subagent, or ending the session, tears down everything it launched with it.

What you see

Nothing reaches stdout except the primary agent’s reply, so outrig run > out.txt still captures only the model’s text. Subagent activity shows up two other ways:

  • On stderr, with each trace labeled by name. Concurrent subagents interleave; filter by name.
  • In <session_dir>/logs/subagent-<name>.log, beside the MCP servers’ stderr logs, holding that subagent’s prompts, replies, and published outcomes.

Ctrl-C behaves as it always has: it abandons whatever the parent was waiting on and returns you to the prompt. Subagents keep running and are still collectable on the next turn – the same way an abandoned shell__exec keeps running to completion inside the container. A second Ctrl-C ends the session, and everything shuts down with it.

The shared workspace

Every subagent writes to the same bind-mounted /workspace as the parent and its siblings. Two subagents told to edit the same file will fight over it, and OutRig does not stop them – keeping concurrent work disjoint is the parent’s job. Read-only analysis fans out safely; parallel edits want non-overlapping scopes.

See also

Usage

This section is the day-to-day operator’s guide: what each subcommand does, what the REPL looks like in practice, what comes back when something goes wrong.

  • outrig init – one-shot setup orchestrator: runs config init if needed, writes .agents/outrig/config.toml, and loops image add to scaffold image-configs.
  • outrig config – group of commands for outrig’s configuration. v0: config init (writes the global ~/.outrig/config.toml).
  • outrig image – group of commands for image-configs. v0: image add (scaffolds a Dockerfile under .agents/outrig/images/<name>/).
  • AI-assisted design – use outrig mcp self when the built-in image templates do not fit.
  • outrig run – start an interactive agent session. The main subcommand.
  • outrig mcp – expose an image-config’s MCP tools to an external client.
  • outrig build – pre-warm the image cache so the next outrig run is instant.
  • Sessionsoutrig ls, outrig logs, outrig discard, outrig clean.
  • Recipes – common patterns (multiple image-configs, capturing transcripts, scripted single-prompt runs).

For exhaustive flag-by-flag reference, see Reference -> CLI.

outrig init

outrig init is the one-shot setup for a new repo. It’s a thin orchestrator over two more focused commands plus one inline repo-config phase:

  1. If ~/.outrig/config.toml is missing, run outrig config init.
  2. Create .agents/outrig/ and .agents/outrig/config.toml if absent.
  3. Offer to call outrig image add in a loop.

Each phase is idempotent. Re-running outrig init on a fully-set-up repo does nothing for the first two phases; the image-config loop is always offered (you may want to add another image-config later).

Synopsis

outrig init [--force]
FlagDefaultDescription
--forceoffOverwrite existing files. Propagates to config init and image add.

Run it

$ cd hello-outrig
$ outrig init

If ~/.outrig/config.toml doesn’t exist, outrig walks you through providers and models first – the same flow as outrig config init, inlined here so first-time users finish in one session. Every prompt shows the default in [default: ...]; press Enter to accept it. Type ? and Enter at any prompt for an explanation of what’s being asked plus the available options.

[outrig] no global config found at ~/.outrig/config.toml -- let's create one.

? Pick a provider style [default: openai]:
? Provider name (used in models) [default: openai]:
? Base URL [default: https://api.openai.com/v1]:
? API key environment variable [default: OPENAI_API_KEY]:
? Define a model now? [Y/n]:
? Model name (used in agents) [default: fast]:
? Model identifier [default: gpt-4o-mini]:
? Provider for this model [default: openai]:
? Use this model as default-model? [Y/n]:

[outrig] wrote ~/.outrig/config.toml

If the global config already exists, that step is skipped:

[outrig] using existing global config at ~/.outrig/config.toml

Then the repo half:

[outrig] no repo config at .agents/outrig/config.toml -- let's create one.

Configuring models
[outrig] models available in your global config: fast (default: fast)
? Would you like to configure LLM models specific to this repo? [Y/n]:
? Model name [default: fast]:
? Provider for this model [default: openai]:
? Model identifier [default: gpt-4o-mini]:
? Add another model? [y/N]:
? Use this model as default-model? [Y/n]:

Configuring your first agent
? Agent name [default: coder]:
? Preamble (one line, edit later) [default: You are a careful coding assistant.]:

Configuring your first image
? Image-config name [default: hello-outrig-standard]:
? Workspace host-path [default: .]:
? Workspace container-path [default: /workspace]:

[outrig] wrote .agents/outrig/config.toml

The default agent name is coder (a role-based constant). The default image-config name is <repo-folder>-standard (kebab-cased), so the image-config carries the repo’s identity while the agent carries its role.

The model section reads your global ~/.outrig/config.toml and lists the available models. Answering “yes” walks the same model-definition prompts as outrig config init, except the new [models.<name>] entries land in the repo config (referencing the global providers). You can then optionally pin one as the repo’s default-model. “No” inherits everything from the global config.

If your global config has no providers, the section instead prints a hint to run outrig config init and skips the prompt – the agent can’t run without a provider.

If you answer “no” to defining repo-specific models, outrig still offers to pin one of the global models as the repo’s default-model. The default flips: when your global config has a default-model, declining makes sense (inherit it, default [y/N]); when it doesn’t, picking is needed to avoid a config with no resolvable model (default [Y/n]).

If no default-model is set anywhere – globally or at the repo level – the agent prompt forces an explicit model selection. That guarantees the resulting config validates and outrig run will work.

Finally the image-config loop:

? Add an image-config now? [Y/n]:

  ... (calls `outrig image add` -- see that page for the full prompt sequence)

? Add another image-config? [y/N]:

Press Ctrl-C at any prompt to stop; partial files are not written until all answers are gathered.

What gets written

A minimal .agents/outrig/config.toml (image-configs will be filled in by image add):

default-image = "hello-outrig-standard"
default-agent     = "coder"

[workspace]
host-path      = "."
container-path = "/workspace"

[agents.coder]
# inherits default-model from the global config
preamble = "You are a careful coding assistant."

The global ~/.outrig/config.toml is written by outrig config init; the per-image Dockerfile and [images.<name>] block come from outrig image add.

Re-running

Without --force, outrig init skips work that’s already done:

  • Global config present -> skip the config init phase.
  • Repo config.toml present -> skip the repo-config phase.
  • Image-config loop -> always offered, since adding more image-configs later is the expected workflow.

With --force, every nested write rewrites in place. You probably want the more targeted commands instead – outrig config init --force or outrig image add <name> --force.

TODO: Incomplete – non-interactive mode (outrig init --provider openai --model fast ...) is deferred.

See also

outrig config

outrig config groups commands that read and write outrig’s configuration files. In v0 only outrig config init is implemented; the rest of the group (config get, config set, config list) is reserved for later – the same shape as git config.

outrig config init

outrig config init walks you through the global config at ~/.outrig/config.toml (or <XDG_CONFIG_HOME>/outrig/config.toml if XDG_CONFIG_HOME is set) – the user/machine-level providers and models that any outrig repo can reuse.

It’s the first phase of outrig init, which runs config init automatically when no global config is present.

Synopsis

outrig config init [--force]
FlagDefaultDescription
--forceoffOverwrite an existing global config. Without it, outrig refuses to clobber.

Run it

Every prompt shows the default in [default: ...]; press Enter to accept it. Type ? and Enter at any prompt for an explanation of what’s being asked plus the available options.

$ outrig config init
[outrig] writing global config to ~/.outrig/config.toml

? Pick a provider style [default: openai]:
? Provider name (used in models) [default: openai]:
? Base URL [default: https://api.openai.com/v1]:
? API key environment variable [default: OPENAI_API_KEY]:

? Add another provider? [y/N]:

? Define a model now? [Y/n]:
? Model name (used in agents) [default: fast]:
? Provider for this model [default: openai]:
? Model identifier [default: gpt-4o-mini]:

? Add another model? [y/N]:
? Use this model as default-model? [Y/n]:

[outrig] wrote ~/.outrig/config.toml

The prompts that follow depend on the style you pick.

anthropic asks the same connection questions with its own defaults (https://api.anthropic.com, ANTHROPIC_API_KEY), and its models get one extra prompt:

? Model identifier [default: claude-sonnet-4-6]:
? max-tokens for this model [default: 64000]:

Anthropic’s API requires an output-token ceiling on every request, so the generated config carries one explicitly rather than leaving a model whose identifier outrig does not recognize to run on the conservative fallback outrig would otherwise apply. See Concepts -> LLM Providers.

If you pick mistralrs as the provider style, the provider itself has no follow-up prompts – it’s just a tag. The weight-source prompts (Use auto-download by model ID?, HuggingFace model-id or Local model-path, revision, context-length) are asked once per model in the model loop, since each model carries its own weight spec.

Help at any prompt

Type ? and Enter at any prompt to get a short explanation of the field and its options. The prompt is then re-displayed so you can answer:

? Pick a provider style [default: openai]: ?

  Which wire format / runtime this provider speaks.
  openai  OpenAI Chat Completions wire format. Works with OpenAI, OpenRouter, vLLM, Ollama.
  anthropic  Anthropic's native Messages wire format. Talks to Claude directly.
  mistralrs  In-process LLM via the mistralrs crate. Loads a local or HuggingFace model.

  See: https://tgockel.github.io/outrig/concepts/llm-providers.html

? Pick a provider style [default: openai]:

The help text comes from the same descriptions used in Reference -> Config; prompts and reference stay in sync.

What gets written

A minimal ~/.outrig/config.toml:

default-model = "fast"

[providers.openai]
style    = "openai"
base-url = "https://api.openai.com/v1"
api-key  = "${OPENAI_API_KEY}"

[models.fast]
provider   = "openai"
identifier = "gpt-4o-mini"

Re-running

Without --force, outrig config init refuses if the global config already exists:

$ outrig config init
error: ~/.outrig/config.toml already exists; pass --force to overwrite.

To change one field, edit the file directly. There is no plan to support partial re-init in v0 – the config is small enough to edit by hand.

TODO: Incomplete – non-interactive mode (outrig config init --provider openai --model fast ...) is deferred.

See also

outrig image

outrig image groups commands for the container images that host an agent’s tools. outrig image add scaffolds a repo-local image-config (a named Dockerfile + MCP-server bundle); outrig image init scaffolds a standalone image project whose build output is a reusable image, outrig image build builds and validates that project, and outrig image inspect reads an image’s declared labels locally by default, or from a registry with --remote. The rest of the group (image ls, image rm) is reserved for later.

outrig image add

outrig image add scaffolds a new image-config. It writes a Dockerfile under .agents/outrig/images/<name>/Dockerfile and appends matching [images.<name>] and [images.<name>.mcp] blocks to your repo’s config.toml.

Run it any time you want to add an image-config – e.g., a planning config alongside the <repo>-standard one outrig init creates by default. init calls image add in a loop for the first (and any further) image-configs you create during initial setup.

Bootstrapping a fresh repo

If you run outrig image add in a directory without an .agents/outrig/config.toml (neither here nor in any parent), outrig prompts before scaffolding:

[outrig] no .agents/outrig/config.toml found in /path/to/repo or any parent.
? Configure outrig in this directory now? [Y/n]:

Answering y walks the same repo-config prompts that outrig init uses (workspace, default agent, preamble), then continues with the image add flow. Answering n exits with the same error a strict find would have produced – run outrig init later when you’re ready.

Synopsis

outrig image add [<name>] [--force]
Argument / flagDefaultDescription
<name>promptedImage-config name (becomes [images.<name>]).
--forceoffOverwrite existing Dockerfile/config entries for this name.

Run it

Every prompt shows the default in [default: ...]; press Enter to accept it. Type ? and Enter at any prompt for an explanation of what’s being asked plus the available options. The default image-config name is <repo-folder>-standard (kebab-cased), so the example below assumes a hello-outrig repo.

$ cd hello-outrig
$ outrig image add
? Image-config name [default: hello-outrig-standard]:
? Base image [default: debian:bookworm-slim]:
? Language toolchains [default: ]: rust, node
? MCP servers [default: fs]:

[outrig] wrote .agents/outrig/images/hello-outrig-standard/Dockerfile
[outrig] added [images.hello-outrig-standard] block to .agents/outrig/config.toml
[outrig] added [images.hello-outrig-standard.mcp] entries: fs

Next: try `outrig build` to verify the image builds, then `outrig run`.

The prompts are intentionally limited – the goal is a known-good starting point you can edit by hand, not an exhaustive Dockerfile generator.

Help at any prompt

? Language toolchains [default: ]: ?

  Pick zero or more language toolchains to install in the image. The Dockerfile
  template adds the corresponding install steps; you can edit the file afterwards.

  rust    rustup + stable toolchain (cargo, rustfmt, clippy).
  node    Node 20 LTS via NodeSource.
  python  CPython 3.12 with pip and venv.
  go      Go 1.22.
  none    Just the base image -- nothing extra installed.

  See: https://tgockel.github.io/outrig/usage/image.html#known-toolchains

? Language toolchains [default: ]:

Known toolchains

The toolchain prompt offers curated options that cover the common cases:

ChoiceWhat gets installed
rustrustup + the stable toolchain, cargo, rustfmt, clippy.
nodeNode 20 LTS via the base image’s package manager (or NodeSource).
pythonCPython 3.12 with pip and venv.
goGo 1.22.
noneJust the base image.

You can pick more than one. The Dockerfile is a starting point – edit it freely afterwards.

Known MCP servers

  • fs – installs @modelcontextprotocol/server-filesystem from npm; default [mcp] entry: { command = ["mcp-server-filesystem", "/workspace"] }.
  • git – installs mcp-server-git from PyPI; default [mcp] entry: { command = ["mcp-server-git", "--repository", "/workspace"] }.

fs is the default. Add git for repo-aware tools, or wire up additional servers by editing the [images.<name>.mcp] block directly – see Concepts -> MCP Servers. A shell MCP server is the usual next tool for coding image-configs; choose a package, install it in the Dockerfile, and declare its command in [images.<name>.mcp].

TODO: Incomplete – the list of “known MCP servers” will grow as the ecosystem does. Anything not listed here you install in the Dockerfile by hand.

When templates do not fit

The prompt flow is intentionally small. If you need an image with a database, internal SDK, unlisted MCP server, or other custom package set, use outrig mcp self. It gives an MCP-capable AI tool the OutRig docs, config schema, suggested tools, and advisory validators so it can propose a Dockerfile and matching [images.<name>] block without being limited to the built-in template menu.

What gets written

.agents/outrig/images/hello-outrig-standard/Dockerfile (excerpt):

FROM docker.io/library/debian:bookworm-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      ca-certificates curl git build-essential \
 && rm -rf /var/lib/apt/lists/*

# rust toolchain
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
       | sh -s -- -y --default-toolchain stable --profile default
ENV PATH=/root/.cargo/bin:$PATH

# node toolchain
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
 && apt-get install -y --no-install-recommends nodejs \
 && rm -rf /var/lib/apt/lists/*

# MCP servers
RUN npm install -g @modelcontextprotocol/server-filesystem

WORKDIR /workspace
CMD ["sleep", "infinity"]

The Dockerfile is generic – no USER directive, no hard-coded UID, and nothing installed for user management. outrig sets up a user matching your host UID/GID at run time, writing the entries into the container from the host (see Concepts -> Workspace).

Appended to .agents/outrig/config.toml:

[images.hello-outrig-standard]
dockerfile = ".agents/outrig/images/hello-outrig-standard/Dockerfile"
context    = ".agents/outrig/images/hello-outrig-standard"

  [images.hello-outrig-standard.mcp]
  fs = { command = ["mcp-server-filesystem", "/workspace"] }

Re-running

Without --force, outrig refuses if either the Dockerfile path or the config block already exists for that name:

$ outrig image add hello-outrig-standard
error: .agents/outrig/images/hello-outrig-standard/Dockerfile
       already exists; pass --force to overwrite.

With --force, the Dockerfile is replaced and the [images.<name>] block is rewritten in place (preserving surrounding TOML).

outrig image init

outrig image init scaffolds a standalone image project: a self-contained directory whose build output is a reusable container image. Unlike image add, it writes no repo config – it creates three files and nothing else:

  • Dockerfile – a Debian-slim image with the filesystem MCP server, ending in the customary CMD ["sleep", "infinity"]. It installs the tool binaries; it does not copy OutRig config.
  • image.toml – the standalone image authoring config: [image].ref plus a [mcp] table. outrig image build validates this file and stamps the [mcp] table into the image’s OCI labels, which outrig reads back at session startup and merges with any repo [images.<name>.mcp].
  • README.md – how to build the image and reference it from a repo.

The command is noninteractive – there are no prompts. The project name (used as [image].ref) defaults to the target directory’s name.

Synopsis

outrig image init [<dir>] [--force]
Argument / flagDefaultDescription
<dir>current dirProject directory; its name becomes the image ref.
--forceoffOverwrite the generated files if they already exist.

The directory name must match ^[a-zA-Z][a-zA-Z0-9_-]*$ (e.g. rust-dev). A target that resolves to no usable name is rejected; . and the no-argument form use the current directory’s name.

What gets written

$ outrig image init rust-dev
[outrig] wrote rust-dev/Dockerfile
[outrig] wrote rust-dev/image.toml
[outrig] wrote rust-dev/README.md
[outrig] next: build this image with `outrig image build`

rust-dev/image.toml:

[image]
ref = "rust-dev"

[mcp]
fs = { command = ["mcp-server-filesystem", "/workspace"] }

[build] is omitted, so the build defaults to the sibling Dockerfile with context ..

Consuming the image from a repo

Once built, reference the image from a repo’s config.toml with image-name. Because the MCP server declarations are stamped into image labels, the consuming block needs no [images.<name>.mcp]:

[images.rust-dev]
image-name = "rust-dev"

That authoring split is the key difference from image add: repo-local blocks keep their [images.<name>.mcp] entries in config.toml and stamp them into cache image labels when built; standalone projects carry the declarations in image.toml so the image can be consumed elsewhere without repo-local MCP config. See Concepts -> Containers for the image-name shape.

Re-running

Without --force, init refuses if any of the three files already exists, naming the conflicts:

$ outrig image init rust-dev
error: rust-dev/Dockerfile, rust-dev/image.toml, rust-dev/README.md
       already present; pass --force to overwrite.

--force regenerates the three files and leaves anything else in the directory untouched.

outrig image build

outrig image build builds a standalone image project (the output of outrig image init) and validates that the result is a working OutRig toolset image. It is the standalone-project counterpart to outrig build, which builds repo-local image-configs.

It reads the project’s image.toml, serializes it into OCI labels, builds the declared Dockerfile with buildah, and tags the result with the [image].ref from image.toml – stamping those labels onto the image. Then it always validates the built image, and – unless --no-test – live-tests every MCP server the image declares.

Synopsis

outrig image build [<dir>] [--tag <ref>] [--no-test] [--no-cache]
Argument / flagDefaultDescription
<dir>current dirProject directory holding image.toml.
--tag <ref>[image].refBuild tag override; does not edit image.toml.
--no-testoffSkip the live MCP test; still validates image.toml.
--no-cacheoffForce a clean build; passed through to buildah.

What it does

$ outrig image build rust-dev
[outrig] building image rust-dev
[outrig]   dockerfile: Dockerfile
[outrig]   context:    .
... buildah build output ...
[outrig] image ready
[outrig] image config validated (1 mcp server)
[outrig] mcp fs: initialized (12 tools)
[outrig] image ok

After the build, outrig reads the stamped org.outrig.mcp label back off the built image and validates it (every server name well-formed, every command non-empty). By default it then starts the image, and for each declared MCP server initializes it and calls tools/list, reporting the tool count per server. The image is built locally; nothing is pushed.

Failure modes

The command exits nonzero – printing error: ... – in these cases:

  • image.toml missing, malformed, or missing required fields (no [image].ref, an empty or invalid [mcp] table, a partial [build]): caught before the build runs.
  • The stamped org.outrig.mcp label is missing or malformed: read back off the built image and validated after the build, even with --no-test.
  • A declared MCP server cannot initialize or return tools/list: caught during the live test. --no-test skips this check (the per-server stderr is captured in the error message).

--no-test

--no-test skips only the live MCP server test. The stamped org.outrig.mcp label is still read back from the built image and validated, so a missing or malformed config still fails. Use it to verify a build quickly, or in environments where starting the servers is not wanted.

--tag and --no-cache

--tag <ref> tags the build output as <ref> for that invocation instead of the [image].ref in image.toml; the file is never rewritten. Short refs are passed to buildah verbatim, so both rust-dev and rust-dev:0.1.0 work.

Unlike repo-local outrig build – which tags <image-config-name>:<content-hash> and skips the build on a cache hit – a standalone build tags a stable, caller-named ref with no content hash, so there is no project-level cache to skip: --no-cache only forwards --no-cache to buildah to force a clean build.

outrig image inspect

outrig image inspect prints the OutRig config labels declared by an image. It is read-only: local mode checks the local image store and reads labels with podman image inspect; remote mode uses skopeo inspect against a registry. Neither mode pulls image layers, creates a container, or starts an MCP server. Live initialization and tools/list checks belong to outrig image build.

Synopsis

outrig image inspect [--remote] <ref>
ArgumentDescription
<ref>Image ref to inspect. Never pulled.
--remoteRead registry metadata with skopeo inspect.

Output

The command writes only the inspect result to stdout. Diagnostics and errors go to stderr.

$ outrig image inspect rust-dev
image: rust-dev
description: Rust tooling
version: 0.1.0
tags: ["rust","build"]
mcp:
  fs:
    command: ["mcp-server-filesystem","/workspace"]
  db:
    command: ["db-mcp"]
    env:
      TOKEN: "${DB_TOKEN}"

description, version, and tags appear only when the image carries those labels. If the image has no org.outrig.mcp label, inspect still prints the image ref and any metadata labels it finds, then omits the mcp: section.

Repo-local images built by outrig build, outrig run, or outrig mcp are stamped with an org.outrig.mcp label on cache misses. For those <image-config-name>:<hash> refs, inspect shows the merged MCP declarations from inherited/Dockerfile labels plus [images.<name>.mcp], without starting a container. Standalone images may additionally show description, version, and tags.

Use --remote when the ref is in a registry and you want the labels without pulling it:

outrig image inspect --remote ghcr.io/acme/rust-dev:latest

Remote inspection requires skopeo on PATH. Authentication uses the normal container credentials that skopeo reads, such as credentials created by skopeo login, podman login, buildah login, or docker login.

Failure modes

The command exits nonzero – printing error: ... – in these cases:

  • The image is not present locally: inspect reports a local-only not-found error and does not attempt a pull.
  • --remote is used but skopeo is not installed: inspect reports the missing runtime dependency.
  • The registry request fails: skopeo errors, including missing credentials or denied access, are reported with the command and stderr tail.
  • The remote ref uses an unsupported transport: use a plain registry ref or docker://<ref>.
  • A declared OutRig label is malformed: invalid org.outrig.tags or org.outrig.mcp values fail before anything is started.

See also

AI-assisted design

When the built-in templates do not fit, attach outrig mcp self to an MCP-capable AI tool and ask it to design an image-config. The server exposes OutRig’s docs, config schema, curated suggestions, and advisory validators over stdio. It cannot write files, run builds, or mutate your repo; the AI can use those read-only tools before writing through its own client, or it can return the exact file contents for you to install.

Run the server

outrig mcp self is a host-side self-description server. It does not start a container, create a session, or require a repo config.

outrig mcp self

The command is meant to be launched by an MCP client. Keep stdout reserved for MCP protocol messages; status and diagnostics go to stderr.

Client setup

Use an absolute path to outrig if your client starts MCP servers from a different working directory than your shell.

Claude Code:

claude mcp add outrig-self -- outrig mcp self

Claude Desktop:

{
  "mcpServers": {
    "outrig-self": {
      "command": "outrig",
      "args": ["mcp", "self"]
    }
  }
}

Codex CLI:

[mcp_servers.outrig-self]
command = "outrig"
args = ["mcp", "self"]

Cursor:

{
  "mcpServers": {
    "outrig-self": {
      "type": "stdio",
      "command": "outrig",
      "args": ["mcp", "self"]
    }
  }
}

Regenerate any of these snippets with:

outrig design prompt --print-mcp-config <tool>

The valid <tool> names are claude-code, claude-desktop, codex, and cursor.

What the AI sees

The server exposes these tools:

ToolWhat it returns
list_docsEmbedded doc pages with titles and summaries.
get_docMarkdown for one embedded page.
get_config_schemaJSON Schema plus path and image-label hints.
list_base_imagesBase-image suggestions, explicitly non-exhaustive.
list_mcp_server_suggestionsMCP server suggestions and shell guidance.
validate_dockerfileAdvisory warnings about OutRig Dockerfile conventions.
validate_configTOML parse and config validation results for image blocks.
validate_image_tomlTOML validation for standalone image projects.

The suggestion tools are not a registry. The AI can pick any base image, package set, or MCP server command that fits the job. In particular, OutRig supports shell MCP servers even when the suggestion list does not name a single maintained package recipe. The Dockerfile validator is advisory for the same reason: it warns about common OutRig conventions without rejecting custom images.

Suggested prompt

Ask for the files you want and tell the AI to validate both artifacts before it reports done:

Design an OutRig image-config for a Rust and Postgres development environment.
Use the filesystem MCP server at /workspace and add a custom MCP server that runs pg-dump-mcp.
Read the OutRig docs and schema first, then validate the proposed Dockerfile and TOML.
If your client can edit this repo, write the Dockerfile and [images.<name>] block directly.
Otherwise return the exact file paths and file contents.

If an AI client is not allowed to write under .agents/outrig, have it return the exact file contents rather than staging files elsewhere and asking for a blind copy into the repo.

Standalone image projects

For a reusable image that other repos consume by tag, ask for a standalone project instead of a repo-local [images.<name>] block. The output should be a directory with Dockerfile, image.toml, and README.md.

Design an OutRig standalone image project for a Rust and git development toolset.
Return complete contents for Dockerfile, image.toml, and README.md.
Use the filesystem MCP server at /workspace and the git MCP server for /workspace.
Read the OutRig docs and schema first, then validate the proposed Dockerfile and image.toml.
Explain how a repo should reference the built image with image-name.

The standalone image.toml is the authoring source. It must contain [image].ref and a non-empty [mcp] table; optional [image] metadata (description, version, tags) and an optional complete [build] section can be added when useful. outrig image build validates that file and stamps its config into OCI labels, so the Dockerfile should install the tools and MCP binaries but should not copy image.toml into the image.

Trust model

OutRig expects MCP servers to be useful inside the container, not artificially narrow. A filesystem server can point at /workspace or another broad container path; a shell server can run commands inside the container. See MCP Trust Model for the boundary this relies on.

Without MCP

When your AI tool cannot attach MCP servers, print the same design context as a single prompt:

outrig design prompt | pbcopy

# Linux:
outrig design prompt | xclip -selection clipboard

Paste that prompt into ChatGPT, Claude.ai, or any chat UI, followed by what you want the image-config to do. The output is intentionally self-contained: OutRig’s version, embedded docs, conventions, and worked examples are all in the prompt.

To inspect or edit the prompt before sending it:

outrig design prompt > prompt.txt
outrig design prompt --standalone > standalone-prompt.txt

Prefer the MCP path when your tool supports it. outrig mcp self lets the AI read docs and run validators as it iterates; outrig design prompt is a one-shot fallback for clients that only accept pasted text.

outrig run

outrig run is the main subcommand. It walks up from the current directory to find .agents/outrig/config.toml, builds (or cache-hits) the container image, starts the container, attaches every MCP server defined for the selected image, and drops you into a stdin/stdout REPL with the agent.

It can also run config-less: in a directory with no .agents/outrig/config.toml, it uses the current directory as the workspace and reads the agent from the global config. See Config-less runs.

Synopsis

outrig run [--agent <name>]
           [--image <name-or-local-ref>]
           [--config <path>]
           [--device <cpu|cuda|cuda:N|metal>]
           [--max-tool-calls <n>]
           [--max-tool-result-bytes <n>]
           [--model <name>]
           [--network <default|audit|filter>]
           [--session-dir <path>]
           [--session-root <path>]
           [--volume <host:container[:ro|rw]>]
           [--verbose]
  • --agent <name> (default: default-agent): selects an [agents.<name>] block. With neither, the session runs with no agent: no preamble, and the image comes from --image or default-image.
  • --image <name-or-local-ref> (default: agent’s image, else default-image): pick an image-config by name; if an explicit --image value does not match config, treat it as a local Podman image ref and run it without pulling.
  • --config <path> (default: walks up from cwd; if not found, run config-less – see Config-less runs): load config from a non-standard location.
  • --device <cpu|cuda|cuda:N|metal> (default: mistralrs model device, else cpu): override the in-process mistralrs model device for this run.
  • --max-tool-calls <n> (default: resolved tool-call-max, else 50): override the per-turn tool-call max for this run.
  • --max-tool-result-bytes <n> (default: resolved tool-result-max, else 262144): override the per-tool-result byte max for this run.
  • --model <name> (default: agent’s model, else default-model): select an existing [models.<name>] entry for this run.
  • --network <default|audit|filter> (default: config [network].mode, else default): choose Podman’s default networking, network audit logging, or global network filtering for this run.
  • --session-dir <path> (default: <session-root>/<sid>): this run’s specific session directory; symlinked from the root.
  • --session-root <path> (default: session-root config, else XDG): root directory containing all sessions.
  • --volume <host:container[:ro|rw]> (repeatable): bind an extra host directory into the container, on top of the default workspace mount. Access defaults to read-only; append :rw for read-write. The host directory must exist; relative host paths resolve against the workspace root.
  • --verbose (default: off): adds buildah/podman command transcripts to stderr and container.log.

Repeat --verbose (-vv) to also enable trace-level logs from outrig’s own modules for that invocation.

Normal startup progress is always printed to stderr so slow container or MCP startup is visible. --verbose adds the underlying buildah/podman command transcript; it is not required for the progress lines. Tracing filters come from OUTRIG_LOG first, then RUST_LOG if OUTRIG_LOG is unset – a set OUTRIG_LOG shadows RUST_LOG entirely.

Three levels of detail, in increasing order:

SettingWhat it adds
(none)Progress lines only: one per startup phase, with elapsed time.
RUST_LOG=debugEvery buildah/podman command line, plus its exit code and timing.
--verbose (-v)The full command output transcript, also written to container.log.
-vvTrace-level logs from outrig’s own modules.

A phase that stalls between progress lines is a stalled child process. RUST_LOG=debug names the exact command it is waiting on, which is the fastest way to reproduce the stall by hand:

$ RUST_LOG=debug outrig run
[outrig] starting container outrig-20260726T170454-da0c
DEBUG outrig::process: spawn command=podman run -d --rm --name outrig-... sleep infinity

When --session-dir is given, outrig writes this run’s session.json and logs/ directly into <path> and creates a symlink at <session-root>/<sid> -> <path> so outrig ls/logs/discard keep working. This lets you launch with a known path and read session.json immediately without looking up an auto-generated id:

$ outrig run --session-dir /tmp/my-debug-run < prompts.txt
$ cat /tmp/my-debug-run/session.json   # known location, no id lookup needed

--session-dir refuses if the path already contains a session.json.

Config-less runs

You can run in a directory with no .agents/outrig/config.toml at all:

$ cd /tmp/scratch
$ outrig run --image my-tool:latest --volume "$PWD/data:/data:rw"

With no repo config found and no --config, outrig uses the current directory as the workspace root (mounted at /workspace) and reads the agent, model, and provider from the global config (~/.outrig/config.toml, or $XDG_CONFIG_HOME/outrig/config.toml). You need not pass --image: with nothing else naming one, the session falls through to the built-in default image-config. If you do pass --image, an unknown ref is used as a local Podman image and is never pulled.

What the global config must still supply is a model. The session does not need an agent – without one it runs with no preamble – but --model alone is not enough on its own: the name is looked up in [models.<name>], and that entry’s provider in [providers.<name>]. So the supported shape is global config, no repo config, which is exactly where outrig config init leaves you:

$ outrig config init     # once per machine: provider, model, API key
$ cd ~/some/other/repo   # no .agents/outrig/ here
$ outrig run

The built-in default image

The image-config outrig falls back to is outrig-default: a docker.io/library/buildpack-deps:bookworm-scm container (Debian with git, curl, and ca-certificates) with two MCP servers attached as view = "primary" sidecars – fs for reading and writing files, and shell for running commands. Both run against the primary container’s filesystem, so the shell sees the same tree fs edits. The banner marks it:

[outrig] no --image, agent image, or default-image configured; using outrig's built-in default
[outrig] image-config:  outrig-default (built-in default)

A session on the built-in default also gets outrig’s own documentation tools (outrig__list_docs, outrig__get_doc, outrig__validate_config, and others), so you can ask the agent how to configure outrig and it will answer from the shipped docs for your build. A repo with its own image-config does not get them, and its tool list is unchanged.

outrig-default is also usable as an explicit --image, so you can opt into it from a repo that has its own default-image. The first such run pulls two images and builds a third; to pay that up front rather than inside your first session, see outrig build.

Two caveats. outrig-default, outrig-default-fs, and outrig-default-shell are reserved names – declare any of them yourself and outrig steps aside entirely, with a note saying so. And if outrig was built without the <arch>-unknown-linux-musl target, the outrig-enter launcher is missing: fs then falls back to a read-write bind mount of the workspace (same tools) and shell is unavailable. Full details in Reference -> Config.

What happens, in order

  1. Locate config. Walks up from the current directory until .agents/outrig/config.toml is found. If none is found and no --config is given, run config-less: the current directory becomes the workspace root and config comes from the global file only (see Config-less runs).
  2. Resolve image. Uses explicit --image first. If that value matches [images.<name>], OutRig uses the config block; otherwise it must already exist in local Podman images. Without explicit --image, agent image and default-image still name config blocks only. With none of the three set, the session falls through to the built-in default.
  3. Build/cache/probe the image. Config build images run buildah build or cache-hit. Config image-name images may be pulled. Raw --image refs are local-only and are checked with podman image exists.
  4. Start the container. podman run -d --rm --name outrig-<sid> -v <repo>:/workspace:rw --userns=keep-id ... <image> sleep infinity. Any [workspace.mounts] and --volume entries become additional -v binds. The trailing sleep infinity is the container’s command and overrides the image’s CMD; an image that sets an ENTRYPOINT gets it appended instead, so a primary image should not set one (see Concepts -> Containers).
  5. Bootstrap the user. From the host, inside the container’s namespaces: ensure a group with $(id -g) and a user with $(id -u) exist – appending the entries to /etc/group and /etc/passwd if not – and that /home/<user> exists and is owned by them. The image needs no useradd/groupadd for this. See Concepts -> Workspace for the full logic.
  6. Start network interception, if enabled. --network audit, --network filter, or matching [network].mode config installs the per-session interceptor and opens <session_dir>/logs/network.jsonl. Filter mode also enforces global [network] policy. The default mode skips this step entirely.
  7. Connect MCP servers. For each entry in [images.<name>.mcp], podman exec -i --user=$(id -u):$(id -g) the configured command, run the MCP initialize handshake, and discover tools via tools/list.
  8. Resolve agent -> model -> provider. From --agent (or default-agent, or neither – an agentless session resolves as though the agent had no keys set), pick the model from --model, else [agents.<a>].model, else top-level default-model. --model must name an existing [models.<name>] entry; it is not a raw provider model identifier. Then [models.<m>].provider, then [providers.<p>]. Resolve the per-turn tool-call max from tool-call-max and the per-result byte max from tool-result-max. For in-process mistralrs models, --device overrides the model’s configured device for this run. For OpenAI-style models, --device is rejected. Then read the API key from the env var named in the provider’s api-key. Build the Rig provider client.
  9. Build the Rig agent. Dynamic tools from every MCP server’s tool list (each prefixed <server>__<tool>), the agent’s preamble and sampling params, assembled with AgentBuilder. An unset preamble – including every agentless session – sends no system prompt at all.
  10. Open the REPL. Banner on stderr, > prompt, ready for input.

If anything before step 10 fails, outrig run reports the error on stderr and exits non-zero without starting the REPL.

REPL banner

A typical startup looks like:

[outrig] loading config
[outrig] config loaded (1ms)
[outrig] resolving agent and image-config
[outrig] agent/image-config resolved: agent coding, image-config coding (0ms)
[outrig] computing image tag
[outrig] image tag computed: coding:8c2a4f7e91d6b5a3 (32ms)
[outrig] ensuring image coding:8c2a4f7e91d6b5a3
[outrig] image ready: coding:8c2a4f7e91d6b5a3 (cache hit) (18ms)
[outrig] starting container outrig-20260502T103412-3f2a
[outrig] container ready: outrig-20260502T103412-3f2a (620ms)
[outrig] bootstrapping container user
[outrig] container user ready (141ms)
[outrig] reading and merging MCP configuration
[outrig] MCP configuration ready: 2 servers (74ms)
[outrig] MCP fs: initializing
[outrig] MCP fs: initialized (188ms)
[outrig] MCP fs: listing tools
[outrig] MCP fs: tools ready: 3 tools (11ms)
[outrig] building agent
[outrig] agent ready (0ms)
[outrig] agent:             coding (model: fast / provider: openai / gpt-4o-mini)
[outrig] tool-call max:     50
[outrig] tool-result max:   262144 bytes
[outrig] image-config:  coding
[outrig] image:             coding:8c2a4f7e91d6b5a3
[outrig] container started: outrig-20260502T103412-3f2a
[outrig] mcp fs:    initialized (3 tools)
[outrig] mcp shell: initialized (1 tool)
[outrig] tools available: fs__read_file, fs__list_directory, fs__write_file, shell__exec
[outrig] session id: 20260502T103412-3f2a   (Ctrl-D to exit, /help for slash commands)
[outrig] entering REPL
>

All startup progress and the banner are on stderr. The only thing that ever goes to stdout is the assistant’s natural-language reply. For in-process mistralrs models, that reply is flushed as chunks while the model decodes; OpenAI-compatible providers print the reply when the turn finishes. The stream separation makes it easy to capture just the model output:

$ echo "summarise this repo" | outrig run > summary.txt

summary.txt ends up with only the model’s text, nothing else.

REPL behavior

Each line you type at > is one user turn:

> add a doc comment to the public function `parse_config` and run cargo check.
[outrig] tool call: fs__read_file({"path": "/workspace/src/config.rs"})
[outrig] tool call: fs__write_file({"path": "/workspace/src/config.rs", ...})
[outrig] tool call: shell__exec({"cmd": "cargo check"})
Done. I added a `///` doc comment describing the function's inputs and the
TOML keys it expects. `cargo check` passed with no warnings. Diff:

  [diff snippet]
>

Behind the scenes the agent may make many tool calls per turn – Rig drives the model-tool-model loop until the model emits a normal text reply with no tool calls. Tool-call traces appear on stderr; assistant text is printed on stdout.

Each turn has a tool-call max. The compiled-in default is 50; a top-level tool-call-max in config changes the default, [agents.<name>].tool-call-max overrides it for one agent, and outrig run --max-tool-calls <n> overrides both for the current run. The max is per turn, so a follow-up prompt starts a fresh count.

Each individual tool result also has a byte max before it is appended to the LLM-visible conversation history. The compiled-in default is 262144 bytes (256 KiB); a top-level tool-result-max in config changes the default, [agents.<name>].tool-result-max overrides it for one agent, and outrig run --max-tool-result-bytes <n> overrides both for the current run. If a tool returns more than the max, outrig keeps the head of the result and appends a marker that includes the original size, the max, and a hint to narrow the next query.

When the tool-call max fires, outrig ends the current turn and keeps protocol-valid partial conversation history. If the model had already asked for the next tool call, outrig records a tool result saying that call was not executed because the max was reached:

[outrig] tool-call iteration max (50) reached; ending turn
[outrig] partial history retained -- send another prompt (e.g. "continue")
        to keep going, or "/reset" to drop it.

Type continue, or any more specific instruction, to let the next turn pick up from the retained tool calls with a fresh max. If the skipped tool call is still needed, the model can ask for it again. Use /reset first when you want to drop that history.

A rate-limited or briefly unreachable provider does not end the session either. outrig retries the call, honoring the server’s own Retry-After when it sends one, and prints its progress:

[outrig] LLM call failed (HTTP 429 Too Many Requests); retry in 21.0s (Retry-After; 63s/600s spent)

If the endpoint is still failing when the budget runs out, the turn ends and you are back at >, with the containers still up and the conversation unchanged:

[outrig] LLM endpoint failed and did not recover (HTTP 429 Too Many Requests); ending turn
[outrig] history unchanged -- send the prompt again to retry, or "/quit" to stop.

Note the difference from the tool-call max above: nothing was appended, so there is no partial turn to continue. Resend the prompt itself once the window clears. The budget is retry-budget-secs; see config reference to change it, and LLM providers for which failures count as transient.

A provider that answers with nothing usable is handled the same way. The call is retried twice before outrig gives up:

[outrig] model returned an unusable response (Response contained no message or tool call
        (empty)); retry in 1.4s (attempt 2/3)
[outrig] the model returned a response outrig could not use (Response contained no message
        or tool call (empty)); ending turn
[outrig] history unchanged -- send the prompt again to retry, or "/quit" to stop.

The REPL is line-buffered. Multi-line input is not supported in v0.

TODO: Incomplete – multi-line / paste-mode input is deferred.

Slash commands

Anything starting with / is a REPL command, not a model prompt:

CommandEffect
/helpPrint the slash-command list to stderr.
/toolsList every tool registered with the agent, with descriptions.
/resetClear conversation history; container and MCP servers stay up.
/sidecar add <name>Start a config-declared start = "manual" sidecar.
/sidecar listShow declared sidecars with status and hosted servers.
/quitExit cleanly (same as EOF/Ctrl-D).
> /tools
[outrig] tools available (4):
  fs__list_directory   List the contents of a directory.
  fs__read_file        Read a file's contents.
  fs__write_file       Write a file (overwrites existing).
  shell__exec          Run a shell command and return stdout/stderr.
> /reset
[outrig] history cleared
>

Manual sidecars

A sidecar declared with start = "manual" (see Config -> sidecars) is planned at session start – its image label is merged and its servers are reserved names – but its container does not start and its tools are absent. /sidecar add <name> starts it mid-session: the container comes up with session labels, network policy attaches (in audit and filter modes), its MCP servers connect, and the new tools become available to the agent on the next turn. Unknown names and already-running sidecars are errors, and there is no /sidecar stop in v0.

> /sidecar list
[outrig] sidecars:
  tools   not started   servers: search
> /sidecar add tools
[outrig] sidecar tools started: outrig-20260504T141907-a83f-tools
[outrig] 1 MCP server connected (search); 2 tools available on the next turn
> /sidecar add tools
[outrig] sidecar "tools" is already running

A failed add – image missing, server crashing on connect – is reported on stderr and changes nothing: the container set, tool list, and conversation stay as they were.

Subagents

The agent can hand scoped work to subagents that share this session’s container and tools, using the built-in outrig__ tools. They are on by default; subagents = false on the [agents.<name>] block leaves them out. Their tool calls appear on stderr labeled by name, and each one’s transcript is written to <session_dir>/logs/subagent-<name>.log.

> audit the config and the mcp wiring in parallel
[outrig] tool call: outrig__subagent({"name": "audit-config", ...})
[outrig] subagent audit-config started
[outrig] tool call: outrig__subagent({"name": "audit-mcp", ...})
[outrig] subagent audit-mcp started
[outrig]   [audit-config] tool call: fs__read_file(...)
[outrig]   [audit-mcp] tool call: shell__exec(...)
[outrig] tool call: outrig__wait_results({"names": [...], "min_count": 1})
[outrig]   [audit-mcp] tool call: outrig__set_result(...)
[outrig] tool call: outrig__get_result({"name": "audit-mcp"})

Nothing a subagent produces reaches stdout, so outrig run > out.txt still captures only the primary agent’s reply. See Concepts -> Subagents.

Interrupting and exiting

  • Ctrl-C during a turn cancels the in-flight LLM/tool call. The REPL prints [outrig] interrupted to stderr and returns to a > prompt with conversation history intact, so you can redirect the agent. It stops the agent waiting, not work already handed to the container: a shell__exec that started a build runs to completion, and any subagents keep working and stay collectable on the next turn.
  • Ctrl-D at an empty prompt ends the session: closes MCP server stdios, stops the container, finalizes the session record, exits.
  • A second Ctrl-C without an intervening prompt also exits.
> please refactor everything   ^C
[outrig] interrupted
> never mind, just summarise the file types in this repo.
...

When something goes wrong

Common failures and what they look like:

$ outrig run
error: no .agents/outrig/config.toml found in current directory or any parent
help: run `outrig init` to initialize

You’re not inside an outrig-configured repo. Either cd into one or pass --config <path>.

$ outrig run
[outrig] image-config: coding
error: image-config "coding" is missing required key: dockerfile

The selected [images.<name>] block is incomplete. See Reference -> Config.

$ outrig run
[outrig] image-config: coding
[outrig] image:            coding:8c2a4f7e91d6b5a3 (cache hit)
[outrig] container started: outrig-20260501T134412-3f2a
[outrig] mcp fs: error: failed to spawn `mcp-server-filesystem` in container
[outrig] caused by: exec: "mcp-server-filesystem": executable file not found in $PATH

The MCP server binary isn’t in the image. Install it in the Dockerfile.

$ outrig run --image outrig-standard:53e082e721df8ecc
error: --image "outrig-standard:53e082e721df8ecc" did not match any [images.<name>]
       and local podman image "outrig-standard:53e082e721df8ecc" was not found

An explicit --image ref that is not an image-config is local-only. Build, tag, or pull it with Podman first, then rerun the command.

$ outrig run
[outrig] image-config: coding
[outrig] image:            coding:8c2a4f7e91d6b5a3 (cache hit)
[outrig] container started: outrig-20260501T134412-3f2a
[outrig] mcp fs: initialized
[outrig] mcp shell: initialized
[outrig] model: gpt-4o-mini
[outrig] session id: 20260501T134412-3f2a
> hi
error: LLM call failed: 401 Unauthorized
caused by: provider returned: invalid_api_key

OPENAI_API_KEY is unset, expired, or pointing at the wrong endpoint.

See also

outrig mcp

outrig mcp turns an outrig image-config into one MCP server for an external client. It starts or attaches to the selected container, launches every [images.<name>.mcp] backing server inside it, and republishes their tools over this process’s stdio by default, or over Streamable HTTP when --listen is set.

Use outrig run when you want outrig to be the LLM client: it resolves an agent, builds a Rig agent, and opens the built-in REPL. Use outrig mcp when another program is the LLM client – Claude Code, Cursor, Zed, or any MCP-capable editor – and you want that program to drive the tools inside your outrig container.

Synopsis

outrig mcp [--image <name-or-local-ref>]
           [--attach <session-id-or-container-name>]
           [--listen <addr>]
           [--network <default|audit|filter>]
           [--session-dir <path>]
           [--config <path>]
           [--global-config <path>]
           [--session-root <path>]
           [--volume <host:container[:ro|rw]>]
           [--verbose]

outrig mcp show-merged [--image <name-or-local-ref>]
                       [--attach <session-id-or-container-name>]

outrig mcp self
  • --image <name-or-local-ref> (default: default-image): selects a [images.<name>] block. If an explicit value does not match config, it is treated as a local Podman image ref and is never pulled. Required with --attach <podman-name>.
  • --attach <session-id-or-container-name> (default: off): reuse an existing container instead of starting one.
  • --listen <addr> (default: off): serve Streamable HTTP at /mcp instead of stdio. Accepts TCP socket addresses such as 127.0.0.1:7331 or 0.0.0.0:7331, plus Unix sockets as unix:/tmp/outrig.sock.
  • --network <default|audit|filter> (default: config [network].mode, else default): choose Podman’s default networking, network audit logging, or global network filtering for this fresh session.
  • --session-dir <path> (default: <session-root>/<sid>): writes to a known path.
  • --config <path> (default: walks up from cwd; if not found, run config-less): path to repo config.toml.
  • --global-config <path> (default: ~/.outrig/config.toml): path to global config.
  • --session-root <path> (default: config, then XDG data directory): root for all sessions.
  • --volume <host:container[:ro|rw]> (repeatable): bind an extra host directory into the container, on top of the default workspace mount. Read-only unless :rw is given; the host directory must exist. Rejected with --attach.
  • --verbose (default: off): adds buildah/podman command transcripts to stderr and container.log.

outrig mcp self is different from the session MCP server. It does not resolve a repo config, start a container, or create a session. It serves OutRig’s own docs, schema, suggestions, and advisory validators so an external AI tool can design an image-config. See AI-assisted design.

There is no --agent flag. outrig mcp has no agent, so it never consults default-agent, agent.image, [agents], [models], [providers], or provider API keys. Image selection is only:

  1. --image <name-or-local-ref>
  2. top-level default-image

If neither is set, startup fails with:

error: no --image or default-image configured

default-image must name a config block. The raw local-image fallback applies only to explicit --image values and to raw image refs saved in session records.

outrig mcp can also run in a directory with no .agents/outrig/config.toml (and no --config): it uses the current directory as the workspace root and merges in the global config. Since there is no agent, all you need is --image <local-ref>; the proxied MCP servers come from the image’s org.outrig.mcp labels.

With --attach, image-config selection is different:

  1. If the attach value matches an exact session id under the resolved session root, outrig reuses that session row’s container_name and image_config_name.
  2. If --image <name-or-local-ref> is also passed, it overrides the session row’s image_config_name.
  3. If the attach value is not a known session id, outrig treats it as a podman container name and requires --image <name-or-local-ref>.

--network audit and --network filter are rejected with --attach; borrowed containers are not retrofitted with a new interceptor.

The selected image must expose at least one backing MCP server after image org.outrig.mcp label entries and [images.<name>.mcp] overrides are merged. Repo-local build cache images already stamp that merged table into their labels on cache misses, but startup still applies the same merge. An image with no merged entries has nothing to proxy, so outrig mcp exits before the client sees an MCP initialize response.

Minimal Config

outrig mcp can run from an image-only repo config:

default-image = "coding"

[workspace]
root = "."

[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"
context    = ".agents/outrig/images/coding"

[images.coding.mcp]
fs    = ["mcp-server-filesystem", "/workspace"]
shell = ["bash", "-lc", "exec shell-mcp-command"]

The MCP server binaries still have to exist inside the image. Install them in the container Dockerfile just as you would for outrig run. In the example above, shell-mcp-command stands for whichever shell MCP server package you choose to install.

An image can also carry the same [mcp] table in its org.outrig.mcp OCI label. Use that when a shared image owns the default tool set, then keep only repo-specific overrides in .agents/outrig/config.toml. See Concepts -> MCP Servers.

To inspect the effective table without serving MCP:

outrig mcp show-merged --image coding

In fresh mode this starts the selected container, reads the org.outrig.mcp label off the primary image and every named sidecar image, applies config.toml overrides, prints the merged [mcp] table to stdout, then stops the container. Sidecar containers are not launched for the read. Each server carries a comment naming its placement and provenance:

[mcp]
# fs: primary (image label org.outrig.mcp)
fs = ["mcp-server-filesystem", "/workspace"]
# search: sidecar "tools" (config.toml)
search = { command = ["mcp-search"], sidecar = "tools" }

For repo-local build images, the cache tag was already stamped with that merged label when it was built (placement-bearing entries excluded – they belong to other containers). With --attach, it borrows the existing container for the same read and leaves it running.

Attach Mode

Use attach mode when a container is already running and you want an external MCP client to share its workspace, installed tools, and environment:

outrig mcp --attach 20260504T141907-a83f
outrig mcp --attach outrig-20260504T141907-a83f --image coding

The first form resolves an existing outrig session id. The second form borrows a podman container directly, which is useful for containers not started by outrig run.

Attach mode cannot own containers, so a config that declares sidecars (or places MCP entries in one) is rejected with --attach; start a fresh session instead.

Attach mode shares the container, not the MCP protocol processes. The attacher starts its own podman exec -i children for each merged MCP server, so the external client has independent MCP state and independent stderr logs. Existing MCP children from the host session keep running.

Every attach invocation writes its own fresh session row and log directory. outrig logs <attached-session> <server> therefore works the same as it does for a fresh-container outrig mcp session.

MCP servers used with attach mode should be reentrant-safe. Servers that bind a fixed port, write a global pidfile, or take an exclusive lock can conflict with the host session’s copy or with another attacher.

Client Configuration

Put outrig on PATH, or use an absolute path to the binary in each client config. MCP clients may start servers with a different working directory than your shell, so passing an absolute --config path is the least surprising setup.

Claude Code can add a stdio server from the command line:

claude mcp add --transport stdio outrig -- outrig mcp \
  --config /path/to/repo/.agents/outrig/config.toml \
  --image coding

Claude Code and Cursor both understand an mcpServers JSON shape. For Cursor, place this in .cursor/mcp.json for project scope or ~/.cursor/mcp.json for global scope. For Claude Code project scope, use .mcp.json in the project root.

{
  "mcpServers": {
    "outrig": {
      "type": "stdio",
      "command": "outrig",
      "args": [
        "mcp",
        "--config",
        "/path/to/repo/.agents/outrig/config.toml",
        "--image",
        "coding"
      ],
      "env": {}
    }
  }
}

Zed uses context_servers in its settings:

{
  "context_servers": {
    "outrig": {
      "command": "outrig",
      "args": [
        "mcp",
        "--config",
        "/path/to/repo/.agents/outrig/config.toml",
        "--image",
        "coding"
      ],
      "env": {}
    }
  }
}

Streamable HTTP

Use --listen when you want one long-lived outrig mcp process that multiple MCP clients can connect to:

outrig mcp --listen 127.0.0.1:7331 --image coding

The MCP endpoint is /mcp, so clients should connect to:

http://127.0.0.1:7331/mcp

Loopback TCP is the intended default deployment shape. Binding 0.0.0.0:7331 or any other non-loopback address is allowed, but outrig prints a warning because v1 has no built-in authentication and anything that can reach the port can call the container’s tools. Put an authenticated reverse proxy in front if you expose it beyond the local machine.

For local multi-process access without a TCP port, use a Unix socket:

outrig mcp --listen unix:/tmp/outrig.sock --image coding

Socket filesystem permissions are the access boundary. HTTP clients still use the Streamable HTTP protocol and the /mcp path over that socket.

What Happens, in Order

  1. Locate config. Walks up from the current directory until .agents/outrig/config.toml is found, or fails. The MCP host’s cwd therefore needs to be the repo, or pass --config <path> explicitly.
  2. Resolve image. Uses explicit --image first. Config entries win; an unknown explicit value is treated as a local Podman image ref. Without explicit --image, top-level default-image still names a config block.
  3. Prepare the container. Fresh mode builds, cache-hits, pulls configured image-name refs, or probes raw local refs, then starts podman run -d --rm --name outrig-<sid> .... Attach mode probes the existing container with podman inspect, verifies that it is running, and does not build, start, stop, or remove it.
  4. Merge MCP config and start sidecars. Read the primary image’s org.outrig.mcp label if present, then overlay [images.<name>.mcp] from config by server name. Each declared sidecar’s image is resolved, its label merged (scoped to that sidecar), and start = "auto" sidecars are started as outrig-<sid>-<sc>. An entrypoint-stdio sidecar (inline image, no command) is instead created and initialized with its ENTRYPOINT held un-executed, env baked in via podman create --env. Sidecar failures follow the block’s on-failure key. A start = "manual" sidecar stays planned but unstarted – its servers are skipped with a notice. outrig mcp has no mid-session start surface; manual sidecars are started from the outrig run REPL (/sidecar add <name>) or the library API.
  5. Start network interception, if enabled. The interceptor attaches to the primary and every sidecar – including created-but-not-started entrypoint sidecars, whose first packet is therefore already subject to policy. Fresh sessions can write <session_dir>/logs/network.jsonl and filter mode can enforce global policy; attach mode cannot install a new interceptor.
  6. Connect MCP servers. For each merged entry, podman exec -i the configured command in the container its placement names – or, for entrypoint-stdio servers, podman start --attach --interactive the held container, whose lifetime now equals the server’s – and run the MCP initialize handshake.
  7. Build the proxy. outrig advertises one merged tool list to its client, with each tool namespaced <server>__<tool>. See Tool Names below.
  8. Serve MCP. Without --listen, rmcp’s stdio transport reads JSON-RPC frames from the process stdin and writes responses to stdout. With --listen, rmcp’s Streamable HTTP service accepts POST/SSE traffic at /mcp. The proxy dispatches tools/call to the right backing server in both modes.

If anything before step 8 fails, outrig mcp prints the error on stderr and exits non-zero without ever advertising a tool list.

Startup Banner

After the image is ready, the container is running, and all backing MCP servers have answered tools/list, outrig mcp prints one banner to stderr:

[outrig] image-config:  coding
[outrig] image:             outrig:coding-1f3a2b
[outrig] container started: outrig-coding-2026-05-04-a83f
[outrig] mcp fs:    initialized (3 tools)
[outrig] mcp shell: initialized (1 tool)
[outrig] tools available: fs__list_directory, fs__read_file, fs__write_file, shell__exec
[outrig] session id: 20260504T141907-a83f
[outrig] transport: stdio
[outrig] mcp server ready

Everything in that banner is on stderr. The client should treat stdout as protocol bytes only for stdio transport.

Attach mode prints container attached: in the same position.

With --listen, the banner says transport: streamable-http, then prints the bound endpoint before the ready line:

[outrig] transport: streamable-http
[outrig] listen: http://127.0.0.1:7331/mcp
[outrig] mcp server ready

Transport Discipline

By default, outrig mcp serves MCP over stdio. Its stdout is reserved for JSON-RPC messages to the client; all other process output goes somewhere else:

  • startup banner: stderr
  • outrig tracing controlled by OUTRIG_LOG, or RUST_LOG when unset: stderr
  • top-level startup errors: stderr
  • backing server stderr: <session_dir>/logs/<server>.stderr

This split is load-bearing. If a wrapper, shell hook, or debug print writes anything non-JSON to stdout before or during the MCP exchange, the external client may fail the handshake or drop the server.

With --listen, stdout is not the protocol channel, but outrig keeps the same stderr-first discipline so wrapper behavior remains predictable.

Tool Names

Outrig publishes tools under <server>__<tool> names. If the backing fs server advertises a tool named read_file, the external client sees:

fs__read_file

Some MCP clients display that relationship as fs.read_file in their UI. The actual tool name on the wire is still fs__read_file; outrig strips the fs__ prefix and dispatches the call to the original read_file tool on the fs backing server.

See Concepts -> MCP Servers for the collision and sanitization rules.

Lifecycle

outrig mcp has three graceful shutdown triggers:

  • stdio stdin EOF, which usually means the external MCP client disconnected
  • SIGINT, such as Ctrl-C in the terminal that launched the process
  • SIGTERM, such as a supervisor asking the process to stop

All paths cancel the rmcp service, wait for the dispatcher to settle, shut down each backing MCP server, and finalize the session record. Fresh-container mode then stops the containers – sidecars first, primary last. Attach mode leaves the borrowed container running.

Sessions with sidecars also watch the primary container: if it dies out from under outrig (a manual podman kill, the OOM killer), the sidecars are reaped and the process exits non-zero. A sidecar dying mid-session only degrades the tool set – its tools return errors and the session continues.

HTTP/SSE mode is daemon-shaped: client disconnects close only that MCP session. The outrig mcp --listen process stays alive until SIGINT, SIGTERM, or attached-container shutdown.

If an attached host session stops the container while outrig mcp --attach is live, the attacher cancels its proxy, shuts down its MCP children, finalizes its session with a non-zero exit, and reports that the attached container stopped.

Sessions and Logs

Every outrig mcp invocation creates a normal outrig session, including attach mode. It appears in outrig ls, its backing-server stderr is readable with outrig logs, and outrig discard removes it like any other session.

Because no agent participates, the in-memory session row has agent_name = None. On disk, new outrig mcp session JSON omits agent_name; older JSON with "agent_name": null still represents the same no-agent state when read.

$ outrig ls
ID                     STARTED              DURATION  IMAGE      EXIT
20260504T141907-a83f   2026-05-04 14:19:07  0m44s     coding     0

$ outrig logs 20260504T141907-a83f fs
[mcp-server-filesystem] starting; root=/workspace
[mcp-server-filesystem] tools/list: 3 tools advertised

See Sessions for session-root resolution, --session-dir, and log inspection.

Future Work

The v0 HTTP surface is intentionally unauthenticated and minimal. Deferred work includes adding built-in auth, exposing a tool-call audit log, proxying MCP prompts/* and resources/*, surfacing backing-server stderr as MCP resources, and paginating tools/list.

See Also

outrig build

outrig build builds (or cache-hits) the image for one or more image-configs, without starting an agent session. It’s useful for:

  • Verifying your Dockerfile builds cleanly before you start an agent.
  • Pre-warming the image so the first outrig run is fast.
  • CI: ensuring the agent’s environment still builds after a dependency bump.

Synopsis

outrig build [--image <name>]
             [--config <path>]
             [--no-cache]
             [--all]
  • --image <name> (default: default-image): build a specific named image-config. This also reaches outrig’s built-in default by name. It has three parts and one invocation warms one of them, so warming the set is three commands: outrig build --image outrig-default (pulls the primary), then --image outrig-default-fs (pulls the filesystem server), then --image outrig-default-shell (builds the shell server, the only part that is built rather than pulled).
  • --all (default: off): build every image-config defined in the config file. The built-in default is deliberately not among them – --all means the image-configs you declared, and pulling and building outrig’s fallback in every repo would be a surprise.
  • --config <path> (default: walks up from cwd): use a non-default config path.
  • --no-cache (default: off): force rebuild even on cache hit. Passes --no-cache to buildah.

What it does

  1. Loads .agents/outrig/config.toml and validates every image-config in the merged config. Agent/model wiring is not required for image builds.
  2. For each selected image-config:
    • Computes the cache key (blake3 over Dockerfile content, resolved build-args, the OutRig labels derived from [images.<name>.mcp], and the context content hash).
    • If a tag matching that key exists and --no-cache is not set, prints image ready (cache hit) and skips.
    • Otherwise runs a buildah build to a temporary tag:
      buildah build --tag <image-config-name>:outrig-tmp-... \
                    --file <dockerfile> \
                    [user build-args] \
                    <context>
      
      Then it reads any inherited/Dockerfile org.outrig.mcp label, overlays [images.<name>.mcp], and commits the final <image-config-name>:<hash> image with the merged org.outrig.mcp label. The repository is the [images.<name>] block key, so the built image is self-describing in podman images; the <hash> is the content-addressed cache key.

Examples

Build the default:

$ outrig build
[outrig] image-config: coding
[outrig] dockerfile:       .agents/outrig/images/coding/Dockerfile
[outrig] context:          .agents/outrig/images/coding
[outrig] image tag:        coding:8c2a4f7e91d6b5a3
[buildah] STEP 1/6: FROM docker.io/library/node:20-bookworm-slim
...
[outrig] image ready: coding:8c2a4f7e91d6b5a3

Cache hit on the second run:

$ outrig build
[outrig] image ready (cache hit: coding:8c2a4f7e91d6b5a3)

Build a specific image-config:

$ outrig build --image planning
[outrig] image-config: planning
...
[outrig] image ready: planning:b91e3a6d217f4c08

Build every image-config in one go:

$ outrig build --all
[outrig] image-config: coding   -> coding:8c2a4f7e91d6b5a3 (cache hit)
[outrig] image-config: planning -> planning:b91e3a6d217f4c08 (built in 27s)
[outrig] all images ready

Force a rebuild without changing files:

$ outrig build --no-cache

Exit codes

  • 0 – every selected image is built or cache-hit.
  • non-zero – at least one image failed to build. The buildah stderr is reproduced with the [buildah] prefix, ending with the exact failing step.

See also

Sessions

A session is the on-disk record of one outrig run or outrig mcp invocation: when it started and ended, which image-config it used, what image tag, plus per-MCP-server stderr captured to disk. Sessions exist so you can go back and inspect what happened – they are not a staging area for workspace changes (outrig writes to your repo directly; see Workspace).

Where sessions live

Sessions live under a session root directory. Resolution order:

  1. --session-root <path> flag (any session-touching subcommand).
  2. session-root key in repo or global config.
  3. <XDG_DATA_HOME>/outrig/sessions/ (default; typically ~/.local/share/outrig/sessions/).

Set session-root once in ~/.outrig/config.toml to keep sessions somewhere persistent like /var/lib/outrig/sessions/:

session-root = "/var/lib/outrig/sessions"

Layout under the root

Each session is a subdirectory of the root, named by session id (a UTC timestamp plus four random hex digits – sortable, unambiguous across concurrent runs):

~/.local/share/outrig/sessions/
└── 20260501T134412-3f2a/
    ├── session.json              # id, timestamps, containers, image, exit status
    └── logs/
        ├── container.log         # buildah/podman transcripts when --verbose is set
        ├── network.jsonl         # network audit/filter records when enabled
        ├── fs.stderr             # MCP "fs" server's captured stderr
        └── shell.stderr          # MCP "shell" server's captured stderr

session.json names every container the session owns: container_name for the primary, plus sidecar_container_names when the config declares sidecars. Sidecar-less sessions omit the field, so records from older versions read and write unchanged.

When you run outrig run --session-dir <path>, outrig writes the session content into <path> directly and creates a symlink at <root>/<sid> -> <path> so outrig ls still finds it:

~/.local/share/outrig/sessions/
├── 20260501T134412-3f2a/        # auto-allocated, real directory
└── 20260501T141907-9b1c -> /tmp/my-debug-run/    # explicit --session-dir, symlink

This means you can launch with a known path and read session.json, logs/, etc. directly without first looking up the auto-generated session id. Useful for scripted runs and quick debugging:

$ outrig run --session-dir /tmp/my-debug-run < prompts.txt
$ cat /tmp/my-debug-run/session.json
$ tail -f /tmp/my-debug-run/logs/shell.stderr

outrig ls

List sessions newest-first:

$ outrig ls
ID                     STARTED              DURATION  IMAGE      EXIT
20260501T141907-9b1c   2026-05-01 14:19:07  0m44s     coding     0   -> /tmp/my-debug-run
20260501T134412-3f2a   2026-05-01 13:44:12  2m18s     coding     0
20260430T091203-44d2   2026-04-30 09:12:03  6m02s     planning   1

EXIT is the outrig process exit code, not the agent’s “did it succeed” signal – there’s no formal way for the agent to declare success or failure. A non-zero exit usually means an LLM API error, a container failure, or a SIGINT after the user gave up. The -> /path suffix marks sessions whose root entry is a symlink (created via outrig run --session-dir).

outrig mcp sessions have no agent. Their in-memory session row has agent_name = None; new on-disk session.json files omit agent_name, and older records with "agent_name": null mean the same thing. outrig ls does not currently show an agent column, so these sessions appear with the same ID / STARTED / DURATION / IMAGE / EXIT columns as outrig run sessions.

Override the root for a single invocation with --session-root:

$ outrig ls --session-root /var/lib/outrig/sessions

outrig logs

Print one server’s captured stderr by session id:

$ outrig logs 20260501T134412-3f2a fs
[mcp-server-filesystem] starting; root=/workspace
[mcp-server-filesystem] tools/list: 3 tools advertised
[mcp-server-filesystem] tools/call: list_directory({"path": "/workspace"})
[mcp-server-filesystem] tools/call: read_file({"path": "/workspace/HELLO.txt"})
[mcp-server-filesystem] EOF on stdin; shutting down

Or read directly from a session directory (skips the id lookup):

$ outrig logs --session-dir /tmp/my-debug-run fs

Omit the server name to get a directory listing of available logs:

$ outrig logs 20260501T134412-3f2a
[outrig] logs for session 20260501T134412-3f2a:
  fs       (1.2 KiB)
  shell    (3.4 KiB)

Follow a still-running session’s log:

$ outrig logs 20260501T141907-9b1c shell --follow
[shell-mcp-command] starting
[shell-mcp-command] exec: cargo check
warning: unused import: `std::collections::HashMap`
   --> src/lib.rs:3:5
    |
3   | use std::collections::HashMap;
...

--follow (-f) tails the file with tail -F-style behavior: continues when the file rotates, returns when EOF is reached and no further writes are expected.

When network audit or filter mode is enabled, logs/network.jsonl contains one Zeek conn.log-style JSON object per outbound connection. It is not selected with the server-name argument because it is not an MCP stderr log; read it directly from the session directory:

$ jq . /tmp/my-debug-run/logs/network.jsonl

The log uses Zeek field names where OutRig has equivalent data: ts is Unix epoch seconds, uid is a per-connection id, id.orig_h / id.orig_p identify the container-side socket, id.resp_h / id.resp_p identify the remote endpoint, proto is the transport, service is the sniffed application (http, ssl, ssh, or -), duration is seconds, and orig_bytes / resp_bytes count bytes sent and received by the container. OutRig-specific metadata is namespaced under outrig.*, including outrig.action, outrig.rule, and outrig.host.

outrig discard

Delete a session’s entire on-disk record (including logs):

$ outrig discard 20260430T091203-44d2 --yes
[outrig] removed ~/.local/share/outrig/sessions/20260430T091203-44d2

For sessions whose root entry is a symlink, discard removes both the real directory at the symlink target and the symlink itself:

$ outrig discard 20260501T141907-9b1c --yes
[outrig] removed /tmp/my-debug-run
[outrig] removed ~/.local/share/outrig/sessions/20260501T141907-9b1c (symlink)

You can also point at a session directory directly:

$ outrig discard --session-dir /tmp/my-debug-run --yes

--yes skips the interactive confirmation. This is destructive (it rm -rf’s the session directory) but only of the session record itself – your repository is untouched. A session that’s still running can’t be discarded; outrig refuses with an error pointing at the running container.

outrig clean

Delete old stopped session records in bulk. Bare outrig clean uses a 30-day retention window, previews the sessions it will remove, then asks once before deleting:

$ outrig clean
[outrig] will remove 2 sessions older than 30d:
  20260401T134412-3f2a  ended 2026-04-01 13:46:30  /tmp/old-a
  20260402T091203-44d2  ended 2026-04-02 09:18:54  /tmp/old-b
Clean 2 sessions? [y/N]:

Use --older-than to pick a different cutoff. Durations are positive integers with s, m, h, or d units:

$ outrig clean --older-than 7d
$ outrig clean --older-than 12h

Use --yes (-y) to skip the prompt:

$ outrig clean --older-than 30d --yes

outrig clean uses the same deletion semantics as outrig discard: auto-allocated sessions remove the session directory, while sessions created with --session-dir remove both the symlink target and the symlink under the session root. Running sessions are skipped so cleanup doesn’t race a live outrig run or outrig mcp writer.

Alongside the record walk, outrig clean sweeps for stray containers: podman containers carrying the org.outrig.session label whose session record no longer exists (a lost record, a SIGKILLed outrig, a failed --rm). Stopped strays older than the cutoff are removed with podman rm -f and appear in the same preview/confirmation; running labeled containers are never removed, only reported:

$ outrig clean --older-than 7d
[outrig] will remove 1 session older than 7d:
  20260401T134412-3f2a  ended 2026-04-01 13:46:30  /tmp/old-a
[outrig] will remove 1 stray container older than 7d (no session record):
  outrig-20260330T101500-88aa-tools  sidecar tools, session 20260330T101500-88aa
Clean 1 session and 1 stray container? [y/N]:

What sessions don’t track

  • Workspace changes. outrig uses a direct bind-mount; mutations land on your host filesystem in real time. Use git diff, git status, etc. for review. There is no per-session changeset on disk.
  • Conversation history. v0 doesn’t persist the LLM conversation. If you need a transcript, redirect stdin/stdout (outrig run < prompts.txt > replies.txt). --verbose captures buildah/podman traces in container.log; it is not a conversation transcript.
  • API keys. The bearer token used for the LLM call is never written to disk and never appears in tracing output.

TODO: Incomplete – opt-in transcript capture (per-turn JSON of user/assistant/tool messages) is deferred.

Concurrent sessions

You can run multiple outrig run invocations against the same repository simultaneously. Each gets its own container, its own session id, its own logs directory. They share the bind-mounted workspace, so file writes in one session are visible to the other immediately – be aware if you’re running two agents on overlapping work.

See also

Recipes

A handful of common patterns. None of them require anything outside the documented commands and config – they’re just compositions worth knowing.

Recipe: scripted single-prompt runs

Pipe a single prompt in, capture only the assistant’s reply:

$ echo "list every Cargo.toml in this repo and summarise what it builds" \
    | outrig run > summary.txt 2> outrig.stderr
$ cat summary.txt
This repo is a Cargo workspace with one crate: outrig (a Rust binary). The
single Cargo.toml at the root declares dependencies on clap, tokio, ...

Because outrig keeps tool-call traces, container-startup messages, and slash-command output strictly on stderr, redirecting stdout gives you only model output.

This is the pattern for embedding outrig in larger shell pipelines (CI checks, batch generation, summarization jobs).

Recipe: separate image-configs for different tasks

A coding config has the full toolchain. A planning config has only research-style MCPs and runs against a cheaper model. Use distinct agents (each pointing at its own image-config) and pick with --agent:

default-image = "coding"
default-agent     = "coding"

[agents.coding]
model     = "fast"
image     = "coding"
preamble  = "You are a careful coding assistant."

[agents.planning]
model     = "fast"
image     = "planning"
preamble  = "You are a research planner. Read, summarise, do not execute."

[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"
context    = ".agents/outrig/images/coding"

  [images.coding.mcp]
  fs    = { command = ["mcp-server-filesystem", "/workspace"] }
  shell = ["bash", "-lc", "exec shell-mcp-command"]

[images.planning]
dockerfile = ".agents/outrig/images/planning/Dockerfile"
context    = ".agents/outrig/images/planning"

  [images.planning.mcp]
  fs       = { command = ["mcp-server-filesystem", "/workspace"] }
  research = { command = ["mcp-research"] }
$ outrig run                  # default-agent = "coding"
$ outrig run --agent planning # different agent, different image, different MCPs

Because each image-config caches independently, switching is fast after the first build of each.

Recipe: pre-warming images in CI

Catch a broken Dockerfile or dependency bump before anyone tries to use the agent:

# .github/workflows/outrig-images.yml (sketch)
- run: cargo install outrig-cli
- run: outrig build --all

outrig build --all rebuilds every image-config, returns non-zero on the first failure, and prints the failing buildah step.

Recipe: capturing what the agent did

Two complementary approaches.

Tool-call traces via --verbose:

$ outrig run --verbose 2> trace.log
> add unit tests for parse_config
...
> ^D
$ less trace.log

trace.log contains every buildah/podman invocation, every MCP tool call with its arguments, and every assistant turn delineator.

git as the source of truth for filesystem changes:

$ git checkout -b agent/test-coverage
$ outrig run
> add unit tests for parse_config
...
> ^D
$ git status
$ git diff
$ git log --oneline main..HEAD     # what the agent committed (if anything)

Combined: --verbose for what the agent decided, git for what landed on disk.

Recipe: testing a config from outside the repo

If you’re iterating on the config and don’t want to cd every time:

$ outrig run --config ~/work/some-repo/.agents/outrig/config.toml

--config overrides the default upward-walk. Pair it with --session-dir to write this run’s session into a known directory you can read directly:

$ outrig run \
    --config ~/work/some-repo/.agents/outrig/config.toml \
    --session-dir /tmp/outrig-experiment

$ cat /tmp/outrig-experiment/session.json   # no id lookup needed
$ tail -f /tmp/outrig-experiment/logs/shell.stderr

--session-dir writes the session content under /tmp/outrig-experiment/ and adds a symlink <session-root>/<sid> -> /tmp/outrig-experiment/ so the run still appears in outrig ls. To move the root (where new auto-generated sessions live) for the duration of one command, use --session-root instead:

$ outrig run --session-root /var/lib/outrig/sessions

Recipe: sharing a config across repositories

The config file is just TOML – symlink or copy it. A common pattern is a single template repo with .agents/outrig/config.toml and a Dockerfile, then git subtree add (or simply copy) into per-project repos. You can keep the Dockerfile centralized too if it doesn’t need per-repo customization.

TODO: Incomplete – outrig has no built-in mechanism for sharing configs; this is plain filesystem composition.

Recipe: limiting the agent’s filesystem reach

Mount a subset of the repo by setting [workspace] host-path to something narrower than .:

[workspace]
host-path      = "src"
container-path = "/workspace"

The agent now sees only src/. It can’t read Cargo.toml, .git, target/, etc. Useful when you want a tightly scoped sandbox for a refactor that shouldn’t need to touch build configuration.

See also

Reference

The reference section is meant for lookup, not for reading start-to-finish. If you’ve read Usage and want the precise list of flags or config keys, you’re in the right place.

  • CLI – every subcommand and every flag.
  • Config – every key in .agents/outrig/config.toml, with types and defaults.

For narrative explanations of why a key exists or how a subcommand fits into a workflow, see Concepts and Usage.

CLI Reference

Synopsis

outrig <SUBCOMMAND> [FLAGS] [ARGS]
outrig --version
outrig --help

Global flags

These are accepted by every subcommand.

FlagDescription
--config <path>Path to repo config.toml. Default: walk up from cwd.
--global-config <path>Path to global config. Default: ~/.outrig/config.toml.
--session-root <path>Root directory containing sessions. Overrides config + XDG.
-v, --verbosePrint buildah/podman transcripts; repeat for trace logs.
--helpPrint subcommand help.

--config resolves in this order: this flag (used verbatim – no walk-up, no existence check), then a walk up from cwd looking for .agents/outrig/config.toml. The walk stops at the filesystem root; a .agents/ directory without an outrig/config.toml inside does not terminate the walk – outrig keeps looking in parents.

--global-config resolves in this order: this flag, then <XDG_CONFIG_HOME>/outrig/config.toml when XDG_CONFIG_HOME is set, then ~/.outrig/config.toml (the outrig-specific fallback – note this is ~/.outrig/, not ~/.config/outrig/).

--session-root resolves in this order: this flag, then session-root in the repo or global config, then <XDG_DATA_HOME>/outrig/sessions/.

--verbose adds buildah/podman command transcripts to stderr and to <session_dir>/logs/container.log for outrig run / outrig mcp. Repeat it (-vv) to also enable trace-level logs from outrig’s own modules for that invocation. It does not change container, MCP, or agent behavior. Normal startup progress is printed to stderr without --verbose.

--verbose controls command output; the tracing filter controls command lines. At debug, every buildah/podman invocation is logged before it is spawned and again on exit with its status and elapsed time (target outrig::process), which identifies the responsible child process when a startup phase stalls. The two are independent – neither implies the other.

Subcommands

outrig init

Idempotent end-to-end setup orchestrator. Runs outrig config init if the global config is missing, writes .agents/outrig/config.toml if it doesn’t exist, then offers to call outrig image add in a loop.

outrig init [--force]
FlagDefaultDescription
--forceoffOverwrite existing files. Propagates to config init and image add.

See Usage -> outrig init.

outrig config init

Interactively write the global config (~/.outrig/config.toml): provider styles, models, and default-model. The first subcommand of the outrig config group; future subcommands (config get, config set, config list) are deferred.

outrig config init [--force]
FlagDefaultDescription
--forceoffOverwrite an existing global config; otherwise refuses to clobber.

See Usage -> outrig config.

outrig design prompt

Print a self-contained prompt for AI-assisted image design, or print an MCP setup snippet for tools that can attach outrig mcp self.

outrig design prompt
outrig design prompt --standalone
outrig design prompt --print-mcp-config <tool>
FlagDefaultDescription
--standaloneoffPrint a prompt for a standalone image project.
--print-mcp-config <tool>nonePrint a setup snippet; wins over --standalone.

The valid <tool> names are claude-code, claude-desktop, codex, and cursor.

See Usage -> AI-assisted design.

outrig image add

Interactively scaffold an image-config: writes a Dockerfile under .agents/outrig/images/<name>/Dockerfile and adds the matching [images.<name>] and [images.<name>.mcp] blocks to the repo config. Use outrig image init instead for a standalone image project; image ls / image rm are deferred.

outrig image add [<name>]
                 [--force]
Argument / flagDefaultDescription
<name>promptedImage-config name.
--forceoffOverwrite existing files for this name.

See Usage -> outrig image.

outrig image init

Noninteractively scaffold a standalone image project: writes a Dockerfile, an authoring image.toml, and a README.md into a target directory. The directory name becomes the image ref; other repos consume the built image via image-name.

outrig image init [<dir>]
                  [--force]
Argument / flagDefaultDescription
<dir>current dirProject directory; its name becomes the image ref.
--forceoffOverwrite the generated files if they already exist.

See Usage -> outrig image.

outrig image build

Build a standalone image project (the output of outrig image init) with buildah, tag it by its image.toml [image].ref, stamp its config into OCI labels, and validate the result: the built image must carry a valid org.outrig.mcp label, and – unless --no-test – every declared MCP server must start and answer tools/list.

outrig image build [<dir>]
                   [--tag <ref>]
                   [--no-test]
                   [--no-cache]
Argument / flagDefaultDescription
<dir>current dirProject directory holding image.toml.
--tag <ref>[image].refTag the output as <ref>; does not rewrite image.toml.
--no-testoffSkip the live MCP test; still validates image.toml.
--no-cacheoffForce a clean build. Passes --no-cache to buildah.

See Usage -> outrig image.

outrig image inspect

Inspect an image’s OutRig OCI labels without pulling it, creating a container, or starting any MCP server. By default this reads the local image store; --remote reads registry metadata with skopeo inspect.

outrig image inspect [--remote] <ref>
ArgumentDescription
<ref>Image ref to inspect. Never pulled.
--remoteRead registry metadata with skopeo inspect.

See Usage -> outrig image.

outrig run

Start an interactive agent session.

outrig run [--agent <name>]
           [--image <name-or-local-ref>]
           [--config <path>]
           [--device <cpu|cuda|cuda:N|metal>]
           [--env <KEY=VALUE>]
           [--global-config <path>]
           [--max-tool-calls <n>]
           [--max-tool-result-bytes <n>]
           [--model <name>]
           [--network <default|audit|filter>]
           [--session-dir <path>]
           [--session-root <path>]
           [--volume <host:container[:ro|rw]>]
           [--verbose]
  • --agent <name> (default: default-agent): selects an [agents.<name>] block. With neither, the session runs with no agent – no preamble, and no agent.image rung in the image cascade.
  • --image <name-or-local-ref> (default: from agent or default-image): image-config to launch. If an explicit value does not match config, it is treated as a local Podman image ref and is not pulled.
  • --env <KEY=VALUE> (repeatable): add or override env vars for MCP servers. KEY=VALUE applies to every server; SERVER:KEY=VALUE targets a single server by name. Values support the ${VAR} host-env-reference syntax described in config.md#mcp-env-value-syntax. Within a scope, last wins on duplicate keys. Precedence per key: config-file env < global --env < per-server --env.
  • --device <cpu|cuda|cuda:N|metal> (default: mistralrs model device, else cpu): override the in-process mistralrs model device for this run. Rejected for OpenAI-style models.
  • --max-tool-calls <n> (default: resolved tool-call-max, else 50): per-turn tool-call max.
  • --max-tool-result-bytes <n> (default: resolved tool-result-max, else 262144): per-tool-result byte max.
  • --model <name> (default: agent’s model, else default-model): configured [models.<name>] entry to use for this run. This is not a raw provider model identifier.
  • --network <default|audit|filter> (default: config [network].mode, else default): choose Podman’s default networking, network audit logging, or global network filtering for this session.
  • --session-dir <path> (default: <session-root>/<sid>): specific directory for this run.
  • --volume <host:container[:ro|rw]> (repeatable): bind an extra host directory into the container, beyond the default workspace mount. Read-only unless :rw; host dir must exist.
  • -v, --verbose (default: off): print container lifecycle traces.

When --session-dir is given, outrig writes this run’s session.json and logs/ directly under <path>, and additionally creates a symlink <session-root>/<sid> -> <path> so outrig ls/logs/discard/clean still find it. When omitted, outrig auto-generates a session id and writes to <session-root>/<sid>/ directly.

Reads the global and repo configs, resolves agent -> model -> provider, builds the image (cache-hit if possible), starts the container, attaches every MCP server, opens the REPL. Exits when stdin reaches EOF, when the user types /quit, or after a second Ctrl-C.

With no repo config found and no --config, run and mcp use the current directory as the workspace root and take all config from the global file; run then needs its agent from the global config and an explicit --image. build still requires a repo config.

See Usage -> outrig run for REPL details.

outrig mcp

Serve the selected image’s backing MCP servers as one MCP server over stdio.

outrig mcp [--image <name-or-local-ref>]
           [--attach <session-id-or-container-name>]
           [--listen <addr>]
           [--env <KEY=VALUE>]
           [--network <default|audit|filter>]
           [--session-dir <path>]
           [--config <path>]
           [--global-config <path>]
           [--session-root <path>]
           [--volume <host:container[:ro|rw]>]
           [--verbose]

outrig mcp show-merged [--image <name-or-local-ref>]
                       [--attach <session-id-or-container-name>]
                       [--session-dir <path>]
                       [--config <path>]
                       [--global-config <path>]
                       [--session-root <path>]
                       [--verbose]

outrig mcp self
FlagDefaultDescription
--image <name-or-ref>default-imageImage-config or local Podman ref.
--attach <id-or-name>offReuse an existing container.
--listen <addr>offServe Streamable HTTP at /mcp.
--env <KEY=VALUE>Override MCP env; repeatable. As run.
`–network <defaultauditfilter>`
--session-dir <path><session-root>/<sid> (auto)Specific directory for this server.
--volume <spec>Extra bind mount; not with –attach.
-v, --verboseoffPrint container lifecycle traces.

There is no --agent flag. outrig mcp does not resolve default-agent, does not let agent.image participate in image-config selection, and does not read provider API keys. Image selection is explicit --image, then top-level default-image, then an error. Config entries win; an unknown explicit --image is treated as a local Podman image ref. default-image remains config-only.

Like outrig run, mcp runs config-less when no .agents/outrig/config.toml is found (and no --config): the current directory becomes the workspace root and config comes from the global file. With no agent to resolve, --image <local-ref> is enough.

With --attach, the value is resolved first as an exact session id under the resolved session root. A session match supplies the podman container name and default image-config or raw image ref. If there is no session match, the value is treated as a podman container name and --image <name-or-local-ref> is required.

Startup builds or cache-hits the image and starts the container unless --attach is set. Attach mode validates the existing container with podman inspect and borrows it without stopping or removing it during teardown. Both modes initialize every entry in the merged MCP table, list their tools, print a banner to stderr, and then speak MCP JSON-RPC on stdout/stdin. The merged table is the image’s org.outrig.mcp label plus [images.<name>.mcp] overrides. Build-from-Dockerfile repo images stamp that merged table into their cache tag on build misses, so outrig image inspect <name>:<hash> can report the same declared servers without serving MCP. All non-protocol output stays off stdout.

When --listen <addr> is set, outrig mcp serves Streamable HTTP instead of stdio. TCP addresses are socket addresses such as 127.0.0.1:7331 or 0.0.0.0:7331; Unix sockets use unix:/tmp/outrig.sock. The HTTP MCP endpoint is always /mcp. Loopback TCP is local-only by default. Non-loopback TCP binds are allowed but print a warning because this v1 surface has no built-in auth; use an authenticated reverse proxy before exposing it broadly. Each HTTP MCP client gets its own rmcp session backed by the same shared outrig proxy and backing MCP processes.

--network audit and --network filter are supported only for fresh-container outrig mcp sessions. Attach mode cannot retrofit a borrowed container with a new interceptor.

outrig mcp show-merged uses the same image-config selection and setup path, but exits after printing the effective [mcp] table to stdout. It is for debugging image-label declarations and repo-local overrides, not for serving MCP JSON-RPC.

outrig mcp self serves host-side self-description tools over stdio. It does not start a container or require a repo config. Use it from an external MCP-capable AI tool when the built-in image templates do not fit.

Trigger or failureExit
Stdio client closes stdin after successful startup0
SIGINT or SIGTERM after successful startup0
Config, image, container, or MCP startup failure1
Bad flags or missing required args2

Environment variables used by outrig mcp:

VariableEffect
OUTRIG_LOGPreferred tracing-subscriber filter.
RUST_LOGFallback tracing filter when OUTRIG_LOG is unset.
XDG_DATA_HOMEDefault base for session-root if not set in config.
XDG_CONFIG_HOMEGlobal config is checked before ~/.outrig/config.toml.

See Usage -> outrig mcp for client configuration and stdio details.

outrig build

Build (or cache-hit) one or more image-config images, without starting an agent.

outrig build [--image <name>]
             [--all]
             [--no-cache]
             [--config <path>]
  • --image <name> (default: default-image): build a specific named image-config.
  • --all (default: off): build every image-config. Mutually exclusive with --image.
  • --no-cache (default: off): force rebuild even on cache hit. Passes --no-cache to buildah.

See Usage -> outrig build.

outrig ls

List sessions newest-first under the session root.

outrig ls [--session-root <path>]

Output columns: ID, STARTED, DURATION, CONTAINER, EXIT. Symlinked sessions (created via outrig run --session-dir) display the symlink target as a hint.

See Usage -> Sessions.

outrig logs

Print or follow a session’s MCP-server stderr.

outrig logs [<session>] [<server>]
            [--follow]
            [--session-dir <path>]
            [--session-root <path>]
Argument / flagDefaultDescription
<session>omit with --session-dirSession id; resolved under session root.
<server>list available logsServer name from [images.<name>.mcp].
--follow, -foffTail the log; continue reading new lines.
--session-dir <path>Read directly from this session dir.

<session> and --session-dir are mutually exclusive: pass one or the other. Substring match on <session> is allowed if unambiguous.

outrig discard

Delete a session’s on-disk record (logs and metadata).

outrig discard [<session>] [--yes]
                           [--session-dir <path>]
                           [--session-root <path>]
Argument / flagDefaultDescription
<session>omit with --session-dirSession id; resolved under session root.
--yes, -yoffSkip the interactive confirmation.
--session-dir <path>Discard exactly this session dir.

<session> and --session-dir are mutually exclusive. If the session was created via outrig run --session-dir <path> (i.e. lives at a user-chosen path with a symlink in the root), discard removes the real directory and the symlink. Refuses if the session’s container is still running. Discards the session directory only – your repository is untouched.

outrig clean

Delete old stopped session records in bulk.

outrig clean [--older-than <duration>]
             [--yes]
             [--session-root <path>]
Argument / flagDefaultDescription
--older-than <duration>30dRemove sessions older than cutoff.
--yes, -yoffSkip the interactive confirmation.

Durations are positive integers with s, m, h, or d units, for example 12h or 7d. The command previews matching sessions and asks once before deleting unless --yes is set. Running sessions are skipped. Sessions created with --session-dir remove both the symlink target and the symlink under the session root.

Exit codes

CodeMeaning
0Success.
1Generic failure (config, image build, LLM API error, etc.).
2Misuse (bad flags, missing required args). clap prints the usage line.
130Interrupted by SIGINT before subcommand-specific handling.

Environment variables

  • [providers.<name>].api-key references via ${VAR}: provider API key.
  • OUTRIG_LOG: preferred tracing-subscriber filter, e.g. OUTRIG_LOG=debug. When set it shadows RUST_LOG completely, including a value that filters out more than RUST_LOG would.
  • RUST_LOG: fallback tracing filter when OUTRIG_LOG is unset. At debug, adds one line per buildah/podman invocation (target outrig::process) naming the command, its exit status, and how long it took.
  • XDG_DATA_HOME: default base for session-root if not set in config.
  • XDG_CONFIG_HOME: global config is checked here before ~/.outrig/config.toml.

See also

Config Reference

outrig reads two TOML files:

  • Global config – user/machine-level. Default location: ~/.outrig/config.toml (or <XDG_CONFIG_HOME>/outrig/config.toml if XDG_CONFIG_HOME is set). Holds [providers.<name>] and (typically) [models.<name>] since those reference API keys and model identifiers that belong to the user, not to any one repo.
  • Repo config at .agents/outrig/config.toml – repository-level, committed to source control. Holds [workspace], [images.<name>], [agents.<name>], and any repo-specific providers or models.

Both files use the same schema. Names declared in either are visible everywhere; if a name appears in both files, the repo entry wins. Outrig keys are kebab-case; only inner-map keys whose values map to environment variables (Dockerfile build-args, MCP env blocks) keep their as-written form. Unknown keys are an error – outrig validates with deny_unknown_fields.

Top level

# repo config (.agents/outrig/config.toml):
default-image = "coding"
default-agent = "coding"

# global config (~/.outrig/config.toml):
default-model      = "fast"
session-root       = "/var/lib/outrig/sessions"       # optional; defaults to XDG data dir
model-cache-root   = "/var/cache/outrig/models"       # optional; defaults to XDG cache dir
tool-call-max      = 100                              # optional; defaults to 50
tool-result-max    = 262144                           # optional; defaults to 256 KiB
subagent-depth-max = 3                                # optional; defaults to 3
subagent-width-max = 8                                # optional; defaults to 8
retry-budget-secs  = 600                              # optional; defaults to 600

[network]
mode = "default"                                      # optional: default, audit, or filter
default = "deny"                                      # optional for filter mode
allow = ["github.com:443", "*.npmjs.org"]             # optional; global only
deny  = ["*:22"]                                      # optional; global only
KeyTypeRequiredWhereDescription
default-imagestringnorepoDefault --image.
default-agentstringnorepoDefault --agent.
default-modelstringif agent omits modelglobalFallback model name.
session-rootpathnoglobalSessions root dir.
model-cache-rootpathnoglobalGGUF download cache dir.
tool-call-maxintegernoglobalPer-turn tool-call max.
tool-result-maxintegernoglobalPer-tool-result byte max.
subagent-depth-maxintegernoglobalMax subagent nesting.
subagent-width-maxintegernoglobalMax live subagents/agent.
retry-budget-secsintegernoglobalLLM retry budget (secs).
network.modestringnoeitherNetwork mode.
network.defaultstringnoglobalFilter fallback action.
network.allowarraynoglobalFilter allow entries.
network.denyarraynoglobalFilter deny entries.

default-agent is optional. With neither --agent nor default-agent, outrig run starts with no agent: no preamble is sent, every knob comes from the top level, and the image cascade is --image, then default-image, then outrig’s built-in default. A model must still resolve from --model or default-model – that is the one thing an agentless session cannot do without.

default-image and default-agent belong in the repo config – image-configs and agents are project-scoped. default-model, session-root, model-cache-root, tool-call-max, and the subagent limits belong in the global config since they’re user/machine-level. tool-result-max usually belongs there too, although repo or agent config can tighten it for a noisy project. retry-budget-secs is a default for every remote provider; a [providers.<name>] row that sets its own overrides it, which is usually the better place since rate limits are a property of the endpoint. [network].mode can live in either file; when both set it, the repo value wins for that repo. Network policy keys (default, allow, and deny) are global-only because they describe the machine’s egress policy, not a project preference. Each may also appear in the other file; repo entries override global by name.

session-root defaults to <XDG_DATA_HOME>/outrig/sessions/ (typically ~/.local/share/outrig/sessions/). The CLI flag --session-root <path> overrides both the config value and the default; --session-dir <path> (on outrig run/logs/discard) instead points at one specific session directory. See Sessions.

model-cache-root defaults to <XDG_CACHE_HOME>/outrig/models/ (typically ~/.cache/outrig/models/). It only matters for style = "mistralrs" models configured with model-id – that’s where the auto-downloaded GGUFs land. See Concepts -> In-process LLMs.

tool-call-max is the default maximum number of tool calls in one user turn. The compiled-in default is 50; config may set any value from 1 through 2000. [agents.<name>].tool-call-max overrides the top-level value for one agent, and outrig run --max-tool-calls <n> overrides both for one invocation.

tool-result-max is the default maximum byte size for one MCP tool result before it is added to the LLM-visible conversation history. The compiled-in default is 262144 bytes (256 KiB); config may set any value from 1024 through 16777216 bytes. [agents.<name>].tool-result-max overrides the top-level value for one agent, and outrig run --max-tool-result-bytes <n> overrides both for one invocation. Results larger than the max are truncated at a UTF-8 boundary and end with an [outrig: tool result truncated] marker that reports the original size and max.

subagent-depth-max bounds how deeply subagents may nest. The primary agent you talk to is the root at depth 1; an agent at depth D may launch subagents (which live at depth D+1) only while D is under the limit. So 1 disables subagents entirely, 2 allows a single layer, and the default 3 allows two. The compiled-in default is 3; config may set any value from 1 through 16. [agents.<name>].subagent-depth-max overrides the top-level value for one agent. The separate [agents.<name>].subagents toggle still applies: false withholds the launch tools regardless of depth.

subagent-width-max bounds how many live subagents one launching agent may hold at once. The compiled-in default is 8; config may set any value from 1 through 16. [agents.<name>].subagent-width-max overrides the top-level value for one agent. The cap is per launching agent, so a subagent’s own budget is independent of its parent’s. A subagent stays live after it finishes, so outrig__subagent_release is what frees a slot.

[network]

Network interception is disabled by default:

[network]
mode = "default"

Accepted modes:

  • default: use Podman’s configured default networking, do not install the interceptor, and do not write logs/network.jsonl.
  • audit: allow all outbound session-container traffic, but write Zeek conn.log-style records to <session_dir>/logs/network.jsonl.
  • filter: install the same interceptor as audit mode, write the same audit log, and enforce global allow/deny policy before opening upstream TCP connections.

Audit and filter mode require host nft and nsenter plus permission to enter the rootless podman container’s user/network namespaces. It rewrites the session container’s /etc/resolv.conf to send DNS to the per-session in-namespace DNS listener, installs nftables redirection for outbound TCP and UDP/53, and removes the nftables table during teardown. If either mode is requested and setup fails, the session fails before MCP servers launch.

Filter policy lives in the global config only:

[network]
mode    = "filter"
default = "deny"              # optional; absent means "deny" in filter mode
allow   = ["github.com:443", "*.npmjs.org", "10.0.0.0/8"]
deny    = ["*:22", { host = "169.254.169.254", port = 80 }]

allow and deny entries can be compact strings or inline tables. The string "host" maps to { host = "host" }; "host:443" maps to { host = "host", port = 443 }; "*:22" maps to { host = "*", port = 22 }; "[2001:db8::1]:443" maps to an IPv6 host plus port; and CIDRs such as "10.0.0.0/8" or "2001:db8::/32" match IP destinations. Inline tables use { host = "...", port = 443 }, with port optional.

Filter evaluation checks deny entries first, then allow entries, then default. Denied connections are closed immediately and still write an audit record with outrig.action = "deny", outrig.rule, and zero byte counts. mode = "filter" requires at least one allow or deny entry, even when default = "allow".

outrig run --network default|audit|filter and outrig mcp --network default|audit|filter override this setting for one fresh session. --network audit and --network filter are rejected with outrig mcp --attach because borrowed containers are not retrofitted with a new interceptor.

[providers.<name>]

A provider tells outrig how to reach a model – either a remote HTTPS endpoint that speaks a known wire format, or a local in-process backend. Multiple providers in either file. Repo entries with the same name override globals, replacing the whole entry rather than merging field by field. The accepted style values are "openai", "anthropic", and "mistralrs". Which other fields are valid depends on style.

style = "openai"

[providers.openai]
style    = "openai"
base-url = "https://api.openai.com/v1"
api-key  = "${OPENAI_API_KEY}"

[providers.local-ollama]
style    = "openai"
base-url = "http://localhost:11434/v1"
api-key  = "${OLLAMA_API_KEY}"
KeyTypeRequiredDefaultDescription
stylestringyesMust be "openai" for this row.
base-urlstring (URL)yesHTTPS endpoint for the provider.
api-keystringyesEnv-var reference, see below.
request-timeout-secsintegerno600HTTP timeout for LLM calls.
retry-budget-secsintegerno600Transient-retry budget, seconds.

request-timeout-secs bounds each individual attempt, and defaults high enough not to cut off long reasoning completions. It must be between 1 and 3600 seconds; 0 is rejected, because it is an immediate timeout rather than a disabled one and every request would fail before it could be answered. retry-budget-secs bounds all the attempts together: an LLM request that fails transiently is retried until it succeeds or the budget runs out, then ends the turn without ending the session. Which failures count as transient, how the wait is chosen, and how Retry-After is honored are described in Concepts -> LLM providers; this page is the reference for the keys themselves.

Two things about the budget that belong here, because they are about the keys:

  • It is wall clock from the first attempt, including time each attempt spends in flight, not only time spent sleeping. That matters when it is set near request-timeout-secs: one attempt that runs the timeout out spends the whole budget and buys no retries. It does not matter for a rate limit, which comes back in milliseconds.
  • 0 disables retries – the first failure is final. Useful for scripted runs that would rather fail fast than wait.
  • It is not the only bound on the second retry layer. A provider that answers with a body outrig cannot use gets at most two retries whatever the budget says, because such a response comes back immediately and the budget alone would spend itself on dozens of them. 0 still switches that layer off along with everything else.

style = "anthropic"

Anthropic’s native Messages API: requests go to {base-url}/v1/messages and authenticate with the x-api-key header. Use this to reach Claude directly. Reaching Claude through an OpenAI-compatible bridge (OpenRouter and friends) is a style = "openai" provider pointed at that bridge instead – both work, and they are different rows.

[providers.anthropic]
style    = "anthropic"
base-url = "https://api.anthropic.com"
api-key  = "${ANTHROPIC_API_KEY}"
KeyTypeRequiredDefaultDescription
stylestringyesMust be "anthropic" for this row.
base-urlstring (URL)yesHTTPS endpoint for the provider.
api-keystringyesEnv-var reference, see below.
request-timeout-secsintegerno600HTTP timeout for LLM calls.
retry-budget-secsintegerno600Transient-retry budget, seconds.

base-url is the API root, without the /v1/messages path – outrig appends that. A trailing /v1, /messages, or /v1/messages is trimmed if you write one anyway, so https://api.anthropic.com and https://api.anthropic.com/v1 behave identically. Timeout and transient-retry behavior match style = "openai" exactly.

Anthropic requires an output-token ceiling on every request. outrig knows one for the model identifiers it recognizes; any other identifier needs max-tokens on the model or the agent, or the first turn fails saying so. See anthropic models.

style = "mistralrs"

In-process LLM backed by the mistralrs crate. No HTTP, no API key. The provider table is bare – just the style tag. Each set of weights is its own [models.<name>] row referencing this provider, so a single mistralrs provider can back many models. See Concepts -> In-process LLMs.

[providers.local]
style = "mistralrs"
KeyTypeRequiredDefaultDescription
stylestringyesMust be "mistralrs" for this row.

base-url and api-key are not allowed on style = "mistralrs". The model-specific fields (model-id, model-path, model-file, revision, context-length, device) live on [models.<name>] – see the mistralrs models subsection.

Always parses, even without --features local-llm

outrig always recognizes style = "mistralrs" for parsing and cross-reference validation, regardless of whether the binary was built with --features local-llm. The build-time feature gates only the use of the provider: trying to resolve an agent that points at a mistralrs provider on a non-feature build fails at run time, with a message that names the missing flag.

The reason is portability – a checked-in .agents/outrig/config.toml can declare both remote and in-process providers, and the same config works for teammates whether or not they built with the feature on.

api-key syntax

api-key must use the env-var-substitution form "${VAR_NAME}" – exactly that, nothing else. outrig refuses any other value, including a plain string that happens to look like an API key. This keeps actual key material out of config files unconditionally:

api-key = "${OPENAI_API_KEY}"   # OK -- outrig reads $OPENAI_API_KEY at run time
api-key = "sk-..."              # ERROR -- looks like a literal key, refused
api-key = "$OPENAI_API_KEY"     # ERROR -- braces required
api-key = "OPENAI_API_KEY"      # ERROR -- ${...} required

The variable name must match ^[A-Z_][A-Z0-9_]*$. If the named environment variable is unset when outrig needs the key, outrig fails with a pointed error.

See Concepts -> LLM Providers.

[models.<name>]

A model points at a provider and supplies whatever that provider needs to identify the weights or wire-format model name. The required fields depend on the provider’s style.

Remote-provider models

Models on a remote provider – style = "openai" or style = "anthropic" – name their model with an identifier. None of the mistralrs weight fields are allowed.

[models.fast]
provider   = "openai"
identifier = "gpt-4o-mini"

[models.smart]
provider   = "openai"
identifier = "gpt-4o"
KeyTypeRequiredDefaultDescription
providerstringyesName of an entry in [providers.<name>].
identifierstringyesModel id passed to the provider API.
max-tokensintegernoOutput-token ceiling per turn, see below.

max-tokens on a model is the fallback for every agent that uses it; [agents.<name>].max-tokens wins where it is set. Leaving both unset lets the provider decide – which is fine for OpenAI-compatible endpoints, and is what the next section is about for Anthropic.

Note the spelling. In config it is max-tokens, like every other key; max_tokens is rejected as an unknown field. The underscored form is what the provider API calls it, so it is what appears in a raw error coming back from one.

anthropic models

[models.sonnet]
provider   = "anthropic"
identifier = "claude-sonnet-4-6"

[models.older]
provider   = "anthropic"
identifier = "claude-3-5-sonnet-20241022"
max-tokens = 8192

The Messages API rejects a request with no max_tokens, so outrig has to send one. Three tiers decide which, highest priority first:

  1. [agents.<name>].max-tokens, then [models.<name>].max-tokens.

  2. The published ceiling for a Claude identifier outrig recognizes – 128000 for the claude-opus-4-6 and later families, 64000 for the claude-opus-4, claude-sonnet-4, and claude-haiku-4-5 families.

  3. Otherwise a fallback of 32768, announced once on stderr:

    [outrig] claude-3-5-sonnet-20241022 has no published output-token ceiling in this
             build, so turns are capped at 32768. Set [models.older].max-tokens (or
             [agents.coding].max-tokens) to choose your own.
    

Tier 2 is also a cap, not only a default: a tier-1 value above the published ceiling is lowered to it. The API refuses an over-ceiling request outright, so the whole turn fails rather than being cut short, and the published number is the only one that runs. This applies where outrig recognizes the identifier; for one it does not, there is no ceiling to cap against and the configured value travels whole.

Tier 3 is a floor under the failure, not a recommendation – prefer setting the ceiling yourself, as [models.older] does above. The recognized set in tier 2 is whatever the pinned rig release knows, so an identifier newer than that release lands in tier 3 even though it is current – and, for the same reason, is not capped.

32768 is chosen to fail in the direction you can see. A model whose real limit is lower – the 3.x families cap at 8192 or 4096 – rejects the request outright, naming its own limit, and one config line fixes it. A ceiling set too low instead cuts replies off mid-sentence with nothing logged, which is much harder to recognize as a config problem. outrig config init prompts for max-tokens when it writes an Anthropic model, so a generated config carries an explicit one either way.

mistralrs models

For an in-process style = "mistralrs" provider, the model row carries the weight spec. Either model-id (HuggingFace auto-download) or model-path (local GGUF file) – exactly one. identifier is not allowed on mistralrs models – the weights are the model.

# HuggingFace auto-download:
[models.phi3-fast]
provider   = "local"
model-id   = "microsoft/Phi-3-mini-4k-instruct-gguf"
model-file = "Phi-3-mini-4k-instruct-q4.gguf"
# revision       = "main"   # optional git ref on the HF repo
# context-length = 4096     # optional override
# device         = "cuda"  # optional; defaults to "cpu"

# Multi-shard quantization (one quant split across files):
[models.llama-70b]
provider   = "local"
model-id   = "MaziyarPanahi/Meta-Llama-3-70B-Instruct-GGUF"
model-file = [
    "Meta-Llama-3-70B-Instruct.Q4_K_M-00001-of-00002.gguf",
    "Meta-Llama-3-70B-Instruct.Q4_K_M-00002-of-00002.gguf",
]

# Local GGUF file:
[models.llama-local]
provider   = "local"
model-path = "/var/cache/outrig/models/llama-3-8b-instruct.q4.gguf"
# device     = "metal" # optional; defaults to "cpu"
KeyTypeRequiredDefaultDescription
providerstringyesName of a style = "mistralrs" provider.
model-idstringone of*HF repo id, e.g. microsoft/Phi-3-mini-....
model-pathpathone of*Local path to a GGUF file.
model-filestr/arrwith idGGUF filename(s) inside the HF repo.
revisionstringno"main"HF git ref to pin. With model-id.
context-lengthintegernomodelOverride the model’s default context window.
devicestringno"cpu"One of cpu, cuda, cuda:N, metal.

* Exactly one of model-id / model-path must be set; setting both, or neither, is an error.

device = "cuda" and device = "cuda:N" require a binary built with --features "local-llm cuda"; device = "metal" requires --features "local-llm metal". The feature check happens when an agent resolves the model. Enabling cuda or metal without local-llm emits a build warning and has no effect. outrig does not fall back to CPU if the requested backend is unavailable. With CUDA, cuda:N selects the base device for mistralrs’s automatic mapper; it is not an exclusive single-device sharding directive. outrig run --device <device> overrides this field for one run without editing config.

Metal is only usable on macOS targets. Non-macOS builds can compile with the metal feature for feature-matrix coverage, but trying to instantiate a Metal device fails with a platform error.

[agents.<name>]

An agent is the runnable unit: a model plus a system prompt, optionally bound to an image-config so outrig run --agent <name> knows which sandbox to use.

The whole table is optional. A run that names no agent behaves as an entry with no keys set: no preamble, no image hint, every limit from the top level. Declare agents when you want a preamble or a per-agent knob; skip them to run a model against a sandbox and nothing more.

[agents.coding]
# model omitted -> falls back to top-level default-model
image = "coding"
preamble  = "You are a careful coding assistant. Repo is at /workspace."
temperature = 0.2
max-tokens  = 4096
tool-call-max = 300
tool-result-max = 1048576

[agents.review]
model    = "smart"        # explicit override of default-model
preamble = "You are a meticulous code reviewer..."
  • model (string, optional, default: default-model): name of an entry in [models.<name>].
  • preamble (string, optional, default: none): system prompt for this agent. If it is omitted, requests carry no system prompt.
  • image (string, optional, default: default-image): default image-config to launch.
  • temperature (float, optional, default: provider default): sampling temperature.
  • max-tokens (integer, optional, default: the model’s max-tokens, else the provider default): output token max per turn. Worth setting in one place or the other for an Anthropic model whose identifier outrig does not recognize, which otherwise runs on a fallback ceiling – see anthropic models.
  • tool-call-max (integer, optional, default: top-level value or 50): tool calls per turn.
  • tool-result-max (integer, optional, default: top-level value or 262144): bytes per result.
  • subagents (bool, optional, default: true): whether this agent may launch subagents. When false, the outrig__ subagent tools are not registered at all, so the agent’s tool list and context cost are unchanged from a build without the feature.
  • subagent-depth-max (integer, optional, default: top-level value or 3): how deeply this agent’s subagents may nest. See the top-level subagent-depth-max.
  • subagent-width-max (integer, optional, default: top-level value or 8): how many live subagents this agent may launch. See the top-level subagent-width-max.

If model is omitted, outrig falls back to the top-level default-model; an error if neither is set, except outrig run --model <name> may supply the selected agent’s model for that run. When outrig run --agent <a> runs, the chosen image-config is --image if given, otherwise agents.<a>.image if set, otherwise default-image, otherwise outrig’s built-in default. A run with no agent drops the middle rung: --image if given, otherwise default-image, otherwise the built-in. tool-call-max is per turn, not per session; follow-up prompts start a fresh count. tool-result-max is per result and applies equally to successful MCP results and MCP error messages. It also caps what outrig__get_result hands back from a subagent.

subagents is on by default. A subagent shares this agent’s container, MCP tools and limits, but not its preamble or context, and may name a different [models.<name>] at launch – see Concepts -> Subagents. Turn it off for agents that should stay single-threaded, or to save the context the five tool schemas occupy. A subagent may itself launch subagents up to subagent-depth-max; each launching agent only sees the subagents it launched.

[workspace]

[workspace]
host-path      = "."
container-path = "/workspace"

[[workspace.mounts]]
host-path      = "../shared-docs"
container-path = "/resources/shared-docs"

[[workspace.mounts]]
host-path      = "/var/tmp/outrig-cache"
container-path = "/resources/cache"
access         = "read-write"
  • host-path (path, optional, default: "."): primary workspace host path. A relative value resolves against the directory of the file that declared it – see path resolution.
  • container-path (path, optional, default: "/workspace"): where the primary workspace is mounted in the container.
  • workspace.mounts (array, optional, default: []): extra directory bind-mounts.
  • mounts[*].host-path (path, required): host directory to mount. Relative paths resolve against the directory of the file that declared the entry – see path resolution. Global and repo mounts are concatenated, so one list can hold entries with different base directories.
  • mounts[*].container-path (path, required): absolute in-container mount point.
  • mounts[*].access (string, optional, default: "read-only"): either "read-only" or "read-write".

The primary workspace bind-mount is always read-write and uses --userns=keep-id so files written inside the container appear with your host UID/GID. Extra mounts default to read-only; set access = "read-write" only for directories the agent should be able to modify. See Concepts -> Workspace.

[images.<name>]

You declare one or more image-configs. The selected one becomes the agent’s environment. Each block takes exactly one of two shapes:

Build-from-Dockerfile (existing form)

[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"
context    = ".agents/outrig/images/coding"
build-args = { NODE_VERSION = "20" }

For a build-from-Dockerfile image, the block name becomes the built image’s repository (the image is tagged <name>:<content-hash>). The content hash includes Dockerfile/context content, resolved build args, and the OutRig labels derived from [images.<name>.mcp], so MCP config changes produce a new inspectable cache tag. Use a repo-specific, lowercase name (e.g. outrig-standard, not standard) so podman images makes clear which repo it came from. The name must be a valid container image repository component – see the validation rules below.

  • dockerfile (path, required*): path to the Dockerfile, relative to the directory of the file that declared the block – see path resolution. For a repo image-config that is the repo root; for one declared in the global config it is that file’s own directory.
  • context (path, required*): path to the build context, same rule.
  • build-args (table str->str, optional, default: {}): extra Dockerfile ARGs. Keys are ARG names. Values are either literal strings or ${VAR} references resolved from the host environment at outrig build time; see the MCP env value syntax below.

Use-existing-image (new form)

[images.scratch]
image-name = "docker.io/library/ubuntu:24.04"
  • image-name (string, required*): image reference passed to podman pull / podman run. Accepts any ref form podman supports: name:tag, registry/name:tag, name@sha256:....

* Exactly one of the two shapes must be set. Setting image-name alongside dockerfile, context, or build-args is an error. Setting neither is also an error.

The built-in default image-config

When nothing names an image – no --image, no agents.<n>.image, no default-image – outrig supplies one of its own rather than failing. This is what makes outrig run work in a repo with no .agents/outrig/config.toml at all. It is ordinary config, injected into the merged result at the bottom of the precedence order:

[images.outrig-default]
image-name = "docker.io/library/buildpack-deps:bookworm-scm"

  [images.outrig-default.mcp]
  fs    = { sidecar = "outrig-default-fs" }
  shell = { sidecar = "outrig-default-shell" }

[images.outrig-default-fs]
image-name = "docker.io/mcp/filesystem:latest"

[images.outrig-default-shell]
# Built from a Dockerfile outrig writes into your user cache directory
# (`~/.cache/outrig/builtin-images/`), never into your repo.

[sidecars.outrig-default-fs]
image = "outrig-default-fs"
view  = "primary"
args  = ["/workspace"]

[sidecars.outrig-default-shell]
image = "outrig-default-shell"
view  = "primary"

buildpack-deps:bookworm-scm is the smallest official image that carries git, curl, and ca-certificates while setting no ENTRYPOINT and baking in no user – both of which outrig needs, since it appends sleep infinity after the image ref (which an ENTRYPOINT would swallow as arguments) and writes the runtime user’s /etc/passwd entry at start. Because both servers run with view = "primary", the commands they spawn resolve in this container’s filesystem, which is why the primary is the one that needs git.

The first run pulls two images and builds one. Each is reachable by name from outrig build and from --image, so that cost can be paid deliberately – one command per part:

$ outrig build --image outrig-default          # pulls buildpack-deps
$ outrig build --image outrig-default-fs       # pulls the filesystem server
$ outrig build --image outrig-default-shell    # builds the shell server

Reserved names. outrig-default, outrig-default-fs, and outrig-default-shell are reserved as [images.<name>]; the latter two are also reserved as [sidecars.<name>]. Your config wins: declare any one of them and outrig injects none of the built-in and says so. Injection is all-or-nothing because a half-injected set is a broken config – your [images.outrig-default] beside outrig’s [sidecars.outrig-default-fs] would leave that block’s args unreachable, which is a hard error for every command in the repo.

Without the outrig-enter launcher. view = "primary" needs the launcher, which is only embedded when outrig was built with the <arch>-unknown-linux-musl target. Without it the built-in degrades rather than failing: fs switches to workspace = "rw", giving the same file tools over a bind mount, and shell is dropped. A shell has no honest degraded form – a bind-mounted sidecar sees none of the primary’s toolchain, so it would report a different environment than the one you have.

default-image cannot name it. default-image = "outrig-default" is still an error: that key is validated when the config is loaded, before the built-in is injected. You never need to write it – the built-in is the rung that fires when the key is absent. To pin it explicitly, declare [images.outrig-default] yourself, which shadows the built-in entirely.

Notes:

  • outrig image add writes its output under .agents/outrig/images/<name>/. You can put Dockerfiles anywhere you want by editing these paths; the .agents/outrig/images/ default just keeps outrig-specific files together.
  • Inner keys of build-args are user-defined Dockerfile ARG names; they’re left as written since they map to env-var-style identifiers.
  • outrig does not inject UID/GID build-args. Host UID/GID are mapped to the container at run time, not baked into the image. See Concepts -> Workspace.

[images.<name>.security]

Optional runtime security controls for the selected container:

[images.coding.security]
capability-profile = "no-net-raw"
cap-drop = ["MKNOD", "SETFCAP"]
cap-add  = ["NET_BIND_SERVICE"]
no-new-privileges = false
devices  = ["/dev/fuse"]
  • capability-profile (string, optional, default: "default"): named Linux capability profile. Accepted values are:
    • "default": preserve podman’s default capability set and emit no capability flags unless cap-drop or cap-add is set.
    • "no-net-raw": emit --cap-drop=NET_RAW.
    • "drop-all": emit --cap-drop=ALL.
  • cap-drop (array, optional, default: []): extra Linux capabilities to drop.
  • cap-add (array, optional, default: []): Linux capabilities to add after profile and explicit drops are rendered.
  • no-new-privileges (bool, optional, default: true): emit --security-opt=no-new-privileges. Setting it to false restores setuid escalation inside the container, which is what a nested rootless container runtime needs and which also lets any setuid-root binary in the image be used; see Containers for the tradeoff.
  • devices (array, optional, default: []): host device nodes to pass through, emitted as one --device=<path> per entry in declaration order. Entries are plain absolute paths; podman’s <src>:<dst>:<perms> form is not accepted.

Capability names may be written as NET_RAW or CAP_NET_RAW; outrig normalizes to the podman form without the CAP_ prefix. This section does not configure seccomp, AppArmor, SELinux, read-only roots, mount policy, or network egress filtering.

[images.<name>.mcp]

Map of MCP server entries, keyed on server name. Each entry is one of two shapes via a serde-untagged dispatch:

[images.coding.mcp]
# Short form -- array of strings, becomes { command = [...] }
shell = ["bash", "-lc", "exec shell-mcp-command"]

# Full form -- table with command + optional env
fs = { command = ["mcp-server-filesystem", "/workspace"] }
build = { command = ["cargo-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }

# Full form with a placement key -- runs in a sidecar container
lint = { command = ["mcp-lint", "--stdio"], sidecar = "tools" }
grep = { command = ["mcp-grep"], image = "ghcr.io/example/mcp-grep:1" }

# entrypoint-stdio -- no command; the container's ENTRYPOINT is the server
fetch = { image = "ghcr.io/example/mcp-fetch:2", env = { TOKEN = "${FETCH_TOKEN}" } }
serve = { image = "docker.io/mcp/filesystem:latest", args = ["/workspace"] }

shell-mcp-command is a placeholder. Replace it with the shell MCP server you install in the image, or declare any other MCP command that should run inside the container.

  • command (array of strings, required unless using the short form or the entrypoint-stdio form): argv of the MCP server.

  • env (table str->str, optional, default: {}): env vars set on the podman exec invocation – or, for entrypoint-stdio servers, baked in via podman create --env (visible to podman inspect on the host, like exec argv).

  • sidecar (string, optional): run this server in the named [sidecars.<sc>] container instead of the primary. With command, the server is exec-stdio in that container; without command, that container’s ENTRYPOINT is the server (entrypoint-stdio).

  • image (string, optional): give this server a dedicated anonymous sidecar created from this image ref (resolved like the sidecar image key). Mutually exclusive with sidecar. With command, the server is exec-stdio in that sidecar; without command, the image’s ENTRYPOINT is the server (entrypoint-stdio). The container’s lifetime equals the server’s: its exit surfaces as tool errors while the session continues.

  • args (array of strings, optional, default: []): positional arguments for an entrypoint-stdio server, appended after the image ref on podman create. Requires sidecar or image and rejects command, which is already a full argv. The same key exists on [sidecars.<sc>]; declaring it in both places for one container is an error rather than a merge.

    podman appends trailing arguments to an exec-form ENTRYPOINT and replaces CMD. So args is for images whose server is an ENTRYPOINT: for an image that puts its server in CMD instead, args replaces that CMD and the server never starts.

  • view (string, optional, default: "none"): "none" or "primary", with the meaning and the exclusions documented on [sidecars.<sc>]. Legal here only on the inline entrypoint form – it needs image and rejects command, because the anonymous sidecar it configures is the one image creates:

    [images.dev.mcp]
    fs = { image = "docker.io/mcp/filesystem:latest", view = "primary", args = ["/workspace"] }
    

    The args written here and the elements the sidecar image declares end up meaning different paths – see Concepts -> MCP Servers.

Notes:

  • The first element of command must be on $PATH inside the container, or absolute.
  • The map key (e.g. fs, shell, build) is the server name. It must match ^[a-zA-Z][a-zA-Z0-9_-]*$ and be unique within an image-config.
  • The server name appears in outrig logs <session> <server> and as the prefix on every tool the server advertises (<server>__<tool>).
  • Each env value is either a literal string forwarded verbatim or a ${VAR} reference resolved from the host environment at MCP startup – see the subsection below.
  • Images can provide the same table via their org.outrig.mcp OCI label (placement keys are repo-config-only and rejected in labels). Repo config entries override image entries by server name; see Concepts -> MCP Servers.
  • Build-from-Dockerfile repo images are stamped with the merged org.outrig.mcp label on cache misses, so outrig image inspect <name>:<content-hash> can show their declared repo-local MCP entries without starting a container.

[sidecars.<sc>]

Named sidecar containers hosting MCP servers away from the primary; see Concepts -> Containers. The block key <sc> is the sidecar name; it embeds in the container name (outrig-<sid>-<sc>) and must match ^[A-Za-z0-9][A-Za-z0-9_-]*$.

Top-level and referenced by name, like [models.<n>] or [providers.<n>], so any number of image-configs can share one block. A session starts the blocks its image-config’s [mcp] entries name and only those – declaring a block instantiates nothing on its own.

[sidecars.tools]
image      = "mcp-tools"
workspace  = "ro"
start      = "auto"
on-failure = "warn"

[[sidecars.tools.mounts]]
host-path      = "~/.cache/example"
container-path = "/cache"
access         = "read-write"

[sidecars.tools.security]
capability-profile = "no-net-raw"
  • image (string, required): resolved exactly like --image – an [images.<name>] config name first, else a raw podman ref that must be present locally.
  • args (array of strings, optional, default: []): positional arguments for the image’s ENTRYPOINT, used only when this block is an entrypoint host – see below. Same semantics and the same podman ENTRYPOINT/CMD rule as the args key on [images.<name>.mcp]; setting it in both places is an error.
  • workspace (string, optional, default: "none"): "none", "ro", or "rw". Mounts the session workspace at the primary’s container path with that access.
  • view (string, optional, default: "none"): "none" or "primary". "primary" runs the sidecar against the primary container’s filesystem view – its rootfs and every mount, at the primary’s paths – via the outrig-enter launcher, so an off-the-shelf MCP image serves the primary’s files without the primary image carrying that tool. Entrypoint-stdio only, and mutually exclusive with workspace (the view already holds the workspace) and with capability-profile = "drop-all" (the view needs the mount capabilities). It grants CAP_SYS_ADMIN and CAP_SYS_PTRACE in the primary’s user namespace – a real posture change; see MCP Trust Model and SECURITY.md. The same key exists on the inline [images.<name>.mcp] one-liner form.
  • start (string, optional, default: "auto"): "auto" starts with the session; "manual" declares a sidecar that starts only when asked – /sidecar add <name> in the REPL, or Outrig::add_sidecar / LaunchSpec::with_sidecar from the library API. Until then its servers are skipped with a notice. Not available on an entrypoint host, whose container lifetime is its server’s.
  • on-failure (string, optional, default: "abort"): how a start/bootstrap/connect failure of this sidecar is handled at session start. "abort" fails the session; "warn" logs, skips the sidecar and its servers, and continues.
  • mounts (array of tables, optional): same shape and validation as [[workspace.mounts]], including path resolution – a relative host-path resolves against the directory of the file that declared the sidecar block.
  • security (table, optional): same keys as [images.<name>.security].

A block is an entrypoint host when the one [images.<name>.mcp] entry naming it omits command – the container process is the server, rather than a shell to podman exec into:

[sidecars.tools]
image     = "docker.io/mcp/filesystem:latest"
args      = ["/workspace"]
workspace = "ro"

[images.coding.mcp]
fs = { sidecar = "tools" }

Container lifetime equals server lifetime, so such a block hosts exactly one server and cannot be start = "manual". It also skips the in-container user bootstrap – there is no podman exec window before the entrypoint runs – so the image’s own USER applies to workspace and mounts.

MCP env value syntax

Each entry on the right-hand side of an env table is one of:

build = { command = ["cargo-mcp"], env = {
  CARGO_HOME = "/workspace/.cargo",  # literal -- forwarded to podman as-is
  GH_TOKEN   = "${GITHUB_TOKEN}",    # reference -- resolved from host env at MCP startup
  STILL_LIT  = "${lower_case}",      # literal -- doesn't match ^[A-Z_][A-Z0-9_]*$
  ALSO_LIT   = "prefix-${X}-suffix", # literal -- embedded substitution is not supported
} }

The reference form is exactly "${VAR}", where VAR matches ^[A-Z_][A-Z0-9_]*$ – the same syntax api-key accepts. Anything else is treated as a literal and passed through verbatim, including malformed-looking references (lower-case names, unmatched braces, or embedded substitution). If the named host env var is unset when the MCP server is about to start, MCP startup fails with an error naming the variable, the server, and the env key.

See Concepts -> MCP Servers.

Resolution: which file wins

Both files are loaded; entries are merged by name. Repo entries win over global entries. A name defined only once is straightforward; a name defined in both means the repo’s definition is used in full (no per-key merging).

~/.outrig/config.toml             .agents/outrig/config.toml         effective
[providers.openai]                                                   global value
[providers.local]                 [providers.local]                  repo overrides
                                  [providers.staging]                repo only

outrig run walks the chain at startup: agent -> model (explicit or default-model) -> provider -> resolved api-key from env. Anything that fails to resolve is an error printed to stderr before the REPL starts.

[workspace] primary fields merge per key. A repo host-path or container-path overrides the corresponding global value; when the repo is silent, the global value remains in effect. If neither file declares a key, its built-in default (. or /workspace) applies. Extra workspace.mounts are combined instead of replaced: global mounts are kept first, followed by repo mounts. Duplicate final container-path values are rejected during validation.

[sidecars.<sc>] merges by name like the other top-level maps, so a repo image-config can reference a sidecar the user declared globally.

[network].mode follows repo precedence when the repo config declares the table. If the repo omits [network], the global mode remains in effect. This matters when global config enables audit or filter mode and a repo explicitly sets mode = "default". Network policy keys are global-only; repo config cannot set network.default, network.allow, or network.deny.

Path resolution

A relative path is resolved against the directory of the config file that declared it, not against whichever repo is current. Provenance is recorded per entry before the merge, so a concatenated workspace.mounts list can hold entries with different base directories.

Declared inRelative paths resolve against
.agents/outrig/config.tomlthe repo root
~/.outrig/config.tomlthat file’s directory (~/.outrig/)
--global-config <path><path>’s parent directory

Absolute paths are used as-is and ignore the rule entirely. This applies to [images.<name>].dockerfile and .context, to [workspace].host-path, and to host-path in both [[workspace.mounts]] and [sidecars.<sc>.mounts]. Because [workspace] merges per key, a host-path inherited from the global file resolves beside that file, not from the repo root.

Provenance is recorded, not written back. A merged config is a flattened snapshot: re-serializing one emits each inherited path as the literal text its source file used, without the base directory that text was resolved against. Writing that snapshot out and loading it as a repo config therefore re-reads relative inherited paths against the repo root. Outrig never does this – the merge result is used in memory and discarded – but a tool that round-trips a loaded config through disk should resolve paths first, or keep the two files separate.

The practical effect is that a global [images.<name>] can use the build shape: its Dockerfile and context live beside ~/.outrig/config.toml and are found from any repo on the machine. One exception remains repo-relative: [models.<name>].model-path, which is documented under Validation rules.

Because provenance is recorded per entry, a diagnostic about a config-declared path names the file that declared it, as a trailing (declared in "<path>"). Every image and mount rule carries it – including the mount rules that judge the value rather than look for a directory, since the question it answers is which file to go edit. The reported path stays the raw config value rather than the resolved one:

workspace mount host-path "shared" does not exist (declared in "/home/you/.outrig/config.toml")

An entry built through the library API rather than loaded from a file records no source, and its diagnostics carry no clause.

Full examples

Global ~/.outrig/config.toml

default-model      = "fast"
session-root       = "/var/lib/outrig/sessions" # optional; default = XDG data dir
model-cache-root   = "/var/cache/outrig/models" # optional; default = XDG cache dir
tool-call-max      = 100                         # optional; default = 50
tool-result-max    = 262144                      # optional; default = 256 KiB
subagent-depth-max = 3                           # optional; default = 3
subagent-width-max = 8                           # optional; default = 8
retry-budget-secs  = 600                         # optional; default = 600

[network]
mode = "default"                                 # optional; default, audit, or filter
default = "deny"                                 # optional for filter mode
allow = ["github.com:443", "*.npmjs.org"]        # optional; global only
deny  = ["*:22"]                                 # optional; global only

[providers.openai]
style    = "openai"
base-url = "https://api.openai.com/v1"
api-key  = "${OPENAI_API_KEY}"

[providers.anthropic]
style    = "anthropic"
base-url = "https://api.anthropic.com"
api-key  = "${ANTHROPIC_API_KEY}"

[providers.local]
# requires `cargo build --features local-llm` to actually use, but always parses.
style = "mistralrs"

[models.fast]
provider   = "openai"
identifier = "gpt-4o-mini"

[models.smart]
provider   = "openai"
identifier = "gpt-4o"

[models.claude]
provider   = "anthropic"
identifier = "claude-sonnet-4-6"
max-tokens = 16384

[models.phi3-fast]
provider   = "local"
model-id   = "microsoft/Phi-3-mini-4k-instruct-gguf"
model-file = "Phi-3-mini-4k-instruct-q4.gguf"
device     = "cpu"

Repo .agents/outrig/config.toml

default-image = "coding"
default-agent = "coding"

[workspace]
host-path      = "."
container-path = "/workspace"

[[workspace.mounts]]
host-path      = "../shared-docs"
container-path = "/resources/shared-docs"

[[workspace.mounts]]
host-path      = "/var/tmp/outrig-cache"
container-path = "/resources/cache"
access         = "read-write"

[agents.coding]
# model omitted -> uses global default-model = "fast"
image       = "coding"
preamble    = "You are a careful coding assistant. Repo is at /workspace."
temperature = 0.2
tool-call-max = 300
tool-result-max = 1048576

[agents.review]
model    = "smart"        # explicit override
preamble = "You are a meticulous code reviewer."

[images.coding]
dockerfile = ".agents/outrig/images/coding/Dockerfile"
context    = ".agents/outrig/images/coding"
build-args = { NODE_VERSION = "20" }

  [images.coding.security]
  capability-profile = "no-net-raw"
  cap-drop = ["MKNOD", "SETFCAP"]
  cap-add  = ["NET_BIND_SERVICE"]

  [images.coding.mcp]
  fs    = { command = ["mcp-server-filesystem", "/workspace"] }
  shell = ["bash", "-lc", "exec shell-mcp-command"]
  build = { command = ["cargo-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }

Validation rules

outrig run and outrig mcp use the full validation path. outrig build validates every image-config in the merged config but does not require agent/model/provider wiring to resolve.

  • default-image must name an existing [images.<name>] block.
  • default-agent must name an existing [agents.<name>] block. Absence is fine; a name that matches nothing is not.
  • Every agents.<name>.model (if set) must name an existing [models.<name>]. If model is omitted, default-model must be set and must name an existing [models.<name>].
  • Every models.<name>.provider must name an existing [providers.<name>].
  • Every agents.<name>.image (if set) must name an existing [images.<name>].
  • Every providers.<name>.style must be one of {"openai", "anthropic", "mistralrs"}. Other styles are reserved for future Rig adapters and listed as TODO in the providers concept page. The build-time feature gate (--features local-llm) is not checked at validate time – see “Always parses, even without --features local-llm” above.
  • Every providers.<name>.api-key (on a remote style) must match ^\$\{[A-Z_][A-Z0-9_]*\}$.
  • Every [models.<name>] whose provider has a remote style must set identifier and must not set any of model-id, model-path, model-file, revision, context-length, device. The error names the style of the provider the model points at.
  • Every [models.<name>] whose provider has style = "mistralrs" must set exactly one of model-id / model-path. When model-id is set, model-file is required – mistralrs’s GGUF loader needs a specific filename and HF repos typically hold many quantizations. model-file accepts either a single string (one GGUF file) or an array of strings (a multi-shard quantization, e.g. *-00001-of-00003.gguf). revision is optional and only meaningful with model-id. A model-path, if set, must exist on disk relative to the repo root (or be absolute). identifier is not allowed on mistralrs models. device, if set, must be one of cpu, cuda, cuda:N, or metal.
  • model-cache-root, if set, must be an absolute path; outrig creates it if missing.
  • tool-call-max, if set at the top level or on an agent, must be between 1 and 2000.
  • tool-result-max, if set at the top level or on an agent, must be between 1024 and 16777216 bytes.
  • subagent-depth-max, if set at the top level or on an agent, must be between 1 and 16 (1 disables subagents).
  • subagent-width-max, if set at the top level or on an agent, must be between 1 and 16.
  • retry-budget-secs, if set at the top level or on a remote provider, must be at most 3600 seconds. 0 is legal and disables retries.
  • request-timeout-secs, if set on a remote provider, must be between 1 and 3600 seconds. Unlike retry-budget-secs, 0 is not legal: it is an immediate timeout rather than a disabled one, so every request would fail before it could be answered.
  • [network].mode, if set, must be default or audit.
  • Every server name in [images.<name>.mcp] must match ^[a-zA-Z][a-zA-Z0-9_-]*$ and be unique within its image-config.
  • Every command array must be non-empty.
  • sidecar and image are mutually exclusive on an MCP entry, sidecar must name a declared [sidecars.<sc>] block, image must not be empty, and an image entry’s server name must not collide with a sidecar name (it occupies that name).
  • args on an MCP entry requires sidecar or image, and is rejected next to command.
  • args on [sidecars.<sc>] requires that some image-config host an entrypoint-stdio server in that block, and the same container’s arguments must not also be declared on the MCP entry.
  • An entrypoint host hosts exactly one MCP server and must be start = "auto".
  • args is rejected in an org.outrig.mcp label and in standalone image.toml, alongside the placement keys: labels declare exec-stdio servers, whose arguments belong in command.
  • dockerfile and context must exist on disk, resolved against the declaring file’s directory (build path only). The error names both the path as written and the file that declared it.
  • Each [images.<name>] must set exactly one of: image-name, or dockerfile + context. Setting both shapes, neither, image-name with build-args, or only one of dockerfile/context without the other is an error.
  • image-name must not be empty.
  • A build-from-Dockerfile [images.<name>] block key must be a valid container image repository component – lowercase alphanumeric separated by ., _, or - (^[a-z0-9]+([._-]+[a-z0-9]+)*$) – because it becomes the built image’s repository. Image-name configs are exempt: their block key is just a label.
  • Every [images.<name>.security].capability-profile, if set, must be one of default, no-net-raw, or drop-all.
  • Every capability name in cap-drop or cap-add must be non-empty and match ^[A-Z0-9_]+$ after optional CAP_ stripping.
  • Capability names must not be duplicated within cap-drop or within cap-add, after optional CAP_ stripping.
  • The same normalized capability name must not appear in both cap-drop and cap-add.
  • Every [images.<name>.security].devices entry must be non-empty and an absolute path. Whether the node exists is not checked – validation may run on a machine that is not the launch host, so podman reports a missing node at launch instead.
  • Device paths must not be duplicated within one devices list.
  • session-root, if set, must be an absolute path; outrig creates it if missing.
  • Every workspace.mounts[*].host-path, if validated with a repo root, must exist and be a directory. Relative host paths resolve against the declaring file’s directory.
  • Every workspace.mounts[*].container-path must be absolute and must not be /.
  • Extra workspace mount container-path values must be unique, including no collision with the primary workspace container-path.
  • Every workspace.mounts[*].access, if set, must be either read-only or read-write.
  • Unknown keys at any level are rejected.

See also

  • Concepts – narrative explanations of what these keys mean.
  • Reference -> CLI – flags that override or interact with config.