contenox
Browse docs/

agents.toml

An agent declaration says one prompt, one tool list, one model and one permission setting. Everything else lives here.

It is TOML rather than JSON because it exists to be read and argued with, and JSON cannot carry the comments that make a value arguable.

Where it lives, and which wins

It is read from each root, weakest first:

  1. the defaults compiled into the binary
  2. ~/.contenox/agents.toml
  3. .contenox/agents.toml — the workspace, which wins
  4. [agents.<name>] in either file — one agent, overriding the rest

Then, stronger than any of them, the values a declaration actually states. And stronger than everything, policy.always_deny, which nothing overrides.

Overlays are partial. A file may set any subset; keys it omits keep the value inherited from the layer below. Overriding one knob does not mean restating the file, and naming one tool does not drop the built-in names.

A missing file is skipped. A malformed one is an error rather than a silent fall back to shipped values — an operator who edited a file and got the defaults anyway has been lied to.


[agents.<name>] — one agent at a time

Everything under [chain], [routing], [tools_policies] and [policy] applies to every agent. Nest it under [agents.<name>] and it applies to one:

# Everyone gets a large budget and a broad shell.
[chain]
token_limit = 131072

# The reviewer reads; it has no business running anything but git and go.
[agents.reviewer.chain]
token_limit = 32768

[agents.reviewer.tools_policies.local_shell]
_allowed_commands = "git,go"

<name> is the name contenox agent list shows. Your own declarations keep the name their frontmatter gave them; an agent read out of another tool’s directory carries that tool’s prefix, so a reviewer.md in .claude/agents/ is [agents.claude-code-reviewer].

The same partial-overlay rule applies, one level down: a key the section omits keeps what it inherited. That includes zero and falseretry_on_failure = 0 under one agent genuinely means zero even when the root says three.

Three things behave differently inside a per-agent section, all so a narrow override cannot quietly widen anything:

  • tools_policies merges per knob. Naming one shell knob keeps the rest of the shell policy and every other toolset.
  • postures merges per posture. Redefining auto_edit leaves read_only and ask_always as the root declared them. ([envelopes.*] is not per-agent: an envelope is a named surface a session runs under, so it lives at the root and is picked by name.)
  • always_deny and always_allow append after the root’s, never replace them. First match wins in the emitted policy, so the root’s credential denies keep their position ahead of anything an agent grants itself.

A section naming an agent that does not exist is reported, not ignored — a mistyped name otherwise reads as a knob that does nothing.

Editing this file regenerates every agent it affects on the next run; you do not have to touch the declaration.


[chain]

KeyTypeDefaultMeaning
token_limitint131072Context budget for chat history, and the source of the per-call tool-result cap. Must be positive — a zero budget reports every tool result as too large regardless of its real size.
max_tokensstring"{{var:max_tokens|16384}}"Output cap per model call. Macros are honoured, so a caller overrides it per run without editing the chain.
thinkstring"{{var:think}}"Reasoning effort. A declaration’s own effort overrides this; the vocabularies coincide.
main_roundsint60Edge traversals before the main stage hands to recovery. Must be positive.
recovery_roundsint10Traversals before recovery summarises the failure rather than looping. Must be positive.
retry_on_failureint0Per-task retries, for transient provider errors.

[routing]

KeyTypeDefaultMeaning
modelstring"{{var:model}}"Emitted model.
providerstring"{{var:provider}}"Emitted provider.
pin_modelboolfalseUse the model the declaration itself names instead of the templates, when the registry resolved it.

Templates are the default deliberately: an agent written against one vendor must not pin this machine to that vendor — routing stays whatever contenox config set default-provider says. The model the declaration named is kept in provenance either way. Turn it on for a single agent that genuinely needs a specific model with [agents.<name>.routing].

[tools_policies.<toolset>]

Free-form key/value passed straight into the emitted chain’s execute_config.tools_policies. A declaration has no word for these, which is the main reason this file exists.

Only the toolsets an agent actually exposes are carried into its chain, so an agent does not carry policy for tools it cannot reach.

Shipped knobs:

[tools_policies.local_shell]
_allowed_commands = "ls,cat,echo,pwd,which,find,grep,git,go,python3,node,npm,make,cargo,curl,wget,jq"
_denied_commands = "sudo,su,dd,mkfs,fdisk,parted,shred"

[tools_policies.local_fs]
_allowed_dir = "."
_max_read_bytes = "262144"
_max_output_bytes = "131072"
_denied_path_substrings = "node_modules,.git/,dist/,/.next/,/out/,package-lock.json"

Add a table for any toolset you connect; see Tools for the keys a given toolset reads.

The shipped file also carries a [tools_policies.webtools] block, which is inert: no provider serves the native web toolset in this build. It is kept for the same reason the network.* envelope axes are — the bounds are the part worth not losing, so a revived toolset comes back to them rather than to nothing.

[policy]

KeyTypeDefaultMeaning
default_actionallow | approve | denyapproveApplied to a tool call no rule matched — including any tool you mapped yourself.

[policy.compute]

KeyTypeDefault
max_tool_callsint300
max_tokensint2000000
on_exhaustedstringfinish_stuck
max_turnsint0

A declaration that states a turn cap (maxTurns) lowers max_tool_calls when it is tighter, and never raises it. The two are not the same unit — a turn may carry several calls — so the mapping only tightens.

max_turns applies to a mission-role agent only, and only two values mean anything, because the drive loop issues at most two prompts — the unit’s own, plus one nudge when it reports nothing. 0 keeps the nudge; 1 drops it. Anything larger is already above the ceiling and is not emitted, since a bound the runtime cannot honour would read as enforced while doing nothing. A primary agent never gets one: its turns are the operator’s own prompts.

max_tool_calls is validated but not enforced by the shipped hosts. max_tokens is best-effort and inert when a provider reports no usage. on_exhausted supports only finish_stuck.

[[policy.always_deny]]

An array of tables emitted first, under every posture, in every emitted policy. Rules are first-match-wins, so their position is what makes them effective.

[[policy.always_deny]]
tools = "local_fs"
tool = "*"
when_key = "path"
when_op = "glob"
when_value = "**/{.ssh,.aws,.kube,.config/gcloud}/**"
KeyMeaning
toolstoolset name, or *
tooltool name, or *
when_key / when_op / when_valueoptional condition; when_op takes any policy operatorglob, eq, host, command_prefix_allowlist, and the rest

No declaration can waive these: the format has no way to talk about credential paths, so it has no way to consent to them.

[[policy.always_allow]]

The mirror of always_deny, for tools the postures do not name — typically ones you connected. Emitted after the denies, so first-match-wins keeps a credential deny ahead of any grant here.

[[policy.always_allow]]
tools = "tavily"
tool = "search"

Without it, a tool you named under [tools] matches no rule and falls to default_action, which asks a human on every call.

[envelopes.<name>]

An envelope is a permission surface with a name. It states what a session may reach, and the runtime transpiles it into the HITL policy the approval engine already evaluates. An envelope transpiles to a policy; nothing about the engine changes.

[envelopes.review]
extends = "read_only"
description = "Read the tree, run the test suite, change nothing."
default_action = "deny"

[envelopes.review.shell]
grant = "deny"
prefix_allowlist = ["go test", "go vet"]

<name> must match ^[a-z0-9][a-z0-9_-]*$. No dots — a dot would collide with TOML sub-table syntax, so [envelopes.a.b] could not name an envelope.

The name is the whole identity. It transpiles to .generated/hitl-policy-<name>.json, and --hitl-policy review, --hitl-policy hitl-policy-review.json and config set hitl-policy-name hitl-policy-review.json all resolve to it. Per-agent policies are emitted into that same namespace under the same filename rule, so an envelope and a declared agent can want the same file. The envelope owns it, and the collision is reported rather than silently overwritten: contenox agent list prints not carried <agent>: posture — "<name>" is also an envelope in agents.toml, which owns hitl-policy-<name>.json; this agent runs under the envelope. The declaration still compiles; the one thing it cannot carry is its own posture. The shipped set uses this on purpose — acpx is both a declared agent and an envelope — so naming an envelope after an agent is how you put that agent under an envelope you wrote.

The render is derived and disposable. A hitl-policy-<name>.json you write at the top level of .contenox/ or ~/.contenox/ shadows it and is never rewritten — see Policy resolution order.

Keys

KeyTypeMeaning
extendsstringOne other envelope in this table — one parent, never a list
descriptionstringProse, carried into the rendered file’s header
default_actiongrantApplied to a call no emitted rule matched. Omitted fail-closes to approve
files.readaxisread_file, read_file_range, and the directory probe
files.writeaxiswrite_file, edit_file, sed
shellaxislocal_shell
network.read / network.writeaxisReserved — see below
missions.fireaxismission_start
missions.answeraxisWho besides a human may answer this mission’s asks
toolstablepattern = grant for tools you connected
computetablemax_tool_calls, max_tokens, max_turns, on_exhausted, model_allowlist, backend_allowlist
attentiontableallow_agent_answers, max_agent_answers, allow_agent_approvals, max_agent_approvals
trusted_binariestabledirs, hashes — see Trusted binaries
always_deny / always_allowarray of tablesSame shape as [[policy.always_deny]] above

An unknown key is an error naming the known ones, not a key that silently does nothing.

Grants

Wherever this table takes an action — an axis, a tools pattern, default_action — it equally takes a table carrying grant plus whatever that position refines. The two forms are the same document: shell = "approve" is sugar for shell = { grant = "approve" }. For an axis, dotted keys and sub-tables are interchangeable too, so files.read = "allow" and an [envelopes.x.files] with read = "allow" arrive identically.

Every grant, in its table form, accepts timeout and on_timeout — see Bounding the wait.

Axes

An axis you leave unset emits no rule at all. It falls through to default_action; it is not implicitly approve.

AxisRefinements
files.read, files.writedeny_paths, approve_paths — lists of globs, one rule emitted per glob
shellblacklist, substitution (deny | approve | off), prefix_allowlist, ask_always
network.read, network.writedeny_hosts
missions.fire, missions.answernone
[envelopes.mine.files.write]
grant = "approve"
deny_paths = ["**/{.ssh,.gnupg}/**", "**/hitl-policy*.json"]

[envelopes.mine.shell]
grant = "approve"
blacklist = ["mkfs", "fdisk", "shred"]
substitution = "approve"
prefix_allowlist = ["go test", "ls", "cat"]
ask_always = ["rm", "sudo", "chmod"]

Every list except the two path lists is joined into one comma-separated condition value, so an entry containing a comma is refused rather than silently read as two.

missions.answer is the one axis whose carrier is not a rule: the mission toolset is exempt from approval, so it compiles into the attention block instead — allow grants agent answers, approve grants answers and rulings on gated calls, deny omits the block so a human decides. An explicit [attention] key wins over what the axis would set.

Bounding the wait

An approve grant stops the call and waits for a person. How long it waits, and what happens when nobody comes, are yours to set — on any grant, in its table form. With timeout, a grant can say all four things there are to say about a call:

What you meanHow you write it
allow — run it unattendedfiles.read = "allow"
ask, and wait an explicit duration, then resolve by on_timeoutshell = { grant = "approve", timeout = "30m", on_timeout = "deny" }
ask, and wait with no deadline — until somebody answersshell = { grant = "approve", timeout = "never" }
deny — refuse it; it never reaches a personfiles.write = "deny"

All four in one envelope:

[envelopes.mine]
description = "Four ways to say what a call may do."
default_action = "approve"

files.read = "allow"
shell = { grant = "approve", timeout = "30m", on_timeout = "deny" }
files.write = "deny"

[envelopes.mine.tools]
"github.merge_pr" = { grant = "approve", timeout = "2h", on_timeout = "deny" }
"deploy.production" = { grant = "approve", timeout = "never" }
KeyValue
timeoutA duration written the way Go writes one: 90s, 30m, 2h, 1h30m. Whole seconds, at most 168h (seven days). Or never (also forever, indefinite): no deadline at all
on_timeoutdeny — what the expired ask resolves to. Refused beside timeout = "never", which never expires

Omit them and nothing changes. The rendered rule carries no timeout_s and no on_timeout, exactly as before these keys existed. That is not “waits forever”: with no rule deadline the ask is bounded by the host’s approval ceiling — contenox config set approval-ceiling <duration|never>, seven days until you set it — and then denied. Writing timeout is how that number stops being one nobody chose.

timeout = "never" is how you say “wait for me”. It is a wait with no deadline, not a very long one:

[envelopes.mine.tools]
"deploy.production" = { grant = "approve", timeout = "never" }

The rendered rule carries "timeout_s": -1. The durable row is written with no expiry, the expiry sweep’s range excludes it, contenox approvals list shows its EXPIRES-IN as never, and it is still pending — and still answerable — after a restart and after the seven-day cap a number could have stated. This is the wait that makes an unattended run survivable: nothing resolves the question on the operator’s behalf. Writing on_timeout beside it is refused, naming the envelope and the axis, because nothing can ever read it.

deny is the only on_timeout this build can express, and both refusals say why: allow is rejected by the policy schema, because an ask that allows itself when nobody answers bypasses the approval it exists to require; approve is rejected because the runtime resolves every expiry that is not allow as a denial, so it would read as its opposite.

A wait only attaches to a rule that asks. It is refused, naming the envelope and the axis, when the grant it sits on emits no approve rule — grant = "allow" with no ask_always, missions.answer (which compiles to the attention block, not a rule), a reserved network.* axis. A grant that never asks may still bound the asks its refinements carve out:

[envelopes.mine.files.write]
grant = "allow"
approve_paths = ["**/{*.pem,*.key,.env}"]
timeout = "10m"
on_timeout = "deny"

Here every rule the approve_paths glob emits carries the ten-minute wait, and the allow floor beneath them carries none.

default_action accepts the same table form, and the transpiler cannot carry it: the policy schema holds one default_action and no field beside it, and only a rule has timeout_s/on_timeout. Rather than fake it as a catch-all rule, the render says so in its //reserved note and the call that matched no rule keeps waiting on the host’s approval ceiling. Bound the wait on the axis or tools pattern that emits the ask instead.

A malformed duration is refused at parse time, naming the envelope, the axis and the value — "half an hour", "30" and "1500ms" (the policy carries whole seconds) are all errors, not silent zeroes. A word that is not one of never, forever or indefinite is refused the same way, and the message names the three that work; "200h" is refused pointing at them, since a wait past the cap is either a typo or an attempt to spell “never” as a number.

Rule order

Rules are first-match-wins, so the emission order is the semantics. Unconditional denies lead, conditional refinements precede the grants they carve out of, and an axis nobody set falls through:

  1. always_deny
  2. files.write deny_paths, then files.read deny_paths
  3. files.read approve_paths, then files.write approve_paths
  4. tools patterns
  5. always_allow
  6. missions.fire
  7. files.read, then files.write grants
  8. shell: blacklistsubstitutionprefix_allowlistask_always → the grant as the floor
  9. default_action

The five shell tiers are in the one order that keeps them meaningful: the blacklist cannot be reached past, substitution is judged before any verb is trusted, the allowlist grants, ask_always claws back, and the grant is where an unrecognized command lands.

tools patterns

A pattern is *, <toolset>, or <toolset>.<tool>, and each half is a literal name or *. Names are compared exactly, so a partial glob like git* is refused at parse time rather than emitted as a rule that can never match.

[envelopes.mine.tools]
"github.*" = "approve"
"tavily.search" = "allow"
"github.merge_pr" = { grant = "approve", timeout = "2h", on_timeout = "deny" }

Each entry is a grant, so a pattern takes the table form and its wait too.

Order in the file is not precedence: the table is emitted most-specific first (exact toolset.tool, then one wildcard, then *), then by action, then lexically, so the same table always renders the same bytes.

This is the only way to reach a tool you connected. A mapping under [tools] makes a tool reachable, not permitted; until you name it here it falls to default_action.

extends

One parent, merged per leaf key, so a child that sets files.write leaves the parent’s files.read alone. Two rules make that predictable, and both cost some repetition:

  • A list replaces the parent’s rather than appending to it. Silent concatenation down a chain would make a deny list impossible to shrink.
  • A bare files.write = "allow" replaces the whole axis, deny_paths included. Restate what you meant to keep; silent patching makes an envelope impossible to read.

always_deny and always_allow are the one exception — they accumulate parent-first, deduplicated, because a rule that exists to be un-waivable must not be waivable from below.

A missing parent, a cycle, and a chain deeper than eight envelopes are all errors naming the offender.

Reserved: the network axes

network.read and network.write bind to the native web toolset, which nothing in this build serves. An envelope that sets one is valid and inert: the render carries a //reserved note saying so instead of a rule.

They are kept because they are the only place the intent behind a host block survives a quarantined toolset. A web-shaped tool you connected is an MCP tool — it is reached by its own name under tools above, and no network axis touches it.

Shipped envelopes

read_only, ask_always, auto_edit, default, strict, acpx, oracle, serve. What each one is for is in Shipped envelopes; what each one says is in the shipped agents.toml, commented.

[policy.postures.<name>]

The older, narrower way to say the same thing, kept for configurations that already use it. Three postures are required and validated: read_only, ask_always, auto_edit.

KeyValue
local_fs_readallow | approve | deny
local_fs_writeallow | approve | deny
local_shellallow | approve | deny
[policy.postures.auto_edit]
local_fs_read = "allow"
local_fs_write = "allow"
local_shell = "approve"

An [envelopes.<name>] of the same name wins. The three postures ship as envelopes, which is where the credential quarantine and the write wall live; a [policy.postures.*] block is consulted only when no envelope of that name is declared, and is then adapted onto the same three axes. Both routes reach the emitter through one vocabulary, so an imported agent’s policy and a profile envelope come out of one transpiler.

Shipped mapping from declaration settings:

SourcePosture
Cursor readonly: trueread_only
Claude Code default / manual, Antigravity defaultask_always
Claude Code / Antigravity acceptEdits, Claude Code autoauto_edit
Claude Code plan, dontAskread_only, with a note in the loss report
Claude Code / Antigravity bypassPermissionsrefused. It names no envelope, and a declaration asking for it is refused rather than widened. Use acceptEdits and grant what the agent needs, where the grant is written down

Loosening a posture here loosens it for every agent that uses it. auto_edit granting local_shell = "allow" would mean an agent whose source asked only to accept edits gets a shell.

[naming]

KeyTypeDefaultMeaning
scope_with_dialectbooltruePrefix chain ids from other tools directories with the product they came from, so two tools’ identically named agents do not collide. Your own declarations are never scoped.

What a declaration registers for itself

agents.toml maps names onto tools you connected. A declaration can also bring its own MCP servers and OpenAPI services — see Tools an agent brings with it. Those are a different tier and this file does not describe them:

Registered byScopeRetired when
contenox mcp add / contenox tools addyouevery agentyou remove it
mcpServers: / remoteTools: in a declarationthe declarationthat one agentthe declaration is deleted

A declaration-scoped registration is named decl-<agent>-<name>. That prefix is a namespace, not an access boundary: it keeps two declarations that each bring a filesystem from colliding, and it marks the row as declaration-owned so reconciliation only ever touches rows carrying it. * means every toolset this machine offers, declaration-owned rows included — an agent that must not reach another agent’s source names the toolsets it wants instead of inheriting. It also never appears in contenox mcp list output as something you own.

[tools] below still applies to both: it is how a declaration’s tool names resolve, whoever registered the thing behind them.

[tools]

What each tool name a declaration may use resolves to, as toolset.tool.

[tools]
WebSearch = "tavily.search"

Overlays merge, so naming one tool leaves the built-in names alone.

A declaration’s tools: line is read through this table, and admission is by toolset: Read resolving to local_fs.read_file grants the local_fs toolset. The vocabulary that line and every chain allowlist share — * for every connected toolset with no exceptions, !name to remove one, a bare name to grant exactly it, an empty list to grant nothing — is in What tools: grants.

A mapping makes a tool reachable, not permitted. The emitted policy carries rules for the tools contenox hosts; a name you mapped yourself matches none of them and falls to default_action — so it works, and asks a human on every call. Name it under [envelopes.<name>.tools] to give it a rule, or under [[policy.always_allow]] to cover every agent.

Two kinds of name are absent from the shipped table for different reasons:

  • Tools nobody has connectedWebSearch, NotebookEdit. contenox ships no tool catalog. Connect the tool as an MCP server, an OpenAPI spec or a shell command, then add a line here.
  • Host capabilities that do not existSkill, TodoWrite, Task, manage_task. No mapping can supply these; they are not tools.

Names containing a dot are treated as MCP tools and pass through unchanged, so MCP needs no entries at all.

Canonical tools contenox hosts:

ToolsetTools
local_fsread_file write_file edit_file sed read_file_range
local_shelllocal_shell

[models]

What a declaration’s model name resolves to, as provider:model.

[models]
sonnet = "anthropic:claude-sonnet-5"
flash = "gemini:gemini-2.5-flash"

Only consulted when pin_model is on. An unrecognised name is not an error: routing stays templated and the raw name is kept in provenance.


Worked example

Run every agent read-only, with a tighter shell, and add an envelope of your own for review sessions:

# .contenox/agents.toml
[chain]
token_limit = 65536

[tools_policies.local_shell]
_allowed_commands = "git,go"

[policy]
default_action = "deny"

[envelopes.review]
extends = "read_only"
description = "Read the tree, run the test suite, change nothing."

[envelopes.review.shell]
grant = "deny"
prefix_allowlist = ["go test", "go vet"]

[naming]
scope_with_dialect = false

Everything not named here — loop bounds, the other envelopes, the credential denies — keeps its shipped value. review renders to .contenox/.generated/hitl-policy-review.json on the next run, and contenox beam --hitl-policy review runs under it.

Esc to close