A single support prompt starts to creak when billing, product questions, and incidents need different context or different models. A classifier followed by specialized handlers keeps those paths explicit.

This design is a deterministic workflow. A model predicts the ticket category, but Ruby selects the handler. The model cannot invent a route, call an undeclared operation, or alter the fallback.

The complete implementation is in examples/workflow_router.rb.

flowchart LR
    In((Ticket))
    Router["Classifier\nDSPy::Predict"]
    Branch{"Ruby dispatch"}
    Billing["Billing\nDSPy::Predict"]
    General["General\nDSPy::Predict"]
    Technical["Technical\nDSPy::ChainOfThought"]
    Out((Routed ticket))

    In --> Router --> Branch
    Branch -->|billing| Billing --> Out
    Branch -->|general| General --> Out
    Branch -->|technical| Technical --> Out

Why start with a workflow?

Use a workflow when the branch set is known and the application must control which operations run. This router has three categories, one handler per category, and one output type. Those constraints make cost, fallback behavior, and traces easier to inspect.

An agent is useful when the model has a real reason to choose actions over several steps. You can later place a DSPy::ReAct agent behind one handler without turning the classifier or the surrounding router into an agent.

Define the category

The classifier returns a T::Enum, so unknown category strings fail during output coercion instead of leaking into dispatch:

class TicketCategory < T::Enum
  enums do
    General = new('general')
    Billing = new('billing')
    Technical = new('technical')
  end
end

class RouteSupportTicket < DSPy::Signature
  input { const :message, String }

  output do
    const :category, TicketCategory
    const :confidence, Float
    const :reason, String
  end
end

The enum constrains the classifier result at runtime. Ruby does not provide compile-time exhaustiveness for the handler hash, so the router still validates missing handlers explicitly.

Give each handler the same result shape

Each playbook changes the task description while sharing its input and output fields:

module SupportPlaybooks
  module SharedSchema
    def self.included(base)
      base.class_eval do
        input { const :message, String }

        output do
          const :resolution_summary, String
          const :recommended_steps, T::Array[String]
          const :tags, T::Array[String]
        end
      end
    end
  end

  class Billing < DSPy::Signature
    include SharedSchema
    description "Resolve billing or refund issues with policy-aware guidance."
  end

  class Technical < DSPy::Signature
    include SharedSchema
    description "Handle technical or outage reports with diagnostic steps."
  end
end

The signatures hold task and field descriptions. DSPy.rb turns them into provider-facing prompts and validates the returned fields. You maintain the program boundary rather than a separate prompt template for every route.

Dispatch with ordinary Ruby

The router injects a classifier and a handler map, then selects exactly one handler:

class SupportRouter < DSPy::Module
  def initialize(classifier:, handlers:, fallback_category: TicketCategory::General)
    super()
    @classifier = classifier
    @handlers = handlers
    @fallback_category = fallback_category
  end

  def forward(**input_values)
    classification = @classifier.call(**input_values)
    handler = @handlers.fetch(
      classification.category,
      @handlers[@fallback_category]
    )
    raise ArgumentError, "Missing handler for #{classification.category.serialize}" unless handler
    issue = handler.call(**input_values)

    RoutedTicket.new(
      category: classification.category,
      model_id: handler.lm&.model_id || DSPy.config.lm&.model_id,
      confidence: classification.confidence,
      reason: classification.reason,
      resolution_summary: issue.resolution_summary,
      recommended_steps: issue.recommended_steps,
      tags: issue.tags
    )
  end
end

The example configures billing and general handlers with Haiku and the technical handler with Sonnet. Model selection remains visible in the handler map and can be changed without altering the signature or call site.

Trace the branch

Because SupportRouter is a DSPy::Module, its child predictor and LM spans share the module trace. The returned RoutedTicket also records the selected model. With observability configured, the trace answers three operational questions: what category the classifier chose, which handler ran, and which model produced the response.

Measurements recorded on November 16, 2025 showed the intended split:

  • A general request stayed on Haiku and completed in 4.37 seconds.
  • A technical request used Haiku for routing, then Sonnet for a 12.39-second handler call.
  • A billing request stayed on Haiku and completed in 6.56 seconds.

These are three example traces, not performance guarantees. Use lf-cli or your OpenTelemetry backend to inspect your own distribution.

Run it

echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env
bundle install
bundle exec ruby examples/workflow_router.rb

Before shipping, evaluate the classifier against historical tickets and test every handler mapping. Replace the classifier with a heuristic when the categories can be decided without an LM. Promote a handler to a ReAct agent only when that branch needs bounded tool selection over several steps.

The router stays a workflow either way. Ruby still owns the route.