A complete agent harness for Ruby, built from scratch. Truffle gives you the loop that turns a language model into an agent: it sends a prompt, lets the model ask for tools, runs those tools, feeds the results back, and repeats until the model answers. It is a faithful port of pi to idiomatic Ruby. No framework, no service, no runtime gem dependencies. Plain Ruby and the standard library.
require "truffle"
weather = Truffle.tool("get_weather", "Look up the weather for a city") do
param :city, :string, "city name", required: true
run { |city:| "It is 22C and sunny in #{city}." }
end
agent = Truffle.agent(
provider: :openai,
model: "gpt-4o-mini",
system_prompt: "You are a concise assistant. Use tools when they help.",
tools: [weather]
)
puts agent.run("What's the weather in Lisbon?")
# => "It's 22C and sunny in Lisbon right now."The model decided to call get_weather(city: "Lisbon"), Truffle ran your Ruby
block, handed the result back, and the model wrote the final answer. That whole
round trip is the agent loop, and it is the thing Truffle exists to give you.
The bundled calculator example. The model called add(12, 8), Truffle ran the
Ruby block and fed back 20, the model called multiply(20, 7), and the loop
returned the final answer. Every arrow is a real tool call printed through the
event API.
Ruby has been missing a tiny, readable agent runtime: the part that owns the turn loop, the tool dispatch, the message history, and the events a UI hangs off. Truffle is that runtime, written from scratch.
It is a faithful port of pi, the
self-extensible coding agent harness. The aim is a byte-for-byte-faithful Ruby
port of pi's agent core, growing into a full harness with skills, commands,
sessions, and memory. You can read the whole loop in one sitting
(lib/truffle/agent.rb) and understand exactly what your agent does.
- Provider-agnostic, built from scratch. The agent talks to a single
chatseam. A provider is any object that answerschat(messages:, tools:, model:). OpenAI Chat Completions, Anthropic Messages, and Google Gemini all ship in the box, each written against the wire API directly with no client gem. - A model catalog you can trust.
Truffle.modelsis a structured registry of every model Truffle can address, current to its provider's published docs: ids, context windows, max output, modalities, reasoning support, and per-token pricing.Truffle.model("claude-opus-4-8")resolves a model (and dated snapshots likegpt-4o-2024-08-06) to its rates and capabilities. - Tools are plain blocks. Define a tool with a name, a description, typed params, and a Ruby block. Truffle generates the JSON Schema the model needs and symbolizes the model's arguments back into keyword args for you.
- Observable. Subscribe to
agent_start,tool_call,tool_result,agent_end, and more. Build a TUI, a log stream, or a web view without the harness knowing how it is rendered. - Dependency-free core. Every provider uses
Net::HTTPand the JSON in the standard library. Nothing to vendor, nothing to audit but the code you see.
# Gemfile
gem "truffle"bundle installOr from a checkout:
gem build truffle.gemspec
gem install ./truffle-0.1.0.gemTruffle targets Ruby >= 3.1.
Set your key and run the bundled calculator example, which shows the model chaining several tool calls:
export OPENAI_API_KEY=sk-...
ruby examples/calculator.rb "What is (12 + 8) multiplied by 7, then add 100?"Q: What is (12 + 8) multiplied by 7, then add 100?
------------------------------------------------------------
-> calling add(a=12, b=8)
<- add returned 20
-> calling multiply(a=20, b=7)
<- multiply returned 140
-> calling add(a=140, b=100)
<- add returned 240
------------------------------------------------------------
A: The final result is 240.
add = Truffle.tool("add", "Add two integers") do
param :a, :integer, "first addend", required: true
param :b, :integer, "second addend", required: true
run { |a:, b:| a + b }
endparam name, type, description, required:declares an input. Types map to JSON Schema (:string,:integer,:number,:boolean, ...).run { |a:, b:| ... }is your handler. The model emits string keys; Truffle symbolizes them into keyword args. Return any value; it is stringified before it goes back to the model.- Raising inside a handler does not crash the loop. The error is caught and fed back to the model as the tool result, so it can recover or apologize.
agent = Truffle.agent(
provider: :openai,
model: "gpt-4o-mini",
system_prompt: "You are a precise calculator.",
tools: [add],
max_turns: 12
)
answer = agent.run("What is 2 + 3?")
agent.reset # clears history, keeps the system prompt and toolsrun drives the loop to completion and returns the final assistant text.
max_turns guards against a model that never settles; exceeding it raises
Truffle::Error.
agent.on(:tool_call) { |e| puts "-> #{e[:call].name}(#{e[:call].arguments})" }
agent.on(:tool_result) { |e| puts "<- #{e[:result]}" }
agent.on { |type, payload| logger.debug(type => payload) } # every eventEvents fire in order: agent_start, then per turn turn_start, message,
tool_call/tool_result (one pair per tool the model invokes), turn_end,
and finally agent_end.
A provider is anything that implements:
def chat(messages:, tools:, model: nil, **options)
# -> Truffle::Response
endThree providers ship in the box. Truffle::Providers::OpenAI talks to the Chat
Completions API, Truffle::Providers::Anthropic talks to the Messages API, and
Truffle::Providers::Google talks to the Gemini Generative Language API, all
over Net::HTTP with no client gem. Each also has a streaming sibling. To
target another backend, subclass Truffle::Providers::Base and implement
chat.
Truffle.models # => every model Truffle knows
Truffle.model("claude-opus-4-8") # => the Opus 4.8 entry
Truffle.model("gpt-4o-2024-08-06") # => resolves the dated snapshot to gpt-4o
opus = Truffle.model("claude-opus-4-8")
opus.context_window # => 1_000_000
opus.reasoning? # => true
opus.cost[:input] # => 5.0 (US dollars per million input tokens)Every entry carries its id, provider, context window, max output, input
modalities, reasoning support, and a per-million-token cost hash
(:input, :output, :cache_read, :cache_write). The catalog is the single
source of truth: Truffle::Pricing reads its rates, and a test fails loudly if
the current flagships ever regress to a stale lineup.
rake testThe default suite is hermetic and offline: it drives the agent loop with a stub
provider, so you can run it anywhere without a key. A handful of live tests
perform real round trips against each provider and are skipped unless that
provider's key is set (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY).
With a key present each verifies the full path: prompt -> model requests a tool
-> Truffle runs it -> model answers with the tool's result, for both the
buffered and streaming code paths.
No local Ruby? The repo ships script/rb, a thin wrapper that runs any command
inside a ruby:3.3-slim container, so script/rb rake test works on a host
with only Docker.
lib/truffle.rb # top-level API: Truffle.agent, Truffle.tool, Truffle.model
lib/truffle/agent.rb # the agent loop (the heart of the port)
lib/truffle/tool.rb # tool DSL + JSON Schema generation
lib/truffle/toolbox.rb # a named collection of tools
lib/truffle/message.rb # message + tool-call value objects
lib/truffle/response.rb # a provider's reply
lib/truffle/model.rb # a single model value object
lib/truffle/models.rb # the model catalog (single source of truth)
lib/truffle/pricing.rb # per-token pricing facade over the catalog
lib/truffle/providers/base.rb # the provider seam
lib/truffle/providers/openai.rb # OpenAI Chat Completions provider (+ openai_stream.rb)
lib/truffle/providers/anthropic.rb # Anthropic Messages provider (+ anthropic_stream.rb)
lib/truffle/providers/google.rb # Google Gemini provider (+ google_stream.rb)
examples/calculator.rb # runnable multi-tool demo
test/ # minitest suite (offline + per-provider live tests)
Truffle is a from-scratch Ruby port of pi by Mario Zechner (MIT). pi is the blueprint; the Ruby implementation is written from the ground up. Thanks to the pi project for the design.
MIT. See LICENSE.
