Skip to content

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.

read_only = true,

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.

tags = { 'web' },

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.