The Agent Pipeline
Brute.agent returns a Brute::Turn::AgentPipeline — a subclass of Rack::Builder that is simultaneously the builder and the agent. You configure it by chaining, and run it with .start:
agent = Brute.agent # => AgentPipeline
.use(Brute::Middleware::SystemPrompt) # => same pipeline (.use returns self)
.run ->(env) { ... } # => same pipeline (.run returns self)
env = agent.start("what changed?") # runs one turn, returns the env
A block form is equivalent (evaluated in the pipeline’s context):
agent = Brute.agent do
use Brute::Middleware::SystemPrompt
run ->(env) { ... }
end
The env
.start seeds a plain Hash and sends it through the stack:
| Key | Value |
|---|---|
:messages |
the conversation log — an Array of Brute::Message with role-tagging sugar (Brute.log) |
:events |
an event sink (<<-able); defaults to a null sink — see Events |
:metadata |
a scratch Hash for middleware (timing, session ids, …) |
:current_iteration |
the tool-loop counter, starts at 1 |
:tools |
set by the ToolPipeline middleware on the way in |
.start accepts a String (becomes a role: :user message), a Brute::Message, a Hash (coerced into one), an Array (used as the log), or nothing (empty log — useful when SessionLog provides the history).
The terminal run proc
The innermost app is the LLM call, and it is yours. Brute has no completion middleware and no LLM configuration — provider, model, and credentials all live in the proc, written with whatever library you like:
run do |env|
# 1. convert env[:messages] to your library's format (transport.dump_all)
# 2. make ONE completion, advertising env[:tools]
# 3. append the response back as Brute::Message values (transport.wrap_each)
end
The MessageTransport classes handle steps 1 and 3 for ruby_llm, llm.rb, openai and anthropic.
The proc does one completion per pass, not the whole loop — Loop::ToolResult re-invokes the stack while the model keeps calling tools, so Brute stays the turn manager.
Slash commands
map registers prompt templates, expanded before the turn starts. $ARGUMENTS is replaced with everything after the command:
agent = Brute.agent
.map("/weather", "Get the weather in the following location $ARGUMENTS")
.map("/echo") { "you said: $ARGUMENTS" }
.run ->(env) { ... }
agent.call("/weather London") # prompt becomes "Get the weather in the following location London"
Agents from .ru strings and files
Because the pipeline is a Rack::Builder, an agent can be defined in rackup syntax and parsed at runtime:
agent = Brute::Turn::AgentPipeline.new_from_string(<<~RU, "(inline)")
use Brute::Middleware::SystemPrompt
run ->(env) { env[:messages].assistant("hi") }
RU
# or from a file:
agent = Brute::Turn::AgentPipeline.parse_file("agent.ru")
agent.start("hello")
This is also what lets an agent serve over HTTP — the same builder drops into a config.ru.
Pipelines everywhere
AgentPipeline composes Brute::Turn::Pipeline, a thin Rack::Builder subclass whose use/run return self for chaining. The same class powers ToolPipeline (tools with middleware) and SubAgent (agents as tools) — one mental model for the whole framework.