Plugins / Core

elelem

The agent loop. Nothing else.

The core gem: the agent loop, the plugin registry, and the elelem command. Tools, slash commands, and model providers all come from plugins.

gem install elelem

The core gem is the harness: a loop that sends the conversation to a model, runs the tools the model asks for, and repeats until the model answers without asking for a tool. It also loads plugins and provides the elelem command. It ships with no file tools, no shell access, and no real model. Those come from plugins, so the core stays small enough to read in one sitting.

Install #

gem install elelem elelem-builtins

elelem alone runs, but has nothing to do: it registers one provider, stub, and no tools. elelem-builtins adds the default tools and slash commands. The elelem executable requires it automatically when it is installed. To talk to a real model, install a provider plugin and pass its name with -p, for example gem install elelem-anthropic and elelem chat -p anthropic.

Configure #

There is no configuration file. Configuration is Ruby: any .rb file in one of these directories is loaded at startup, in this order:

DirectoryScope
~/.agents/plugins/every project
.agents/plugins/the current working directory

Only files directly in those directories are loaded, not subdirectories. Files are loaded with load, so /reload runs them again.

The system prompt is built from these files on every request:

  • ~/.agents/AGENTS.md, if it exists.
  • The first AGENTS.md found walking up from the current directory.

When both exist, the global file comes first. They are inserted into the default prompt, which starts with “You are a terminal coding agent.” Text passed with --system-prompt is placed before all of it.

Use #

Usage: elelem [command] [options] [args]

Commands:
  chat              Interactive REPL (default)
  ask <prompt>      One-shot query (reads stdin if piped)
  files             Output files as XML (no options)
  help              Show this help

Options:
    -p, --provider NAME              Provider to use (default: stub)
        --model NAME                 Model to use, passed to the provider
        --system-prompt TEXT         Role text prepended to the system prompt
    -h, --help                       Show this help

chat starts the interactive loop. A line that starts with / runs a slash command; any other line is a prompt for the model. An unknown slash command prints the list of known ones. Ctrl-D ends the session.

elelem chat -p anthropic

ask runs one turn and exits. If stdin is a pipe, its contents are appended to the prompt in a fenced code block.

git diff | elelem ask -p ollama "Write a commit message for this diff"

When stdout is a terminal, you see the reply as it is produced. When stdout is piped, progress goes to stderr and only the final reply is written to stdout. ask runs without an interactive input, so nothing can stop to ask you a question.

files prints files in an XML envelope you can paste into any model. With no stdin it uses git ls-files; otherwise it reads one path per line from stdin.

git ls-files lib | elelem files
<documents>
<document index="1"><source>lib/elelem.rb</source><document_content><![CDATA[...]]></document_content></document>
</documents>

--model is passed to the provider as a model: option. What it accepts depends on the provider.

If -p NAME is not registered by a loaded plugin, elelem tries require "elelem/NAME", so an installed provider gem works without a plugin file. If that fails, it stops with:

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

Write a plugin #

A plugin is a Ruby file that calls Elelem.configure. Put this in .agents/plugins/hello.rb:

Elelem.configure do |config|
  config.setup(:hello) do |agent|
    agent.toolbox.add("hello",
                      description: "Greet someone",
                      params: { name: { type: "string" } },
                      required: ["name"]) do |args|
      { greeting: "hello #{args["name"]}" }
    end

    agent.commands.register("hello", description: "Say hello") do |args|
      agent.output.say "hello #{args}"
    end
  end
end

config accepts four kinds of registration:

CallRegisters
config.provider(:name) { |**options| ... }a model provider, selected with -p name
config.setup(:name) { |agent| ... }a setup block, run against every new session
config.output { ... }the object that renders output (the last one registered wins)
config.input { |**options| ... }the object that reads user input

The agent passed to a setup block is the session. It exposes toolbox, commands, conversation, output, input, provider (readable and writable), context, and turn(prompt).

Tools. toolbox.add(name, description:, params:, required:, aliases:) takes a block that receives the arguments as a string-keyed hash and returns a hash. params is the properties object of a JSON Schema, and arguments are validated against it before the block runs. A returned hash with an :error key is a failure. Results are sent back to the model as JSON with ok: true or ok: false added. An exception in a tool becomes a failure result instead of ending the session.

Hooks. toolbox.before(name) { |args| } and toolbox.after(name) { |args, result| } run around one tool. Without a name they run around every tool and also receive tool_name:. Raising in a before hook stops the call; the error goes back to the model.

Commands. commands.register(name, description:, completions:) adds a slash command. The block receives the text after the command name, or nil. completions is an array or a lambda returning one, used for tab completion.

Providers. A provider responds to fetch(messages, tools) and yields events: { type: "saying", text: }, { type: "thinking", text: }, and { type: "doing", id:, name:, arguments: } for a tool call. The built-in stub shows the whole contract:

class StubProvider
  def fetch(messages, tools = [], &block)
    block.call(type: "thinking", text: "considering the request")
    return block.call(type: "saying", text: "done.") if messages.last[:role] == "tool"

    block.call(type: "saying", text: "let me check.")
    return unless (tool = tools.first)

    block.call(type: "doing", id: "stub-1", name: tool[:function][:name], arguments: {})
  end
end

The same API is available from Ruby without the REPL:

require "elelem"

reply = Elelem.ask("What does this repo do?", provider: "anthropic")

How it works #

Elelem::CLI parses arguments and builds a session through the registry. The first build loads the plugin directories, then creates the provider, the agent, the input, and the output, and runs every setup block against the session.

Each turn, the agent sends the system prompt, the conversation, and the tool definitions to the provider. If the reply contains tool calls, it runs each one through the toolbox, appends the results, and asks again. When a reply has no tool calls, the turn ends.

Only the user prompt and the final reply of each turn are kept in the conversation. Intermediate tool calls and their results are sent to the model during the turn and then dropped.

Errors are contained. A provider error ends the turn with the error as the reply. A tool error becomes a failure result. A slash command error is printed and the loop continues. A plugin file that raises, including one with a syntax error, is skipped.

Limitations #

  • Without plugins, elelem cannot read files, run commands, or reach a model. -p stub is the default provider and only exists so a bare install starts and exits cleanly.
  • The default output prints replies as plain fenced text. Rendering and the . progress indicator come from elelem-builtins.
  • exe/elelem runs Ruby with -W0, which silences warn. A plugin file that fails to load is skipped without a message, and the hint to install elelem-builtins is not shown. Set ELELEM_LOG_LEVEL=debug for logger output; it does not cover these two messages.
  • /reload clears every setup block, then re-runs the files in the plugin directories. Setup blocks that came from a gem loaded with require are not registered again, so their tools and commands disappear until you restart. This includes everything in elelem-builtins, /reload itself among them.
  • There is no session persistence in core. Quitting loses the conversation.

Development #

git clone https://git.mokhan.ca/elelem/elelem.git
cd elelem
bin/setup
bin/test
bin/lint

bin/setup runs bundle install and mise install. bin/run runs exe/elelem through Bundler. Patches go by email; see git-send-email.io.

Environment #

VariableDefaultDescription
ELELEM_LOG_LEVELwarnLog level for messages written to stderr (debug, info, warn, error, fatal).