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)
      local report = capture('weather ' .. shquote(args.city))
      return 'The weather in ' .. args.city .. ' is ' .. report
    end,
  }
end)

(capture and shquote above are the same small shell helpers shown in the rtk example below.)

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) and ctx.spill_dir (a scratch directory for output too large to return inline, which the model can read back).

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, and ctx.expand for the user's full-output toggle), 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 = capture('weather ' .. shquote(args.city))
    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.

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)
  return capture('command -v weather') ~= ''
end,

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.