Config Reference
outrig reads two TOML files:
- Global config – user/machine-level. Default location:
~/.outrig/config.toml(or<XDG_CONFIG_HOME>/outrig/config.tomlifXDG_CONFIG_HOMEis 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 anapi-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. Keylessstyle = "mistralrs"providers are the exception. For the same reason it may reference a host variable inbuild-argsor an MCPenvonly 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
| Key | Type | Required | Where | Description |
|---|---|---|---|---|
default-image | string | no | repo | Default --image. |
default-agent | string | no | repo | Default --agent. |
default-model | string | if agent omits model | global | Fallback model name. |
session-root | path | no | global | Sessions root dir. |
model-cache-root | path | no | global | GGUF download cache dir. |
tool-call-max | integer | no | global | Per-turn tool-call max. |
tool-result-max | integer | no | global | Per-tool-result byte max. |
subagent-depth-max | integer | no | global | Max subagent nesting. |
subagent-width-max | integer | no | global | Max live subagents/agent. |
retry-budget-secs | integer | no | global | LLM retry budget (secs). |
mcp-call-timeout-secs | integer | no | either | MCP tools/call deadline. |
network.mode | string | no | either | Network mode. |
network.default | string | no | global | Filter fallback action. |
network.allow | array | no | global | Filter allow entries. |
network.deny | array | no | global | Filter deny entries. |
repo.env-allow | array | no | global | Variables 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 writelogs/network.jsonl.audit: allow all outbound session-container traffic, short of the per-container connection limit below, but write Zeekconn.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 – followsdefaultdirectly.
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
denyentry that names a host and has no port refuses the lookups it matches, under either default. Adenyentry 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 anallowhostname entry matches the name, whatever its port:github.com:443opens the lookup ofgithub.com.*.npmjs.orgdoes not matchnpmjs.org, so list the apex too. Anallowentry 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 adenyentry 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 anallowentry of its own, or the container has to connect by address. Underdefault = "allow", a name a hostnamedenyentry 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/hostsentry, a hardcoded address, DNS over HTTPS – earns no binding, so hostnameallowentries do not cover it. Address and CIDR entries still do. host.containers.internalandhost.docker.internal, which reach the host, are such/etc/hostsentries, so an entry naming them never matches; name their address instead. Under podman 5’s default rootless network, pasta, that is169.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 covering169.254.0.0/16covers 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.exampleagainst10.0.*) grants nothing.allow = ["*"]anddeny = ["*:22"]mean exactly what they always did. - A hostname
denyentry 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: underdefault = "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), ordefault = "deny"binds such a client. - A hostname
allowentry grants the address its name resolved to, not the site. Under the policy above, a container that has looked upallowed.examplecan connect to that address and announceother.example, a name served from the same address by a CDN or shared host, and is allowed for as long as the binding lasts. Onlydenyentries 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 hostnameallowentry 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, andoutrig buildalike, before anything is built or started, and before the variable is read. The error names the image, the build-arg or the server and itsenvkey, 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:
--envvalues on the command line, and thebuild-argsandenvof 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
--configfile 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.mcplabel 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’senvthere 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, byoutrig run,outrig mcp, andoutrig mcp show-mergedalike. The error names the server, theenvkey, 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--imagetoo. 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 – anoutrig image buildimage whoseimage.tomlhas${DB_TOKEN}– needs it listed here to start.outrig image builditself 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’senv– 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}"
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
style | string | yes | – | Must be "openai" for this row. |
base-url | string (URL) | yes | – | HTTPS endpoint for the provider. |
api-key | string | yes | – | Env-var reference, see below. |
request-timeout-secs | integer | no | 600 | HTTP timeout for LLM calls. |
retry-budget-secs | integer | no | 600 | Transient-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. 0disables 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-secsfirst. Both are fixed and have no key; the loop takes whichever of the two budgets is shorter, so setting this one below it, or to0, 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 isrequest-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.
0still 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}"
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
style | string | yes | – | Must be "anthropic" for this row. |
base-url | string (URL) | yes | – | HTTPS endpoint for the provider. |
api-key | string | yes | – | Env-var reference, see below. |
request-timeout-secs | integer | no | 600 | HTTP timeout for LLM calls. |
retry-budget-secs | integer | no | 600 | Transient-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-llmbuild 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 astyle = "openai"provider with alocalhostbase-urlinstead. Nothing is removed in this release: configs carrying this style still parse, still validate, and still run on a--features local-llmbuild. 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"
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
style | string | yes | – | 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 –
providerplus whatever that provider needs to identify the weights or wire-format model name. The required fields depend on the provider’sstyle. - 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"]
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
alias | str/arr | yes | – | 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"
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
provider | string | yes* | – | Name of an entry in [providers.<name>]. |
identifier | string | yes | – | Model id passed to the provider API. |
max-tokens | integer | no | – | 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:
-
[agents.<name>].max-tokens, then[models.<name>].max-tokens. -
The published ceiling for a Claude identifier outrig recognizes – 128000 for the
claude-opus-4-6and later families, 64000 for theclaude-opus-4,claude-sonnet-4, andclaude-haiku-4-5families. -
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"
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
provider | string | yes** | – | Name of a style = "mistralrs" provider. |
model-id | string | one of* | – | HF repo id, e.g. microsoft/Phi-3-mini-.... |
model-path | path | one of* | – | Local path to a GGUF file. |
model-file | str/arr | with id | – | GGUF path(s) inside the HF repo. |
revision | string | no | "main" | HF git ref to pin. With model-id. |
context-length | integer | no | model | Override the model’s default context window. |
device | string | no | "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’smax-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 or50): tool calls per turn.tool-result-max(integer, optional, default: top-level value or262144): bytes per result.subagents(bool, optional, default:true): whether this agent may launch subagents. Whenfalse, theoutrig__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 or3): how deeply this agent’s subagents may nest. See the top-levelsubagent-depth-max.subagent-width-max(integer, optional, default: top-level value or8): how many live subagents this agent may launch. See the top-levelsubagent-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 DockerfileARGs. Keys are ARG names. Values are either literal strings or${VAR}references resolved from the host environment atoutrig buildtime. A reference reaches buildah by name and outrig shows it only as the reference, as the MCPenvvalue syntax below describes for podman. buildah itself records every build-arg aRUNstep 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 topodman 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 addwrites 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-argsare user-defined DockerfileARGnames; 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 unlesscap-droporcap-addis 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 tofalserestores 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 ofr,w,m. Rootless podman does not enforce<permissions>–:rleft 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 withfilepath.Matchglob syntax, and reach podman verbatim – asking for/proc/*never widens intoALL. The one non-path value isALL, 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 thepodman execinvocation – or, for entrypoint-stdio servers, baked in viapodman create --env, which stores them in the container’s config, visible topodman inspecton 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’sorg.outrig.mcplabel, 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. Withcommand, the server is exec-stdio in that container; withoutcommand, 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 sidecarimagekey). Mutually exclusive withsidecar. Withcommand, the server is exec-stdio in that sidecar; withoutcommand, 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 onpodman create. Requiressidecarorimageand rejectscommand, 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
ENTRYPOINTand replacesCMD. Soargsis for images whose server is anENTRYPOINT: for an image that puts its server inCMDinstead,argsreplaces thatCMDand 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 needsimageand rejectscommand, because the anonymous sidecar it configures is the oneimagecreates:[images.dev.mcp] fs = { image = "docker.io/mcp/filesystem:latest", view = "primary", args = ["/workspace"] }The
argswritten 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-levelmcp-call-timeout-secs, else600): how long onetools/callto this server may run, 1 to 3600. Past it outrig sends the servernotifications/cancelledand 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;initializeandtools/listare 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
commandmust be on$PATHinside 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
envvalue 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-allowlists the variable. - Images can provide the same table via their
org.outrig.mcpOCI label (placement keys are repo-config-only and rejected in labels;call-timeout-secsis allowed). Repo config entries override image entries by server name, wholesale – a label’scall-timeout-secsdoes 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.mcplabel on cache misses, sooutrig 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’senvorcall-timeout-secsrebuilds 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’sENTRYPOINT, used only when this block is an entrypoint host – see below. Same semantics and the same podmanENTRYPOINT/CMDrule as theargskey 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 theoutrig-enterlauncher, 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 withworkspace(the view already holds the workspace) and withcapability-profile = "drop-all"(the view needs the mount capabilities). It grantsCAP_SYS_ADMINandCAP_SYS_PTRACEin the primary’s user namespace – a real posture change; see MCP Trust Model andSECURITY.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, orOutrig::add_sidecar/LaunchSpec::with_sidecarfrom 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 relativehost-pathresolves 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 startingLC_,LD_,XDG_, orCLICOLOR - 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 startingLISTEN_,WATCHDOG_, orDBUS_ - 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 startingCONTAINER_,CONTAINERS_,STORAGE_,BUILDAH_,PODMAN_,DOCKER_,CNI_, orOCICRYPT_ - internal markers: any key starting
_, such as_PODMAN_PAUSEor_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.
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 in | Relative paths resolve against |
|---|---|
.agents/outrig/config.toml | the repo root |
--config <path> | <path>’s parent directory |
~/.outrig/config.toml | that 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
--configfile: 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 aworkspace.mountsorsidecars.<sc>.mountslist; 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 itsstyle– the table goes too, with a warning of its own. In a repo config or a--configfile 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-imagemust name an existing[images.<name>]block.default-agentmust 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>]. Ifmodelis omitted,default-modelmust be set and must name an existing[models.<name>]. - Every
models.<name>.providermust name an existing[providers.<name>]. - Every
[models.<name>]must set exactly one ofprovideroralias. Setting neither, or settingaliasalongside any provider-shape field (includingmax-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 buildincluded – they establish the entry’s shape rather than resolve a cross-reference. - Every
aliasmust name at least one model;alias = []is an error. - Every name in an
aliasmust name an existing[models.<name>]. - Alias entries must not form a cycle. The error names the cycle, e.g.
a -> b -> a. - An
aliaschain 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>.stylemust 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 anhttp://orhttps://URL. The error says what is wrong – no scheme, another scheme, no parse – without quoting the value, which may carry credentials.outrig builddoes not check it. - Every
[models.<name>]whose provider has a remote style must setidentifierand must not set any ofmodel-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 hasstyle = "mistralrs"must set exactly one ofmodel-id/model-path. Whenmodel-idis set,model-fileis required – mistralrs’s GGUF loader needs a specific filename and HF repos typically hold many quantizations.model-fileaccepts either a single string (one GGUF file) or an array of strings (a multi-shard quantization, e.g.*-00001-of-00003.gguf).revisionis optional and only meaningful withmodel-id. Amodel-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 directoryoutrigwas invoked from.identifieris not allowed on mistralrs models.device, if set, must be one ofcpu,cuda,cuda:N, ormetal. model-cache-rootmay 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 between1and2000.tool-result-max, if set at the top level or on an agent, must be between1024and16777216bytes.subagent-depth-max, if set at the top level or on an agent, must be between1and16(1disables subagents).subagent-width-max, if set at the top level or on an agent, must be between1and16.retry-budget-secs, if set at the top level or on a remote provider, must be at most3600seconds.0is legal and disables retries.request-timeout-secs, if set on a remote provider, must be between1and3600seconds. Unlikeretry-budget-secs,0is 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-secsat the top level andcall-timeout-secson an MCP entry, if set, must be between1and3600seconds.0is not legal, for the same reason asrequest-timeout-secs.outrig buildchecks both too.[network].mode, if set, must bedefault,audit, orfilter.filteradditionally requires at least one globalallowordenyentry.- A repo config may set
[network].modeonly;[network].default,[network].allow, and[network].denyare rejected there. - A repo config may declare only providers without an
api-key(style = "mistralrs"); anopenaioranthropicprovider in.agents/outrig/config.toml, or in a--configfile, is rejected there, naming the provider and the variable its key references. [repo].env-allowmay be set only in the global config; a repo config that declares it, even asenv-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 thebuild-argsor an MCPenvof an image that.agents/outrig/config.toml, or a--configfile, declares must name a variable the global config’s[repo].env-allowlists. The load is refused otherwise, naming the image, the build-arg or the server and itsenvkey, the variable, and the file. Images the global config declares, literal values, and--envvalues are not held to the list. - Every
${VAR}in theenvof a server an image’sorg.outrig.mcplabel declares must name a variable the global config’s[repo].env-allowlists, whoever declared the image. It is the session start that is refused, naming the server, theenvkey, 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, andoutrig image builddoes not hold theimage.tomlit is pointed at. - Every
envkey on an MCP entry and everybuild-argskey must match^[A-Za-z_][A-Za-z0-9_]*$. podman and buildah read each entry asKEY=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 anorg.outrig.mcplabel’senvkey and a standaloneimage.toml’s –outrig image buildrefuses one before stamping the label, and a session refuses a label that carries one – and a--env KEY=VALUEyou 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
commandarray must be non-empty. sidecarandimageare mutually exclusive on an MCP entry,sidecarmust name a declared[sidecars.<sc>]block,imagemust not be empty, and animageentry’s server name must not collide with a sidecar name (it occupies that name).argson an MCP entry requiressidecarorimage, and is rejected next tocommand.argson[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". argsis rejected in anorg.outrig.mcplabel and in standaloneimage.toml, alongside the placement keys: labels declare exec-stdio servers, whose arguments belong incommand.call-timeout-secsin anorg.outrig.mcplabel or a standaloneimage.tomlis held to the same1to3600range:outrig image buildrefuses it before stamping the label, and a session refuses a label that carries one out of range.dockerfileandcontextmust 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, ordockerfile+context. Setting both shapes, neither,image-namewithbuild-args, or only one ofdockerfile/contextwithout the other is an error. image-namemust not be empty or whitespace-only. A reference that is nothing but blanks names no image, and left to podman it fails at launch asimage not known, naming neither the key nor the file; a sidecar’simagehas 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 prependsdocker.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":devicesentry ... must be an absolute path, rather than against an image config namedsidecars.tools, which does not exist. - Every
[images.<name>.security].capability-profile, if set, must be one ofdefault,no-net-raw, ordrop-all. - Every capability name in
cap-droporcap-addmust be non-empty and match^[A-Z0-9_]+$after optionalCAP_stripping. - Capability names must not be duplicated within
cap-dropor withincap-add, after optionalCAP_stripping. - The same normalized capability name must not appear in both
cap-dropandcap-add. - Every
[images.<name>.security].devicesentry 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 ofr,w,mat 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 inunmaskbecause podman splits anunmaskvalue into several paths, while the colons of a--devicevalue 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
deviceslist. - Every
[images.<name>.security].unmaskentry must be non-empty and either an absolute path or the literalALL. ALLmust be spelled in exact uppercase, and must be the only entry in the list. Measured against podman 5.7, both near-misses fail silently: lowercaseallstill clears the masked paths – so/proc/acpilooks unmasked – while leaving the read-only ones, so/sys/fs/cgroupstays read-only.["/proc/*", "ALL"]does the same, because podman lifts the read-only paths only whenALLcomes first. outrig rejects both rather than silently reordering the list; every other entry is redundant besideALLanyway.- Every non-
ALLentry must be a well-formedfilepath.Matchglob – 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
unmaskentry 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
unmasklist. session-rootmay be set only in the global config, and resolves asmodel-cache-rootdoes; outrig creates it if missing.workspace.host-pathand everyworkspace.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 undeclaredworkspace.host-pathis., the repo root.workspace.container-pathand everyworkspace.mounts[*].container-pathmust be absolute and must not be/.- No bind-mount path may contain
:–workspace.host-path,workspace.container-path, and both halves of everyworkspace.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-pathvalues must be unique, including no collision with the primary workspacecontainer-path. Container paths are compared after folding.and.., so/workspace/../workspaceduplicates/workspaceand/a/..is/. - Every
workspace.mounts[*].access, if set, must be eitherread-onlyorread-write. - An unknown key, at any level, is ignored with a warning rather than rejected, except in
[network]and thesecuritytables; 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.