Getting Started
This guide installs brute, builds a working coding agent, and runs a multi-step task against a local or hosted model.
Installation
# Gemfile
gem "brute"
# plus the LLM library you want to call — brute depends on none of them
gem "ruby_llm" # or "llm.rb", "openai", "anthropic"
Requires Ruby >= 3.3.
The shape of an agent
Brute.agent returns an AgentPipeline — a Rack-style builder that is also the runnable agent. You chain .use for middleware and .run for the terminal LLM-call proc (both return the pipeline), then invoke it with .start(prompt):
agent = Brute.agent
.use(SomeMiddleware)
.run ->(env) { ... } # the LLM call — provider/model/credentials live HERE
env = agent.start("do the thing")
env[:messages].last.content # the agent's final answer
There is no agent-level configuration. Tools go to the ToolPipeline middleware, the conversation log to SessionLog, and everything about the LLM (provider, model, keys) lives inside the run proc.
A complete agent
This is examples/ruby_llm.rb, trimmed. It defaults to a local Ollama; set BRUTE_PROVIDER / BRUTE_MODEL / an API key to use a hosted model.
require "brute"
require "ruby_llm"
PROVIDER = ENV.fetch("BRUTE_PROVIDER", "ollama").to_sym
MODEL = ENV.fetch("BRUTE_MODEL", "llama3.2")
# Advertise Brute's tools to ruby_llm: each neutral adapter (name,
# description, JSON schema via #to_h) becomes a RubyLLM::Tool.
def rubyllm_tools(tools)
Brute.tools(tools).transform_values do |adapter|
schema = adapter.to_h[:parameters]
Class.new(RubyLLM::Tool) do
description adapter.description
params schema
define_method(:name) { adapter.name }
define_method(:execute) { |**args| adapter.call(args) }
end.new
end
end
agent = Brute.agent
.use(Brute::Middleware::SessionLog, path: "tmp/session.jsonl")
.use(Brute::Middleware::SystemPrompt)
.use(Brute::Middleware::Loop::ToolResult)
.use(Brute::Middleware::MaxIterations)
.use(Brute::Middleware::ToolPipeline, tools: Brute::Tools::ALL)
.run do |env|
context = RubyLLM.context do |config|
config.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
end
model, provider = RubyLLM::Models.resolve(
MODEL, provider: PROVIDER, assume_exists: true, config: context.config
)
response = provider.complete(
Brute::MessageTransport::RubyLLM.dump_all(env[:messages]),
tools: rubyllm_tools(env[:tools]),
temperature: 0.7,
model: model,
)
Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
env[:messages] << message
end
end
env = agent.start("What files are in the current directory? List them.")
puts env[:messages].last.content
Run it:
ruby examples/ruby_llm.rb
# or against Anthropic:
BRUTE_PROVIDER=anthropic BRUTE_MODEL=claude-opus-4-8 ANTHROPIC_API_KEY=sk-... ruby examples/ruby_llm.rb
What happens in a turn
.start("...")builds the env:{ messages:, events:, metadata:, current_iteration: }, with your prompt as arole: :usermessage.- Middleware runs top-down:
SessionLogloads history from disk,SystemPromptprepends the system message,ToolPipelineadvertises the tools onenv[:tools]. - Your
runproc convertsenv[:messages]to the library’s format (the MessageTransport does this), makes ONE completion, and appends the response back asBrute::Messagevalues. - On the way back up,
ToolPipelineexecutes any tool calls the model made — concurrently, with output truncation — and appendsrole: :toolresults. Loop::ToolResultsees the last message is a tool result and sends control back down. The loop ends when the model answers with text (orMaxIterationstrips).SessionLogpersists the whole conversation as JSONL.
Every message in env[:messages] is a Brute::Message — a plain, immutable value. Nothing in that flow touched an LLM library except your proc.
Not a ruby_llm shop?
The identical agent runs on llm.rb, the openai gem, or the anthropic gem — only the run proc changes. See examples/llm.rb, examples/openai.rb, and examples/anthropic.rb in the repo, or the examples overview.