main

Plugin Authoring

Step-by-step guides for creating each plugin type.

Tool Plugins

Tools are functions the LLM can call. The LLM sees the tool name, description, and parameter schema.

Step 1: Create the file

# ~/.elelem/plugins/weather.rb
Elelem::Plugins.register(:weather) do |agent|
end

Step 2: Define the tool

Elelem::Plugins.register(:weather) do |agent|
  agent.toolbox.add("weather",
    description: "Get current weather for a city",
    params: {
      city: { type: "string", description: "City name" }
    },
    required: ["city"]
  ) do |args|
    # Tool implementation
  end
end

Step 3: Implement the logic

The block receives args (a Hash) and must return a Hash:

agent.toolbox.add("weather",
  description: "Get current weather for a city",
  params: {
    city: { type: "string", description: "City name" }
  },
  required: ["city"]
) do |args|
  city = args["city"]
  response = Net::HTTP.get(URI("https://wttr.in/#{city}?format=j1"))
  data = JSON.parse(response)
  {
    city: city,
    temp_c: data.dig("current_condition", 0, "temp_C"),
    condition: data.dig("current_condition", 0, "weatherDesc", 0, "value")
  }
end

Step 4: Add output formatting (optional)

Use an after hook to display results to the user:

agent.toolbox.after("weather") do |_args, result|
  agent.terminal.say "  #{result[:city]}: #{result[:temp_c]}°C, #{result[:condition]}"
end

Complete example

# ~/.elelem/plugins/weather.rb
Elelem::Plugins.register(:weather) do |agent|
  agent.toolbox.add("weather",
    description: "Get current weather for a city",
    params: {
      city: { type: "string", description: "City name" }
    },
    required: ["city"]
  ) do |args|
    city = args["city"]
    response = Net::HTTP.get(URI("https://wttr.in/#{city}?format=j1"))
    data = JSON.parse(response)
    {
      city: city,
      temp_c: data.dig("current_condition", 0, "temp_C"),
      condition: data.dig("current_condition", 0, "weatherDesc", 0, "value")
    }
  end

  agent.toolbox.after("weather") do |_args, result|
    agent.terminal.say "  #{result[:city]}: #{result[:temp_c]}°C, #{result[:condition]}"
  end
end

Command Plugins

Commands are slash commands invoked by the user (not the LLM).

Step 1: Create the file

# ~/.elelem/plugins/todo.rb
Elelem::Plugins.register(:todo) do |agent|
end

Step 2: Register the command

Elelem::Plugins.register(:todo) do |agent|
  agent.commands.register("todo", description: "Show todo list") do |args|
    # Command implementation
  end
end

The block receives the argument string after the command name.

Step 3: Implement the logic

agent.commands.register("todo", description: "Manage todo list") do |args|
  case args&.strip
  when nil, ""
    todos = File.readlines("TODO.md").map(&:strip)
    agent.terminal.say todos.join("\n")
  when /^add (.+)/
    File.open("TODO.md", "a") { |f| f.puts "- [ ] #{$1}" }
    agent.terminal.say "  → added"
  end
end

Step 4: Add tab completion (optional)

agent.commands.register("todo",
  description: "Manage todo list",
  completions: -> { %w[add done list] }
) do |args|
  # ...
end

Complete example

# ~/.elelem/plugins/todo.rb
Elelem::Plugins.register(:todo) do |agent|
  agent.commands.register("todo",
    description: "Manage todo list",
    completions: -> { %w[add list] }
  ) do |args|
    case args&.strip
    when nil, "", "list"
      if File.exist?("TODO.md")
        agent.terminal.say File.read("TODO.md")
      else
        agent.terminal.say "  (no todos)"
      end
    when /^add (.+)/
      File.open("TODO.md", "a") { |f| f.puts "- [ ] #{$1}" }
      agent.terminal.say "  → added"
    end
  end
end

Hook Plugins

Hooks run before or after tool execution. Use them for logging, confirmation, or post-processing.

Before hooks

Run before a tool executes. Return false to cancel execution.

Tool-specific:

Elelem::Plugins.register(:logger) do |agent|
  agent.toolbox.before("execute") do |args|
    File.open("commands.log", "a") { |f| f.puts args["command"] }
  end
end

Global (all tools):

Elelem::Plugins.register(:audit) do |agent|
  agent.toolbox.before do |args, tool_name:|
    File.open("audit.log", "a") { |f| f.puts "#{Time.now} #{tool_name}" }
  end
end

After hooks

Run after a tool completes. Receive both args and result.

Tool-specific:

Elelem::Plugins.register(:notify) do |agent|
  agent.toolbox.after("execute") do |args, result|
    if result[:exit_status] != 0
      system("notify-send", "Command failed", args["command"])
    end
  end
end

Global:

Elelem::Plugins.register(:timing) do |agent|
  starts = {}

  agent.toolbox.before do |_args, tool_name:|
    starts[tool_name] = Time.now
  end

  agent.toolbox.after do |_args, _result, tool_name:|
    elapsed = Time.now - starts[tool_name]
    agent.terminal.say "  (#{tool_name}: #{elapsed.round(2)}s)"
  end
end

Confirmation hook

A common pattern is requiring confirmation before dangerous operations:

# Name starts with zz_ to load last
# ~/.elelem/plugins/zz_confirm.rb
Elelem::Plugins.register(:confirm) do |agent|
  agent.toolbox.before("execute") do |args|
    agent.terminal.say "  run: #{args["command"]}"
    response = agent.terminal.ask("  proceed? [y/n] ")
    response.strip.downcase == "y"
  end
end

Provider Plugins

Providers are LLM backends. They implement the fetch interface.

Step 1: Create the client class

The client must implement:

fetch(messages, tools = []) { |event| ... } -> Array<tool_calls>

Messages (OpenAI format):

{ role: "system", content: "..." }
{ role: "user", content: "..." }
{ role: "assistant", content: "..." }
{ role: "tool", tool_call_id: "...", content: "..." }

Tools (OpenAI format):

{ type: "function", function: { name:, description:, parameters: } }

Events (yield to block):

{ type: "saying", text: "..." }      # assistant response text
{ type: "thinking", text: "..." }    # reasoning/thinking text
{ type: "tool_call", id:, name:, arguments: }

Return value:

[{ id: "...", name: "tool_name", arguments: { ... } }, ...]

Step 2: Create the provider plugin

# ~/.elelem/plugins/gemini.rb
require_relative "gemini_client"

Elelem::Providers.register(:gemini) do
  GeminiClient.new(
    model: ENV.fetch("GEMINI_MODEL", "gemini-pro"),
    api_key: ENV.fetch("GEMINI_API_KEY")
  )
end

Step 3: Implement the client

# ~/.elelem/plugins/gemini_client.rb
class GeminiClient
  def initialize(model:, api_key:)
    @model = model
    @api_key = api_key
  end

  def fetch(messages, tools = [])
    tool_calls = []

    # Convert messages to Gemini format
    body = build_request(messages, tools)

    # Stream response
    stream(body) do |chunk|
      # Parse chunk and yield events
      if chunk["text"]
        yield(type: "saying", text: chunk["text"])
      end

      if chunk["functionCall"]
        tc = parse_tool_call(chunk["functionCall"])
        yield(type: "tool_call", **tc)
        tool_calls << tc
      end
    end

    tool_calls
  end

  private

  def build_request(messages, tools)
    # Convert to Gemini API format
    {
      contents: messages.map { |m| convert_message(m) },
      tools: tools.map { |t| convert_tool(t) }
    }
  end

  def stream(body)
    # POST to Gemini API with streaming
    # Parse SSE events and yield chunks
  end

  def parse_tool_call(fc)
    {
      id: SecureRandom.uuid,
      name: fc["name"],
      arguments: fc["args"]
    }
  end
end

Complete minimal example

# ~/.elelem/plugins/echo.rb
# A mock provider for testing

class EchoClient
  def fetch(messages, tools = [])
    last = messages.last[:content]
    yield(type: "saying", text: "You said: #{last}")
    []
  end
end

Elelem::Providers.register(:echo) do
  EchoClient.new
end

Use with: elelem chat --provider echo


Agent API Reference

Plugins receive an agent object with:

Method Description
agent.toolbox Add tools and hooks
agent.commands Register slash commands
agent.terminal Output to user (say, ask, markdown)
agent.conversation Access message history
agent.client Current LLM client
agent.fork(system_prompt:) Create sub-agent
agent.turn(prompt) Get LLM response