Skip to content

How-To Guides

Switch LLM Libraries

Everything about the LLM lives in your terminal run proc, so switching libraries is a change to that proc and nothing else: same middleware stack, same tools, same session log. The MessageTransport for each library handles the translation at the boundary.

Switch to a library with a shipped transport

Section titled “Switch to a library with a shipped transport”

Four transports ship with the gem:

Transport Library
MessageTransport::RubyLLM ruby_llm
MessageTransport::LLM llm.rb
MessageTransport::OpenAI openai
MessageTransport::Anthropic anthropic

Each absorbs that provider’s quirks (Anthropic’s top-level system_, OpenAI’s JSON-string tool arguments, ruby_llm’s id-keyed tool-call hash), so the swap is mechanical. Going from the ruby_llm example to the anthropic gem means replacing only the run proc:

require "anthropic"
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|
transport = Brute::MessageTransport::Anthropic
client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
response = client.messages.create(
model: "claude-opus-4-8",
max_tokens: 16_000,
system_: transport.system_text(env[:messages]),
messages: transport.dump_all(env[:messages]),
tools: anthropic_tools(env[:tools]), # adapter.to_h -> API shape
)
transport.wrap_each(response) { |m| env[:messages] << m }
end

The tool-advertising helper changes too — it reshapes adapter.to_h into the new library’s tool format — but the tools themselves (Brute::Tools::ALL) do not.

The repo ships this exact agent four times over in examples/ruby-llm/, examples/llm-rb/, examples/openai/, and examples/anthropic/. Diff their main.rb files to see precisely what changes: the run proc and the tool helper, nothing else.

Subclass Brute::MessageTransport and override .dump (outbound) and #wrap (inbound); the base class handles flattening and enumerator plumbing:

class MyTransport < Brute::MessageTransport
def self.dump(message)
# Brute::Message -> your library's request message
end
private
def wrap(message)
# your library's response message -> Brute::Message
end
end

Two conventions keep it optional like the shipped ones:

  • Reference the library lazily inside those methods — no top-level require — so users who don’t use it pay nothing.
  • Accept whatever the proc got back in wrap_each (a single message, an array, or anything responding to #messages).

See Message Transports for the full four-entry-point API.