Rhai tools and policy rules
This page covers two things that both live in Rhai scripts. Writing a tool gives your agents a new capability. Policy rules decide whether a tool call is allowed to fire. They are unrelated jobs, so read whichever half you came for.
Every .rhai file in ~/.leviath/tools/ is compiled at spawn and offered as a tool to every
agent. That is how you give all your agents a shared capability without editing each blueprint.
Per-agent tools live in that agent's own tools/ directory instead, and are checked by
lev validate <agent>. A per-agent tool with the same name shadows the global one.
Warning
The directory is ~/.leviath/tools/, inside Leviath's data root next to providers/ and
agents/. It is not $HOME/tools/. Every .rhai file here becomes a tool for every agent, and
not every file in it was written by you: once a run uses install_global_tool, the
directory holds model-authored code as well. Each installed file starts with a
// installed by leviath: agent run in <workdir> at <unix seconds> line naming where it came
from, and every call to any tool here is still gated by the tool policy (ask by default, waived
only by --yolo). Audit the directory with lev tools, which lists each tool with its
description and parameters, and remove a tool by deleting its .rhai file (and any sibling
.toml): the next spawn no longer sees it.
Declaring a tool
A tool declares itself with leading // @ directives and reads its arguments from the params
object. The recognized directives:
// @tool <name>is required and names the tool. A stage sees it when itsavailable_toolslists that name or includes@scripts, the tool group that grants every Rhai tool the install has.// @description <text>is an optional one-liner shown to the model.// @param <name> <type> <required|optional> "<description>"is repeatable.<type>is a JSON schema type:string,integer,number,boolean,array,object. A typo here produces a schema that does not compile, which switches off argument validation for the tool (the daemon logs a warning); calls still run, with no argument check.// @requires <cap> [<cap>...]lists platform capabilities the tool needs (network,shell,filesystem), comma or space separated and repeatable. Leviath drops the tool where the platform cannot provide one.// @accepts <type> [...]and// @produces <type> [...]name the mime types the tool reads as parts and hands back as parts (image/*,audio/wav), comma or space separated and repeatable. Advisory:lev toolsshows them, and a stage that needs avideo/mp4can be checked against the tools it holds.
The script's return value becomes the tool result: a string is returned verbatim, anything else is
JSON-encoded, and a bare () is an empty string. A missing optional param reads as (). One shape
is special: a map with a parts list, #{ content: "...", parts: [ ... ] }, is a result that
carries typed parts beside its text. Each entry is a part map as write_part or find_part handed
it back; the text is the content, or nothing when it is left off.
Host functions inside a tool
A tool gets a wider host surface than a provider script, because it acts on behalf of a running agent. The functions come in two kinds.
These reach the outside world, and each one is gated per function by
[tool_script_permissions], resolved at spawn. A
tool's @requires line is not a gate: it only filters which platforms discover the tool at all.
| Function | Does |
|---|---|
http_get(url [, headers]) |
An HTTP GET, as text. A body that is not text is refused, not decoded. See below |
http_get_bytes(url [, headers]) |
The same GET, as bytes: a map with mime_type and bytes, ready for write_part. See below |
http_post(url, body [, headers]) |
An HTTP POST |
shell(cmd) |
Runs a shell command |
read_file(path) |
Reads a file, always confined to the workdir |
read_file_bytes(path) |
The same file, as bytes: an exact Rhai blob, ready for write_part. See below |
write_file(path, content) |
Writes a file |
env_var(name) |
Reads an environment variable. Credential-shaped names need allow_env_vars |
read_part(name) |
The bytes of a stored part the run holds, as a Rhai blob. See below |
write_part(bytes [, type [, name]]) |
Stores bytes as a part of the run and returns its map. See below |
list_parts() |
Every stored part the run holds, as maps |
find_part(name) |
One part's map by name or hash prefix, or () |
http_get refuses a body that is not text, such as an image, a sound or a PDF, and the message
names its type. It does not decode those bytes into noise. http_get_bytes is how you fetch them.
Its mime_type is the one the server declared, its bytes are a Rhai blob, and bodies over 32 MiB
are refused before they are read. It is gated like http_get.
read_file_bytes is for a file that is not text, like an image your shell command rendered or a
design someone handed you. The blob is exact. Files up to [mime] max_part_bytes are read, and
larger ones are refused before they are read. It is gated and confined like read_file.
read_part takes a file name, or the first six or more characters of a part's sha256. Reading
needs no permission, because the part is already the run's. write_part sniffs the type when you
leave it off and makes up a name, such as part-3.png. It is gated like write_file, and charged
to the run's write budget.
A part map carries mime_type, name, sha256, size, width, height, duration_ms,
tokens and stand_in. The parts a tool can name are the ones in the agent's context window when
the batch was dispatched, plus whatever the tools in that batch wrote. A tool that takes an image by
name reads params.image and calls read_part on it; the model names parts the way the stand-in
in its context does, by file name.
These are pure and need no permission, because they only transform values you already have:
| Group | Functions |
|---|---|
| JSON and encoding | parse_json, to_json, encode_uri, encode_base64, decode_base64, html_to_text |
| Strings | contains, starts_with, ends_with, trim, join, split |
| Content | count_tokens, is_json, is_markdown, is_mermaid, is_empty, content_format |
decode_base64 fails rather than returning something wrong, in two ways worth telling apart. Input
that is not valid base64 says so. Input that is valid base64 but decodes to bytes that are not UTF-8
says that. Base64 carries any bytes and a Rhai string holds text, so a script decoding an image
has asked for something the function cannot return. Both reach the model as an [error] line naming
your tool, so a script that hits one stops rather than carrying on with an empty string.
A complete tool
A minimal transform tool, ~/.leviath/tools/upper.rhai:
// @tool upper
// @description Upper-case text
// @param text string required "input to transform"
params.text.to_upper()A tool that does real I/O, ~/.leviath/tools/web_fetch.rhai. It declares the network capability,
fetches a URL, and hands the model readable prose instead of raw HTML:
// @tool web_fetch
// @description Fetch a URL and return its readable text
// @param url string required "the URL to fetch"
// @requires network
let body = http_get(params.url);
html_to_text(body)A tool that makes a part. It renders a diagram with a command-line tool, then reads the picture the command wrote as bytes and hands it back typed. A model that sees images sees the diagram. One that does not sees a line naming it:
// @tool render_diagram
// @description Render a Mermaid diagram to a PNG
// @param source string required "the Mermaid source"
// @produces image/png
// @requires shell
write_file("work/diagram.mmd", params.source);
shell("mmdc -i work/diagram.mmd -o work/diagram.png");
let png = read_file_bytes("work/diagram.png");
#{ content: "rendered the diagram", parts: [write_part(png, "image/png", "diagram.png")] }The source goes through a file rather than into the command line, so nothing the model writes is ever run by the shell.
For parameter shapes that directives cannot express (enums, array items, numeric bounds), drop a
sibling .toml named after the script (export.toml beside export.rhai). When present it
overrides the annotations entirely:
# ~/.leviath/tools/export.toml (beside export.rhai)
[tool]
name = "export"
description = "Export in a chosen format"
requires = ["filesystem"]
[[tool.params]]
name = "format"
required = true
schema = { type = "string", enum = ["json", "yaml"], description = "output format" }Installing a tool from a run
A running agent can add a tool itself. Both built-ins take the tool's name, the complete .rhai
source, and an optional overwrite flag, compile the script, and write it out. This is the
persist path for mechanical learnings. A step an agent worked out by hand once, such as a parsing
routine, a repeated lookup or a fixed transformation, becomes a tool that later runs call instead of
rediscovering it.
What differs is who ends up with the tool:
| Built-in | Writes to | Who sees it |
|---|---|---|
install_self_tool |
the agent's own tools/ |
that agent's runs, and nothing else |
install_global_tool |
~/.leviath/tools/ |
every agent on the machine that asks for script tools |
Reach for install_self_tool. What an agent learns is usually about its own job, and the wide one
arms every agent that names @scripts or @all with code one agent wrote. install_tool is the
name they were split out of, and it still works: it means install_global_tool, which is what it
always did.
Neither one falls back to the other. An agent with no blueprint directory to write to is told so, rather than quietly installing machine-wide.
The install is refused, and nothing is written, when the script:
- Does not compile.
- Has no
// @toolor// @description. - Declares a
// @toolname that differs fromname. - Takes the name of a built-in, sub-agent or MCP tool.
- Exceeds 256 KiB.
- Would replace an existing script without
overwrite.
A script that takes one of those reserved names is dropped at discovery, so it would never run.
A sibling <name>.toml that declares a different [tool] name is refused too, since the TOML
would win and the tool would appear under the other name. The result the model reads back names
the file, the description, the parameters and the required capabilities.
Three things keep the directory yours:
- Every installed file starts with a
// installed by leviath: agent run in <workdir> at <unix seconds>comment, socatandlev toolsshow which run wrote it. The comment carries no@directive and compiles as an ordinary comment. - Both are
askby default, likewrite_fileandshell. A blueprint orconfig.tomlcan setinstall_self_tool = "allow"under[tool_permissions], and--yolowaives the prompt for an unattended run. See Security for what an unattended run can persist. - The script's own calls are still gated when it runs: an installed tool reads the same
[tool_script_permissions]as a hand-written one.
A tool is meant for repeatable mechanical steps, never for judgement. A script that encodes a decision the model should be making each time ages badly and is hard to notice from the outside.
To use an installed tool in the same run, the agent needs
tool_rescan: after_writes picks the new tool up on its
next turn, and before_dispatch also picks it up in the turn that wrote it. Any later run sees it at spawn, and a stage advertises it when its
available_tools names it or includes @scripts. See Tools for how a stage's tool
set is put together.
Inspecting the inventory
lev tools lists the global inventory without starting the daemon. Compiled tools are marked. A
file that failed to compile is shown with its reason, and is not advertised at all. A tool whose
@requires capability the platform cannot satisfy is flagged unavailable:
lev tools # human-readable inventory, params, requires, and skipped files
lev tools --json # machine-readable, including param schemas and required capabilitiesSee Tools for how a stage's available_tools and tool_permissions gate which tools
an agent may actually call.
Policy rules
The taint gate blocks any tool that could send data off the machine when that data is more sensitive than the tool is cleared for. Sometimes a specific case is fine and you want to say so.
policy.toml handles the simple cases with a static allowlist. For anything that needs a decision
rather than a list, write a rule as a .rhai file in the leviath/rules/ directory under your OS
config dir. That is ~/.config/leviath/rules/ on Linux and
~/Library/Application Support/leviath/rules/ on macOS.
Rules are consulted after the static allowlist, and the first script that allows a call wins. The filename becomes the rule's name in any decision it makes.
Each rule receives a context map with tool, target, and taint_level (a string: "public",
"internal", or "private"), and evaluates to a boolean. true allows the call.
// <config dir>/leviath/rules/company.rhai
context.tool == "send_email"
&& context.target == "ops@corp"
&& context.taint_level == "internal"Rules are re-read when they change. Add a file, edit one, or delete it, and the next run is gated
against what the directory holds now, with no daemon restart. policy.toml beside it reloads the
same way. A run already going keeps the rules it started under.
A script that errors or does not evaluate to a boolean is treated as no match, so a broken rule can never accidentally open the gate. Inspect and dry-run rules with the CLI:
lev policy list # static + scripted rules
lev policy test send_email --target ops@corp --taint internalImportant
Scripted rules only ever allow calls the gate would otherwise block. They cannot tighten the gate or override a deny. See Security for the taint model and the full gate decision flow.