Guides

Write a provider plugin

Connect elelem to a new model API by implementing one method, fetch, that streams events back to the agent.

Advanced 45 minutes elelemelelem-builtinselelem-openaielelem-anthropicelelem-ollama

At the end of this guide you will have a working provider, compat, that connects elelem to any server speaking the OpenAI chat completions format, including tool calls. You will know the whole provider contract: one factory, one method, three event types.

Before you start #

  • elelem and elelem-builtins installed. See your first session.
  • A server that speaks POST /v1/chat/completions, such as llama.cpp’s llama-server, vLLM, or a hosted API. Step 1 needs no server at all.
  • Familiarity with tool plugins. Providers load the same way.

How the agent uses a provider #

A provider is any object with one method:

def fetch(messages, tools = [], &block)

For each model call, the agent passes the conversation and the tool list, and the provider calls the block once per event. The agent ignores the return value. When the model asks for tools, the agent runs them, appends the results to messages, and calls fetch again. A turn ends when a fetch produces no tool calls.

messages is an array of hashes with symbol keys and string roles. The first is always the system prompt.

[
  { role: "system", content: "You are a terminal coding agent.\n\n..." },
  { role: "user", content: "what is in the readme?" },
  { role: "assistant", content: "",
    tool_calls: [{ id: "call_1", name: "read", arguments: { "path" => "README.md" } }] },
  { role: "tool", tool_call_id: "call_1",
    content: "{\"content\":\"# hello\\n\",\"path\":\"README.md\",\"ok\":true}" }
]

Assistant messages carry tool_calls only while a turn is in progress. Each arguments is a hash, and each tool result content is a JSON string. Once the turn ends, elelem keeps only your prompt and the final reply in the history.

tools is an array in OpenAI’s function format, also with symbol keys:

[{ type: "function",
   function: { name: "read", description: "Read file",
               parameters: { type: "object", properties: { path: { type: "string" } },
                             required: ["path"] } } }]

Events are hashes passed to the block. There are three:

EventKeysWhat elelem does
"saying"textAppends text to the reply.
"thinking"textPrints text dimmed. Not kept.
"doing"id, name, argumentsRecords a tool call to run after fetch returns.

arguments in a "doing" event must be a hash with string keys, which is what JSON.parse gives you. Tools read args["path"]. The id comes back to you as tool_call_id on the tool’s result message.

If fetch raises, the agent catches it, prints the error, and ends the turn with the reply Error: <message>.

1. Write the smallest provider #

Before talking to a network, see the contract work. Create .agents/plugins/parrot.rb in a project that has a README.md:

module Parrot
  class Provider
    def fetch(messages, tools = [], &block)
      last = messages.last
      if last[:role] == "tool"
        block.call(type: "saying", text: "the tool said: #{last[:content]}")
      elsif (tool = tools.find { |t| t.dig(:function, :name) == "read" })
        block.call(type: "thinking", text: "I should read README.md first.")
        block.call(type: "doing", id: "parrot-1", name: tool.dig(:function, :name),
                                  arguments: { "path" => "README.md" })
      else
        block.call(type: "saying", text: "you said: #{last[:content]}")
      end
    end
  end
end

Elelem.configure do |config|
  config.provider(:parrot) { |**| Parrot::Provider.new }
end

config.provider(:parrot) registers a factory under the name you pass to -p. elelem calls it once per session with the provider options from the command line as keyword arguments. Today that is model: when you pass --model, so accept ** even if you ignore them.

elelem ask -p parrot "what is in the readme?"
I should read README.md first.+ read({"path" => "README.md"})
# hello

```
the tool said: {"content":"# hello\n","path":"README.md","ok":true}
```

The first line is the "thinking" text (dimmed in a terminal) followed by the tool call. # hello is the read tool showing you the file. ask prints the reply in a code fence.

That was two model calls. The first emitted a "doing" event, elelem ran read, and the second saw the tool message and replied.

2. Write a real provider #

Create .agents/plugins/compat.rb:

require "json"
require "net/http"
require "uri"

module Compat
  class Provider
    def initialize(base_url:, model:, api_key: nil)
      @uri = URI.join("#{base_url}/", "chat/completions")
      @model = model
      @api_key = api_key
    end

    def fetch(messages, tools = [], &block)
      body = { model: @model, messages: messages.map { |m| normalize(m) } }
      body[:tools] = tools unless tools.empty?
      message = post(body).dig("choices", 0, "message")

      text = message["content"].to_s
      block.call(type: "saying", text: text) unless text.empty?

      Array(message["tool_calls"]).each do |call|
        block.call(type: "doing",
                   id: call["id"],
                   name: call.dig("function", "name"),
                   arguments: JSON.parse(call.dig("function", "arguments") || "{}"))
      end
    end

    private

    def normalize(message)
      return message unless message[:tool_calls]

      message.merge(tool_calls: message[:tool_calls].map do |call|
        { id: call[:id], type: "function",
          function: { name: call[:name], arguments: JSON.generate(call[:arguments]) } }
      end)
    end

    def post(body)
      request = Net::HTTP::Post.new(@uri)
      request["content-type"] = "application/json"
      request["authorization"] = "Bearer #{@api_key}" if @api_key
      request.body = JSON.generate(body)
      response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https", read_timeout: 600) do |http|
        http.request(request)
      end
      raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

      JSON.parse(response.body)
    end
  end
end

Elelem.configure do |config|
  config.provider(:compat) do |model: nil, **|
    Compat::Provider.new(
      base_url: ENV.fetch("COMPAT_BASE_URL", "http://localhost:8080/v1"),
      model: model || ENV.fetch("COMPAT_MODEL", "default"),
      api_key: ENV.fetch("COMPAT_API_KEY", nil)
    )
  end
end

Three things carry the weight:

  • The factory reads configuration. elelem has no config file. Every provider gem reads environment variables in its factory and lets --model override the model. Follow the pattern so -p compat --model x works like the others.
  • normalize translates elelem’s messages into the API’s format. elelem’s tool calls are { id:, name:, arguments: Hash }. OpenAI wants { id:, type: "function", function: { name:, arguments: String } }. The tool messages already match OpenAI’s shape, so they pass through. Every provider has a version of this method: elelem-anthropic turns tool results into tool_result blocks in a user message, and elelem-ollama replaces tool_call_id with tool_name.
  • The response becomes events. Text becomes one "saying" event and each tool call becomes a "doing" event with parsed arguments.

Run it against your server:

COMPAT_BASE_URL=http://localhost:8080/v1 elelem chat -p compat

Inside a session, /provider compat switches to it, and /provider with no argument lists the providers registered so far. A provider built by /provider gets no options, so the factory falls back to COMPAT_MODEL.

3. Stream the reply #

The example waits for the whole response, then emits one "saying" event. Streaming means calling the block as chunks arrive, so the reply appears as it is written. The agent does not care how many events you send.

The three published HTTP providers share one shape:

  1. Send the request with stream: true.
  2. Read the body with response.read_body { |chunk| ... }, buffer it, and split it into events: lines of data: {...} for OpenAI and Anthropic, one JSON object per line for Ollama.
  3. Emit a "saying" or "thinking" event for each text delta as it arrives.
  4. Collect tool calls as they stream. OpenAI and Anthropic send the arguments as JSON fragments, so append the fragments to a string and call JSON.parse only at the end. Then emit one "doing" event per call.

The providers in elelem-openai and elelem-anthropic are one short file each and are the best reference.

4. Test it without a model #

fetch is a plain method, so you can call it with a block that records events:

events = []
provider.fetch([{ role: "user", content: "hi" }], []) { |event| events << event }

The published providers take an http: keyword in their constructor so specs can swap in a fake that returns canned responses. Do the same once your provider grows past a sketch.

5. Package it as a gem #

Name the gem elelem-compat and put the plugin at lib/elelem/compat.rb, starting with require "elelem". Provider gems need no loader file: when you run -p compat and no provider of that name is registered, elelem runs require "elelem/compat" and looks again. If the gem is missing, elelem says so:

elelem: unknown provider: "compat". Available: stub. Run: gem install elelem-compat

The gemspec is the same as for a tool gem, with spec.add_dependency "elelem", "~> 0.13". To ship a shortcut executable like elelem-ollama, add exe/elelem-compat:

#!/usr/bin/env ruby
require "elelem/cli"

Signal.trap("INT") { exit 1 }

Elelem::CLI.new(ARGV, provider: "compat").run

Then set spec.bindir = "exe" and spec.executables = ["elelem-compat"] in the gemspec.

What you built #

Two providers: parrot, which shows the event contract with no network, and compat, which connects elelem to any OpenAI-compatible server, including tool calls. You know the message and tool shapes elelem sends, the three events it expects back, and how -p NAME finds a provider gem.

Next steps #