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
endSave 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)| Argument | Meaning |
|---|---|
name | The 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. |
&handler | Runs 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:
- Global
beforehooks, in the order added. beforehooks for this tool.- The handler.
- Global
afterhooks. afterhooks 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 #
| Method | Returns |
|---|---|
toolbox.tools | Hash of name to Elelem::Tool |
toolbox.aliases | Hash 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| ... }| Argument | Meaning |
|---|---|
name | The 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. |
| block | Runs 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 #
| Method | Returns |
|---|---|
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.