Documentation menu

Concepts

How it works

The agent loop step by step, the objects behind it, and the path a single tool call takes.

elelem is a loop: send the conversation to a model, run the tools it asks for, send the results back, and stop when it asks for nothing. This page walks through that loop and names the objects involved, so the Plugin API makes sense.

The objects #

ObjectJob
ReplReads lines from the input and dispatches each one: a slash command or a turn.
SessionBundles an agent, an input, and the slash commands. Setup blocks receive the session.
AgentRuns one turn: calls the provider, runs tools, repeats until done. Holds the provider, toolbox, output, and transcript.
TranscriptThe conversation plus the system prompt. agent.conversation returns this.
ConversationAn ordered list of messages with roles user, assistant, or tool.
ToolboxTools the model can call, plus before and after hooks.
CommandsSlash commands you can type.
Input, OutputReading your lines and printing to the terminal. Plugins can replace both.

A Session forwards provider, provider=, toolbox, output, conversation, context, and turn to its agent, so plugin code rarely touches the agent directly.

Startup #

When you run elelem chat -p anthropic:

  1. exe/elelem loads core, which registers the stub provider. It then requires elelem-builtins if it is installed.
  2. The CLI parses options and calls Elelem.start.
  3. elelem loads plugin files from ~/.agents/plugins/ and .agents/plugins/. See Plugins.
  4. It builds the provider named by -p. If no plugin registered anthropic, it requires the elelem/anthropic gem and looks again. See Providers.
  5. It builds the output and input (a plugin’s factory if one is registered, otherwise the core Output and Input), creates the agent and the session, and runs every setup block against the session. Setup blocks add tools, hooks, and commands.
  6. The Repl prints elelem v0.13.0 and prompts with > .

A turn #

You type fix the failing test and press Enter.

  1. Dispatch. The REPL skips empty lines. A line starting with / runs a slash command. Anything else becomes a turn.
  2. Record the prompt. The agent appends { role: "user", content: "fix the failing test" } to the conversation.
  3. Build the messages. The transcript renders the system prompt fresh, then appends the conversation, then any tool calls and results from earlier in this turn.
  4. Call the provider. The agent calls provider.fetch(messages, tools), where tools is the toolbox as a list of JSON Schema function definitions. The provider streams events back through a block:
    • saying events add to the reply text.
    • thinking events print dimmed.
    • doing events are tool calls, collected for the next step.
  5. Print the reply so far, as Markdown.
  6. Stop or continue. If the provider asked for no tools, the turn is over. Otherwise the agent runs each tool call through the toolbox, appends an assistant message carrying the tool calls and one tool message per result, and goes back to step 3.
  7. Record the reply. The text of the last response is appended as { role: "assistant", content: ... } and returned.
you ──> Repl ──> Agent#turn
                   │
                   ├─> provider.fetch(system + history + this turn's tool traffic, tools)
                   │      └─ events: saying / thinking / doing
                   │
                   ├─ no tool calls? ──> save reply, return
                   │
                   └─ tool calls ──> Toolbox#run each ──> results ──> back to fetch

What the conversation keeps #

Only your prompt and the final reply are saved to the conversation. The tool calls and tool results of a turn exist only while that turn runs. On the next turn, the model sees the system prompt and the user and assistant messages, not the earlier tool output. Use /context to see exactly what is kept.

When the provider fails #

If fetch raises, the agent logs a warning, prints the message as an error, and ends the turn with the reply Error: <message>. That reply is saved to the conversation like any other. The REPL keeps running.

How a tool call flows #

The provider emits a doing event:

{ type: "doing", id: "toolu_01", name: "read", arguments: { "path" => "README.md" } }

The agent prints + read({"path" => "README.md"}) and calls toolbox.run("read", arguments):

  1. Resolve the name. An alias maps to its tool, so open becomes read. An unknown name gets a stand-in tool whose name ends in ?.
  2. Validate. The arguments are checked against the tool’s JSON Schema. On failure, the result is an error and nothing else runs: no hooks, no handler. An unknown tool fails here with unknown tool: "name". Available: read, write, execute.
  3. Before hooks. Global hooks first, then hooks for this tool.
  4. Handler. The tool’s block runs with the arguments hash. Keys are strings, because they come from JSON.
  5. After hooks. Global hooks first, then hooks for this tool. They receive the handler’s return value.
  6. Wrap the result. If the returned hash has an :error key, the result is a failure (ok: false); otherwise a success (ok: true).
  7. Catch exceptions. If a hook or the handler raises, the result is a failure carrying the error message, the tool name, and the arguments. A before hook can refuse a call this way.

The agent serializes the result to JSON and sends it back as a tool message:

{"content":"# My project\n...","path":"README.md","ok":true}

A validation failure looks like this:

{"error":"object at root is missing required properties: path","ok":false}

The model reads the error and usually tries again. Nothing in elelem retries for it.

Where to go next #