Skip to content

Tutorials

Getting Started

This guide installs brute, builds a working coding agent, and runs a multi-step task against a local or hosted model.

# 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.

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.

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_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::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.content

Run it:

Terminal window
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-llm
  1. .start("...") builds the env: { messages:, events:, metadata:, current_iteration: }, with your prompt as a role: :user message.
  2. Middleware runs top-down: SessionLog loads history from disk, SystemPrompt prepends the system message, ToolPipeline advertises the tools on env[:tools].
  3. Your run proc converts env[:messages] to the library’s format (the MessageTransport does this), makes ONE completion, and appends the response back as Brute::Message values.
  4. On the way back up, ToolPipeline executes any tool calls the model made — concurrently, with output truncation — and appends role: :tool results.
  5. Loop::ToolResult sees the last message is a tool result and sends control back down. The loop ends when the model answers with text (or MaxIterations trips).
  6. SessionLog persists 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.

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.