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.