Write a provider plugin
Connect elelem to a new model API by implementing one method, fetch, that streams events back to the agent.
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-builtinsinstalled. See your first session. - A server that speaks
POST /v1/chat/completions, such as llama.cpp’sllama-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:
| Event | Keys | What elelem does |
|---|---|---|
"saying" | text | Appends text to the reply. |
"thinking" | text | Prints text dimmed. Not kept. |
"doing" | id, name, arguments | Records 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 }
endconfig.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
endThree things carry the weight:
- The factory reads configuration. elelem has no config file. Every
provider gem reads environment variables in its factory and lets
--modeloverride the model. Follow the pattern so-p compat --model xworks like the others. normalizetranslates 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 } }. Thetoolmessages already match OpenAI’s shape, so they pass through. Every provider has a version of this method:elelem-anthropicturns tool results intotool_resultblocks in ausermessage, andelelem-ollamareplacestool_call_idwithtool_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 compatInside 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:
- Send the request with
stream: true. - Read the body with
response.read_body { |chunk| ... }, buffer it, and split it into events: lines ofdata: {...}for OpenAI and Anthropic, one JSON object per line for Ollama. - Emit a
"saying"or"thinking"event for each text delta as it arrives. - 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.parseonly 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-compatThe 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").runThen 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 #
- Compare it with elelem-evals against the published providers.
- Read the source of elelem-openai, elelem-anthropic, and elelem-ollama for streaming implementations.
- The plugin API reference covers the rest of
Elelem.configure.