Concepts
Providers
The one method a provider implements, the messages and events it handles, and how -p NAME finds it.
A provider connects elelem to a model. It is any Ruby object with a fetch method, registered under a name with config.provider. You pick one with -p NAME.
The interface #
def fetch(messages, tools = [], &block)That is the whole contract. elelem calls fetch once per round trip to the model, passes the conversation and the tool definitions, and reads events from the block. The return value is ignored.
messages #
An array of hashes with symbol keys. The first is always the system prompt.
[
{ role: "system", content: "You are a terminal coding agent.\n\n..." },
{ role: "user", content: "what is in README.md?" },
{ role: "assistant", content: "", tool_calls: [{ id: "toolu_01", name: "read", arguments: { "path" => "README.md" } }] },
{ role: "tool", tool_call_id: "toolu_01", content: "{\"content\":\"# My project\",\"path\":\"README.md\",\"ok\":true}" }
]| Role | Keys |
|---|---|
system | content |
user | content |
assistant | content, and tool_calls when the model asked for tools earlier in this turn |
tool | tool_call_id, content (the tool result as a JSON string) |
Each entry in tool_calls is { id:, name:, arguments: }, in the same shape your provider emitted. Your provider translates all of this into whatever its API expects. elelem-anthropic, for example, pulls the system message out into Anthropic’s system field.
tools #
An array of tool definitions in the OpenAI function format:
[
{
type: "function",
function: {
name: "read",
description: "Read file",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
}
}
]Events #
Call the block with a hash. The type is a string.
| Event | Keys | What elelem does |
|---|---|---|
saying | text | Appends to the reply. |
thinking | text | Prints it dimmed. Not saved. |
doing | id, name, arguments | Records a tool call to run after fetch returns. |
Any other type is ignored. Stream saying and thinking as the tokens arrive; elelem prints the reply once fetch returns. Emit doing once per complete tool call, with arguments already parsed into a hash with string keys, as JSON.parse returns it.
When fetch returns, elelem runs the tool calls in the order you emitted them. If there were none, the turn is over.
Errors #
Raise. elelem catches StandardError and ScriptError around fetch, prints the message, and ends the turn with the reply Error: <message>. Do not retry or swallow errors inside the provider unless the API calls for it.
A complete provider #
Save this as .agents/plugins/echo.rb:
class EchoProvider
def initialize(prefix:)
@prefix = prefix
end
def fetch(messages, tools = [], &block)
last = messages.reverse.find { |m| m[:role] == "user" }
block.call(type: "saying", text: "#{@prefix}#{last[:content]}")
end
end
Elelem.configure do |config|
config.provider(:echo) do |model: nil, **|
EchoProvider.new(prefix: model ? "#{model}: " : "")
end
endelelem ask -p echo --model parrot "hello there"parrot: hello thereThe factory #
config.provider(:name) { |model: nil, **| ... }The block is a factory: it returns a new provider. elelem calls it every time it builds a provider, with no caching. The keyword arguments are the provider options. Today there is one, model:, set by --model. Accept ** as well, so new options do not break your factory.
A factory that takes no arguments also works. It ignores --model. elelem-ggml, elelem-candle, and elelem-llama2 work this way and read their model from environment variables only.
Registering a second factory under the same name replaces the first.
How -p NAME resolves #
elelem loads plugin files from
~/.agents/plugins/and.agents/plugins/, once.It looks for a provider registered as
NAME. Names are compared as strings, so:echoand"echo"are the same.If there is none, it runs
require "elelem/NAME", which loads the gemelelem-NAME, and looks again.If the require fails, you get:
elelem: unknown provider: "NAME". Available: stub, echo. Run: gem install elelem-NAMEIf the gem loads but registers no provider called
NAME, you getelelem-NAME did not register a provider named "NAME".It calls the factory with the provider options.
Two things follow from step 3:
A gem is found automatically only under its own name.
elelem-anthropicalso registersvertex, but-p vertexrequireselelem/vertex, which does not exist. Load the gem first with a one-line plugin file,~/.agents/plugins/anthropic.rb:require "elelem/anthropic"Any
LoadErrorduring that require, including one from a missing dependency inside the gem, is reported asunknown provider. If a provider you installed shows up as unknown, tryruby -e 'require "elelem/NAME"'to see the real error.
Known providers #
-p name | Gem | Honors --model |
|---|---|---|
stub | elelem (core) | No |
anthropic | elelem-anthropic | Yes |
vertex | elelem-anthropic (load it first, as above) | Yes |
openai | elelem-openai | Yes |
ollama | elelem-ollama | Yes |
llama | elelem-llama | Yes, a path to a .gguf file |
ggml | elelem-ggml | No |
candle | elelem-candle | No |
llama2 | elelem-llama2 | No |
Each provider’s settings are on its plugin page and in Environment variables.
Switching provider in a session #
elelem-builtins adds /provider. With no argument it lists registered providers. With a name, it builds that provider and swaps it into the running session. The conversation is kept. --model is not passed on a switch, so the new provider uses its default model.
In your own code, set it on the session:
session.provider = Elelem::Config.build_provider("ollama", model: "gpt-oss:latest")