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 format
inbound the library response ── .wrap_each ──▶ Brute::Message
# outbound: hand your library a request it understands
messages = Brute::MessageTransport::RubyLLM.dump_all(env[:messages])
response = provider.complete(messages, tools:, model:)
# inbound: fold the reply back into the Brute log
Brute::MessageTransport::RubyLLM.wrap_each(response) do |message|
env[:messages] << message
end
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.rb, examples/llm.rb, examples/openai.rb, examples/anthropic.rb. Only the run proc differs between them.
The API
Every transport is a subclass of Brute::MessageTransport with four entry points:
transport.dump_all(messages) # class method — whole log → library format
transport.dump(message) # class method — one message → library format
transport.wrap_each(result) { |m| } # class or instance — library response → Brute::Message
transport.new(result).messages # the response flattened to a list, pre-wrap
wrap_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
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::Anthropic
client.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
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
end
end
Reference 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.