Built-in tools

Every agent advertises a set of tools to its LLM. The runtime ships a fixed catalog of built-in tools (file access, a shell, context memory, human-in-the-loop prompts, review surfaces, and sub-agent management) that need no configuration to exist. A stage decides which of them the model may actually call via available_tools, and tool_permissions gates each call at allow / ask / deny.

For tools beyond this catalog you have two options: connect an MCP server, or write your own with Rhai scripting.

Files

Read and modify files relative to the agent's working directory.

Tool Purpose Arguments
read_file Read one file, up to 256 KiB, with a note when the content is truncated. path
read_files Read several files in one call, separated by path headers. paths (array)
write_file Write content to a file, creating parent directories as needed. With append, add to the end instead of replacing. path, content, append (optional)
edit_file Replace an exact string that occurs exactly once in a file. path, old_str, new_str
list_dir List a directory's contents. path (optional; defaults to the working root)
install_self_tool Compile a Rhai tool script and install it into this agent's own tools/, where only its own runs see it. name, source, overwrite (optional, default false)
install_global_tool The same, into ~/.leviath/tools/, for every future run on the machine. name, source, overwrite (optional, default false)

read_file stores a file that is not text (an image, a PDF, a model) as a typed part on the result rather than inlining it. A model that takes the type sees the bytes, and one that does not sees a one-line stand-in.

write_file with append lets a file too large for one reply be written in parts. Both installers refuse a script that does not compile, lacks // @tool or // @description, or takes an existing tool's name. See installing a tool from a run.

Tip

read_files is cheaper than repeated read_file calls; reach for it after list_dir when you already know several paths you need.

Shell

Tool Purpose Arguments
shell Run a shell command in the working directory, using the system shell. It has a 60-second timeout. command

bash is an accepted alias for shell: a stage's available_tools may name either, and both resolve to the same tool advertised to the model.

Which shell you get

Leviath resolves the shell per platform. It tells the model which one it resolved, instead of leaving it to guess:

Platform Shell
Windows cmd.exe /C
Unix $SHELL when it exists and is a sh/bash/zsh, else the first of /bin/bash, /usr/bin/bash, /bin/zsh, /usr/bin/zsh, /bin/sh

The resolved shell is named in the shell tool's own description. A model on Windows reads cmd.exe rather than a list of every platform's shell. On top of that, a stage that advertises the shell tool carries a short system block when the platform warrants one. Today only Windows does: it says commands run through cmd.exe rather than a POSIX shell, and gives the PowerShell stand-ins for cat, grep, ls, and wc -l. On Linux and macOS nothing is added.

Turn it off with shell_hint = false, globally in config.toml or per agent or stage in a blueprint. See Configuration.

Note

Rhai tool scripts are a separate path: their shell() host function always uses /bin/sh on Unix, never $SHELL, because a script is authored once and runs everywhere.

Warning

shell requires the platform's process-spawn capability. On platforms that don't provide it, the tool (and its bash alias) is filtered out and never advertised.

Where a redirect may write

echo x > report.md is a file write that no tool name describes, so it answers to the write_file policy rather than the shell's alone. A denied write_file denies the redirect too, and no [safe_commands] entry can pre-approve one.

It also answers to the same workspace confinement. A redirect whose target resolves outside the working directory is refused, exactly as write_file refuses that path, and no flag lifts it, --yolo included. That is deliberate: --yolo grants permission, and this is not a permission question.

Discarded writes cost nothing. 2>/dev/null, > /dev/null 2>&1, &> /dev/null and > NUL write nowhere anyone can read back, so they are neither clamped nor confined.

Some targets the parser cannot name at all, such as > $OUT or bash's /dev/tcp/host/port. Those have no path to check, so they are ungrantable: they prompt every time, and no approval makes them reusable.

A line the parser cannot read as commands at all is different again. A backtick, an unterminated quote or an unbalanced $( could put a redirect anywhere, so a line carrying one is refused outright the moment it also carries a >. Guessing where it writes is exactly what the fence exists not to do. Rewrite the line without the construct, or use the write_file tool.

A heredoc is read, not refused. Its body is standard input, so this runs with the whole script on stdin:

bash
python3 - <<'EOF'
print("the whole script arrives on stdin")
EOF

A > or a -> inside that body is text rather than a redirect. A real redirect beside the operator still counts: cat <<EOF > report.md writes report.md and is confined to the workspace like any other.

Two heredoc shapes could still hide a command, and both keep the old refusal. One is a body that never reaches its delimiter. The other is an unquoted heredoc (<<EOF, not <<'EOF') whose body contains a $(...) or backtick the shell would expand and run. Quoting the delimiter makes the body pure data. On Windows there are no heredocs, so the construct stays unreadable there and is refused.

How much output comes back

At most 1 MB of stdout and 1 MB of stderr per call. Past that the bytes are counted and dropped, and the result ends with a [truncated] line naming how much the command actually wrote.

The command still runs to completion. Leviath keeps draining both pipes after the cap so the command never blocks on a full one, which would turn a truncated answer into a timed-out call.

The cap is a memory bound, not a context bound. A megabyte of shell output already overruns any region budget an agent has, so what survives this cap gets trimmed again by the region it lands in. The cap exists because a command printing at local-pipe speed for its full 60 seconds is gigabytes, and the daemon holds that output in memory.

Context

Read and write the agent's own context window: named sections the runtime folds back into the system prompt on later turns.

Tool Purpose Arguments
context_write Store or replace a keyed entry in a named section. region, content, key (optional)
context_append Add to a section without replacing existing content. region, content, key (optional)
context_read Read a section, or one entry of it by key or by the index context_list shows. region, key (optional), index (optional)
context_delete Release an entry the agent is finished with, freeing its tokens. See letting the agent decide what to forget. region, and one of key / index / oldest
context_list List sections with their token counts and entry counts. region (optional)
context_attach Put a file from the working directory into a section as a typed part. region, path, key, caption, type, deliver (all but the first two optional)
context_export Write a stored part back into the working directory, by its file name or the start of its sha256. name, path (optional)
todo_add Add an open item to a checklist region, returning its id. region, item
todo_done Tick a checklist item off. region, id
todo_note Record a note against an item without closing it. region, id, note

context_read takes a key on any region kind. context_attach takes an image, a recording or a document; a key makes a newer version replace the older one, and a caption is stored beside it. context_export puts the bytes back where a shell tool or a script can work on them.

Final output

One tool, for the answer the run hands back. See Final outputs.

Tool Purpose Arguments
submit_output Submit the run's final answer. Every surface reports it. content

Its description is built for the stage that offers it. If you declared a shape, the format, your instructions, and your example appear in the description the model reads. That is how a format Leviath has never heard of still gets produced.

mode = "output" grants this tool, so a stage using that mode does not list it.

Human-in-the-loop

Pause the run and hand control to the user. Each of these blocks until the user responds.

Tool Purpose Arguments
ask_user_text Ask a free-form question and wait for a written answer. prompt
ask_user_choice Ask the user to pick one option from a list. prompt, options (array, at least two)
ask_user_confirm Ask a yes/no question. prompt

Review

Present work to the user for approval or direct editing.

Tool Purpose Arguments
present_for_review Pause and display a markdown document (plan, design, report) for the user to read and approve. title, markdown
edit_document Show a document in an editable field, pre-filled with its current text. content, prompt (optional)

The text edit_document returns is the user's authoritative edit.

These tools need someone there

Every tool in the two tables above does the same thing: it opens a prompt and waits. That is fine when you are watching the run. When nobody is, the wait has no end. The agent sits in WaitingInput until somebody answers or you cancel the run. It hands its tool-lane slot back while it waits, and a daemon restart does not free it either: the open prompt is written to disk, and the run comes back still parked on it.

So an unattended run does not get them. A run launched with --yolo (and every sub-agent and fan-out worker under it) has these five tools removed from the set advertised to the model, per stage, before the first inference. The model never sees them and decides for itself instead.

A stage that genuinely needs a person can opt out, tool by tool:

toml
[stages.plan]
available_tools = ["read_file", "ask_user_text", "ask_user_choice"]
# Kept even when the run is unattended.
required_tools = ["ask_user_text", "ask_user_choice"]

required_tools entries must also appear in available_tools. lev validate rejects a blueprint where they do not. It also warns (blocking-tool-in-autonomous-stage) about an autonomous stage that grants one of these tools without saying it meant to. Naming the tool in required_tools settles that, and keeps the tool through an unattended run; allow_blocking_tools = true on the stage settles the warning alone, for a stage that only ever runs attended.

The run then waits for a person however long it takes. Pair the opt-out with interaction_timeout_secs if a prompt nobody answers should release the run instead.

Sub-agents

Spawn and coordinate child agents from within a run. These are advertised to the model but executed by the engine's tool registry, since they act on the shared agent world.

Tool Purpose Arguments
spawn_agent Spawn a sub-agent from a blueprint; returns its ID (blocks and returns the result when wait is true). blueprint, task, wait (default false), seed_context (optional), parts (optional), max_child_depth (optional), output_format (optional), output_instructions (optional)
check_agent Non-blocking status check; returns the child's answer once it is done. agent_id
wait_for_agent Block until a sub-agent completes, then return its answer. agent_id
send_to_agent Send a message into a running sub-agent's context. agent_id, message, target_region (optional; defaults to the conversation)
kill_agent Kill a sub-agent and all its descendants. agent_id

parts names stored parts of this run to hand the child, by name or sha256 prefix.

A child reports whatever it submitted through submit_output. A child that submitted nothing says so, rather than returning an empty result. output_format asks the child for a particular shape, overriding what its blueprint declares. A label that differs retires the child's declared validator and schema, and the warning appears only in the daemon log.

Environment

What time it is, what machine this is, and what the run itself is doing. A model has no way to know any of it. Its training data has a cutoff and your run does not, so an agent asked about anything current will otherwise reason from whenever it was trained.

Tool Purpose Arguments
current_time The date and time now, in UTC and local. none
system_info Operating system and version, architecture, CPU count, hostname, path separator, line ending, and free disk space. none
locale_info The user's language and region as a BCP-47 tag, such as en-US. none
environment_info The working directory, the home, temporary, config and data directories, and the entries on PATH. none
which_command Whether a program is installed and where, the way which or where would. command
runtime_info This run reporting on itself: agent, stage, iteration and limit, model and provider. none

current_time carries the IANA timezone, UTC offset, weekday, ISO week and unix epoch alongside the two clocks. system_info measures free disk space in the working directory, and environment_info also lists the environment variables this agent may see. which_command looks the name up on PATH without running it. runtime_info adds the context tokens spent, and whether anyone is available to answer a question.

All six are read-only, take no action, and default to allow, so grounding a run in the present costs no approval prompt.

Tip

If an agent researches current events, cites release dates, or reasons about what is recent, give it current_time and say so in the stage prompt. A tool that is advertised but never called changes nothing.

Note

runtime_info is the one to reach for before ask_user_text, ask_user_choice, ask_user_confirm or present_for_review. It reports whether the run is unattended, and an unattended run has nobody to answer those, so an agent that checks first can decide and record the assumption instead of waiting.

What these tools do not reveal

environment_info reports environment variables through the same gate a Rhai env_var read answers to. A credential-shaped name is listed under withheld_variables with its value omitted, so an agent can tell ANTHROPIC_API_KEY is set without seeing it. Name it in [security] allow_env_vars to hand the value over. See environment variables.

system_info reports the machine's hostname, and environment_info reports absolute paths that usually contain the user's account name. Both travel to whichever provider the stage calls, like everything else in the context window. Neither is granted to a stage that does not list it in available_tools.

Not every platform answers every question. os_version is read from /etc/os-release on Linux and SystemVersion.plist on macOS; Windows publishes neither, so it reports null there rather than guessing. Fields that cannot be answered are always present and null, never missing.

Web access

Web tools are not built in. They ship as Rhai script tools beside the agents that use them, which is why they are editable and why an agent without them cannot reach the network at all.

Tool Purpose Arguments
web_search Search the web and return a JSON list of {title, url, snippet, date}. See below query, count (optional, default 5), freshness (optional)
web_fetch Fetch a URL and return its readable text, with HTML stripped to prose. See below url

web_search uses Brave Search when BRAVE_API_KEY is readable. Otherwise there is no search engine, and the call falls back to a keyless Wikipedia lookup. Pass freshness to bound results by age, as pd / pw / pm / py (past day, week, month, year) or YYYY-MM-DDtoYYYY-MM-DD. Each result carries the page's date when Brave knows it. That is what lets an agent judge "latest" against the run's own clock rather than its training cutoff.

web_fetch truncates large pages, and a blocked or oversized request comes back as a diagnostic rather than failing the run. A URL that answers with a file rather than a page (an image, a sound, a PDF) is stored as a part of the run and attached to the tool result. A model that takes the type sees the file itself, and any other sees the usual stand-in line. So does a page whose text is rendered client-side. Fetching a Reddit thread returns HTML that strips to the single word "Reddit", so the tool names that instead of handing back the husk as if it were the discussion.

Both ship with data-analyst, researcher, deep-researcher, and wide-researcher. To give another agent web access, copy them into that agent's tools/ directory, or drop them in ~/.leviath/tools/ to offer them to every agent.

Important

Setting BRAVE_API_KEY is not enough on its own. The name ends in KEY, so Leviath treats it as a credential and refuses to hand it to a script unless you list it explicitly:

toml
[security]
allow_env_vars = ["BRAVE_API_KEY"]

Without that line the tool falls back to Wikipedia. It now says so in every result rather than returning a bare empty list, and lev doctor reports it as a search warning, but the searches still find nothing until you add the line. The grant is config, which the daemon re-reads before every run, so adding it needs no restart. See environment variables.

Important

The key itself is different: it has to be in the daemon's environment, not only the shell you type lev in. A process inherits its environment when it starts, so a daemon that was already running when you exported the key cannot see it, and every search falls back to Wikipedia while your own shell looks correctly configured.

lev doctor asks the daemon rather than itself, so it catches exactly this and tells you to lev daemon restart from a shell that exports the key. (A daemon started from a desktop session or a service manager inherits that environment, not your shell's, so export the key where the daemon actually starts.)

A search that cannot reach an engine is the quietest failure a research agent has. The model is handed an empty result set, cannot tell it apart from "nobody has written about this", and closes the gap from its training data, citing what it remembers. The report comes out confident and fully referenced. Three things make that visible now: web_search returns prose naming the problem instead of [], lev doctor runs a search check, and every run records searches_run and searches_empty in its meta.json. Equal counts, with a non-zero total, mean the run saw nothing and wrote a report anyway.

Warning

Fetches cannot reach loopback, private, or link-local addresses unless you set [security] allow_local_network = true. That default blocks cloud metadata endpoints, your own lev serve, and the LAN, because the model chooses the URL out of context an attacker may have influenced.

Granting tools per stage

A tool existing in the catalog does not mean an agent can call it. Two independent settings on each stage control access:

  • available_tools: the allowlist of tool names advertised to the model in that stage. A tool not listed here is invisible to the LLM, and a call to one it guessed is refused before it runs, with the refusal returned as that call's result. Names may use aliases (e.g. bash for shell).
  • tool_permissions: a per-tool map whose values are allow, ask, or deny. allow runs the call outright, ask requires user approval first, and deny blocks it. Stage-level entries are narrower than agent-level [tool_permissions] and wider than launch-time flags. Any other value is a load error, because a misspelled deny that quietly resolved to ask would hand the author of the typo a prompt where they had written a refusal.
toml
[stages.implement]
available_tools = ["read_file", "read_files", "edit_file", "shell"]

[stages.implement.tool_permissions]
shell     = "ask"      # require approval before running commands
edit_file = "allow"    # apply edits without prompting

Note

available_tools and tool_permissions are separate gates: a tool must be listed in available_tools to be offered at all, and its tool_permissions value then decides whether a call is allowed, prompted, or refused.

Tool groups

Listing twenty-eight built-ins by hand to say "everything" is a chore, and a list written that way goes stale the day a tool is added. An available_tools entry that starts with @ names a whole kind of tool instead of one:

Token Grants
@builtin every tool compiled into Leviath (this page's catalog)
@subagent spawn_agent, check_agent, wait_for_agent, send_to_agent, kill_agent
@scripts every Rhai tool, the agent's own and the global ones
@mcp every tool every connected MCP server advertises
@all all of the above

Groups and names mix freely. The four shapes people actually want are each one line:

toml
available_tools = ["read_file", "edit_file", "shell"]          # a hand-picked set
available_tools = ["@builtin", "summarize", "tracker__search"] # every built-in, plus a few others by name
available_tools = ["@builtin", "@scripts", "tracker__search"]  # every built-in and script, one MCP tool
available_tools = ["@all"]                                     # everything this install has

A group is resolved when the stage runs, not when the blueprint is written. A script dropped into ~/.leviath/tools/ or a server added with lev mcp add is offered to a stage granting @scripts or @mcp without touching the manifest. Two things a group never grants: submit_output and fan_out, which decide what a stage is rather than what it can do. Name those, or use the mode that carries them.

A group is a grant of visibility and nothing more. Every tool it reaches still goes through tool_permissions, the taint gate, and the approval prompts, exactly as a tool you named would. The @ prefix can never collide with a real tool, since tool names are limited to [A-Za-z0-9_-]. lev validate knows the groups. An entry like @builtins is refused as naming no group, and a required_tools entry no group reaches is an error. An autonomous stage granting @builtin gets one blocking-tool-in-autonomous-stage warning for the group, rather than five for its members.

Default permissions

With nothing configured, tools fall back to these:

Tool Default
read_file, read_files, list_dir allow
write_file, edit_file, shell (and its bash alias) ask
install_self_tool ask
install_global_tool ask
context_read, context_write, context_append, context_delete, context_list, context_attach, context_export allow
todo_add, todo_done, todo_note allow
ask_user_text, ask_user_choice, ask_user_confirm, edit_document allow
spawn_agent, check_agent, wait_for_agent, send_to_agent, kill_agent allow
current_time, system_info, locale_info, environment_info, which_command, runtime_info allow
Everything else, built-in or MCP ask

Read-only built-ins and the agent's own context tools run freely, mutating ones ask, and human-in-the-loop tools are always allowed because prompting before a prompt would be circular. The environment tools read the host and the run and change nothing, so they are allowed on the same grounds as the file readers.

How a policy is resolved

Narrowest scope wins: launch flag, then stage, then agent, then config.toml, then the built-in default above.

Two rules constrain that:

  • A blueprint may only tighten what your config set. A downloaded agent cannot grant itself shell = "allow" over your ask. For a tool you have not configured there is nothing of yours 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 research agents pre-approve and which can neither write nor execute. To trust one agent with more, name the tool in [agent_tool_permissions.<agent>] in your own config, or set [security] allow_blueprint_permissions for every agent.
  • A launch flag (--allow, --yolo) can turn an ask into an allow, but it can never lift a deny.

The four gates a call passes through

Permissions are only one of them. A tool call has to get past all four, in this order:

flowchart TD
  M["The model asks for a tool"] --> V{"1. available_tools<br/>Is the stage offering it?"}
  V -->|no| R1["Refused. The stage never advertised it"]
  V -->|yes| S{"2. Argument schema<br/>Do the arguments fit?"}
  S -->|no| R2["Refused, with the violations named"]
  S -->|yes| P{"3. tool_permissions<br/>allow, ask, or deny?"}
  P -->|deny| R3["Refused"]
  P -->|ask| A["Wait for a person"]
  P -->|allow| T{"4. Taint gate<br/>Could this send data out?"}
  A --> T
  T -->|blocked| R4["Refused, or surfaced as an approval"]
  T -->|clear| RUN["The tool runs"]

Each gate answers a different question:

Gate Asks Configured by
1. Visibility Does this stage offer the tool at all? available_tools on the stage
2. Schema Are the arguments the right shape? The tool's own schema, nothing to set
3. Approval Is this call allowed, and does a person need to say so? tool_permissions
4. Data flow Would this carry sensitive data off the machine? The taint gate

There is a fifth for script tools specifically. [tool_script_permissions] limits what a Rhai tool may do internally, such as whether it can run a shell command or read a file. See Rhai tools.

Warning

A tool set to ask in a headless context blocks until someone answers. It never auto-denies, and by default it never times out. For unattended runs, either grant the tools explicitly with --allow or use --yolo, which cannot override a deny. Set interaction_timeout_secs to put a deadline on any prompt that goes unanswered, whichever way it was raised.

Argument validation

Before a call runs, its arguments are checked against the exact JSON Schema the tool advertised. Every kind of tool declares one: built-in, Rhai script, MCP, and the sub-agent tools alike.

A call that does not fit its schema is refused back to the model as an [error] naming what was wrong. Three examples: a missing required argument, a number where a string belongs, a value outside a declared enum.

Two things follow from that. The call never runs and never reaches an approval prompt, so a malformed call cannot wake anybody up. And the refusal does not count as work the agent did, so it cannot be used to satisfy an edge gate. The model reads the message and corrects itself on its next turn.

If a schema cannot be compiled, validation is turned off for that tool rather than refusing all its calls. That happens with a mistyped @param line in a Rhai tool, or an MCP schema fragment the engine cannot interpret. The daemon logs a warning so the broken schema gets noticed.

Schemas using external $ref references land in that same bucket, deliberately. The validator never fetches anything over the network or from disk, so a schema that needs an external reference fails to compile and is skipped.