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.


This site uses Just the Docs, a documentation theme for Jekyll.