When we started building DSPy.rb, we could have translated Python syntax line by line. We chose the more troublesome option: preserve DSPy’s programming model while making the public API behave like Ruby.
That choice shows up in typed classes, blocks, modules, and ordinary control flow. Provider prompts remain an adapter artifact rather than the architecture of the application.
Signatures Are Ruby Classes
A signature declares the task boundary with Sorbet types:
class Sentiment < T::Enum
enums do
Positive = new("positive")
Negative = new("negative")
Neutral = new("neutral")
end
end
class ClassifyReview < DSPy::Signature
description "Classify the sentiment of a customer review"
input do
const :review, String, description: "Review text"
end
output do
const :sentiment, Sentiment
const :confidence, Float
end
end
DSPy::Predict uses that class to build the provider request and validate the result:
classifier = DSPy::Predict.new(ClassifyReview)
result = classifier.call(review: "The keyboard is excellent.")
result.sentiment # => Sentiment::Positive
The type boundary catches incompatible output at runtime. Sorbet can also understand the generated prediction class where the API exposes it, but the model itself remains an external runtime dependency.
Modules Compose with Ruby
Fixed workflows do not need an agent abstraction. Write their control flow directly:
class ReviewPipeline < DSPy::Module
def initialize
super
@classifier = DSPy::Predict.new(ClassifyReview)
end
def forward_untyped(reviews:)
results = reviews.map { |review| @classifier.call(review: review) }
results.group_by(&:sentiment)
end
end
Ruby decides the iteration and grouping. DSPy modules own LM behavior at the points where the program calls them.
Agents Use Typed Tools
Use DSPy::ReAct when the model should choose an action over several steps. Current tools are DSPy::Tools::Base instances or tools generated by a DSPy::Tools::Toolset; arbitrary lambdas are not enough because the agent needs a name, description, and parameter schema.
class LookupOrder < DSPy::Tools::Base
extend T::Sig
tool_name "lookup_order"
tool_description "Fetch an order by ID"
sig { params(order_id: String).returns(T::Hash[Symbol, T.untyped]) }
def call(order_id:)
Orders.fetch(order_id)
end
end
agent = DSPy::ReAct.new(
ResolveSupportRequest,
tools: [LookupOrder.new],
max_iterations: 5
)
The model chooses among the tools. The application still defines their side effects, permissions, schemas, iteration limit, and termination boundary. That surrounding machinery is the agent harness; it is engineering, not prompt decoration.
Configuration Uses Blocks
DSPy.configure do |config|
config.lm = DSPy::LM.new(
"openai/gpt-4o-mini",
api_key: ENV.fetch("OPENAI_API_KEY")
)
end
Modules can also receive or configure an LM directly. DSPy.with_lm supplies a temporary fiber-local override:
DSPy.with_lm(review_lm) do
reviewer.call(draft: draft)
end
The block restores the previous model afterward. It does not schedule work or make provider I/O concurrent.
Module Configuration Propagates Deliberately
Modules can expose child predictors through named_predictors. Calling configure on the parent propagates its LM to those children, while configure_predictor can override one child by name.
program.configure { |config| config.lm = default_lm }
program.configure_predictor("reviewer") do |config|
config.lm = review_lm
end
This keeps model selection in program configuration rather than scattering it through prompt strings.
Inspection Uses Stable Objects
Signatures expose field definitions and JSON schemas. Modules expose named_predictors; responses expose normalized content, usage, and metadata. OpenTelemetry spans record module and LM execution when observability is configured.
pp ClassifyReview.input_json_schema
pp ClassifyReview.output_json_schema
pp program.named_predictors.map(&:first)
Prefer those interfaces to reaching into instance variables. An inspectable program is easier to evaluate, serialize, and optimize.
The Boundary
Ruby makes composition explicit. Signatures define tasks, modules choose execution strategies, agents receive typed tools, and optimizers compile supported prompt artifacts from examples and metrics.
That is the useful part of being Ruby-idiomatic: the LM participates in a Ruby program instead of replacing it.