Documentation menu

Concepts

Tools and commands

Add tools the model can call, guard them with hooks, and add slash commands you can type.

A tool is a function the model can call. A command is a slash command you type. Both are added from a setup block, which receives the session:

Elelem.configure do |config|
  config.setup(:wc) do |session|
    session.toolbox.add("wc",
                        description: "Count lines, words, and bytes in a file",
                        params: { path: { type: "string", description: "File to count" } },
                        required: ["path"],
                        aliases: ["count"]) do |args|
      path = args["path"]
      next { error: "not found: #{path}" } unless File.file?(path)

      text = File.read(path)
      { lines: text.lines.count, words: text.split.count, bytes: text.bytesize }
    end

    session.toolbox.before("execute") do |args|
      raise "refusing to run rm" if args["command"].match?(/\brm\b/)
    end

    session.commands.register("wc", description: "Count a file", completions: -> { Dir["*"] }) do |path|
      session.output.say(session.toolbox.run("wc", { "path" => path.to_s }).to_h.inspect)
    end
  end
end

Save that as .agents/plugins/wc.rb and restart elelem. The rest of this page explains each part. Plugins covers where files go and when they load.

Tools #

toolbox.add #

toolbox.add(name, description:, params: {}, required: [], aliases: [], &handler)
ArgumentMeaning
nameThe name the model calls. Use a string.
description:What the tool does. The model reads this to decide when to call it.
params:JSON Schema properties: a hash of parameter name to schema.
required:Names of required parameters, as strings.
aliases:Other names that resolve to this tool.
&handlerRuns the tool. Receives the arguments hash; returns a hash.

Adding a tool with a name that already exists replaces it.

The handler gets the arguments the model sent, with string keys: args["path"], not args[:path]. It must return a hash. Return { error: "..." } to report a failure the model should see. Anything else is a success. Raising also works: the exception message becomes the error.

What the model sees #

toolbox.to_a turns every tool into an OpenAI-style function definition, and the provider sends that list to the model:

{
  type: "function",
  function: {
    name: "wc",
    description: "Count lines, words, and bytes in a file",
    parameters: {
      type: "object",
      properties: { path: { type: "string", description: "File to count" } },
      required: ["path"]
    }
  }
}

Aliases are not sent. They exist to catch names the model guesses. elelem-builtins maps bash, sh, and exec to execute this way.

Validation #

Before a tool runs, its arguments are validated against { type: "object", properties: params, required: required } with json_schemer. Any JSON Schema keyword inside a property works: enum, items, minimum, and so on. Extra arguments are allowed, because the schema does not set additionalProperties.

A call that fails validation never reaches hooks or the handler. The model gets the errors joined with commas:

{"error":"value at `/path` is not a string","ok":false}

Results #

toolbox.run(name, args) returns an Elelem::Result. It wraps the handler’s hash and adds ok: true or ok: false.

session.toolbox.run("wc", { "path" => "README.md" }).to_h
# => {lines: 2, words: 3, bytes: 6, ok: true}

session.toolbox.run("wc", { "path" => "nope" }).to_h
# => {error: "not found: nope", ok: false}

When the handler or a hook raises, the result also carries name: and args:, and elelem logs a warning with the backtrace.

A tool can call another tool through toolbox.run. The call goes through validation and hooks like any other. elelem-tools builds glob, grep, and git on top of execute this way.

Hooks #

toolbox.before(tool_name = :*) { |args| ... }
toolbox.after(tool_name = :*) { |args, result| ... }

Hooks run around the handler, after validation. With a tool name, a hook runs for that tool only. Without one, it runs for every tool and also receives the tool’s name:

toolbox.before { |args, tool_name:| ... }
toolbox.after { |args, result, tool_name:| ... }

The order for one call is:

  1. Global before hooks, in the order added.
  2. before hooks for this tool.
  3. The handler.
  4. Global after hooks.
  5. after hooks for this tool.

Hook return values are ignored. To stop a call, raise in a before hook; the message goes back to the model as the error. elelem-tools builds its confirmation prompt this way: a global before hook that asks you and raises User denied permission: execute if you answer n.

after hooks receive the handler’s raw hash, before ok: is added. They run even when that hash has an :error key, so check for it. Hooks are matched by the tool’s real name, never an alias. A call to an unknown tool runs no hooks.

Inspect the toolbox #

MethodReturns
toolbox.toolsHash of name to Elelem::Tool
toolbox.aliasesHash of alias to name
toolbox.hooks{ before: {name => [blocks]}, after: {name => [blocks]} }
toolbox.tool_for(name)The tool, following aliases. An unknown name returns a stand-in whose name ends in ?.

With elelem-builtins, /tools prints a table of tools and /tools NAME prints one tool’s parameters.

Slash commands #

commands.register #

commands.register(name, description: "", completions: nil) { |args| ... }
ArgumentMeaning
nameThe command without the slash. Use a string.
description:Shown by /help in elelem-builtins.
completions:An array of strings, or a lambda that returns one. Used for tab completion of the argument.
blockRuns the command.

The block receives everything after the first space as one string, or nil if there is nothing. /context 3 passes "3". /skills fetch example.com passes "fetch example.com". Registering the same name again replaces the command.

A command can do anything with the session: print, change the conversation, swap the provider, or run a turn. The /compact command in elelem-tools calls session.turn to ask for a summary, clears the conversation, and stores the summary as the new first message.

How the REPL runs commands #

  • A line that starts with / is a command. The REPL splits off the name at the first space.
  • If no command has that name, the REPL prints the names of all commands.
  • If the command raises, the REPL logs a warning and prints the message as an error. The session keeps running.

Tab completion #

The default input completes:

  • Command names, when the line starts with / and has no space yet.
  • Command arguments, from completions:, after /name .
  • File names everywhere else, up to 20 matches.

Other methods #

MethodReturns
commands.run(name, args = nil)true if the command exists and ran, false otherwise
commands.names["/exit", "/help", ...]
commands.each { |name, description| }Yields "/name" and its description. Commands is Enumerable.
commands.completions_for(name, partial = "")The completions that start with partial
commands.clear!Removes every command

Full signatures are in the Plugin API.