Defining your own tools¶
Beyond the built-in tools, you can define your own in Lua and
offer them to the model. A
discover_tools handler runs when
your config loads and again on every /reload, with a
ToolRegistry to add tools to, so
editing your config and reloading adds or removes a tool without restarting
the process:
agent.on('discover_tools', function(registry)
registry:add {
name = 'weather',
description = 'Look up the current weather for a city.',
-- A JSON Schema object describing the arguments, shown to the model.
parameters = {
type = 'object',
properties = {
city = { type = 'string', description = 'The city to look up.' },
},
required = { 'city' },
},
run = function(args, ctx)
-- process.run spawns the command directly with no shell, so the city
-- name is passed as one argument with nothing to quote against.
local report = process.run{ cmd = { 'weather', args.city } }.stdout:trim()
return 'The weather in ' .. args.city .. ' is ' .. report
end,
}
end)
process.run above is from the standard
process module.
The run callback receives the model's args and a
RunCtx, and returns the result the
model sees. Return a plain string for a successful result, or a { content =
'...', is_error = true } table to report a failure the model can react to.
The context exposes
ctx.cwd (the working
directory), ctx.spill_dir
(a scratch directory for output too large to return inline, which the model
can read back), and
ctx.session_id (the
ULID of the session the call runs in, empty for a session-less run).
A long-running tool can drive its own live display while it works. Inside
run, ctx:show(state)
publishes a snapshot -- any plain serializable table -- that the tool's block
renders from, and
ctx:title(text) sets a
short label shown after the tool's name in the block header (like the path an
edit shows).
To control how the block looks, add a render callback. It is handed the
latest snapshot from ctx:show and a
RenderView describing the space
available (ctx.width,
ctx.height,
ctx.expand_output
for the user's tool/shell expansion toggle, and
ctx.expand_thinking
for the thinking-expansion setting), and returns the body to show -- a string,
or an array of lines:
registry:add {
name = 'weather',
description = 'Look up the current weather for a city.',
parameters = { type = 'object', properties = {} },
run = function(args, ctx)
ctx:title(args.city)
ctx:show { phase = 'fetching', city = args.city }
local report = process.run{ cmd = { 'weather', args.city } }.stdout:trim()
ctx:show { phase = 'done', city = args.city, report = report }
return 'The weather in ' .. args.city .. ' is ' .. report
end,
render = function(state, view)
if state.phase == 'fetching' then
return 'looking up ' .. state.city .. '...'
end
return state.city .. ': ' .. state.report
end,
}
The last snapshot is persisted with the result, so on a resumed session the
block re-renders from it without running run again. If your config no longer
defines the tool, its block falls back to the recorded model-facing text.
A tool can also delegate part of its work to a sub-agent, which runs the full
agentic loop to completion under a personality. Inside
run,
ctx:spawn_agent(opts)
takes the same options the model's spawn tool does -- personality, mode,
task, and context (fresh or fork). It returns a handle: handle.session
is the child's session ULID (known up front for a fork), and handle:run()
runs the child and returns { personality, session, reply }:
local handle = ctx:spawn_agent {
personality = 'reviewer',
task = 'review the diff and flag anything risky',
context = 'fork',
}
ctx:show { status = 'reviewing in sub-agent ' .. handle.session }
local r = handle:run()
return r.reply -- the child's final message
handle.session is populated only for context = 'fork', whose child session
log is written before the child runs. A fresh spawn's handle.session stays
empty, since a fresh session materializes only inside the child's own run --
read r.session from run()'s result instead. A tool can show the fork's
session id while the sub-agent runs, letting the user open that session to
watch the transcript. A setup failure -- an unknown personality, the depth
limit, or nothing to fork -- raises. Wrap the call in pcall to react to it. A
child that fails to run raises from run().
The child runs headless and its intermediate work does not stream into the
block. The tool controls the display of the block. The final message of the
child is what r.reply carries, so return it (or a transformation of it) for
the model to see.
Unlike the model-facing spawn tool, ctx:spawn_agent is not restricted to
the spawn.personalities allowlist of the mode. A Lua tool may delegate to
any personality registered in the config. The depth limit that guards model
spawn calls applies here too, which keeps a tool from spawning without
bound.
An optional available callback gates whether the tool is offered at all. It
is called with the same context and returns a boolean, so a tool can withhold
itself when its prerequisites are missing (mirroring how find and grep are
only offered when ripgrep is present):
available = function(ctx)
-- A string cmd runs via the shell, so `command` (a shell builtin) works;
-- .ok is true only when weather is found on PATH.
return process.run{ cmd = 'command -v weather' }.ok
end,
Set read_only = true on a tool that only reads and has no observable side
effects. A provider that runs tool calls in parallel may then dispatch it
alongside other read-only calls; anything that changes files or state should
leave it unset (the default), so it is never run concurrently with others.
Assign tags to a tool to place it in one or more capability groups a
personality mode can select with a
tag:<name> entry instead of naming the tool. The names are yours. Reusing a
built-in tag such as web joins the tool to that group.
As with the rtk example, a tool is best kept in a self-contained
module alongside your config (e.g. ~/.config/wallah/weather.lua) that you
pull in with require('weather'), so it reads and reuses independently of the
rest of your config.