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. Both are covered by a live end-to-end run in CI, not only by a build. WSL2 counts; macOS and native Windows are not supported.
  • Rust toolchain (you’ll need it to install outrig itself).
  • podman 4.3 or newer, installed and working in rootless mode. Verify with podman info – it should print without errors. The floor is 4.3 because outrig’s cancellation cleanup removes containers with podman rm --filter, which older podman does not accept. The engine must share this machine’s kernel and PID namespace: a socket on this host is fine, but a remote engine (another host, a VM, another container) is not supported, and outrig refuses it.
  • 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.2.2

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 text or yes/no prompt for an explanation; pickers print their options’ descriptions before they open.

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] base-url:          https://api.openai.com/v1
[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
> 

Up and Down recall the prompts you typed earlier in the session, and the prompt edits like any readline prompt; see Line editing and history.

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 – deprecated, pending removal: a feature-gated provider that runs the model inside the outrig process itself. Run local models under an OpenAI-compatible server (Ollama, vLLM, llama.cpp) and point a style = "openai" provider at it instead.
  • 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.

What the image installs has to work for that user. The build runs as root, but outrig execs nothing as root: every exec gets your UID and HOME=/home/<user> (see What outrig sets in an exec). Install tools under a shared prefix such as /usr/local, not into root’s home, which is 0700. A tool that keeps its state under $HOME by default – rustup, nvm, pyenv, pip install --user – needs that state pointed at the prefix as well, or at run time it looks under your home and finds nothing there; and whatever it writes as it works, as cargo does its registry cache, has to be writable by you. The rust toolchain outrig image add generates does all of this.

Download for the image’s architecture

outrig names no platform when it builds or pulls an image, so the image takes the architecture of the machine its engine runs on – x86-64 or AArch64 – and a multi-arch base, as every curated one is, resolves to that architecture. A tool you download as a prebuilt binary has to follow it too: choose the archive at build time, from uname -m, rather than from a URL that names amd64. An archive for the other architecture unpacks all the same, so the build succeeds, and the failure waits for the first run of the binary: exec format error. The go toolchain outrig image add generates chooses its archive this way, and fails the build on any other architecture.

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.

Create the directory a VOLUME names

outrig runs every container with --image-volume=ignore, so a VOLUME in the image creates no volume. The path holds whatever the image’s layers put there, and what the container writes there lands in its own layer and is removed with it, like any other write. Left to its default, podman would mount an anonymous volume there, and does not always remove it with the container.

VOLUME does not create the directory, so a path the layers never create is not there at all, where podman’s default would have mounted an empty one. If the image’s program expects the directory and does not make it, the image has to: RUN mkdir -p <path> ahead of the VOLUME.

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.

NET_ADMIN (or ALL) in cap-add does not combine with network interception: under mode = "audit" or "filter" the container is refused, since it could delete the table that intercepts it. See [network].

Devices and privilege escalation

Three further keys in the same block cover what capabilities cannot express. A device node is not a capability, a masked path 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 []
unmask            = ["/proc/*"]    # 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=<entry> per entry, in declaration order. An entry is <host-path>[:<container-path>][:<permissions>]; the permissions (r, w, m) are not enforced by rootless podman. This is the sharpest of the three 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.

unmask excludes paths from podman’s default masking, one --security-opt=unmask=<path> per entry. Entries are absolute paths, globs allowed, or the literal ALL – and they reach podman as written, so an image that asks for /proc/* is never widened into ALL. ALL is fussier than it looks: podman lifts the read-only paths (making /sys/fs/cgroup writable) only for that exact uppercase spelling, and only when it comes first, so outrig requires it to be the list’s sole entry rather than accept a spelling that half-works. This turns a hardening flag off. Podman hides /proc/acpi, /proc/scsi, /proc/keys, /proc/kcore, and the rest of its masked set behind read-only tmpfs mounts and /dev/null binds; unmasking /proc/* puts all of them back. Inside a rootless container in a user namespace these are read-only to an unprivileged user and mostly uninteresting, but that is a reason the exposure is usually tolerable, not a reason it is absent.

Nested container runtimes

The motivating case for all three is a nested container runtime – an agent whose job is to build an image or run a throwaway container. The measured recipe, against podman 5.7 on Linux 7.0:

[images.coding.security]
cap-add = ["SYS_ADMIN"]
devices = ["/dev/fuse", "/dev/net/tun"]
unmask  = ["/proc/*"]

Each line is load-bearing, and removing any one of them breaks the inner podman run:

  • unmask = ["/proc/*"] is what the kernel’s “fully visible” procfs rule demands. A process in a non-initial user namespace may mount a fresh procfs only while the /proc already in its mount namespace is unobstructed, and podman’s masking mounts are an obstruction. They are created by a more privileged namespace and locked, so the container cannot unmount them itself, whatever capabilities it holds. Without this the inner create fails with crun: mount `proc` to `proc`: Operation not permitted.
  • cap-add = ["SYS_ADMIN"] because the capabilities available in the inner user namespace are bounded by the outer set. Without it: crun: sethostname: Operation not permitted.
  • /dev/fuse because the session container’s rootfs is overlayfs and the kernel refuses overlay-on-overlay, so the nested runtime needs fuse-overlayfs.
  • /dev/net/tun because pasta, podman 5’s default rootless network backend, opens it to build its tap device. A container passing through only /dev/fuse gets as far as pulling the inner image and then fails to configure its network.

no-new-privileges is not in that list, and does not need to be. The usual claim is that a nested rootless podman calls setuid-root newuidmap to map its subordinate UID range, so no_new_privs must be cleared. Under --userns=keep-id that never happens. The primary’s user namespace is created by the host user, so its owner is inside-UID 1000 rather than 0; the kernel grants capabilities in a user namespace only to a process whose effective UID is that owner, so execveing newuidmap to euid 0 gains nothing and fails anyway. With no /etc/subuid entry, podman instead takes its rootless single-mapping path, which creates the namespace with a plain unshare – making the caller its owner – and never invokes newuidmap at all. That is the path the recipe above runs on, with --security-opt=no-new-privileges still applied.

The corollary is a trap worth naming: adding /etc/subuid and /etc/subgid entries to the image breaks nesting, because podman then takes the newuidmap path and hits the failure above. Leave them out.

outrig supplies the primitives; assembling them into a working nested runtime is yours to do. Your Dockerfile still has to install podman, fuse-overlayfs, and passt, select the overlay storage driver with mount_program = "/usr/bin/fuse-overlayfs" and ignore_chown_errors = "true", and set default_sysctls = [] in containers.conf.

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 build context. When the context is in a git repo, that means every file in it that .gitignore does not exclude, committed or not, by path, permission bits, and content, and every directory it does not exclude, empty ones included, by path and permission bits. If .gitignore matches the context directory, or every file in it, committed or not, then every file counts: the rules say nothing about which of them the build uses. Outside a git repo, it is a tarball hash of the whole directory. 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. Either command shows the build as it runs, as [buildah] lines on stderr ([podman] lines for a pull), so a rebuild is visible rather than a pause before image ready. To force a rebuild without changing files, run outrig build --no-cache. In a git repo, unless .gitignore excludes the context whole, a file it excludes does not count when the Dockerfile copies it by its own path, so run outrig build --no-cache after changing one. A symlink counts by what it points at, ignored or not, since copying the link copies that.

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"  # ~ is your home directory
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 – outrig never pulls one. A ref that is not local is refused with image "<ref>" (from [sidecars.<sc>]) is not an [images.<name>] ... and the podman pull <ref> to run first; an inline image on an MCP entry reads (from the inline image of MCP server "<name>"). An optional [sidecars.<sc>.security] block reuses the whole security surface of the primary – capability keys, no-new-privileges, devices, and unmask 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 four cleanup layers – explicit stop, the name guard, Drop, and the panic sweep – cover every container. The panic sweep acts only on a panic that ends outrig, never on one the session carries on from, such as a subagent round or an MCP connection whose task panicked. In addition, sessions with sidecars run a podman events 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. It reaps by org.outrig.instance, a per-container label whose value is unique to the session, rather than by container name – a sidecar dies at the same moment the primary does and --rm frees its name on the spot, so a removal that resolved a name a moment later could reach whatever had taken it. 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.

The start guard is the layer that covers cancellation. Between the moment a container name is reserved and the moment outrig holds a handle for the container, there is no handle for Drop to run on – so a start that is abandoned partway (a timeout, a cancelled task, an error) used to leave both the podman client and, potentially, the container itself. The guard owns that window instead: it is armed before the first podman command and removes the container unless a handle takes over. The same shape covers an image build’s temporary tag and the outrig-label-<pid>-<nonce> working container the label-stamping pass creates, neither of which any sweeper knows the name of. Both are outrig’s own: it chose those names, and no other build can share them.

It removes by a per-attempt label, not by the name it asked for. --name can fail because the name is already in use – another session, a stray, a container you made yourself – and that is an ordinary outcome, not a reason to delete whatever holds the name. So each attempt stamps a fresh org.outrig.attempt value on the container it asks for and removes by that: a create that collided made nothing carrying it, and a cleanup still in flight cannot reach a container you have since started under the same name. Labels are part of the creation request, so there is no instant in which the container exists without the mark that identifies it.

Every layer uses that selector, not just the start guard. The panic sweep used to hold container names and remove by them, so a panic arriving while a start had collided would delete the container that already held the name – one outrig never created. It now replays each outstanding attempt’s label-scoped removal instead, and it tracks those attempts separately, so two starts asking for one name are two obligations and neither can cancel the other’s. No layer that covers a container for the lifetime of the session that made it – stop, start guard, Drop, panic sweep, sidecar reap – addresses that container by name any more. outrig clean still does, and necessarily: a stray belongs to a process that is gone, so the per-attempt label it was started with died with the outrig that chose it.

A cancelled build is asked to stop rather than killed, and it is the only command that is. buildah creates a working container per stage, and those are not outrig’s to name: buildah derives the name from the base image, offers no way to label one, and buildah containers --filter selects only on id, name, and ancestor. So there is nothing to arm a guard against – a removal scoped to <base>-working-container would be a removal by a string two concurrent builds from one base both answer to.

The stop is what resolves that. buildah installs a signal handler while a RUN instruction’s command is executing; a SIGTERM there fails the step, and the build unwinds through its own stage cleanup, removing each working container by the id only it holds. Attribution becomes buildah’s, which is the one place perfect knowledge of it exists. outrig waits a bounded grace and escalates to SIGKILL if the stop is refused, so a wedged build is still terminated.

Three limits, stated rather than implied. Outside a RUN – during a pull, a COPY, the commit, or the seam between two RUNs – no handler is registered and buildah ends where it stands. The grace is only worth something while outrig’s own runtime is alive; a process on its way out escalates immediately. And a stopped child has to be able to write: outrig holds the read ends of its pipes open until the reap, because a Go program that gets EPIPE on stdout or stderr dies of SIGPIPE on the spot – which was exactly the abrupt ending the stop was trying to avoid. What the first two limits leave behind is collected by outrig clean --build-containers; see Sessions -> outrig clean.

Abandoning a call also terminates the podman client it was waiting on. The signal is sent synchronously, as the call is abandoned; the process is reaped shortly after, without the caller doing anything further. Note what that does not mean: killing a podman exec client does not stop the command running inside the container, which conmon supervises in the container’s own namespaces. Removing the container is what stops that, which is why the guard exists rather than just a kill.

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. When podman’s keep-id mapping already planted the entries, nothing is appended, but the planted user entry’s home – the container’s working directory – is rewritten to /home/<user>. 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, --device=<entry> flags only when it declares devices, and --security-opt=unmask=<path> flags only when it declares unmask. Session containers additionally carry the org.outrig.session label (and sidecars org.outrig.sidecar). Every container gets --image-volume=ignore, so its image’s VOLUMEs create no volumes; see Create the directory a VOLUME names. Every container also gets --http-proxy=false, so podman copies none of the host’s proxy variables into it; see The container’s environment.

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.

Its stderr normally reaches <session_dir>/logs/<server>.stderr through podman start --attach, which can drop all of it for a server that exits within milliseconds of starting. So that a server that fails at once still says why, outrig creates such a container with --log-driver=json-file, an empty --log-opt=tag=, a --log-opt=path= naming a private file of its own (under $XDG_RUNTIME_DIR, else the temporary directory), and --log-opt=max-size=1mb. When a start fails and the stderr file is empty, outrig copies the container’s stderr from that file into it. The log replaces the host’s default driver for this container – journald on most systemd hosts – though podman logs still works while it runs, and it goes away with the session. The driver and the empty tag keep the host’s log_tag from containers.conf off the container, since conmon refuses a tag with a file log. json-file is podman’s alias of k8s-file, and podman before 6 gives the host’s tag to every driver but that one. The empty tag counts as one already given on podman 5.5.1 and later, which matters on podman 6, where json-file gets the host’s tag too and a tag on any driver but journald is an error. Where SELinux is enforcing, or the log’s path holds a ,, " or :, outrig makes no log and the container logs as the host says.

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.

The container’s environment

A container’s environment is its image’s ENV, the container and HOSTNAME podman sets in any container, the HOME outrig sets, and the env entries the config and outrig run --env give its MCP servers. Nothing is copied from the host’s own environment unless your containers.conf sets env_host and asks podman to copy all of it. That includes the proxy variables podman otherwise copies from the environment it runs in – http_proxy, https_proxy, ftp_proxy, no_proxy, and their uppercase forms – because a proxy URL can carry a user:password@ that everything the agent runs could then read. outrig passes --http-proxy=false to every container it starts.

Behind a proxy, name it on the servers that need it. A ${VAR} reference takes the value from your environment when the server starts:

[images.coding.mcp.shell]
command = ["bash", "-lc", "exec shell-mcp-command"]
env     = { HTTPS_PROXY = "${HTTPS_PROXY}", NO_PROXY = "${NO_PROXY}" }

A repo config may reference a host variable only if the global config lists it, so for the config above ~/.outrig/config.toml needs the two names under [repo]:

[repo]
env-allow = ["HTTPS_PROXY", "NO_PROXY"]

outrig run --env 'HTTPS_PROXY=${HTTPS_PROXY}' does the same for every server in one session, single-quoted so your shell leaves the reference for outrig, and needs no entry: you typed it. See the value syntax for how each form reaches podman.

A proxy named this way is the agent’s to read, credentials included: every process the server starts inherits it. Under a filter network policy, traffic sent through the proxy reaches the interceptor as a connection to the proxy, so the policy rules on the proxy’s host and port rather than on what lies behind it – allowing the proxy allows whatever it will reach. A Dockerfile ENV works for a proxy that needs no password, but it is baked into the image, so keep credentials out of it.

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. A ${VAR} value is passed by name, off podman’s command line, and outrig shows it only as the reference; see the value syntax. In a repo config a ${VAR} may name only a variable the global config lists in [repo] env-allow.

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 the tools the server exposes (see Tool name prefixing for the length that prefix holds to).

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.

/etc/ld.so.preload is empty for the payload. glibc’s loader reads that file on every start, by that absolute name, and no loader option turns it off. After the join it is the primary’s file, so a primary that ships one – for an allocator, say, or a shim – would have its library loaded into the sidecar’s server before main, with the server’s environment and network, and one built for a newer glibc than the sidecar’s would stop the server from starting, with a loader error naming a file the sidecar never had. So when the payload’s loader is not musl’s, the launcher mounts /dev/null over the file in the private namespace it already holds, and the loader finds it empty. The primary keeps its own file. The mount is in the payload’s namespace, though, so everything the server starts sees the file empty too, the primary’s own binaries included: they do not preload what the primary configured. That holds only while the primary leaves the file alone. The mount covers the file as it is when the server starts, and the kernel drops a mount whose file is renamed over or deleted, so when the primary replaces the file later – with sed -i, or by deleting it and writing another – the server’s later children read the replacement, and a glibc one preloads what it lists. A write in place keeps the mask. A file the primary creates after the server started is never masked. The server itself read the file once, at its start, and is not affected. A musl loader has no such file and a static payload has no loader, so none of this applies to either.

The engine must be local. The launcher binds the primary’s /proc/<pid>/ns from this machine, so the podman engine has to share this machine’s kernel and PID namespace. A socket on this host works; a remote engine (another host, a VM, another container) is refused up front.

A dynamic payload cannot re-execute itself. The kernel looks for a binary’s loader (its PT_INTERP) in the process’s own root, which after the join is the primary’s rootfs – where the sidecar’s loader is absent or is a different one. So the launcher does not exec a dynamically linked payload directly; it runs the sidecar’s loader explicitly, as /mnt/lib/ld-musl-x86_64.so.1 --library-path /mnt/lib:... /mnt/usr/local/bin/node .... The payload then gets wrong answers to the questions a self-re-exec asks: /proc/self/exe names the loader, so executing it runs a loader with no program, and argv[0] is the server’s path under the graft – a file that exists but that the kernel cannot run by name, because it goes looking for the loader in the primary. Failures are oblique. Python’s subprocess.run([sys.executable, ...]), and so multiprocessing’s spawn and forkserver, get ENOENT for a file that is plainly there; Node’s child_process.fork() runs process.execPath, which is the loader, and the loader tries to load the script as a program; a Go os.Executable() or Rust current_exe() re-exec fails the same way.

A static payload has no loader and is exec’d from an open file, so executing /proc/self/exe itself – the idiom of container runtimes – does re-run it. What fails are the paths: none of the sidecar’s rootfs is grafted for it, so argv[0] names a file the view does not have (ENOENT), and what /proc/self/exe reads back as – which is what os.Executable() and current_exe() hand over – is /, which cannot be executed.

A server that does its work in one process – the quickstart’s filesystem server – is unaffected. One that starts copies of itself cannot take view = "primary" unless it is a static binary that executes the literal path /proc/self/exe; run it as an entrypoint sidecar in its own image, with the project mounted through workspace instead.

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.

“The image” there is the one the primary container is running, addressed by the engine’s id for it, not whatever the tag it was started from names at the moment of the read. A tag is a pointer and a pull of a re-pushed ref moves it, while every container already started from the old image goes on running that one. So the merged servers are always ones the container can serve, even when outrig mcp --attach borrows a container started before the last pull. A repo-local build image is named by a content hash, which no pull can re-point.

A label entry may carry call-timeout-secs, since the image’s author knows how long its tools run (see What if a server hangs?). The placement keys and args are rejected in a label.

A ${VAR} in a label entry’s env is read from your environment and handed to the server. The label is text the image’s author wrote – a Dockerfile in the repo, or an image the repo names – so it is held to the same [repo] env-allow list as a repo config’s references. A reference to a variable the list omits is refused when the session starts, naming the server, the key, the variable, and the image; the variable is never read. A server your config declares by name replaces the label’s entry and is not held by this. An image of your own that references a variable needs it listed to start.

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 errors because they mean the image metadata is broken: a startup error for an image outrig did not build, and a build error for a repo-built image, whose build reads the label it inherited before it merges [images.<name>.mcp] into it.

That build error names the image-config (image "coding" embedded config: ...) and ends with the way past it. The label comes from a LABEL in the image’s Dockerfile or from its base image, so replace it in the Dockerfile, after the FROM:

FROM registry.example.com/shared/tools:3
LABEL org.outrig.mcp='{}'

An empty table declares no servers, so the build keeps only what [images.<name>.mcp] adds. Set a corrected value instead to keep the servers the base image meant to declare.

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 – including one that stops answering – outrig run reports the error on stderr, with the server’s stderr so far, 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 prefixes every tool with its server name, as far as the 64-character limit on a name allows (see Long server names):

fs__list_directory
fs__read_file
fs__write_file
shell__exec
build__cargo_check

The separator is __ (double underscore). A combined name that already fits OpenAI’s ^[a-zA-Z0-9_-]{1,64}$ constraint and splits back into its pair – its first __ is the separator – is advertised exactly as written, which is the ordinary case. Any other name is lossy, in one of three ways: a character outside the set had to become _, the name was longer than 64 characters, or the separator was ambiguous because the server name itself ended in _ or contained __. A lossy name is truncated to fit and tagged with a stable hash suffix over the (server, tool) pair, so two tools that would otherwise collapse onto one name stay distinct:

ServerToolAdvertisedWhy
fsread_filefs__read_filealready well-formed
fsread/filefs__read_file_5d2270/ had to be replaced
fsread filefs__read_file_4e31b7so did the space

The suffix is derived from the server and tool names alone, so an uncontested name is the same in every session and a client may cache it across reconnects. Sixty-four characters cannot encode an unbounded pair of names, though, so two tools can still land on one name – two suffixes agreeing, or a tool whose own name already ends the way a suffix does. outrig detects that, widens one side’s suffix until the two names differ, and logs both identities.

Which side moves is decided by the two tools’ identities, not by the order a server listed them in, so a restarted server that relists its tools differently cannot hand a name a client cached to a different tool. A widened name does depend on the rest of the session’s tool set, so that one is not cacheable across a change to the servers in play.

The LLM sees fs__write_file in its tool list and emits tool calls under that name. outrig’s router looks that name up in a table it built at startup and dispatches to the matching MCP client under the original tool name. It does not recover the pair by splitting the string, so a suffixed name routes like any other.

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

Long server names

The server-name rule bounds spelling, not length, and a rewritten name is cut at a fixed length without regard for where the server name ends, so the prefix survives a rewrite only while the server name leaves room for it. At the default six-character suffix, a rewritten name for a server whose name is:

  • 54 characters or fewer reads <server>__<start of the tool>_<hash>.
  • 55 characters keeps <server>__ and none of the tool’s name.
  • 56 characters keeps half the separator, so it reads <server>__<hash>.
  • 57 characters or more is cut inside the server name: the first 57 characters of it, _, and the hash, with no __.

A widened suffix leaves less room, so a widened name loses the prefix at a shorter server name. A name advertised as written is not affected, and begins <server>__ in full. Routing is not affected either, since a name is looked up whole rather than split. What suffers is legibility: a person or a model reading the tool list cannot tell which server hosts such a tool, which is why outrig mcp tells its client to treat names as opaque and call each exactly as listed. Keeping server names to 54 characters or fewer avoids it for every name outrig does not have to widen.

Protocol revisions and list caching

Both servers outrig exposes – the session proxy (outrig mcp) and the self-description server (outrig mcp self) – advertise an explicit list of MCP protocol revisions rather than deferring to whatever the underlying SDK happens to know:

RevisionRequested in
2024-11-05initialize
2025-03-26initialize
2025-06-18initialize
2025-11-25initialize
2026-07-28each request’s _meta

The list is pinned deliberately. An SDK upgrade that teaches the library a newer revision would otherwise widen what outrig agrees to speak without anyone checking that outrig actually meets the new revision’s requirements – and a revision can add mandatory response fields, which turns a silent widening into a client that cannot load any tools at all.

2026-07-28 replaced the initialize handshake with metadata on every request: a client speaking it names the revision in each request’s _meta and is answered in it. An initialize asking for anything the handshake cannot grant – a revision outside this list, or 2026-07-28 and later – is answered in 2025-11-25, the newest revision that has a handshake. Note that this is a fallback rather than a step down: a client asking for something older than anything listed is answered in a newer revision, not an older one. A revision named per request has no fallback: one outside the list is refused with an error that lists the revisions served.

From 2026-07-28 onward, list results must carry cache metadata (SEP-2549): how long the result may be treated as fresh, and who may cache it. Both servers tag tools/list accordingly:

outrig mcp self
cacheScope: public. The tool set is compiled into the binary, so it is identical for every user of a given outrig version and any intermediary may cache it.
outrig mcp
cacheScope: private. The tool list is the union of this session’s backing servers, which depends on the active config, image-config, and any --env overrides – not shareable.

Both declare a five-minute freshness window. Neither tool list can actually change mid-connection (the table is built once at startup, and no listChanged capability is advertised), so a longer window would still be truthful; five minutes is chosen so that a config edit or a rebuilt image is picked up promptly on the next session.

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.

What if a server hangs?

Every request outrig sends a server has a deadline, so a server that stops answering costs a bounded wait rather than the session:

  • initialize and tools/list get 120 seconds each. A server that misses either fails startup the way a crashed one does, with the stderr it has written so far. A tools/list whose pages would never end – one that hands back a nextCursor it already sent, or is still paging after 1,000 pages – fails startup the same way, without waiting out its 120 seconds.
  • tools/call gets the server’s own call-timeout-secs, else the top-level mcp-call-timeout-secs, else 10 minutes. A call that runs past it is cancelled at the server with notifications/cancelled, and the model gets a timeout error in place of a result. The session goes on, and the server stays connected.

A call outrig stops waiting for early is cancelled the same way: one in a turn interrupted with Ctrl-C, or one whose outrig mcp client sent its own cancel. Cancellation is advisory – the MCP spec lets a server finish the work anyway – so a tool with side effects may still complete them.

Raise call-timeout-secs on a server whose tools legitimately run long, such as a build or a test suite, rather than the session-wide default; see Reference -> Config.

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.
  • Sessions – outrig 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. Installing and removing that policy runs nothing from a container’s image, either: outrig rewrites and restores the container’s /etc/resolv.conf itself, so a hostile image gets no foothold outside its own namespaces while policy is being put in place.

Part of the config that shapes a session is inside the boundary. .agents/outrig/config.toml lives in the workspace, which is mounted read-write, so an agent can edit it in one session and have the next session load the result. That is why what reaches the host’s secrets stays global-only: a provider carrying an api-key – a reference to a variable in the operator’s environment, paired with the base-url it is sent to – is refused in a repo config, as the [network] policy keys are. A repo config may still name global providers from its models. A repo config file may reference a host variable, in build-args or an MCP server’s env, only if the global config lists it in [repo] env-allow: the value goes to code the repo controls, so a variable on that list is the repo’s to route. For the same reason, a session whose workspace would contain the global config is refused, whether a repo config declared that workspace or outrig picked it; the global config’s own [workspace] and --volume are the operator’s choice.

A repo can also reach the host through an image. An org.outrig.mcp label is not a config file, but a reference in a server’s env there is read from the host the same way, so the label is held to the same env-allow list. It is text the image’s author wrote, whether that is a Dockerfile in the repo, which the agent can edit, or an image the repo names.

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. The one exception is a reused user entry’s home, covered below. /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. --userns=keep-id usually gets there first: podman appends an entry for you before the bootstrap looks, and that is the one reused.

A reused user entry keeps its name, but its home is changed to /home/<name> if it names another, so getpwuid(), ~<name>, and su - agree with $HOME. podman’s own entry names the container’s working directory, which for the primary is /workspace – your checkout, where a tool that finds its home that way would leave its caches. Only that field changes. The file is truncated where the entry starts and the entry and the lines after it appended back, so it keeps its owner and mode, and nothing before the entry is rewritten. That rewrite is not atomic, so it is done only in a container outrig has just started and nothing else is using yet. A container borrowed with outrig mcp --attach keeps its entry as it is. The name has to work as a directory under /home: an entry named ., .., or anything with a / in it fails the bootstrap with an error naming the entry, and nothing is written or created.

A /home/<user> the image already has is reused too, and chowned to you, as long as it is a directory. Unlike mkdir -p, anything else there – a file, a FIFO, or a symlink, even one to a directory – fails the bootstrap with an error naming the path. Taking it anyway would hand every tool a $HOME it can’t write under, and a symlink would redirect the chown to whatever it points at. /home itself may be a symlink to a directory; only the last component is held to this.

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. Its container-path must be absolute and cannot be /.

outrig never picks your home directory on its own. With host-path undeclared, the workspace is the repo root – the directory holding .agents/outrig/, or the current directory when no repo config is found – and a session refuses to start when that is your home directory or a directory above it, such as / or /home. Running from ~, an MCP client that starts outrig in /, or a stray ~/.agents/outrig/config.toml would otherwise put ~/.ssh in the container. To mount your home directory on purpose, declare it in the global config, for example host-path = "~".

Nor does it pick a directory that contains your global config. The workspace is read-write, so an agent working in one could rewrite that file – a provider’s api-key, the [network] policy, session-root – for the next session. A session therefore also refuses to start when an undeclared workspace contains the global config file, or a directory its path is looked up through (a symlink or a missing component there could be swapped to redirect it). Running from ~/.outrig with no repo config above it does this, as does running from a project that holds the file --global-config names. Run from a project directory that does not hold it. To give the agent the global config on purpose, pass --volume for it, or declare host-path in the global config, which sets the workspace of every session whose repo config declares none.

Extra workspace.mounts entries are for supporting directories: sibling repos, generated docs, SDK checkouts, model artifacts, or caches. A relative host path resolves against the repo root when the repo config declares it, and beside the file that declares it otherwise – the global config, or a --config file kept outside .agents/outrig/; a leading ~ is your home directory. A --volume on the command line is declared in no file, so its relative host path resolves against the directory you ran outrig from, as your shell would read it. See path resolution. 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.

No path in a bind mount may contain :, host or container, here or on a sidecar: podman reads a mount as <host>:<container>[:<options>], so a : in a path makes a fourth field it refuses at launch – long after the config was read, naming neither the key nor the file. Config validation refuses what a config spelled, and a session that would mount a path resolving through such a directory – a repo root with a : in its name, say – is refused as it starts. There is no escaping form: a host directory whose name contains : cannot be mounted.

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 TCP connection, and one per name lookup, to <session_dir>/logs/network.jsonl. Audit mode is allow-and-log only: every connection and every lookup 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. The interceptor carries TCP and DNS over UDP/53; any other datagram leaves by podman’s default route and is not recorded.

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. Where both match, a deny entry wins over an allow entry, and a connection neither matches uses 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.

Entries match TCP connections. Anything else the container sends – UDP to any port but 53, which is how QUIC and so HTTP/3 travel, ICMP, any other transport – matches no entry and takes default directly, so a deny default stops it. Such a datagram never reaches the interceptor and writes no network.jsonl record; see Reference -> Config for what is carried and what the sending tool sees.

Name lookups, which are UDP/53, are decided by the name asked for, since a name travels to whoever serves its zone. Under a deny default the container resolves only the names an allow hostname entry matches, and an address or CIDR entry opens none; a refused lookup is answered REFUSED without a resolver being asked. Every lookup is recorded either way. See Reference -> Config.

A hostname entry in allow grants only against a destination outrig resolved to that name itself, through the container’s own lookup at the interceptor’s DNS listener. A name the container writes into a Host: header or a TLS ClientHello is a claim rather than evidence: it can match a deny entry, but it cannot authorize an address outrig never resolved to that name. A glob with no letter in it (*, 10.0.*) is matched against the destination address instead, and never against a name. An allow entry with no such binding does not match, and evaluation falls through to the remaining entries and then default. This narrowed in 0.2.0; see Reference -> Config for what an existing allowlist now permits.

Two limits follow. A hostname deny entry cannot stop a container that reaches an address without a lookup here and announces no name, and a hostname allow entry grants the address its name resolved to, so any other site served from it. See Reference -> Config.

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 – either a named identifier living on one provider (e.g. gpt-4o-mini on openai), or an alias naming one or more other models.
  • 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"]
        al["[models.cheap]<br/>alias=[fast, smart]"]
    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
    al -. "1st" .-> m1
    al -. "2nd" .-> m2
    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 in your global config – 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, in either file. A repo config may not declare a provider that carries an api-key; see Where things live below.

[models.<name>]

A model entry does one of two things: it picks a specific identifier on a specific provider, or it aliases one or more other models.

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

Aliases

That hop is spent on the wire identifier, which leaves two things it cannot express: a name for a name, and a name for a set of equivalents. An alias entry covers both.

# A name for a name. `opus-5` stays pinned for reproducing a result while
# `opus` floats to whatever you consider current.
[models.opus]
alias = "opus-5"

# A name for a set of equivalents: the same weights sold by three vendors,
# in preference order.
[models.smart]
alias = ["opus-5-bedrock", "opus-5-anthropic", "opus-5-azure"]

An alias is a model. It sits in the same table, and anything that takes a model name takes it: --model, default-model, [agents.<name>].model, and a subagent’s model argument. Aliases may name aliases; the graph flattens depth-first in config order, keeping the first occurrence of a repeated name. A cycle is rejected when the config loads.

An entry sets alias or the provider-shape fields, never both. See Reference -> config for the full rules.

Which candidate gets picked

When an alias names several models, outrig picks the first one this build can actually reach: its provider is defined, its style is one the binary has a client for, and its api-key variable is set and non-empty. The choice is made once, when the session starts, and the banner prints the hop it took:

[outrig] agent:             coding (model: smart -> opus-5-anthropic / provider: anthropic / ...)

That is what lets one committed config serve a laptop with ANTHROPIC_API_KEY set and a CI runner holding a Bedrock role, without editing default-model per machine or keeping divergent configs.

Be clear about what this choice is. Building a remote client performs no network I/O, so selection answers “am I configured for this” and not “is this endpoint up”. If every candidate is unreachable, the session fails at startup naming each one and why it was skipped.

Which endpoint is working is settled at runtime instead, by failover – see Failover between candidates.

Failover between candidates

Selection picks a candidate before the session starts, which cannot know that a vendor will rate-limit an hour later. So when an alias names more than one candidate, the order is also a runtime fallback: a model call that fails against candidate one is retried against candidate two, and outrig says so as it happens.

[outrig] model opus-5-bedrock failed (HTTP 429 Too Many Requests); trying opus-5-anthropic

A move happens when a candidate’s own retry loop has given up – so everything under Transient failures is tried against that endpoint first, and failover is what happens after. It also covers failures no retry would fix: a revoked key answers 401, which is final for that vendor but says nothing about the next one, so the chain tries it rather than ending the session.

When every candidate has failed, what happens next depends on why. The message names each candidate with its own reason either way:

  • At least one failed recoverably – a rate limit, an unreachable host, a response that could not be used – and the turn ends. If that was the turn’s first model call nothing was appended to the history, and sending the prompt again retries it; a later call keeps the tool calls the turn already ran, and continue picks up from them (see outrig run). One vendor rate-limiting while another’s key is revoked lands here too: the rate limit is the reason that can lift on its own, so it is worth waiting out.
  • Every one was terminal – a revoked key answering 401 at all three vendors, say – and the session ends, exactly as that failure ends it for a single model. No resend can satisfy a prompt whose credentials are refused everywhere, and advising one would loop forever.

Two properties worth knowing:

  • The whole chain is bounded by one retry-budget-secs, not one per candidate. Three candidates at the ten-minute default is a half-hour turn against a total outage, most of it spent on endpoints already known to be down, so the budget is shared rather than repeated. The value is the one on the first selectable remote candidate’s provider; retry-budget-secs on any later candidate’s provider is not consulted. A chain spanning providers that disagree has no single right answer, and the head of a preference order is the defensible one – so a 0 there disables retries for every candidate, and reordering the alias can change which budget governs.

    In-process candidates are skipped when finding it, because style = "mistralrs" has no retry-budget-secs to give – it does no HTTP, so it has nothing to retry. An alias whose head is a local model takes its budget from the first remote candidate after it, which is the one the setting can actually be configured on. A chain of only local candidates has no budget at all, and needs none.

  • Every model call starts again at the head of the list. The order is a preference, so one rate-limit window does not demote candidate one for the rest of the session.

A move happens between model calls, never around a whole turn, so tool calls the turn already ran are not re-executed – a turn that fails on its fifth model call has already run the tool calls from the first four, and those stay done.

The cost is that one reply can be half one model’s work. That is why a move prints, why the banner lists the fallbacks a session may reach before it starts, and why a subagent’s transcript records which models answered a round that left the first candidate.

The history travels with the chain, with one exception: an Anthropic candidate is sent only the reasoning Anthropic issued itself. It refuses anyone else’s – a local model’s, or an OpenAI-style endpoint’s reasoning_content – so the turns another candidate answered reach it as their text and tool calls alone.

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

Reasoning an endpoint returns beside its reply – in reasoning_content, or OpenRouter’s reasoning – goes back to it on that turn’s assistant message in later requests. A turn that produced nothing but reasoning, usually one cut off at the output-token ceiling, goes back as an assistant message with empty text, so the conversation keeps alternating between user and assistant.

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 answers and then stops, timing out or losing its connection mid-request – 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. So is a call the HTTP client refuses to make – a redirect loop that reaches its limit of ten hops, or a URL it cannot send to – because each replay is refused the same way.

A call that never reaches the endpoint at all is retried on a much shorter leash. A refused connection, an unresolvable host, or a TLS mismatch usually means the address is wrong rather than busy, and a wrong address does not come right in ten minutes – so while no attempt has got bytes back, the loop is bounded by 30 seconds instead. That is still long enough to ride out a gateway that is restarting, which is the case worth waiting for. The short bound is fixed and not configurable, unlike retry-budget-secs; it is a property of how long a restart takes rather than a preference. Setting retry-budget-secs below it, or to 0, still wins: the loop always takes whichever bound is shorter.

Getting connected is bounded on its own, at ten seconds per attempt, which is what makes that leash real. A host that answers a connection attempt with silence rather than a refusal – a firewall dropping packets, say – would otherwise hold one attempt open for the whole request-timeout-secs and reach the short bound only afterwards. Note what this does not bound: once connected, waiting for the response is request-timeout-secs’ business and nothing else’s, because a non-streaming completion answers only when the model has finished, so a long wait for the first byte is what a long reasoning turn looks like from outside.

Both budgets bound the retrying and not an attempt already in flight, which is the same rule retry-budget-secs has always followed. An attempt is never cancelled by the clock running out; the loop simply declines to start another. So an address that never answers is settled in about 30 seconds and at worst 40 – the bound, plus one last connection attempt that had already begun. A retry-budget-secs shorter than ten seconds does not shorten the connection cap either; it just buys no retries.

The distinction is per request, not per turn, and it latches: once a call has produced a response, the full budget applies for the rest of that request. A call that connects, gets a 503, and then cannot reconnect for its retry is a provider having a bad minute – not an address that was never right – and it keeps the full budget. The practical effect of the split is that a typo in base-url’s host ends the turn in seconds, naming the connection failure, rather than after ten minutes of retry lines. A base-url that is not an http:// or https:// URL does not get that far: the config is refused when it loads, naming the key.

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 full budget, so retry-budget-secs = 0 switches off both layers – and the short connect bound never applies here, since a 200 OK is by definition an endpoint that answered. 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.

Every line above is printed by a layer beneath the agent, so when the call belongs to a subagent it carries that subagent’s label, in the column its tool-call traces use:

[outrig]   [audit] model opus-5-bedrock failed (HTTP 429 Too Many Requests); trying opus-5-anthropic

Without it a fan-out’s retries and chain moves arrive among the parent’s traces naming no one. The primary agent’s lines are unlabeled, as they have always been.

If the budget or the attempts do run out, the turn ends and the REPL prompts again. The conversation keeps the tool calls the turn had already run, with their results, and is otherwise 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)

Deprecated. style = "mistralrs" and the local-llm build feature are deprecated and will be removed in a future release. Run the model under an OpenAI-compatible local server (Ollama, vLLM, llama.cpp) and use a style = "openai" provider with a localhost base-url instead – see Migrating off the in-process provider. Existing configs keep working unchanged for now.

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 any other value – a literal key, a missing ${...} wrapper, anything. A repo config holding one fails to load; in the global config the provider holding one is left out, with a warning that names the provider but not the value. Either way the value is never sent. This guarantees:

  • No config file holds key material, so none of them can leak it.
  • 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.

The reference is resolved on the machine running outrig, and the provider’s base-url says where the result is sent. That pairing is the operator’s to make, so a provider that carries an api-key may be declared only in the global config: a committed .agents/outrig/config.toml cannot name your key, and so cannot redirect it. (The repo file sits inside the read-write workspace, where the agent could edit it for the next session.)

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>]home for every keyed providerstyle = "mistralrs" only
[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. Providers are the one map with a repo-side restriction: a repo config may declare only providers without an api-key, so a keyed provider is always the global file’s, base-url included, and a repo [models.<name>] names it. A repo config that declares a keyed provider is rejected at load.

[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

Deprecated. The local-llm feature and the style = "mistralrs" provider are deprecated and will be removed in a future release. Nothing is removed in this one: a build with --features local-llm still works, configs still parse and validate, and no key has changed spelling. The removal can only land in a release after the one that first carried this warning. New setups should run the model under an OpenAI-compatible local server – Ollama, vLLM, or llama.cpp’s server – and point a style = "openai" provider at its localhost base-url. See Migrating off the in-process provider below.

Running a local model well is a problem with good dedicated tools, and outrig was a worse place to solve it than any of them: the in-process backend roughly tripled the dependency count, and outrig’s own configuration surface duplicated knobs (weights, quantization, device selection) those servers already expose better. This page keeps the original rationale below, including the part this deprecation retracts – see What this retracts.

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.

Migrating off the in-process provider

The replacement is a local server speaking the OpenAI wire format, which every tool in this space now does. Start the model however that tool wants – note the tool-calling flags, which outrig’s agent loop needs and none of these servers turn on by default:

ollama serve                 # then: ollama pull qwen3:4b
# or: vllm serve Qwen/Qwen3-4B --enable-auto-tool-choice --tool-call-parser hermes
# or: llama-server -m ./qwen3-4b-q4.gguf --port 8080 --jinja

Then replace the bare mistralrs provider and its weight-bearing model row with an ordinary remote pair. The weight fields (model-id, model-path, model-file, revision, context-length, device) have no counterpart here on purpose – the server owns all six, and that is the point of the move:

# Before -- deprecated:
[providers.local]
style = "mistralrs"

[models.local-fast]
provider   = "local"
model-id   = "microsoft/Phi-3-mini-4k-instruct-gguf"
model-file = "Phi-3-mini-4k-instruct-q4.gguf"

# After -- in your global config, since a provider with an api-key may not live in the repo file:
[providers.local]
style    = "openai"
base-url = "http://127.0.0.1:11434/v1"   # Ollama's default; vLLM 8000, llama-server 8080
api-key  = "${OLLAMA_API_KEY}"           # see the note below

[models.local-fast]
provider   = "local"
identifier = "qwen3:4b"                  # whatever name the server serves it under

The [models.<name>] key is yours. Keep whatever the old row was called and every [agents.<name>].model reference to it keeps working – only the row’s contents change.

Three wrinkles worth knowing before you hit them:

  • The model must support tool calling. outrig’s loop needs the model to emit tool calls in the provider’s native format, and not every local model does. Ollama’s phi3 has no tools capability and rejects outright a request that carries any; vLLM emits no tool calls at all without --enable-auto-tool-choice --tool-call-parser <parser>; llama-server needs --jinja to apply the template that produces them. Pick a tool-capable model (qwen3, llama3.1, mistral-nemo) and pass those flags – see Tool calling for the symptom and a one-shot test. A session with no MCP servers and subagents disabled sends no tools at all, so a model without the capability still answers plain prompts there; it just cannot run an agent.
  • api-key is still required, and must still use the "${VAR}" form – outrig refuses a literal. Local servers generally ignore the value, so export any non-empty placeholder (export OLLAMA_API_KEY=unused). An unset or empty variable makes the model unselectable, which is a deliberate rule and not a bug: see Providers, Models, and Agents.
  • --device does not apply. Device placement moves to the server’s own flags (CUDA_VISIBLE_DEVICES, --n-gpu-layers, and friends). outrig run --device is mistralrs-only and is deprecated with it.

What you keep, given a tool-capable model: the tool loop, subagents, the tool-call and tool-result limits, aliases and failover, and the ability to name the local model as one candidate of an alias beside a hosted one. What you give up is the trust property the next two sections describe.

What this retracts

The section below (“Why in-process and not localhost”) argued that a localhost server is not an adequate substitute, because the payload still crosses a socket and can be observed by any process with the right uid. That argument was sound and is being knowingly traded away. It is recorded here rather than deleted, because it is the actual cost of this deprecation and a future reader deciding whether to reverse it needs the real reason it existed:

  • A localhost server does mean sensitive payloads get serialized and cross a socket.
  • outrig no longer claims the stronger property. If your threat model genuinely requires that a question never leave the outrig process, outrig is not the tool that gives you that, and after the removal it will not pretend to.

The three downstream features named under “Why you might want this” – the egress filter, the tool-use filter, and the prompt-injection scanner – were the stated consumers of that property. They are unimplemented, and if they land they will need to answer this question again on their own terms rather than inheriting an answer from a provider that shipped as plumbing for them and was never wired up.

Why you might want this

Historical. This section states the original rationale, which the deprecation above supersedes. Kept for the record.

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

Historical, and retracted. This section is the argument the deprecation above trades away; see What this retracts. A localhost server is now the recommended answer.

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; the GGUF's path inside the repo
# 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 key in the global config; see Reference -> Config). Subsequent runs reuse the cached file.

The cache uses Hugging Face’s layout, models--<org>--<repo>/snapshots/<commit>/<file>, where the file is a symbolic link into blobs/, so model-cache-root must be on a filesystem that supports them. Pointing the key at ~/.cache/huggingface/hub reuses what the Hugging Face tools have already downloaded. Alongside the GGUF, outrig downloads the repo’s generation_config.json when it has one, because it can name stop tokens that the GGUF does not, such as Llama 3’s <|eom_id|>. It does so only when it downloads the model, so a model that is already in the cache loads as it is, with or without that file. A private or gated repo needs a token: set HF_TOKEN, or run huggingface-cli login.

model-file is required with model-id, even when the repo ships a single .gguf; most publish several quantizations (-q4, -q5_k_m, -f16, etc.) to pick from. It is the file’s path inside the repo, so a quantization kept in a directory of its own names the directory too: model-file = "Q4_K_M/model-Q4_K_M.gguf".

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, start with ~ for your home directory, or be relative to the repo root – and the repo root is the base for both halves: the one the existence check runs against and the one the weights are opened against, whatever directory outrig was invoked from. A relative path in a global config follows whichever repo is current rather than sitting beside that file, which is the one place models departs from the usual rule; name an absolute or ~ path there.

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 macOS host support (#295) 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

Deprecated. Enabling this feature now also emits a build warning saying so.

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 stay on stderr and the prompt on the terminal, so outrig run > reply.txt still captures only assistant text.

A reasoning model’s reasoning – the <think> block mistralrs separates from the reply when the GGUF’s chat template uses one – is not streamed. A turn that produces nothing but reasoning, usually because it hit max-tokens before finishing the thought, is reported on stderr along with the reasoning it produced. Unless the last chunk is lost (below), the report opens with the finish reason mistralrs gave – length when max-tokens or the model’s context length ended the turn – and the max-tokens the request carried, or says it carried none, in the words a remote model’s report uses.

A reply that stops partway through a multi-byte character, such as an emoji cut off at max-tokens, never gets its last chunk from mistralrs 0.8.1. That chunk would carry the reason generation stopped, along with any text mistralrs was still holding back – up to the whole reply, when it opened like a tool call. outrig warns on stderr that the reply may be incomplete, and the turn ends as usual. The missing text cannot be recovered.

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 are the session’s as they stand at launch. A sidecar started with /sidecar add joins them for every subagent launched after it, at any depth. A subagent already running keeps the tools it was launched with; release it and launch it again to give it the new ones.

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. That holds at every depth: a subagent launched onto fast is the launching agent for its own children, so they run on fast too unless they name another.

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.

That includes aliases, since an alias is a model like any other. An alias is offered when at least one of its candidates is reachable in this build, which is what lets alias = ["opus-local", "opus-anthropic"] stay launchable without --features local-llm where naming opus-local directly would not be. A launch under an alias reports the hop it took – (model: cheap -> fast) – in the launch trace, the tool result, and the transcript header, which name the candidate the subagent starts on. An alias that lists more than one can move mid-round, so the transcript also records each round a later candidate answered: an --- answered by --- block naming every model that answered a call that round, in the order the alias lists them. A round the first candidate answered throughout adds nothing. Together they mean the answer to “which model actually ran” is never inferred.

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. A failure later in the round does not supersede it: if a model call after the report fails for good, the parent still reads the report, and the failure is noted on stderr and in the subagent’s transcript. If the parent sent a message after the report, though, the report is no answer to it, and the parent reads the failure instead.

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.

When the round has already reported and the parent sent nothing after that report, the earlier report stands and the 1st and 2nd asks say so instead: shorten the revision if it matters, or stop calling. They never ask for status: "error", because a short error that fits would replace the report in the inbox. If the parent did send something after the report, the report is no answer to it, and the table above applies as written.

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.

Unless the round has reported already: a report that fit stands, and a later one that will not fit does not replace it. The subagent is told its earlier report stands instead, and the give-up is noted on stderr and in the subagent’s transcript. If the parent sent a message after the report, the report is no answer to it, so the parent reads the report if it had not, then, once the round ends, that the subagent’s report did not fit.

Having given up, the round stays given up: it publishes, or keeps the report, 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, and its result says so. 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.

A read counts once its result is in the parent’s conversation. A turn can end without it: Ctrl-C keeps only the tool calls whose results had already gone back to the model, so a read made alongside a call still running is dropped with that call, and a subagent round that ends in an error keeps none of its calls. The read position moves back for such a read, and the next outrig__get_result returns that result again rather than block for a newer one.

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.

Once collected, a name counts again only when that subagent reports again. One still working is waited on. One that is idle is not: only outrig__subagent_send gives it something new to report, and the parent cannot send while it waits, so it neither counts toward min_count nor appears in the answer. A call naming only such subagents fails rather than block.

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. That holds for a round its model endpoint failed, too: the parent reads round failed, or the round’s report if it sent nothing after that, and the tool calls the round had already run stay in its history, so a send picks up from them rather than running them again. A running subagent sees the message at its next step, appended to its next tool results, so the parent never has to know whether it is busy. A report the round made before the message is no answer to it: if the round ends without reporting again, reading its result says the subagent stopped. One already past its last step when the message arrives runs it as a round of its own instead. Rounds run in the order their messages were sent, and a subagent with one waiting is not idle: reading its result waits for that round rather than returning how the last one stopped. 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 the subagent’s path: audit for one the primary launched, parent-a/audit for one parent-a launched, as its transcript header spells it. Concurrent subagents interleave; filter by label. A round that ends early – at its tool-call max, at the repeat breaker, or on a model call that failed for good – says why on a line labeled the same way. It leaves out the advice printed when the primary’s turn ends early: continue, /reset, and the rest act on the primary’s conversation, and what a subagent does next is its parent’s call. Lines about the model call itself carry the label too – a retry, a move to the next candidate of an alias chain, a turn that produced no text – so a fan-out’s waits and failures are attributable to the round that is having them. The primary agent’s own lines are unlabeled.
  • In a transcript holding that subagent’s prompts, any steer delivered mid-round, replies, and published outcomes. A subagent the primary launched writes <session_dir>/logs/subagent-<name>.log, beside the MCP servers’ stderr logs. One launched by a subagent writes into a directory named for its parent, beside the parent’s own transcript: audit’s subagent scan writes logs/subagent-audit/subagent-scan.log, and scan’s own go in logs/subagent-audit/subagent-scan/. Subagents sharing a name under different parents never share a file.

Each launch starts its transcript with a header naming the subagent’s whole path, and its model when the launch named one, such as === subagent audit/scan ===. A name released and launched again appends to the same file, under a header of its own.

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. So is a result the interrupted turn had read but not yet handed to the model. 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.
  • Sessions – outrig 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 text or yes/no prompt for an explanation of what’s being asked; pickers print their options’ descriptions before they open.

[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. A folder name with a letter outside ASCII suggests standard instead. The image-config name becomes the repository of the image built from it, so a name typed here that can’t be one is asked for again; see outrig image add for the rule.

The workspace answers are held to the rules a config load applies: host-path must name an existing directory, resolved against the repo root, and container-path must be absolute and not /. An answer that breaks one is asked for again, after a line saying which.

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). A provider name the global config lacks is asked again rather than defined: a provider carries an API key, and a repo config may not declare one. You can then optionally pin one of the new models 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. With no model defined anywhere there is nothing to pick: outrig says so and writes the agent without a model, and outrig run refuses the config until you add one.

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

Before writing the file, outrig reads it the way the next outrig run will: merged over your global config and validated. If that would fail, nothing is written, and the error says why – a global default-model that names no model, for one. Two things count as settled: the image-config image add writes next, which default-image – or an agent in your global config – may name, and the agent’s model when no model is defined anywhere, as above.

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 to an absolute path) – 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 text or yes/no prompt for an explanation of what’s being asked; pickers print their options’ descriptions before they open, and ? there is a search character.

$ 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

Each provider and each model needs a name of its own. A name already given in this run is asked for again, after a line saying so, and the suggested name moves on: a second openai provider is offered openai-2, and a second model fast-2.

An answer the written config would refuse is asked for again the same way, after a line saying why: a Base URL that is not an http:// or https:// URL, an API key variable that is not a variable name, or a max-tokens or context-length that is not a number. The API key prompt takes the name of the variable that holds the key – OPENAI_API_KEY, or ${OPENAI_API_KEY} as it is stored – never the key itself, and its refusal does not repeat what was typed.

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 – deprecated, and offered only for builds that still carry --features local-llm – 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. A relative Local model-path is read from the repo root, not from the directory config init runs in, and the global config serves every repo – so give that prompt an absolute path. New configs should pick openai and point it at a local Ollama/vLLM/llama.cpp server instead; see In-process LLMs.

Help at any prompt

Type ? and Enter at any text or yes/no prompt to get a short explanation of the field and its options. On a terminal, pickers print the same block before they open and do not treat ? as help; on piped stdin, ? works at pickers too, as here. 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  DEPRECATED (will be removed): in-process LLM via the mistralrs crate. Prefer
  'openai' pointed at a local Ollama/vLLM/llama.cpp server.

  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 (models, default agent, preamble, image-config name, workspace), then continues with the image add flow for the image-config you named. Given a <name>, it skips the image-config name prompt: <name> is both the image-config image add scaffolds and the new config’s default-image. 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.

The name keys the [images.<name>] block, names the Dockerfile’s directory, and becomes the repository of the image built from it, so it must be lowercase letters and digits, separated by one ., one or two _, or a run of - (e.g. rust-dev; see Reference -> Config). A <name> that isn’t is refused before the first prompt, even with --force, and nothing is written; one typed at the prompt is asked for again.

Run it

Every prompt shows the default in [default: ...]; press Enter to accept it. Type ? and Enter at any text or yes/no prompt, or at a picker on piped stdin, for an explanation of what’s being asked plus the available options; on a terminal, pickers print their options’ descriptions before they open and do not treat ? as help. The default image-config name is <repo-folder>-standard (kebab-cased), so the example below assumes a hello-outrig repo. A folder name with a letter outside ASCII, which no image name can hold, suggests standard instead.

$ 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 24 LTS via NodeSource (apk on Alpine).
  python  CPython 3.12 with pip and venv.
  go      Go 1.27.
  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 24 LTS via NodeSource; apk on Alpine.
pythonCPython 3.12 with pip and venv.
goGo 1.27.
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:

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
ENV RUSTUP_HOME=/usr/local/rustup \
    CARGO_HOME=/usr/local/cargo \
    PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
       | sh -s -- -y --no-modify-path --default-toolchain stable \
                  --profile minimal --component rustfmt,clippy \
 && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME"

# node toolchain
RUN node_major=24 \
 && curl -fsSL "https://deb.nodesource.com/setup_$node_major.x" | bash - \
 && apt-get install -y --no-install-recommends nodejs \
 && rm -rf /var/lib/apt/lists/* \
 && case "$(node --version)" in \
      "v$node_major".*) ;; \
      *) echo "node toolchain: expected Node $node_major, found $(node --version)" >&2; exit 1 ;; \
    esac \
 && npm --version

# 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).

That user is not root, so the rust toolchain installs under /usr/local, as the official rust images do, with RUSTUP_HOME and CARGO_HOME set and left writable (see Concepts -> Containers): cargo keeps its registry cache in CARGO_HOME, and rustup installs any toolchain a project’s rust-toolchain.toml pins into RUSTUP_HOME. To add to either – rustup component add, cargo install – extend that RUN ahead of its chmod; a RUN of its own leaves what it adds writable by root alone.

The node toolchain checks its own result. NodeSource’s setup script exits 0 even when one of its steps failed, and apt-get install nodejs then takes the distro’s Node 18, which has no npm; the build passes and the image is cached. So the RUN ends by requiring node to report the major its setup_N.x URL names – set once, as node_major – and npm to run, and fails the build otherwise.

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"] }

If your config spells images as an inline table, images = { base = { ... } }, image add first rewrites it as a standard [images] table holding the same entries, since the new blocks can’t nest inside an inline one. The table lands after your top-level keys. Comments above the images line and at its end move to the [images] header; comments between the entries of a multi-line inline table are not kept. An images that isn’t a table at all, such as images = "legacy" or [[images]], is refused before the first prompt, even with --force, and nothing is written.

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

Nothing is written until every prompt is answered, and a config that can’t be written leaves the Dockerfile unwritten too, new or replaced, so a retry isn’t refused over it.

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 becomes the image’s ref, so it follows the same rule as an image add name: lowercase letters and digits, separated by one ., one or two _, or a run of - (e.g. rust-dev), though not 64 hex digits, which podman reads as an image ID. 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.

A ${VAR} in a [mcp] entry’s env is read from the machine that runs the image, not the one that built it, and only if that machine’s global config lists the variable in [repo] env-allow: a session refuses an image whose label references an unlisted one, naming the server, the key, the variable, and the image. outrig image build does not hold the image.toml it is pointed at – you chose that directory, and the live test runs its servers with their references resolved.

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, every call-timeout-secs between 1 and 3600). 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.

Ctrl-C (or SIGTERM, or SIGHUP) once the image is built stops and removes that validation container before outrig exits, with 130, 143, or 129. A server that never answers its initialize no longer needs it: the test gives up on it after 120 seconds, and on a tools/list after another 120. During the build itself, Ctrl-C reaches buildah directly, which removes the working containers it created; see Cancelling a build.

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, including one that does not answer either within 120 seconds: 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.

The build itself runs into a temporary outrig-tmp-* tag and is renamed to <ref> once it succeeds, so a cancelled or failed build cannot leave a half-built image under the name you asked for – which may already belong to something else. The rename adds a name to an image that already exists; no layer is copied. A cancelled build is asked to stop rather than killed, so buildah removes the per-stage working containers it made; see Concepts -> Containers.

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_configErrors and unknown-key warnings for config 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>]
           [--env <KEY=VALUE>]
           [--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, default-image, or the built-in default.
  • --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): read the repo config from this file instead. See Reference -> CLI for which repo the session runs against.
  • --device <cpu|cuda|cuda:N|metal> (default: mistralrs model device, else cpu): override the in-process mistralrs model device for this run. Deprecated with the in-process backend; see In-process LLMs.
  • --env <KEY=VALUE> (repeatable): add or override env vars for the MCP servers this session starts. KEY=VALUE applies to every server; SERVER:KEY=VALUE targets one server by name. Values take the same ${VAR} host-env-reference syntax config files use, described in Reference -> Config. You typed them, so they are not held to the global config’s [repo] env-allow list, which governs the references in a repo config and in an image’s label. Within a scope the last value given for a key wins; across scopes the per-key precedence is config-file env < global --env < per-server --env. A SERVER: prefix naming a server the image does not declare is an error.
  • --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, which must be empty or not exist yet; symlinked from the root.
  • --session-root <path> (default: global config’s session-root, 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; a relative host path resolves against the directory you ran outrig from, the way your shell reads one, and a leading ~ is your home directory. A config file’s [[workspace.mounts]] resolves against that file instead – see path resolution.
  • --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.

When the image is not cached, the buildah build (or the podman pull for an image-name config) that produces it is shown as it runs, as [buildah] / [podman] lines between ensuring image and image ready, so a first run does not look hung. --verbose is not required for that either; it adds the command line itself and writes everything to container.log. Sidecar images report the same way, one at a time.

Three levels of detail, in increasing order:

SettingWhat it adds
(none)Progress lines, plus the output of a build or pull on a cache miss.
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

An env value that came from a ${VAR} reference appears in that line as 'KEY=${VAR}', never as its value. Pasted as shown, the single quotes would hand podman that literal text; to rerun the command by hand, write it as "KEY=${VAR}" so your shell fills the value in.

When --session-dir is given, outrig writes this run’s session.json, logs/, and (for a view = "primary" sidecar) outrig-enter 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

<path> has to be empty or not exist yet; outrig creates it, with any missing parents. A path that already holds anything – even a .gitkeep, or an earlier run’s session.json – is refused, because outrig discard removes the directory along with the session. See Sessions.

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 when that is an absolute path). 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. The current directory can’t be your home directory, one above it, or one that contains the global config, unless the global config declares [workspace] host-path; see Concepts -> Workspace.

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). A config found that another user owns is refused, not read (see Reference -> CLI). Startup names the config file it read and the workspace it will mount before anything is built or started. A key this outrig does not know – a typo, or one a newer outrig reads from the same global config – is ignored with a [outrig] warning: line naming the file and line (see Reference -> Config).
  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. A build or pull streams its output to stderr while it runs.
  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, is owned by them, and is the home their /etc/passwd entry names. The image needs no useradd/groupadd for this. See Concepts -> Workspace for the full logic.
  6. Merge MCP config and start sidecars. Read the primary image’s org.outrig.mcp label, then overlay [images.<name>.mcp] from config by server name. Each declared sidecar’s image is ensured and its label merged, and the start = "auto" sidecars are started. A sidecar failure follows the block’s on-failure key; a malformed label, two labels claiming one server name, or a label whose env references a host variable the global config’s [repo] env-allow does not list, is a startup error. See Config -> sidecars.
  7. 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.
  8. 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. Each of those two gets 120 seconds; a server that misses either fails startup, with its stderr in the error.
  9. 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.
  10. 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.
  11. Open the REPL. Banner on stderr, > prompt on the terminal, ready for input.

If anything before step 11 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: /home/you/coding/.agents/outrig/config.toml (1ms)
[outrig] workspace: /home/you/coding
[outrig] resolving agent and container
[outrig] agent/container resolved: agent coding, container 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] base-url:          https://api.openai.com/v1
[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
>

When the image is not cached, the build runs between ensuring image and image ready, and its output is shown as it happens:

[outrig] ensuring image coding:8c2a4f7e91d6b5a3
[buildah] STEP 1/6: FROM docker.io/library/node:24-bookworm-slim
[buildah] STEP 2/6: RUN apt-get update && apt-get install -y git
...
[outrig] image ready: coding:8c2a4f7e91d6b5a3 (built) (4m12s)

The lines are buildah’s own (podman pull’s, as [podman], for an image-name config). Without --verbose the command line itself is not shown and nothing is written to container.log; with it, each line still appears once. A sidecar image that needs building shows the same, under its own ensuring sidecar image <name> / sidecar image ready pair.

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. On an interactive terminal the > prompt and the echo of what you type go to the terminal itself rather than to either stream, so neither outrig run > out.txt nor outrig run 2> err.txt captures them, and the prompt stays visible in both. 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. What the conversation keeps depends on how far the turn got. A turn whose first model call failed has done nothing, and the conversation is 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.

Resend the prompt itself once the window clears. A later model call comes after tool calls the turn has already run, and those stay in the conversation with their results, as they do when the tool-call max fires:

[outrig] LLM endpoint failed and did not recover (HTTP 429 Too Many Requests); ending turn
[outrig] partial history retained -- send another prompt (e.g. "continue")
        to keep going, or "/reset" to drop it.

Once the window clears, send continue rather than the prompt again: the model sees the tool calls that already ran, where a resent prompt could have it run them a second time. 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, down to what the conversation keeps. 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.

Line editing and history

On an interactive terminal the > prompt is a full line editor. Arrow keys and the usual readline bindings move and edit within the line – Ctrl-A/Ctrl-E for the ends, Ctrl-W and Ctrl-K to cut a word or the tail, Alt-B/Alt-F to move by word. Up and Down recall the prompts you typed earlier in this session, and Ctrl-R searches them.

That history lives in memory for the length of the session. outrig writes no history file: nothing you type at the prompt is persisted anywhere, and a new outrig run starts with an empty recall list. /reset clears the conversation history the model sees; it does not touch the prompts the editor recalls.

Pasting several lines at once sends them as a single prompt, with the line breaks intact, rather than as one turn per line. Typing multi-line input is still not supported – Enter always ends the prompt.

The editor needs a terminal it can drive. When stdin is a pipe or a file, or a terminal other than the one you are typing at (outrig run < /dev/pts/7), or TERM is dumb, cons25, or emacs, the prompt falls back to reading one line at a time with no editing and no recall, which is what keeps scripted use (echo "..." | outrig run) working unchanged.

Input is read as UTF-8. A line from a pipe or a file with bytes that are not UTF-8 – a Latin-1 é in a prompts file – is still sent, with U+FFFD (�) in place of each bad sequence. The line editor does not do this yet: at the first such byte, from an Alt key that sets the eighth bit or a paste out of a Latin-1 source, its read fails and the session ends with an error. Redirect stdin to send such text (#516).

RUST_LOG=debug and OUTRIG_LOG=debug do not record what you type. The editor’s own debug output names every keystroke, so it stays at warn unless the filter mentions rustyline.

TODO: Incomplete – typed multi-line 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. Subagents launched from then on get them too; one already running keeps the tools it was launched with (see Concepts -> Subagents). 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. An add interrupted with Ctrl-C changes nothing either, and either one can simply be run again.

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 (parent/child for a nested one), as do the lines about their model calls – the retries and chain moves shown above – so a fan-out’s waits are attributable to the round having them. The primary agent’s own lines are unlabeled. Each subagent’s transcript is written to <session_dir>/logs/subagent-<name>.log – or, for one a subagent launched, under a subagent-<parent>/ directory there.

> 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"})

A subagent round that ends early says why on a line labeled the same way, without the advice shown above for your own turns: continue or /reset would act on the primary’s conversation, and what the subagent does next is up to the agent that launched it.

[outrig]   [audit-mcp] tool-call iteration max (50) reached; ending turn

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. A turn interrupted before it finished keeps the tool calls whose results had already gone back to the model, with those results and the prompt that led to them, so the next turn knows those calls ran. The rest of the turn is not kept: a tool call still running when Ctrl-C lands, any the model asked for alongside it, and the whole of a turn interrupted before its first results went back, which leaves the conversation as it stood before that prompt. Say what you still need rather than referring back to them. A local model’s reply that had already streamed in full is kept, even if Ctrl-C lands while its output is still being written. It stops the agent waiting, not work already handed to the container: a shell__exec that started a build may run to completion, and any subagents keep working and stay collectable on the next turn, as does a result the turn read from one of them but did not keep.

    Nothing is killed. The MCP servers, and the podman exec transports outrig talks to them over, belong to the session and not to the turn, so they stay up for the next prompt; what Ctrl-C abandons is the request in flight over one of them. outrig sends that server notifications/cancelled for it, but cancellation is advisory: a server that ignores it runs the work to completion and simply has no one to answer. Ending the session, with Ctrl-D, is what stops those. A tool call that nobody interrupts is bounded too: past its call-timeout-secs (10 minutes unless configured), it is cancelled the same way and the model is told it timed out.

  • Ctrl-C during a slash command cancels it the same way, with the same [outrig] interrupted. A /sidecar add waiting on an image or on a server that never answers initialize stops there, and whatever it had started goes: the sidecar container is removed in the background and its tools never appear. The session is left as a failed add leaves it.

  • Ctrl-D at an empty prompt ends the session: closes MCP server stdios, stops the container, finalizes the session record, exits.

  • Ctrl-C at the prompt discards whatever is typed on that line and draws a fresh > . A second Ctrl-C, with nothing entered in between, exits.

  • Ctrl-C before the first prompt – while the image builds, the container starts, or an MCP server initializes – ends the session the way Ctrl-D does: whatever had started is stopped, and the session record is finalized. So does SIGTERM or SIGHUP at any point, prompt and turn included: kill <pid>, a closed terminal, a service manager stopping the process, a CI job timing out. outrig prints [outrig] SIGTERM received; ending the session (or the signal’s own name) and exits with 128 plus the signal number – 130 for SIGINT, 143 for SIGTERM, 129 for SIGHUP – which is also the exit code the session record keeps.

  • A SIGINT or SIGTERM while that shutdown is still stopping containers stops the waiting: outrig finalizes the record and exits, and the remaining containers are force-removed in the background. A SIGHUP does not hurry it, since a closed terminal can deliver one twice.

> 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
error: configuration: refusing /tmp/.agents/outrig/config.toml: it is owned by uid 1001,
       not by you (uid 1000), so another user could have written it
help: run `outrig init` where you are to start a config of your own; outrig reads a
      config you do not own only when --config names it

The nearest config above the current directory belongs to someone else, so outrig will not run on it. If it should not be there, outrig init gives the current directory a config of its own, which the walk finds first. If you trust it – a checkout shared with your team – pass it with --config.

$ 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: configuration: image "outrig-standard:53e082e721df8ecc" (from --image) is not an
       [images.<name>] and is not in podman's local image store; outrig does not pull a raw
       image ref, so run `podman pull outrig-standard:53e082e721df8ecc` (or build and tag it)
       first

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. A sidecar’s image that is not an image-config is refused the same way, with (from [sidecars.<name>]) in place of (from --image).

$ 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, which must be empty or not exist yet.
  • --config <path> (default: walks up from cwd; if not found, run config-less): read the repo config from this file instead. See Reference -> CLI for which repo the session runs against.
  • --global-config <path> (default: ~/.outrig/config.toml, skipped if missing): read the global config from this file, which has to exist.
  • --session-root <path> (default: global 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. A relative host path resolves against the directory you ran outrig from, not against the repo root. 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
  3. outrig’s built-in default image-config

With neither of the first two set, startup does not fail. The session falls through to the built-in default and marks it on stderr:

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

Declaring a reserved name yourself vetoes the injection, and outrig steps aside with a note saying so. Five names do it: [images.outrig-default], [images.outrig-default-fs], [images.outrig-default-shell], [sidecars.outrig-default-fs], and [sidecars.outrig-default-shell]. [sidecars.outrig-default] is not reserved – the built-in declares no sidecar by that name, so yours cannot clash with it.

What a veto leaves behind depends on which name you took. An [images.outrig-default] of your own still resolves, so the session runs on it. Any of the other four leaves nothing to fall through to, and startup ends there – so a repo that declares only [images.outrig-default-fs] and no default-image fails rather than falling back.

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. With no agent it resolves no model and no provider either, so a working podman is all it needs – the built-in default supplies the image and two MCP servers. Pass --image <local-ref> to proxy a different image instead; its proxied MCP servers come from that image’s org.outrig.mcp labels. As with outrig run, the current directory can’t be your home directory, one above it, or one that contains the global config, unless the global config declares [workspace] host-path – so an MCP client that starts outrig in / or ~ needs --config naming a repo.

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>.
  4. If the session row predates image_config_name there is nothing to inherit, so --image <name-or-local-ref> is required there too.

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

The primary’s label is read off the image its container is running, by the engine’s id for that image, not off whatever its tag names at the time of the read. The two differ once a tag moves: a pull of a re-pushed registry/x:latest re-points the ref while every container started from the old one keeps running it. This matters most with --attach, which borrows a container someone else may have started long ago – the servers merged are the ones that container can actually run. A repo-local build’s tag is content-addressed and cannot move, so nothing changes there.

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"

[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. A ${VAR} in a label entry’s env is held to the global config’s [repo] env-allow list, as a repo config’s references are, and a session whose label names an unlisted variable is refused. 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. A label that names an unlisted host variable is refused here too, as at a session start. 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 the absolute path of the repo’s .agents/outrig/config.toml as --config 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, so the socket is created 0600 – only the user running outrig mcp can connect, whatever the process umask is. HTTP clients still use the Streamable HTTP protocol and the /mcp path over that socket.

A socket file left behind by a process that is gone is removed and the path reused. A path another outrig mcp is still serving is refused instead, naming it: taking it over would leave that instance running and holding its container with nothing able to reach it. Stop it, or pick another path. A socket that answers neither way – one whose server has stopped accepting, or that this user cannot connect to – is also refused, since the risk of cutting off a running instance outweighs an error message.

Point --listen at a directory only you can write. In a shared one without the sticky bit, another user can replace the socket’s directory entry; outrig refuses the path instead of following the replacement, but it cannot stop the entry from being taken.

Browser defenses

Two request headers are checked before anything is dispatched, and both are about pages in a browser rather than about MCP clients:

  • Host guards against DNS rebinding. A loopback TCP listener accepts only localhost, 127.0.0.1, and ::1, so a name that resolves to the loopback address does not reach the tools under a hostname outrig does not serve. A non-loopback bind and a Unix socket are reached under names outrig cannot enumerate – whatever a reverse proxy publishes – so the check is off for them.
  • Origin guards against a web page driving the listener, which the MCP transport spec requires a local server to do. A loopback listener accepts the origins that name itself, port included (http://localhost:7331, http://127.0.0.1:7331, http://[::1]:7331); every other origin is refused with 403, including another local port, because a page on http://localhost:3000 is as cross-origin as one on the internet. A non-loopback bind and a Unix socket recognize no origin as their own and refuse every one.

A request that carries no Origin header is served in all cases. That is what every non-browser client sends – an editor, a CLI, a script, rmcp’s own HTTP transport – so these rules cost an ordinary client nothing. A browser is what attaches the header, and a browser is what the rules are for.

Neither check is authentication. They keep a page a user did not open from reaching tools that the user’s own machine can; anything that can send a request without a browser attaching headers to it is still limited only by what can reach the port or the socket.

What Happens, in Order

  1. Locate config. Walks up from the current directory until .agents/outrig/config.toml is found, else runs config-less there. The MCP host’s cwd therefore needs to be the repo, or pass --config <repo>/.agents/outrig/config.toml explicitly. A config found that another user owns is refused, not read (see Reference -> CLI). Startup names the config file it read and the workspace it will mount on stderr before anything is built or started.
  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>. A label whose env references a host variable the global config’s [repo] env-allow does not list is a startup error, before any sidecar starts. 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 (a ${VAR} value by name, from podman’s own environment). 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. A server that has not answered within 120 seconds fails startup, with its stderr in the error.
  7. Build the proxy. outrig advertises one merged tool list to its client, with each tool namespaced <server>__<tool>. See Tool Names below. Each backing server’s tools/list gets 120 seconds too.
  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, under that server’s call-timeout-secs (10 minutes unless configured). A client’s notifications/cancelled for a call is forwarded to the backing server, and so is outrig’s own when a call runs past its deadline; the client gets a timeout error.

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
  • a cache-miss image build or pull, as [buildah] / [podman] lines: 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 looks that name up in a table it built at startup and dispatches the call to the original read_file tool on the fs backing server.

A tool whose name outrig had to change to satisfy ^[a-zA-Z0-9_-]{1,64}$ – one carrying a character outside that set, one too long to fit, or one on a server whose own name ends in _ or contains __ – is advertised with a short hash suffix instead, as fs__read_file_5d2270. The suffix depends only on the server and tool names, so a client reconnecting to the same servers sees the names it saw before, whatever order those servers list their tools in. That hash-suffixed name keeps the <server>__ prefix only while the server’s name is 54 characters or fewer; past that the cut can fall inside the server name, which is why the proxy’s instructions tell a client to treat names as opaque and call each exactly as listed.

Sixty-four characters cannot encode every possible pair, so two tools can still want one name. outrig widens one side’s suffix rather than dropping either, and picks which side moves from the two tools’ identities rather than from listing order. A widened name does depend on the rest of the session’s tool set, so it is the one kind of name worth re-reading instead of caching when the servers in play change. outrig run applies the same rule, so a tool set gets the same names on either surface. The one difference is /sidecar add: names the model has already seen cannot move, so a tool added mid-session is the one widened.

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

Tool Results

A tools/call answer is an ordered list of content blocks, and outrig forwards it whole: every block, in the order the backing server sent it, with its boundaries, payload, annotations, and _meta intact. structuredContent and result-level _meta come through beside the blocks. A client connected to outrig mcp therefore sees an image as an image.

Two things are outrig’s rather than the backing server’s:

  • The tool name. tools/list re-advertises the upstream descriptor unchanged – title, description, both schemas, behavioral hints, icons, _meta – under the namespaced name.
  • resultType. outrig answers complete for every dispatch. The other values promise tasks/* follow-up methods the proxy does not implement, so relaying one would advertise a surface that is not there.

A block kind newer than outrig’s MCP SDK never arrives: the SDK rejects it before outrig sees it. A kind the SDK knows and outrig does not is carried through as the JSON it arrived as, so the proxy is not the thing that drops it.

The rendered view

A language model’s tool-result channel is a string, so outrig run gives the model a rendering of the blocks rather than the blocks themselves. It is the library’s McpToolResult::render_text, and it is what tool-result-max caps – see Reference -> Config. Blocks are joined with newlines:

BlockRendering
textthe text
embedded text resourcethe text
image[image: {mime}, {n} base64 bytes]
audio[audio: {mime}, {n} base64 bytes]
embedded binary resource[blob: {mime}, {n} base64 bytes]
resource link[resource link: {uri}]
anything else[unsupported content block: {kind}]

{n} counts base64 characters, not decoded bytes – it describes what arrived, not what it decodes to. structuredContent is not rendered; a server that wants a model to read it is required by the spec to also send it as text.

So the two exits differ on purpose. An external client reaching outrig through outrig mcp gets the protocol’s data; the agent loop inside outrig run gets the rendering, because that is the shape its tool channel has.

Lifecycle

outrig mcp has four 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
  • SIGHUP, such as the terminal that launched the process closing

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. A signal is how a server is stopped, so once startup has finished it exits 0.

Under --listen, a signal gives requests still in flight five seconds to finish. Anything unfinished after that – a request whose body has stalled, say – is abandoned so teardown can proceed, and a second signal ends the wait early.

A signal during startup – while the image builds, the container starts, a backing server initializes, or a stdio client has yet to send initialize – stops whatever had started and finalizes the record too, and exits with 128 plus the signal number: 130, 143, 129. A SIGINT or SIGTERM while the containers are being stopped stops the waiting: outrig finalizes the record, exits, and leaves the remaining containers to a forced removal in the background.

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, SIGHUP, 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): read the repo config from this file instead. See Reference -> CLI for how its paths resolve.
  • --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> \
                    [--build-arg KEY=value | --build-arg KEY ...] \
                    <context>
      
      A literal build-arg is passed as KEY=value. A ${VAR} one is passed as a bare KEY, with the value in buildah’s environment rather than on its command line, and outrig’s errors and logs show it as KEY=${VAR} – see the value syntax. In a repo config a ${VAR} build-arg may name only a variable the global config lists in [repo] env-allow; any other is refused when the config loads, before buildah runs. 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. A label it cannot decode fails the build here, before anything is committed, with an error naming the image-config; see Concepts -> MCP Servers. The temporary tag is then removed, whether or not the build succeeded. If buildah refuses the removal, outrig warns and reissues it in the background rather than leaving the tag behind; the build’s own result stands either way. When the label-stamping working container could not be removed either, the tag is removed after it, since buildah refuses to remove an image a container was made from. 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:24-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

Cancelling a build

Ctrl-C, a timeout, or an abandoned MCP call stops a build in flight. outrig asks buildah to stop rather than killing it outright, and waits a bounded grace before escalating, because a buildah that unwinds removes the per-stage working containers it created and a killed one does not. Those are the one engine resource outrig cannot name for itself: buildah derives their names from the base image and offers no way to label them, so the only process that can prove which belong to this build is buildah.

What a cancelled build leaves:

  • The temporary outrig-tmp-* tag is removed. It carries this build’s pid and a nonce, so nothing else on the machine can be what the removal names.
  • The stage working containers are removed by buildah, if the stop landed while a RUN was executing.
  • The final <name>:<hash> tag was never created; the commit is the last step.

The gap is the second row’s condition. buildah installs its signal handler only while a RUN’s command is running, so a build stopped during a pull, a COPY, the commit, or the seam between two RUNs still ends where it stands and can leave a working container behind. Collect those with outrig clean --build-containers.

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 the 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"

A relative value resolves against the global config’s directory, and a leading ~ is your home directory, so session-root = "~/outrig-sessions" works too. The key is global-only: one machine keeps one root, so outrig ls finds the same sessions from any directory, and a repo config that sets session-root is rejected at load. See Reference -> Config.

Because ls, logs, discard, and clean need this one key and nothing else from the file, a global config that does not load does not stop them: outrig takes session-root out of it, warns that nothing else it sets is in effect, and carries on. outrig run reads the whole file and still refuses it. Only a root the damaged file states outright is used – one plain string, nothing the parser had to repair, and the only mention of the key in the file. If the damage reaches the session-root declaration, or the file names the key anywhere else, the session commands stop too – better than quietly listing, or cleaning, some other directory.

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
    ├── outrig-enter              # launcher for view = "primary" sidecars, if any start
    └── 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
        ├── subagent-audit.log    # transcript of the subagent "audit"
        └── subagent-audit/       # transcripts of the subagents "audit" launched
            └── subagent-scan.log

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.

A session record is history: it describes a run that already happened, so it has to keep reading after the schema moves on. image_config_name may be absent – records written before the container -> image rename stored the same value under container_config_name, which is still accepted on read and rewritten under the current name the next time outrig updates the record. A record with no image-config name at all shows - in the IMAGE column.

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

<path> has to be empty or not exist yet; outrig creates it, with any missing parents. A directory that already holds anything – even a .gitkeep – is refused, because from then on the directory is the session’s. Running again with the same path is refused for the same reason, since the first run’s session.json is still there: discard that session first, or give each run its own path.

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 or a container failure. 130, 143, and 129 mean a SIGINT, SIGTERM, or SIGHUP ended the session – Ctrl-C during startup, a kill, a closed terminal – and it was torn down like any other. 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.

An entry that can’t be read – a truncated write, or a schema outrig no longer understands – costs only its own row. outrig ls reports it on stderr and lists everything else:

[outrig] skipping 20260430T091203-44d2: session.json at ...: missing field `image_tag`

If nothing in the root parsed, the notice says no readable sessions (N skipped) rather than no sessions, so an unreadable store isn’t mistaken for an empty one. Either way the exit status is 0 – skipping applies to reading and parsing a session.json, which is reported rather than failed on. A fault reaching the entry at all (an unreadable session root, or a directory entry outrig can’t stat) is still an error. outrig clean treats an unreadable record as still owning its container, so the container is not swept up as a stray. outrig discard <sid> removes such a record after the usual confirmation, which says why it is unreadable, and outrig clean --unreadable removes the ones past the cutoff.

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)

A file in a subdirectory, like the transcript of a subagent another subagent launched, is listed by its path under logs/.

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, and returns on its own once the session’s record shows it has ended – there is nothing left to write. Ctrl-C ends it earlier, same as any other command.

When network audit or filter mode is enabled, logs/network.jsonl contains one Zeek conn.log-style JSON object per outbound TCP connection and one per name lookup. 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, outrig.host, and outrig.host_source. The last of those is resolved when the name in outrig.host is one the interceptor validated for that address and asserted when the container merely claimed it – only a resolved name can have granted a hostname allow rule. A connection that a hostname allow entry granted may therefore show an asserted host: the name that earned the grant is a resolved one, which can be the name shown or another the container resolved to that address. outrig.rule says which entry decided, so read it against the policy; see Reference -> Config. A connection refused because its container already had 256 open through the interceptor is recorded with outrig.action deny and outrig.rule connection-limit. One that reached the interceptor’s own listener without being redirected there – a process in the container connecting straight to the listener’s port, which the redirect leaves alone because it is on loopback – is closed on arrival and recorded with outrig.action deny and outrig.rule not-redirected. Its only destination is the listener itself, which the interceptor would otherwise dial from the host’s network namespace.

A name lookup is recorded as the UDP exchange with the interceptor’s DNS listener that it is: proto is udp, service is dns, id.resp_p is 53, and outrig.host is the name the container asked for, with outrig.host_source asserted. id.resp_h is the address the query reached the listener at, which is loopback whichever resolver the tool aimed at, since the redirect has rewritten it by then. orig_bytes is the query’s size. The record is written when the lookup is decided, before any resolver is asked, so duration and resp_bytes are zero and conn_state is S0 however the lookup came out; outrig.action and outrig.rule say whether it was answered or refused, and why. A query that is not a plain question for one name is refused under outrig.rule dns-unparsed where any lookup could be, and has no outrig.host when even its question could not be read. See Reference -> Config for what decides a lookup.

Interception ends with the session, and ending it is the exact inverse of starting it. Every connection still open through the interceptor is cut, and each one is recorded before teardown returns, so the last line of network.jsonl is in the file by the time the session is down rather than after it. In the file, not synced to the disk: outrig does not fsync per record, so every record is there for anything that reads the file, and a host that loses power mid-session can still lose the tail the kernel had not flushed.

The container’s /etc/resolv.conf, which interception rewrote to point at the per-container DNS listener, is restored byte for byte, and the container’s redirect rules are removed. The same applies to one sidecar detached mid-session: it goes back to resolving and routing the way it did before it was attached. A container whose resolver was supplied at creation is the one exception – nothing was rewritten, so nothing is restored.

What teardown removes is what interception created, and nothing else. The redirect table is removed by the handle the kernel assigned it, and only while its name is still present, so a table something replaced under that name is left alone and so is a namespace this removal reaches by a pid that has since been reused.

If any part of that fails – the resolver cannot be written back, the rules cannot be removed, a task does not end, a listener had stopped taking the container’s traffic before teardown began – the remaining parts still run, and the failure is returned rather than logged and swallowed. Returned to the caller of the interceptor, that is: a library embedding outrig gets it and decides. outrig the command is one such caller, and what it decides is to log it and carry on stopping containers, because stopping them is the more urgent half, so from the command line these failures appear in container.log rather than in the exit status.

Attaching has the same property from the other side: a failed or interrupted attach leaves the container exactly as it found it, so a container never ends up resolving to a listener that is not there.

outrig discard

Delete a session’s on-disk record – session.json, logs/, and outrig-enter – and the directory that held it:

$ 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, or at its symlink under the root, which discards the directory the symlink names:

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

--yes skips the interactive confirmation. Discard removes those three names and nothing else. logs/ is the session’s, so it goes whole, with anything you put in it. Anything else in the directory is yours – and so is a logs that is not a directory, which outrig never makes – so the directory stays and discard says what it kept. Versions before 0.2.2 let a session start in a directory you keep other files in, and this is what discarding one of those looks like:

$ outrig discard 20261003T120000-aaaa --yes
[outrig] removed the session record from /home/me/notes
[outrig] kept /home/me/notes, which also holds KEEP_ME.txt, photos
[outrig] removed ~/.local/share/outrig/sessions/20261003T120000-aaaa (symlink)

If such a directory had a logs/ of its own before the session, the session’s logs went into it, and it goes with them.

A session that’s still running can’t be discarded; outrig refuses with an error pointing at what is holding it. Two things count as running, and either is enough: the process that launched the session still holds its session directory, or the container named in the record is up. The first is what protects a session whose container does not exist yet – a cold image build takes minutes, and until it finishes the record looks exactly like a session that ended without writing an ended_at. The hold is released once the session’s record is finalized, and the kernel releases it however the launching process dies, so a record left behind by a killed outrig run stays discardable.

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. A directory that holds anything besides the record stays, and clean says what it kept. Running sessions are skipped so cleanup doesn’t race a live outrig run or outrig mcp writer – by the same two-part test discard uses, so a short --older-than cannot sweep up a session that is still building its image.

A record outrig cannot read is not shown to you, so --yes does not remove it: clean names it and leaves it. Add --unreadable to have it previewed and removed with the rest, aged by the start time in its directory name; an entry whose name is not a session id is never removed, and neither is a record too damaged to say which container it owns – outrig discard <sid> removes that one.

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]:

A stray whose creation time cannot be read has no age to compare with the cutoff, so clean reports it under skipped labeled containers with no creation time and leaves it alone.

Each [outrig] removed container <name> line is a claim about that container, checked by re-reading the container list rather than inferred from the batched podman rm -f’s exit status – one status for every name in the batch cannot say which of them went away. A name the batch skipped is retried on its own; one that is still there afterwards is reported as could not remove container <name>, and a sweep that ends with any of those exits 1.

A third sweep, --build-containers, is available for the buildah working containers an interrupted build can leave behind. It is off by default and is the one sweep outrig cannot scope to its own work: buildah offers no way to mark these containers, so the sweep covers every buildah working container past the cutoff, including one you made yourself with buildah from. Removal is by container id, everything is previewed before the prompt, and a container whose creation time cannot be read is reported and left alone:

$ outrig clean --older-than 7d --build-containers
[outrig] will remove 1 build container older than 7d (buildah working containers):
  213c716fa504  outrig-cache-working-container  image localhost/outrig-cache:d8aa33
Clean 1 build container? [y/N]:

The cutoff is what keeps this away from a build that is running right now, so do not pair --build-containers with a short --older-than while one is in flight.

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.
  • Referenced env values. A ${VAR} build-arg or MCP env value appears in container.log, errors, and debug output only as KEY=${VAR}, and reaches podman and buildah through their environment rather than their command line – with the exceptions listed under the value syntax. What a build step or an MCP server prints is recorded as printed.

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. The directory has to be empty or not exist yet, so a second run with the same path is refused while the first run’s session is there, even if both started at once: discard it first, or give each run its own path. 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, 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.

The walk takes only a config of your own. The file, the .agents/ and outrig/ directories above it, and the directory they sit in must all belong to the user running outrig; a symlink counts as itself, not as what it points to. Anyone who owns one of them could have written the config, and the config decides what is mounted, which commands run, and where API keys go. So a config found anywhere else – /tmp/.agents/outrig/config.toml, say, planted by another user of a shared host – stops the command with an error naming it, as git does for a repository another user owns. It is not read, and not skipped in favor of a config-less run. The walk finds the nearest config first, so outrig init in your own directory puts one between you and it. To read a config you do not own on purpose, such as a checkout shared with your team, name it with --config, which is never refused for its owner.

The flag names a file, and that file has to exist: a path that is not one is an error, not a config-less run. Which repo the command then runs against depends on where the file is:

  • <repo>/.agents/outrig/config.toml is that repo’s own config, and the flag means what running from <repo> means. This is the way to point an MCP client, which may start outrig in any directory, at a repo.
  • Any other file is read in place of the repo’s config, for the repo found from cwd as if there were no flag: the walk above, else cwd itself. Relative paths in the file resolve beside it, as a --global-config file’s do (see path resolution). Where the file sits never decides which directory is mounted as the workspace.

--global-config resolves in this order: this flag, then <XDG_CONFIG_HOME>/outrig/config.toml when XDG_CONFIG_HOME is set to an absolute path, then ~/.outrig/config.toml (the outrig-specific fallback – note this is ~/.outrig/, not ~/.config/outrig/). As the XDG Base Directory Specification says, an empty XDG_CONFIG_HOME counts as unset and a relative one is ignored, so the global config is never looked for under the current directory. A home directory that is not absolute is ignored the same way. With no absolute XDG_CONFIG_HOME and no absolute home directory there is no default location: a command that reads the global config runs without one, and outrig init and outrig config init, which would have nowhere to write it, stop and ask for --global-config.

Only the default may be missing: a machine with no global config runs on the repo config alone. A path the flag names has to exist wherever outrig reads the global config, and one that does not is an error naming it, not an empty global config. To run without a global config whatever the default holds, pass --global-config /dev/null. outrig init and outrig config init write the file the flag names, so for them it need not exist yet.

--session-root resolves in this order: this flag, then session-root in the global config, then <XDG_DATA_HOME>/outrig/sessions/. The config key is global-only, so ls, logs, discard, and clean read no repo config at all and ignore --config: they find the same root from any directory. Given --session-root, or --session-dir for logs, they read no global config either, so a --global-config path is not checked.

Those four commands need one key out of the global config, so a global config that does not load in full does not stop them. When the whole-file read fails – a TOML syntax error, or a value refused rather than set aside, such as a bad [network].mode – outrig re-reads the file for session-root alone, warns on stderr that nothing else in it is in effect, and carries on. The fault is not thereby fixed: run, mcp, and build read the file for everything in it and still refuse it.

The degraded read never guesses a root. It uses only a session-root the damaged file still states outright: one plain, non-empty string, declared once, with nothing the parser had to repair. A file whose text cannot be declaring the key at all falls back to the default, which is the usual case for a file broken while a model or provider was being edited. Anything between the two – the parse error landed on the value, the value holds an invalid escape or an unterminated string, the key is declared twice – stops the command and reports the parse error. A root that is not the one the operator chose is worse than no answer: clean turned loose on a directory holding no records reads every container outrig labeled as a stray.

“Declared once” is judged from the file’s text rather than from what the parser managed to read, because a syntax error can stop its recovery part-way and hide a second declaration below. So a damaged file that so much as mentions session-root anywhere outside the one declaration – in a comment, inside another value, or behind a \ escape the broken file leaves unresolvable – stops the command too. The test is coarse on purpose: the alternative to refusing is acting on a root the operator did not choose.

--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, and so is a cache-miss buildah build or podman pull during startup, as [buildah] / [podman] lines while it runs; --verbose adds the command lines and the output of every other command, and writes it all to container.log.

--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; you typed them, so they are not held to the global config’s [repo] env-allow list, which governs the references in a repo config and in an image’s label. The key is held to ^[A-Za-z_][A-Za-z0-9_]*$, as a config file’s env key is: the split is at the first =, so a value may hold one but a key may not. 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): deprecated with the in-process backend it selects hardware for; override the in-process mistralrs model device for this run. Rejected for OpenAI-style models, and for an alias spanning more than one candidate – it selects hardware for one in-process model, and an alias may span several styles. A single-target alias is accepted.
  • --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. An alias entry is accepted like any other name; the banner prints the hop it resolved to.
  • --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; must be empty or not exist yet.
  • --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. A relative host path resolves against the current directory, and a leading ~ is your home directory; a config file’s mounts resolve against that file instead. : separates the fields, so neither path can contain one – including one a relative host path only resolves through, which is refused before the image is built, as a [[workspace.mounts]] entry is.
  • -v, --verbose (default: off): print container lifecycle traces.

When --session-dir is given, outrig writes this run’s session.json, logs/, and outrig-enter (only for a view = "primary" sidecar) directly under <path>, and additionally creates a symlink <session-root>/<sid> -> <path> so outrig ls/logs/discard/clean still find it. A <path> that does not exist is created, with any missing parents; one that already holds anything is refused, as is the second of two launches started at once with one <path>. 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. A Ctrl-C before the first prompt, or a SIGTERM or SIGHUP at any point, ends the session the same way – its containers stopped, its record finalized – and exits 130, 143, or 129. An interactive run gets a line editor with in-session history; a piped or redirected one reads plain lines.

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 exactly one thing from it: a resolvable model. The agent is optional – without one the session sends no preamble – and so is the image, which falls through to outrig’s built-in default. mcp resolves no model either, so it needs nothing beyond a working podman. build still requires a repo config and does not fall through; pass --image outrig-default to pre-warm the built-in.

Whichever way the repo root is found, a workspace no config declares is never your home directory or a directory above it, nor one that contains the global config: run and mcp refuse to start, naming the directory and what chose it. Declare [workspace] host-path in the global config to mount it on purpose; see Concepts -> Workspace.

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)New or empty dir 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 outrig’s built-in default image-config. Config entries win; an unknown explicit --image is treated as a local Podman image ref. default-image remains config-only. The fallback is announced on stderr, and declaring a reserved name yourself takes it away: [images.outrig-default], [images.outrig-default-fs], [images.outrig-default-shell], [sidecars.outrig-default-fs], or [sidecars.outrig-default-shell]. [sidecars.outrig-default] is not reserved. Your own [images.outrig-default] still resolves after the veto; the other four leave nothing to fall through to, which is when naming no image is still an error.

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, it reads no model and no provider either, so --image <local-ref> is enough – and, with the built-in default behind it, not required.

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, created 0600 regardless of umask, and a path another outrig mcp is still serving is refused rather than taken over. 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.

Requests are screened on two headers first. A loopback listener accepts only loopback Host values and only the Origin values naming itself, port included; any other bind accepts any Host and no Origin at all. A request carrying no Origin – what every non-browser client sends – is always served. See Usage -> outrig mcp.

--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, SIGTERM, or SIGHUP after successful startup0
SIGINT, SIGTERM, or SIGHUP during startup130, 143, 129
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 global config.
XDG_CONFIG_HOMEAbsolute only: global config is checked before ~/.outrig/.

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. A symlink whose directory is gone has no record to list: ls reports it on stderr, and discard or clean removes it.

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 (session.json, logs/, outrig-enter) and its directory.

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; --session-dir given that symlink discards the directory it names. A symlink whose directory is gone is removed by discard <sid>, or by --session-dir given the path the directory had. Discard removes the record’s three names and nothing else: a directory that also holds anything else stays, and discard names what it kept. A session whose session.json cannot be read is discarded the same way after the confirmation, which names why it is unreadable; with no record to name the container, the check for a running one uses outrig-<sid>, and discard warns on stderr, --yes or not, when the record did not name its container. Refuses if the session is still running: either a live process holds the session directory, which covers a launch whose container does not exist yet, or the session’s container is up.

outrig clean

Delete old stopped session records in bulk.

outrig clean [--older-than <duration>]
             [--yes]
             [--build-containers]
             [--unreadable]
             [--session <id>]
             [--session-root <path>]
Argument / flagDefaultDescription
--older-than <duration>30dRemove sessions older than cutoff.
--yes, -yoffSkip the interactive confirmation.
--build-containersoffAlso remove buildah working containers.
--unreadableoffAlso remove unreadable session records.
--session <id>allRestrict both sweeps to one session id.

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, by the same test discard uses: a live process holding the session directory, or the session’s container being up. The first is what keeps a short --older-than from sweeping a session whose image is still building. Symlinks whose directory is gone, older than the cutoff by the link’s own modification time, are removed too. Sessions created with --session-dir remove both the symlink target and the symlink under the session root. As with discard, a directory that holds anything besides the record stays. A record that cannot be read is aged by the start time in its directory name and is removed only with --unreadable: without it, even --yes leaves the record and names it, since what is in it cannot be shown. An entry whose name is not a session id is never removed, and an unreadable record keeps its container from being swept as a stray until the record itself is gone. A record cut off before it names its container is kept too, since an outrig mcp --attach session borrows a container that is not outrig-<sid>; discard removes it. Alongside the record walk, clean sweeps stray containers: containers carrying org.outrig.session whose session record is gone. Stopped strays older than the cutoff are removed; running ones are only reported. A stray whose creation time cannot be read has no age to compare with the cutoff, so it is reported and left alone.

--session <id> narrows both sweeps to one session: only that session’s record is considered for removal, and only containers labeled org.outrig.session=<id> are stray candidates. Without it the label sweep is machine-wide, which matters because a stray is defined by the absence of a record. A container whose record lives under a different --session-root – another checkout, a parallel CI job, a test suite with its own session root – has no record this invocation can see, so an unscoped sweep reads it as a stray and removes it.

--build-containers adds a third sweep, for the buildah working containers an interrupted build can leave behind, and needs buildah on PATH. It removes by container id, subject to the same --older-than cutoff, and previews every container before asking. It is opt-in because it is the one sweep outrig cannot scope: buildah offers no way to mark these containers and buildah containers --filter selects only on id, name, and ancestor, so the sweep covers every buildah working container past the cutoff, including one you created yourself with buildah from. A container whose creation time cannot be read is reported and left alone. Do not pair it with a short cutoff while a build is running – the cutoff is the only thing keeping it away from a build in flight.

Exit codes

CodeMeaning
0Success.
1Generic failure (config, image build, LLM API error, etc.).
2Misuse (bad flags, missing required args). clap prints the usage line.
129Ended by SIGHUP.
130Ended by SIGINT.
143Ended by SIGTERM.

Once outrig run or outrig mcp has written its session record, it catches all three signals: it stops the session’s containers and finalizes the record, which keeps the same code, before it exits. outrig mcp exits 0 on a signal once its startup has finished; see outrig mcp. outrig image build catches them once it starts its validation container, and stops that container before it exits.

outrig clean also exits 1 when the sweep ran but a container it tried to remove is still there; the run is reported in full, and the container is named on stderr.

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 the global config.
  • XDG_CONFIG_HOME: global config is checked here before ~/.outrig/config.toml, when it is an absolute path. An empty or relative value is ignored.

These are read on the host. None of them, nor any other host variable such as HTTPS_PROXY, reaches a container unless an MCP server’s env or --env names it; see Containers -> The container’s environment.

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 to an absolute path). 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. It is also where [repo] lives, which says which host environment variables a repo config may reference.
  • Repo config at .agents/outrig/config.toml – repository-level, committed to source control. Holds [workspace], [images.<name>], [agents.<name>], and any repo-specific models, which may name the global providers. A repo config may not declare a provider that carries an api-key: the file sits inside the read-write workspace, so an agent could edit it for the next session, and a key names a secret in the operator’s environment. Keyless style = "mistralrs" providers are the exception. For the same reason it may reference a host variable in build-args or an MCP env only if the global config lists it in [repo] env-allow.

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. A key outrig does not know is ignored with a warning rather than refused; see What a load sets aside.

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
mcp-call-timeout-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

[repo]
env-allow = ["GITHUB_TOKEN"]                          # 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).
mcp-call-timeout-secsintegernoeitherMCP tools/call deadline.
network.modestringnoeitherNetwork mode.
network.defaultstringnoglobalFilter fallback action.
network.allowarraynoglobalFilter allow entries.
network.denyarraynoglobalFilter deny entries.
repo.env-allowarraynoglobalVariables a repo may use.

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, 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. mcp-call-timeout-secs is how long an MCP tools/call may run before outrig cancels it at the server and hands the model a timeout in place of a result – 1 to 3600, default 600. It is the default for every server in the session, sidecars and image-label servers included; a server’s own call-timeout-secs overrides it, and in either file the repo value wins. The server is sent notifications/cancelled, which it may or may not act on. session-root and model-cache-root are global-only: they choose where this machine writes session records and model downloads, and for sessions also where outrig clean and outrig discard delete. A repo config that sets either is rejected at load. [network].mode can live in either file; when both set it, the repo value wins for that repo. A [network] table that declares no mode declares nothing, so it inherits. Network policy keys (default, allow, and deny) are global-only because they describe the machine’s egress policy, not a project preference; a repo config that sets one is rejected at load. [repo].env-allow is global-only too: it says which host variables a repo config’s ${VAR} references, and an image label’s, may name, so a repo file cannot answer for itself. A repo config that declares it is rejected at load. See [repo].

session-root defaults to <XDG_DATA_HOME>/outrig/sessions/ (typically ~/.local/share/outrig/sessions/). A relative value resolves against the global config’s directory, and a leading ~ is your home directory – see path resolution. Since only the global config can set it, outrig run and the session commands (ls, logs, discard, clean) find the same root from any directory, inside a repo or not. 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.

Because those four commands need session-root and nothing else in the file, a global config that fails to load does not stop them: outrig re-reads it for that one key, warns that nothing else it sets is in effect, and carries on. run, mcp, and build read the whole file and still refuse it. That degraded read takes only a root the damaged file states outright – one plain string, nothing the parser had to repair, and declared once as the file’s text has it, since a syntax error can stop the parser’s recovery before a second declaration below it. If the file might be declaring a root and no such value can be read, the session commands stop too, rather than fall back to one the operator did not choose. See the CLI reference.

model-cache-root defaults to <XDG_CACHE_HOME>/outrig/models/ (typically ~/.cache/outrig/models/), and a configured value resolves the way session-root’s does. 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. The cap applies to the rendered text – the single string the model is given, which is what a non-text content block contributes a bracketed placeholder to – not to the server’s content blocks, which reach a client connected to outrig mcp whole.

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, short of the per-container connection limit below, but write Zeek conn.log-style records to <session_dir>/logs/network.jsonl: one per TCP connection and one per name lookup.
  • filter: install the same interceptor as audit mode, write the same audit log, and enforce global allow/deny policy before opening upstream TCP connections and before answering a name lookup (see Name lookups). Traffic the interceptor cannot carry – UDP to any port but 53, ICMP, anything else – follows default directly.

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 port 53 over both IPv4 and IPv6, and removes the nftables table during teardown. A lookup a tool sends to some other resolver address, such as dig @8.8.8.8, is redirected to the same listener and answered from the address it was sent to. That holds over TCP as well as UDP: the stub’s retry after a truncated answer, dig +tcp, and use-vc are answered as a UDP lookup is, forwarded to the host’s resolvers over TCP. The listener forwards each lookup on its own, up to 64 at once per container, so one name a host resolver is slow to answer does not hold up the container’s other lookups. TCP connections are carried up to 256 at once per container, in either mode: each holds two of outrig’s open files, and the limit keeps one container from taking all of them. A connection past it is closed as soon as it is accepted and recorded with outrig.action = "deny" and outrig.rule = "connection-limit". For the same reason outrig raises its own soft open-file limit to the hard limit when it starts. A connection that reaches the interceptor’s own listener without having been redirected to it, such as a process in the container connecting straight to the listener’s port, is closed as soon as it is accepted and recorded with outrig.action = "deny" and outrig.rule = "not-redirected", in either mode. If either mode is requested and setup fails, the session fails before MCP servers launch.

Either mode refuses a container that holds NET_ADMIN. CAP_NET_ADMIN in a container’s own network namespace is control of its nftables, so a root process in that container could delete the redirect table and leave no record. A container holds it when its cap-add lists NET_ADMIN – CAP_NET_ADMIN and net_admin mean the same – or lists ALL without NET_ADMIN in cap-drop; no capability-profile adds it. The refusal applies whether the mode came from the config or from --network, and the error names cap-add. The primary fails the session before MCP servers launch, a sidecar follows its on-failure (warn drops it, abort ends the session), and Outrig::launch and Outrig::add_sidecar return the error. SYS_ADMIN is not refused: it does not let a container change its netfilter, so the nested-runtime recipe in Containers runs under either mode. A container that needs NET_ADMIN runs under mode = "default", or --network default, which installs nothing to remove. A container outrig did not start is not inspected.

Lookups are forwarded to the host’s own resolvers: the nameserver entries in the host’s /etc/resolv.conf, in order. Forwarding happens in the host’s network namespace, so a loopback entry answers just as it does for the host. On a host using systemd-resolved, that entry is its stub, 127.0.0.53, and resolved routes each container lookup as it routes the host’s own: a name in a VPN’s routing domain goes to the VPN’s server. A bare single-label name such as nas can reach resolved as it is, since the resolver interception installs names none of the host’s search domains, and resolved does not look such a name up over DNS by default – name such a host in full. When /etc/resolv.conf is missing or names no nameserver, the listener uses the upstream servers systemd-resolved lists in /run/systemd/resolve/resolv.conf instead. If neither file names a resolver, setup fails rather than choosing one on the host’s behalf.

Each lookup goes to those resolvers one at a time until one answers. One the host cannot reach at all, such as an IPv6 server on a kernel booted without IPv6, is passed over at once, and so is one that refuses the lookup; one that stays silent is passed over after 5 seconds. A lookup none of them answers is answered SERVFAIL rather than not at all, so the tool that asked is told the lookup failed – at once, when every resolver refused – and outrig warns about the first such lookup in each container.

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". What a hostname entry can match, and so stop, is bounded: see What a hostname rule matches.

allow and deny entries match TCP connections, which the interceptor carries and can name a host for. Anything else the container sends – UDP to any port but 53, which is how QUIC and so HTTP/3 travel, ICMP, any other transport – matches no entry and takes default directly. Under default = "deny" the kernel drops it, by a rule in the same nftables table: the sending tool fails at once with “Operation not permitted” rather than reaching the network. Such a datagram never reaches the interceptor, so it writes no network.jsonl record. Under default = "allow", and in audit mode, it leaves by podman’s default route, unrecorded. Name lookups, which are over UDP or TCP port 53, are decided by the name asked for instead; see below. Traffic between processes in the container over loopback is carried either way.

Name lookups

A name the container looks up travels to whoever serves its zone, so a lookup is a way out of the container like a connection is. The interceptor’s DNS listener decides each one by policy and records it. A lookup carries no port, so an entry’s port counts neither for nor against it.

  • A deny entry that names a host and has no port refuses the lookups it matches, under either default. A deny entry with a port, or one that is an address or a CIDR, refuses none.
  • Under default = "allow", every other lookup is answered.
  • Under default = "deny", a lookup is answered only when an allow hostname entry matches the name, whatever its port: github.com:443 opens the lookup of github.com. *.npmjs.org does not match npmjs.org, so list the apex too. An allow entry that is an address or a CIDR opens no lookup – it grants connections to that address, and a name is not one – unless it matches every address (*, 0.0.0.0/0, ::/0), which opens every lookup.
  • A query that is not a plain question for one name is not decided by its name: an answer, an opcode other than QUERY, extra records beyond one EDNS record, a compressed name, or one longer than 253 bytes. It is refused wherever some lookup could be – under default = "deny", and wherever a deny entry names a host without a port – and answered as before elsewhere.

A refused lookup is answered REFUSED without any resolver being asked, so nothing about the name leaves the host. The tool that asked sees the lookup fail: glibc tries again before giving up, and musl waits out its own timeout. Under default = "deny" a reverse lookup, or the lookup of a name no allow entry names, fails the same way.

Every lookup writes a network.jsonl record, answered or refused, before the query is sent to a resolver. It is a record for service dns, udp or tcp as the lookup was made, its outrig.host is the name asked for (with outrig.host_source asserted, since the container chose it), and outrig.action and outrig.rule say what was decided and by which entry; a query that is not a plain question is refused under outrig.rule dns-unparsed. A zone transfer (AXFR or IXFR) over TCP is refused under dns-transfer: its answer is several messages, which the listener does not carry. Its other fields are those of a connection’s record.

An allowed name can still carry data. allow = ["*.example.com"] lets the container look up <data>.example.com, which reaches the name servers of example.com, and the same entry grants connections to every host under it, so name only zones you would trust with either. An EDNS record a query carries is forwarded to the host’s resolver whole. A lookup is decided by its name alone, over TCP as over UDP, so an entry for port 53 – deny = ["*:53"] – does not govern it; no connection to port 53 is made that a destination could decide.

Changed in 0.2.2. Before this release every lookup was answered, whatever the policy. Under default = "deny", a name that resolved only because it fell in an allowed address or CIDR now needs an allow entry of its own, or the container has to connect by address. Under default = "allow", a name a hostname deny entry matches is no longer resolved; its connection used to be denied, and now the denial is recorded as the lookup.

What a hostname rule matches

A hostname entry in allow grants only against a destination outrig itself resolved to that name. The evidence is the container’s own DNS lookup: the interceptor answers the query, validates the response, and binds the addresses that response attributes to the queried name. A binding lasts as long as the answering record’s TTL, clamped to between 30 seconds and one hour – the floor keeps a TTL-0 answer usable by the connection that prompted it, and the ceiling stops a generous TTL from pinning a name for the whole session. Bindings belong to the container that earned them and are shared with nothing else in the session.

The name a client writes into a TLS ClientHello or an HTTP Host: header is a claim, not evidence. It can match a deny entry – a forged name there costs that client only its own connection – but it never matches an allow entry. Under the policy below, a container that connects straight to 203.0.113.66:443 and announces allowed.example is denied, and the same container is allowed once it has resolved allowed.example to that address through the interceptor:

[network]
mode    = "filter"
default = "deny"
allow   = ["allowed.example:443"]

A hostname allow entry with no binding simply does not match. Evaluation continues with the remaining entries and then default; the entry is never turned into a deny, so default = "allow" keeps meaning what it says.

Consequences worth planning for:

  • A container that reaches an address without asking the interceptor’s resolver – a baked /etc/hosts entry, a hardcoded address, DNS over HTTPS – earns no binding, so hostname allow entries do not cover it. Address and CIDR entries still do.
  • host.containers.internal and host.docker.internal, which reach the host, are such /etc/hosts entries, so an entry naming them never matches; name their address instead. Under podman 5’s default rootless network, pasta, that is 169.254.1.2, an address that means the host only inside the container – so "169.254.1.2:8080" allows one host port, and a deny covering 169.254.0.0/16 covers the host too. Under podman 4 it is the host’s own address. Either way the connection reaches what it would with interception off, which excludes a host service listening only on loopback, and its record names the address the container asked for.
  • A host glob with no letter in it (*, 10.0.*, 192.168.*) describes addresses rather than names: it is matched against the destination address and never against a resolved name, so a registrable hostname that happens to match it (10.0.attacker.example against 10.0.*) grants nothing. allow = ["*"] and deny = ["*:22"] mean exactly what they always did.
  • A hostname deny entry is advisory against a client that does not cooperate. A container that reaches an address without a lookup here, and announces no name, leaves it nothing to match: under default = "allow", deny = ["evil.example"] lets a connection to that address by literal IP through. A portless entry also refuses the lookup (see Name lookups), which shuts the interceptor’s own resolver and no other path. Only an address, a CIDR, an address glob (203.0.113.0/24, *:22), or default = "deny" binds such a client.
  • A hostname allow entry grants the address its name resolved to, not the site. Under the policy above, a container that has looked up allowed.example can connect to that address and announce other.example, a name served from the same address by a CDN or shared host, and is allowed for as long as the binding lasts. Only deny entries weigh the announced name; nothing checks it against the entry that earned the allow.

Client-asserted names are still recorded. An audit record carries the best known name in outrig.host and says where it came from in outrig.host_source, which is resolved for a name the interceptor validated and asserted for one the client claimed.

Changed in 0.2.0. Before this release a client-supplied Host: or SNI satisfied a hostname allow entry on its own, so an allowlist permitted more than it appeared to. The same allowlist is narrower now. A container whose name resolution does not go through the interceptor may need address or CIDR entries to keep working.

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.

[repo]

Global config only. [repo] says what a repo may take from your machine, through its config or through the labels of the images it builds or names. Today that is one key, env-allow:

[repo]
env-allow = ["GITHUB_TOKEN", "HTTPS_PROXY"]    # optional; default: none

A ${VAR} reference in a repo config’s build-args or in an MCP server’s env is resolved from the host environment, and the value goes to code the repo controls: a build of its own Dockerfile, or a server command it chose. The repo config sits inside the read-write workspace, so it may be committed hostile, or edited by the agent for the next session. A repo config file may therefore name only the variables env-allow lists, and the list is empty by default:

  • A reference to a variable that is not listed is refused when the config loads – by outrig run, outrig mcp, and outrig build alike, before anything is built or started, and before the variable is read. The error names the image, the build-arg or the server and its env key, the variable, and the file that declared it, and says to list the variable here.
  • A literal needs no entry. Neither does anything you wrote or typed yourself: --env values on the command line, and the build-args and env of images declared in the global config, which may reference any variable.
  • Every image a repo config file declares is checked, used or not, because loading does not know which one a session will select. A --config file is a repo config for this rule, so a profile of your own loaded that way needs its variables listed here too.
  • An image’s label is held to it too. The org.outrig.mcp label is not a config file, but it is text the image’s author wrote – a Dockerfile in the repo, which the agent can edit, or an image the repo names – and a ${VAR} in a server’s env there is resolved from your environment just the same. Once the label has merged in, a reference to a variable that is not listed is refused when the session starts, by outrig run, outrig mcp, and outrig mcp show-merged alike. The error names the server, the env key, the variable, and the image, and it comes before any sidecar starts or any server runs, so the variable is never read. This covers the primary image’s label and each named sidecar’s, whoever named the image: your global config and --image too. A server your config declares by name replaces the label’s entry whole and follows the rules above instead. An image of your own that references a variable – an outrig image build image whose image.toml has ${DB_TOKEN} – needs it listed here to start. outrig image build itself is not held: it runs the servers of a directory you chose.
  • One list covers all of these places – build-args, MCP env, and a label’s env – and applies to every repo this machine runs. An entry is an exact variable name, ^[A-Z_][A-Z0-9_]*$ as in a ${VAR} reference. There are no patterns.

A variable on the list is the repo’s to route. The repo decides which build-arg or server environment receives it, in its config or in a label it controls, and the Dockerfile RUN step or MCP server that gets it can do anything the container may with it: print it, write it into the workspace, or send it wherever the session’s network policy lets traffic out. A build-arg is also recorded in the image’s history (podman history --no-trunc). List a variable only if you would hand it to everyone who can change that repo’s config or the images it builds or names, the agent included.

A repo config may not declare env-allow, even as an empty list, any more than it may set session-root or the [network] policy keys: it would be answering for itself. The load rejects the file and names the key.

The global config is read leniently (see What a load sets aside), so an env-allow that is misspelled, is not a list, or holds an entry that is not a variable name is set aside with a warning instead of stopping every repo on the machine. That leaves the list empty and every reference refused, an image label’s included: a mistake here narrows what a repo may read and never widens it. The warning is printed before the refusal it causes.

[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. The remote styles carry an api-key, which names a variable in the operator’s environment, so they belong to the global config: a repo config that declares one is rejected at load, whatever name it uses. A repo config may declare keyless style = "mistralrs" providers, and its models may name any global provider. Where both files declare the same name, the repo entry replaces the whole global 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
stylestringyes–Must be "openai" for this row.
base-urlstring (URL)yes–HTTPS endpoint for the provider.
api-keystringyes–Env-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 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 a call that never reaches the endpoint. While no attempt has got bytes back – a refused connection, an unresolvable host, a TLS mismatch – the loop stops after 30 seconds instead, on the reasoning that a wrong address does not come right in ten minutes. Getting connected is separately capped at ten seconds per attempt, so a host that drops packets rather than refusing them cannot hold an attempt open for the whole request-timeout-secs first. Both are fixed and have no key; the loop takes whichever of the two budgets is shorter, so setting this one below it, or to 0, still wins – for the retrying. Neither budget cancels an attempt already in flight, so a value below ten seconds does not shorten the connection cap, and an unreachable address is settled in about 30 seconds rather than exactly 30. Neither bounds the wait for a response once connected – that is request-timeout-secs, and it is why that default is high.
  • 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
stylestringyes–Must be "anthropic" for this row.
base-urlstring (URL)yes–HTTPS endpoint for the provider.
api-keystringyes–Env-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"

Deprecated. This provider style and the local-llm build feature are deprecated and will be removed in a future release. Run the model under an OpenAI-compatible local server (Ollama, vLLM, llama.cpp) and use a style = "openai" provider with a localhost base-url instead. Nothing is removed in this release: configs carrying this style still parse, still validate, and still run on a --features local-llm build. The removal can only land in a release after the one that first carried this warning. See Concepts -> In-process LLMs for a before/after migration.

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
stylestringyes–Must be "mistralrs" for this row.

base-url, api-key, and any other key are unknown on style = "mistralrs": the table above is the whole of it, and a key outside it is set aside with a warning exactly as it is anywhere else in this schema. 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 an in-process provider and the models over it, and the same config works for teammates whether or not they built with the feature on. (The remote providers beside it live in each teammate’s global config; see api-key syntax.)

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.

A provider that carries an api-key is accepted only from the global config (or the file named by --global-config). The reference is resolved on the host, and the provider’s base-url says where the host process sends the result; a repo config that could pair the two would decide where the operator’s secret goes. outrig run, outrig mcp, and outrig build all refuse a repo config – .agents/outrig/config.toml or a --config file – that declares one, naming the provider and the variable.

See Concepts -> LLM Providers.

[models.<name>]

A model entry has two shapes, and sets exactly one of them:

  • A provider shape – provider plus whatever that provider needs to identify the weights or wire-format model name. The required fields depend on the provider’s style.
  • An alias shape – alias, naming one or more other models. No provider-shape field is allowed alongside it.

Alias models

An alias gives one name to another model, or to an ordered set of provider-equivalent ones.

# One name for one model. Repoint this line and every agent naming `opus`
# follows, with no other edit.
[models.opus]
alias = "opus-5"

# One name for a set of equivalents, in preference order.
[models.smart]
alias = ["opus-5-bedrock", "opus-5-anthropic", "opus-5-azure"]

# Aliases may name aliases. This flattens to opus-5-bedrock,
# opus-5-anthropic, opus-5-azure, haiku-5.
[models.default]
alias = ["smart", "haiku-5"]
KeyTypeRequiredDefaultDescription
aliasstr/arryes–Other model names, in preference order.

An alias is a model. It lives in the same table, and every place that accepts a model name accepts it unchanged: --model, default-model, [agents.<name>].model, and the outrig__subagent tool’s model argument.

An alias resolves by flattening depth-first in config order, keeping the first occurrence of a repeated name. OutRig then picks the first candidate this build could actually reach: its provider exists, its style is one this build has a client for, and its api-key variable is set and non-empty. That is what lets one committed config serve a laptop with ANTHROPIC_API_KEY and a CI runner with a Bedrock role.

Selection happens once, when the session starts, and answers “am I configured for this” – not “is this endpoint up”. An alias with no reachable candidate fails the session, naming each candidate and why it was skipped.

The order is a runtime fallback as well. A model call that fails against one candidate – after that endpoint’s own retries are spent, or immediately on a failure no retry fixes, such as a 401 – moves to the next, and prints that it did. Every call starts again at the head of the list, so one rate-limit window does not demote the preferred vendor for the rest of the session.

The whole chain is bounded by one retry-budget-secs rather than one per candidate, and the value used is the one on the first selectable remote candidate’s provider – a retry-budget-secs set on any later candidate’s provider is not consulted. So a 0 there disables retries for the whole chain, and reordering an alias can change which budget governs it.

In-process candidates are skipped when finding that value, since style = "mistralrs" has no retry-budget-secs key – it does no HTTP and so has nothing to retry. An alias headed by a local model takes its budget from the first remote candidate after it. An alias of only local models has none, and needs none.

When every candidate has failed the report names each one’s reason, and what ends depends on why. If at least one failed recoverably – a rate limit, an unusable response – the turn ends, keeping any tool calls it had already run, and can be continued, or sent again if it failed on its first call. If every one was terminal, such as a 401 at each vendor, the session ends, exactly as that failure ends it for a single model: no resend can satisfy credentials that are refused everywhere.

A single-target alias is pure renaming, so it keeps its target’s own errors – an unset key still names the variable, and an in-process model in a build without local-llm still says to rebuild.

Two spelling notes. alias = "opus-5" and alias = ["opus-5"] are the same config; a round-trip through OutRig rewrites the first as the second, the same way model-file behaves. And an OutRig older than this feature refused every key it did not know, so it rejects a config using alias outright rather than degrading – worth knowing before putting one in a shared repo config.

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
providerstringyes*–Name of an entry in [providers.<name>].
identifierstringyes–Model id passed to the provider API.
max-tokensintegerno–Output-token ceiling per turn, see below.

* Required unless the entry sets alias instead, which forbids every field in this table – max-tokens included.

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. A value must be at least 1: 0 leaves no room to answer and is refused at load, on a model and on an agent alike.

Note the spelling. In config it is max-tokens, like every other key; max_tokens is an unknown key, ignored with a warning. 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

Deprecated with the provider style above, and removed at the same time. The six weight fields below have no counterpart on a style = "openai" model: a local server owns the weights, quantization and device placement itself. See Concepts -> In-process LLMs.

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
providerstringyes**–Name 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 id–GGUF path(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.

** Required unless the entry sets alias instead, which forbids every field in this table.

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, at least 1, 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, an existing directory. 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, an absolute path other than /.
  • 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".

No path here may contain :, which is how podman separates the fields of a bind mount – see Validation rules.

A repo config may not declare a host-path (primary, extra, or sidecar mount) that covers your home directory or one above it, or – read-write – the global config file, its directory, or a directory its path passes through (a symlink or missing component there could be swapped to redirect it): the repo config sits inside the workspace, so an agent could otherwise rewrite the global config for the next session. Declare such a mount in the global config if you mean it. A workspace no config declares is held to the same two rules.

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. A reference reaches buildah by name and outrig shows it only as the reference, as the MCP env value syntax below describes for podman. buildah itself records every build-arg a RUN step sees, value included, in the image’s history (podman history --no-trunc), so a build-arg does not keep a secret out of the image. In a repo config a reference may name only a variable the global config lists in [repo] env-allow; one you declare in the global config may name any.

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"]
unmask   = ["/proc/*"]
  • 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, letting any setuid-root binary in the image be used; see Containers for the tradeoff. A nested container runtime does not need this cleared – see Nested container runtimes.
  • devices (array, optional, default: []): host device nodes to pass through, emitted as one --device=<entry> per entry in declaration order. An entry is podman’s own form, <host-path>[:<container-path>][:<permissions>]: a bare absolute path passes the node through, <container-path> (absolute) puts it at another name, and <permissions> is some of r, w, m. Rootless podman does not enforce <permissions> – :r left the node writable – so do not rely on it.
  • unmask (array, optional, default: []): paths to exclude from podman’s default masking, emitted as one --security-opt=unmask=<path> per entry in declaration order. Entries are absolute paths with filepath.Match glob syntax, and reach podman verbatim – asking for /proc/* never widens into ALL. The one non-path value is ALL, podman’s “mask nothing” token, which must be spelled in exact uppercase and must be the list’s only entry; see the validation rules below for why. This turns a hardening flag off; see Containers for what it re-exposes.

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, told apart by type – an array is the short form, a table the full form:

[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" } }
tests = { command = ["test-mcp"], call-timeout-secs = 1800 }

# 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. In a repo config,
# FETCH_TOKEN has to be listed in the global config's [repo] env-allow.
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, which stores them in the container’s config, visible to podman inspect on the host. A ${VAR} value reaches podman by name rather than on its command line; see the value syntax. In a repo config, or in an image’s org.outrig.mcp label, a reference may name only a variable the global config lists in [repo] env-allow.

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

  • call-timeout-secs (integer, optional, default: the top-level mcp-call-timeout-secs, else 600): how long one tools/call to this server may run, 1 to 3600. Past it outrig sends the server notifications/cancelled and the model gets a timeout error in place of a result. Any transport, any placement. For a server whose tools run builds or test suites; initialize and tools/list are not affected – they have a fixed 120-second bound each.

Any other key in a full-form entry is set aside with a warning, as it is in every other table. Read that warning before the session goes on: an entry whose command is misspelled has none, so beside sidecar or image it runs as an entrypoint-stdio server. In an org.outrig.mcp label or an image.toml [mcp] table the key is still an error.

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 the tools the server advertises (<server>__<tool>). The rule bounds no length, but a tool name outrig has to rewrite to fit 64 characters keeps the prefix only for a name of 54 characters or fewer – see Concepts -> MCP Servers.
  • Each env value is either a literal string forwarded verbatim or a ${VAR} reference resolved from the host environment at MCP startup and shown only as the reference – see the subsection below. A reference in a repo config file is refused at load, and one in an image label when the session starts, unless the global config’s [repo] env-allow lists the variable.
  • Images can provide the same table via their org.outrig.mcp OCI label (placement keys are repo-config-only and rejected in labels; call-timeout-secs is allowed). Repo config entries override image entries by server name, wholesale – a label’s call-timeout-secs does not survive a config entry of the same name that omits it; 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. The label is part of the image’s cache key, so changing a primary-hosted entry’s env or call-timeout-secs rebuilds the image.

[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 key on the left is held to ^[A-Za-z_][A-Za-z0-9_]*$, which is the wider of the two grammars on this line: a key names a variable inside the container, where http_proxy is ordinary, while a ${VAR} reference names one in your own environment. podman and buildah read each entry as KEY=value and split it at the first =, so a key holding one – "A=B" = "c" – would set A to B=c, and an empty key is refused by the engine itself; both are refused when the config loads, naming the key and the file. The same rule holds a build-args key, an org.outrig.mcp label’s env key, a standalone image.toml’s, and a --env key you type.

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.

In a repo config file a reference is first held to the global config’s [repo] env-allow: a variable that is not listed there is refused when the config loads, whether or not it is set. A reference in an image’s org.outrig.mcp label is held to it too, when the session starts and the label has merged in. A literal is not a reference and needs no entry, and neither does a value you type with --env.

A reference’s value stays out of what outrig prints and off podman’s command line, which any local user can read with ps for as long as the command runs – for an exec-stdio server, the whole session. outrig’s errors, the --verbose transcript (container.log and the terminal), and the RUST_LOG=debug log all show the entry as GH_TOKEN=${GITHUB_TOKEN}, the way the config wrote it. podman is given a bare --env GH_TOKEN, with the value in podman’s own environment, which only your user can read, and copies it into the container from there. build-args reach buildah the same way, as a bare --build-arg.

The exception is a key podman or buildah would read from that environment themselves, where setting it would change what they do. Such a key keeps its value on the command line, though outrig still shows it as the reference:

  • process and locale: HOME, PATH, TMPDIR, USER, LOGNAME, SHELL, TERM, TZ, TZDIR, LANG, LANGUAGE, SUDO_USER, container, and any key starting LC_, LD_, XDG_, or CLICOLOR
  • proxies: any key ending _PROXY, in either case
  • the Go runtime and its TLS roots: GODEBUG, GOGC, GOMAXPROCS, GOMEMLIMIT, GOTRACEBACK, SSL_CERT_FILE, SSL_CERT_DIR
  • systemd and D-Bus: NOTIFY_SOCKET, INVOCATION_ID, JOURNAL_STREAM, and any key starting LISTEN_, WATCHDOG_, or DBUS_
  • the containers stack: REGISTRY_AUTH_FILE, REGISTRIES_CONFIG_PATH, GNUPGHOME, SSH_AUTH_SOCK, SOURCE_DATE_EPOCH, BUILD_REGISTRY_SOURCES, CI_DESIRED_DATABASE, SUPPRESS_BOLTDB_WARNING, DISABLE_HC_SYSTEMD, LOGLEVEL, OPT, MOBY_DISABLE_PIGZ, BBOLT_VERIFY, BURNTSUSHI_TOML_110, and any key starting CONTAINER_, CONTAINERS_, STORAGE_, BUILDAH_, PODMAN_, DOCKER_, CNI_, or OCICRYPT_
  • internal markers: any key starting _, such as _PODMAN_PAUSE or _CONTAINERS_*

The list covers what podman 4.9 and 5.7 and buildah 1.33 and 1.42 read, including the libraries they are built from.

A proxy key that references the variable of its own name, such as HTTPS_PROXY = "${HTTPS_PROXY}", is passed by name all the same: podman and buildah already have that value and read proxies as they find them. No other key on the list is, even self-referenced, because the engine may rewrite its own copy first – buildah makes a relative TMPDIR absolute. To keep a secret off it, give the secret a key outside these – a key is yours to choose, and the server reads whichever name you set.

outrig cannot redact what the server, or a build step, prints itself.

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
[models.fast]                     [models.fast]                      repo overrides
                                  [models.review]                    repo only

Two maps have a repo-side restriction. A repo config may declare only providers without an api-key (style = "mistralrs"), so every keyed provider in the effective config is one the global file declared, base-url included; a repo [models.<name>] names a global provider the way fast does above. And an image a repo config declares may reference a host variable, in build-args or an MCP env, only if the global config’s [repo] env-allow lists it. A repo image replaces a global one of the same name whole, references included, so the restriction follows whichever file’s entry wins: the references in an image you declare in the global config are yours, and are not held to the list. A server an image’s label declares is held to it wherever the image was declared, since the label is the image author’s text; the entry your config declares for the server by name replaces the label’s, and follows the rule above.

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 merges per key like [workspace]’s primary fields: a repo mode overrides the global one, and when the repo is silent – whether it omits [network] entirely or writes the table without a mode – the global mode remains in effect. If neither file declares it, the built-in default applies. This matters when global config enables audit or filter mode and a repo explicitly sets mode = "default", which is the only way a repo turns interception off. Network policy keys are global-only: a repo config that sets network.default, network.allow, or network.deny is rejected at load, and no repo value can reach the effective policy even if it carries one.

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
--config <path><path>’s parent directory
~/.outrig/config.tomlthat file’s directory (~/.outrig/)
--global-config <path><path>’s parent directory
--volume (no file at all)the current directory

A --config path that ends in .agents/outrig/config.toml is that repo’s own config and takes the first row; any other takes the second. See Reference -> CLI.

The last row is not a config file: outrig run --volume and outrig mcp --volume add a mount that no file declared, and the base that gives a typed path its meaning is the directory it was typed in – the one the shell itself would resolve it against. The repo root is not that directory for a --volume passed from a subdirectory of the repo.

Absolute paths are used as-is and ignore the rule entirely. This applies to [images.<name>].dockerfile and .context, to [workspace].host-path, to host-path in both [[workspace.mounts]] and [sidecars.<sc>.mounts], and to session-root and model-cache-root. Because [workspace] merges per key, a host-path inherited from the global file resolves beside that file, not from the repo root. The two roots are global-only, so they always resolve beside the global config.

A path whose first component is ~ starts from your home directory instead: ~ alone is the home directory, and ~/.cache/example a directory inside it. Like an absolute path, it means the same place whichever file declares it. The ~ has to be the whole first component, so ~alice/src is an ordinary relative path, and ./~/src reaches a directory really named ~. Nothing else is expanded: the ${VAR} form that api-key, build-args, and MCP env accept is literal in a path. Your home directory is $HOME, or your passwd entry’s when HOME is unset or empty; without either, ~ is an ordinary directory name too.

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. session-root and model-cache-root are the exception: having one possible base, they are resolved as they are read, so a loaded config holds them absolute and re-serializes them that way.

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 is repo-relative rather than file-relative: [models.<name>].model-path resolves against the repo root no matter which file declared it, and does so for the existence check and for the load alike. A global [models.<name>] with a relative model-path therefore follows whichever repo is current, so give that one an absolute or ~ path. See 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, [workspace].host-path, 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
mcp-call-timeout-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

[repo]
env-allow = ["GITHUB_TOKEN"]                     # 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"

[network]
mode = "audit"   # the only `[network]` key a repo config may set

# No `[providers.<name>]` -- a provider with an `api-key` belongs in the global config; the
# agents below name the global models.

[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" } }
  tests = { command = ["test-mcp"], call-timeout-secs = 1800 }

What a load sets aside

Every outrig on a machine reads the same global config, so a file written for a newer outrig should not stop an older one. A load sets aside what it cannot use, prints a warning on stderr for each thing it set aside, and goes on:

[outrig] warning: /home/you/.outrig/config.toml:5: unknown key `events`, ignored
  • An unknown key is ignored, in the global config, the repo config, or a --config file: a typo, or a key a newer outrig reads. An unknown table is one warning, not one per key in it.
  • A value outrig cannot read, in the global config, is dropped with the smallest entry that holds it: a wrong type, an unknown variant such as a newer provider style, or a missing required key. That is the key itself, or one mount of a workspace.mounts or sidecars.<sc>.mounts list; a bad element of any other list drops the whole key, so no argv runs short an argument. When what is left of a table cannot stand alone – a provider without its style – the table goes too, with a warning of its own. In a repo config or a --config file the same value is an error, and when a misspelled required key is what an entry lacks, the error names the misspelling.

Validation then runs on what is left, so an entry something else needed is still an error – printed after the warning that explains it. A misspelled provider is warned about, and then its model is refused for having no provider. Likewise an env-allow that is set aside leaves the [repo] list empty, so a repo config’s references, and an image label’s, are refused, after the warning that says why. Some things are never set aside:

  • anything in [network], [images.<name>.security], or [sidecars.<sc>.security], where leaving a key out could give a container more access than the file asks for;
  • a TOML syntax error, which leaves nothing to read;
  • a table header with an unquoted . in a name, such as [models.opus-4.7], which is refused with a hint to quote it: [models."opus-4.7"].

outrig mcp self’s validate_config tool returns the same warnings beside its errors. An org.outrig.mcp image label and an image.toml, which no config load reads, still refuse an unknown key.

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 [models.<name>] must set exactly one of provider or alias. Setting neither, or setting alias alongside any provider-shape field (including max-tokens), is an error naming every offending key. Unlike the other model rules, this one and the three below are checked on every path, outrig build included – they establish the entry’s shape rather than resolve a cross-reference.
  • Every alias must name at least one model; alias = [] is an error.
  • Every name in an alias must name an existing [models.<name>].
  • Alias entries must not form a cycle. The error names the cycle, e.g. a -> b -> a.
  • An alias chain may be at most 32 hops deep. A graph that deep is a mistake rather than a configuration, and the limit is what makes it report as one.
  • 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 providers.<name>.base-url (on a remote style) must be an http:// or https:// URL. The error says what is wrong – no scheme, another scheme, no parse – without quoting the value, which may carry credentials. outrig build does not check it.
  • 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, or start with ~ for your home directory); the repo root is also the base it is opened against when the model loads, whatever directory outrig was invoked from. identifier is not allowed on mistralrs models. device, if set, must be one of cpu, cuda, cuda:N, or metal.
  • model-cache-root may be set only in the global config. A relative value resolves against that file’s directory, and a leading ~ is your home directory; 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.
  • mcp-call-timeout-secs at the top level and call-timeout-secs on an MCP entry, if set, must be between 1 and 3600 seconds. 0 is not legal, for the same reason as request-timeout-secs. outrig build checks both too.
  • [network].mode, if set, must be default, audit, or filter. filter additionally requires at least one global allow or deny entry.
  • A repo config may set [network].mode only; [network].default, [network].allow, and [network].deny are rejected there.
  • A repo config may declare only providers without an api-key (style = "mistralrs"); an openai or anthropic provider in .agents/outrig/config.toml, or in a --config file, is rejected there, naming the provider and the variable its key references.
  • [repo].env-allow may be set only in the global config; a repo config that declares it, even as env-allow = [], is rejected there. Each entry must be a variable name, matching ^[A-Z_][A-Z0-9_]*$; in the global config a bad entry sets the whole key aside with a warning.
  • Every ${VAR} in the build-args or an MCP env of an image that .agents/outrig/config.toml, or a --config file, declares must name a variable the global config’s [repo].env-allow lists. The load is refused otherwise, naming the image, the build-arg or the server and its env key, the variable, and the file. Images the global config declares, literal values, and --env values are not held to the list.
  • Every ${VAR} in the env of a server an image’s org.outrig.mcp label declares must name a variable the global config’s [repo].env-allow lists, whoever declared the image. It is the session start that is refused, naming the server, the env key, the variable, and the image, because the label is read off the image rather than from a config file. A server the config declares by name replaces the label’s entry whole and is not held here, and outrig image build does not hold the image.toml it is pointed at.
  • Every env key on an MCP entry and every build-args key must match ^[A-Za-z_][A-Za-z0-9_]*$. podman and buildah read each entry as KEY=value, so a key holding = sets a shorter name with the rest of itself at the head of the value, and an empty key is refused by the engine as it runs – after the image is acquired, naming neither the key nor the file. Every image-config is checked, whoever declared it: a key grants no access to your environment, so unlike a ${VAR} reference it is not a question of trust. The same grammar holds an org.outrig.mcp label’s env key and a standalone image.toml’s – outrig image build refuses one before stamping the label, and a session refuses a label that carries one – and a --env KEY=VALUE you type, refused by argument parsing.
  • 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.
  • call-timeout-secs in an org.outrig.mcp label or a standalone image.toml is held to the same 1 to 3600 range: outrig image build refuses it before stamping the label, and a session refuses a label that carries one out of range.
  • dockerfile and context must exist on disk, resolved against the declaring file’s directory, or your home directory for a leading ~ (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 or whitespace-only. A reference that is nothing but blanks names no image, and left to podman it fails at launch as image not known, naming neither the key nor the file; a sidecar’s image has always been held to the trimmed test, and now both are.
  • A build-from-Dockerfile [images.<name>] block key must be a valid container image repository component – lowercase letters and digits, separated by one ., one or two _, or a run of - (^[a-z0-9]+(([._]|__|-+)[a-z0-9]+)*$), the separators podman accepts – and at most 237 characters, because it becomes the built image’s repository and buildah refuses one over 255 once it prepends docker.io/library/. Image-name configs are exempt: their block key is just a label.
  • Every [security] rule below holds for a [sidecars.<sc>.security] block as well as an [images.<name>.security] one – a sidecar reuses the whole block. A sidecar’s failure is reported against the sidecar, sidecar "tools": devices entry ... must be an absolute path, rather than against an image config named sidecars.tools, which does not exist.
  • 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 shaped as podman parses it: <host-path>[:<container-path>][:<permissions>], at most three fields, the host and container path absolute or empty (meaning the host path), <permissions> each of r, w, m at most once, or empty (podman allows that for a device directory and refuses it for a single node, so validation leaves it to podman). : is accepted here but not in unmask because podman splits an unmask value into several paths, while the colons of a --device value separate the parts of one device. 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.
  • Every [images.<name>.security].unmask entry must be non-empty and either an absolute path or the literal ALL.
  • ALL must be spelled in exact uppercase, and must be the only entry in the list. Measured against podman 5.7, both near-misses fail silently: lowercase all still clears the masked paths – so /proc/acpi looks unmasked – while leaving the read-only ones, so /sys/fs/cgroup stays read-only. ["/proc/*", "ALL"] does the same, because podman lifts the read-only paths only when ALL comes first. outrig rejects both rather than silently reordering the list; every other entry is redundant beside ALL anyway.
  • Every non-ALL entry must be a well-formed filepath.Match glob – no unterminated [ class, no dangling \ escape. Podman logs a pattern syntax error for a malformed one and creates the container with the path still masked, so an unchecked typo would go quiet at launch and resurface later as an unrelated-looking failure.
  • No unmask entry may contain :. Podman splits an unmask value on it, so a colon-joined entry would be several paths wearing one entry’s clothes; declare one path per entry.
  • Unmask paths must not be duplicated within one unmask list.
  • session-root may be set only in the global config, and resolves as model-cache-root does; outrig creates it if missing.
  • workspace.host-path and 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, and a leading ~ is your home directory; an undeclared workspace.host-path is ., the repo root.
  • workspace.container-path and every workspace.mounts[*].container-path must be absolute and must not be /.
  • No bind-mount path may contain : – workspace.host-path, workspace.container-path, and both halves of every workspace.mounts[*] and [sidecars.<sc>.mounts][*] entry. Podman reads a bind mount as <host>:<container>[:<options>], so a path carrying one becomes a fourth field it refuses at launch, after the image is acquired and naming neither the key nor the file. This rule is about the path as written. A relative path that spells no : but resolves through a directory whose name has one is refused when a session starts, not here: it is a fact about this machine, and attaching to an existing container (outrig mcp --attach) mounts nothing and is unaffected. There is no escape for one in either case; a host directory whose name contains : cannot be mounted.
  • Extra workspace mount container-path values must be unique, including no collision with the primary workspace container-path. Container paths are compared after folding . and .., so /workspace/../workspace duplicates /workspace and /a/.. is /.
  • Every workspace.mounts[*].access, if set, must be either read-only or read-write.
  • An unknown key, at any level, is ignored with a warning rather than rejected, except in [network] and the security tables; see What a load sets aside.

See also

  • Concepts – narrative explanations of what these keys mean.
  • Reference -> CLI – flags that override or interact with config.