Concepts
Message Transports
A MessageTransport is the seam that makes Brute framework-agnostic. Calling an LLM is trivial with any library, so Brute has no completion middleware — the terminal run proc makes the call, and a transport translates at the boundary in both directions:
outbound Brute::Message log ── .dump_all ──▶ the library's request formatinbound the library response ── .wrap_each ──▶ Brute::Message# outbound: hand your library a request it understandsmessages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])response = provider.complete(messages, tools:, model:)
# inbound: fold the reply back into the Brute logBrute::MessageTransport::RubyLLM.wrap_each(response) do |message| env[:messages] << messageendShipped transports
Section titled “Shipped transports”Four transports ship with the gem. Each references its library lazily — you require the gem, Brute does not depend on it.
| Transport | Library | Absorbs |
|---|---|---|
MessageTransport::RubyLLM |
ruby_llm | ruby_llm’s id-keyed tool_calls hash ↔ flat list |
MessageTransport::LLM |
llm.rb | LLM::Function::Return for tool results; provider-native tool-call extras |
MessageTransport::OpenAI |
openai | choice unpacking; JSON-string tool arguments |
MessageTransport::Anthropic |
anthropic | top-level system_:; tool_use/tool_result content blocks; alternating roles |
Each has a matching runnable agent in the repo: examples/ruby-llm/, examples/llm-rb/, examples/openai/, examples/anthropic/. Only the run proc differs between them.
The API
Section titled “The API”Every transport is a subclass of Brute::MessageTransport with four entry points:
transport.dump_all(messages) # class method — whole log → library formattransport.dump(message) # class method — one message → library formattransport.wrap_each(result) { |m| } # class or instance — library response → Brute::Messagetransport.new(result).messages # the response flattened to a list, pre-wrapwrap_each normalizes whatever the proc got back — a single message, an array, or a transcript-shaped object (anything responding to #messages) — and yields each as a Brute::Message. Without a block it returns an Enumerator.
Provider-specific shapes
Section titled “Provider-specific shapes”The transports exist because providers disagree on message shape, and Brute shouldn’t. Two examples of what a transport absorbs so your run proc doesn’t have to:
Anthropic puts the system prompt in a top-level parameter, not the messages array, and folds tool results into user turns as content blocks:
transport = Brute::MessageTransport::Anthropicclient.messages.create( model: "claude-opus-4-8", max_tokens: 16_000, system_: transport.system_text(env[:messages]), # extracted from :system messages messages: transport.dump_all(env[:messages]), # tool results folded into user turns tools: anthropic_tools(env[:tools]),)OpenAI delivers tool-call arguments as JSON strings; the transport parses them into a Hash inbound and re-encodes them outbound, so Brute::ToolCall#arguments is always a real Hash.
Writing your own
Section titled “Writing your own”To support a library that has no shipped transport, subclass Brute::MessageTransport and override #wrap (inbound) and .dump (outbound). The base class handles flattening and the 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 endendReference the library lazily inside those methods (don’t require it at the top of the file) so the transport stays optional, matching the shipped ones.