Tutorials
Getting Started
This guide installs brute, builds a working coding agent, and runs a multi-step task against a local or hosted model.
Installation
Section titled “Installation”# Gemfilegem "brute"
# plus the LLM library you want to call — brute depends on none of themgem "ruby_llm" # or "llm.rb", "openai", "anthropic"Requires Ruby >= 3.3.
The shape of an agent
Section titled “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 answerThere 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
Section titled “A complete agent”This is examples/ruby-llm/main.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_symMODEL = 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 endend
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::DefaultToolPipeline, 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.contentRun it:
nix run ./examples/ruby-llm
# or against Anthropic:BRUTE_PROVIDER=anthropic BRUTE_MODEL=claude-opus-4-8 ANTHROPIC_API_KEY=sk-... nix run ./examples/ruby-llmWhat happens in a turn
Section titled “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?
Section titled “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/, and examples/anthropic/ in the repo, or the examples overview.