Build a Workflow Router in Ruby
Route every ticket to the right Language Model, only escalate to heavy LLMs when needed, keep every hop observable, and never touch a handwritten prompt along the way.
Vicente Reig
Fractional Engineering Lead • • 4 min read
Successful LLM implementations rely on simple, composable patterns instead of sprawling frameworks. DSPy.rb lets you compose that workflow with typed signatures so every prompt is generated programmatically instead of hand-written.
This simple classifier-plus-specialists layout becomes essential once a single catch-all prompt starts to creak—complex requests need different context, follow-up instructions, or even different models.
For the impatient, jump straight to the sample script in examples/workflow_router.rb.
flowchart LR
In((Input))
Router["LLM Call Router\nRouteSupportTicket\nDSPy::Predict"]
Billing["LLM Call 1\nBilling Handler\nDSPy::Predict"]
General["LLM Call 2\nGeneral Handler\nDSPy::Predict"]
Technical["LLM Call 3\nTechnical Handler\nDSPy::ChainOfThought"]
Out((Output))
In --> Router
Router -.->|billing| Billing -.-> Out
Router -.->|general| General -.-> Out
Router -.->|technical| Technical -.-> Out
style In fill:#ffe4e1,stroke:#d4a5a5,stroke-width:2px
style Out fill:#ffe4e1,stroke:#d4a5a5,stroke-width:2px
style Router fill:#e8f5e9,stroke:#81c784,stroke-width:2px
style Billing fill:#e8f5e9,stroke:#81c784,stroke-width:2px
style General fill:#e8f5e9,stroke:#81c784,stroke-width:2px
style Technical fill:#e8f5e9,stroke:#81c784,stroke-width:2px
Rather than letting one mega prompt struggle to cover every edge case, DSPy.rb lets you compose a lightweight classifier plus a handful of specialized predictors that stay focused and easy to optimize.
Why a workflow before you build an agent?
Workflows1 keep LLMs and tools on predefined code paths—you still need to tune prompts, choose models, and explicitly wire every branch—so you retain deterministic control while you validate the solution. Once you’ve validated routing and specialized handlers, you can upgrade specific branches to autonomous ReAct agents without rewriting the classifier.
Architecture at a glance
Breaking down the router into components, we can delegate their predictions to specific models based on cost or performance.
| Component | Prompting Technique | Default Model | Purpose |
|---|---|---|---|
| RouteSupportTicket classifier | DSPy::Predict |
anthropic/claude-haiku-4-5-20251001 |
Categorize each ticket + explain reasoning |
| Billing / General playbooks | DSPy::Predict |
anthropic/claude-haiku-4-5-20251001 |
Cheap follow-up guidance for routine issues |
| Technical playbook | DSPy::ChainOfThought |
anthropic/claude-sonnet-4-5-20250929 |
Deeper reasoning + escalation steps for tricky tickets |
| SupportRouter | DSPy::Module |
anthropic/claude-haiku-4-5-20251001 |
Orchestrates classifier, handlers, and output struct |
- A signature to anchor ticket classification – one
DSPy::Predictcall decides which category is the best fit and reports confidence/reasoning:class RouteSupportTicket < DSPy::Signature input { const :message, String } output do const :category, TicketCategory const :confidence, Float const :reason, String end end classifier = DSPy::Predict.new(RouteSupportTicket) classification = classifier.call(message: 'hello hello') - Specialized playbooks – each downstream signature tweaks the description/goal while reusing the shared schema. They are independent and they are predicted by different prompting techniques as need.
class SupportPlaybooks::Billing < DSPy::Signature include SharedSchema description "Resolve billing or refund issues with policy-aware guidance." end class SupportPlaybooks::Technical < DSPy::Signature include SharedSchema description "Handle technical or outage reports with diagnostic steps." end class SupportPlaybooks::GeneralEnablement < DSPy::Signature include SharedSchema description "Answer broad questions or point folks to self-serve docs." endInstead of writing prompts, you adjust the signature description and let DSPy compile the right instructions for each specialized LLM call.
- Router module – plain Ruby orchestrator that wires classifier + handlers, ensures every branch returns the same struct, and records the exact model that ran.
Touring the Router Workflow
The full walkthrough lives in examples/workflow_router.rb. Notice that every interaction goes through a DSPy::Signature, so we never drop into raw prompt strings—inputs/outputs are typed once and automatically compiled into prompts behind the scenes. Key pieces:
- Typed categories solve mystery intents
class TicketCategory < T::Enum enums do General = new('general') Billing = new('billing') Technical = new('technical') end endWhen the classifier returns a
TicketCategory, the router can’t receive unexpected strings like"refund?"or"tech_support"; all branches are exhaustively checked at compile time. - Shared playbook schema keeps outputs uniform
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 endEvery follow-up predictor returns the same fields, so downstream logging/analytics doesn’t need per-branch adapters.
- Per-stage specialized models keep costs and performance predictable
billing_follow_up = DSPy::Predict.new(SupportPlaybooks::Billing) billing_follow_up.configure do |config| config.lm = DSPy::LM.new(LIGHTWEIGHT_MODEL, api_key: ENV['ANTHROPIC_API_KEY']) end technical_follow_up = DSPy::ChainOfThought.new(SupportPlaybooks::Technical) technical_follow_up.configure do |config| config.lm = DSPy::LM.new(HEAVY_MODEL, api_key: ENV['ANTHROPIC_API_KEY']) endEach handler pins its own LM (
LIGHTWEIGHT_MODELvsHEAVY_MODEL), so moving billing/general flows to a cheaper Haiku snapshot or moving technical flows to Sonnet is just an env tweak, not a code change. - SupportRouter centralizes dispatch + telemetry context
class SupportRouter < DSPy::Module def classifier @classifier ||= DSPy::Predict.new(RouteSupportTicket) end def handlers @handlers ||= { TicketCategory::Billing => DSPy::Predict.new(SupportPlaybooks::Billing).tap.configure do |config| config.lm = DSPy::LM.new('anthropic/claude-haiku-4-5-20251001', api_key: ENV['ANTHROPIC_API_KEY']) end, TicketCategory::Technical => DSPy::ChainOfThought.new(SupportPlaybooks::Technical).tap.configure do |config| config.lm = DSPy::LM.new('anthropic/claude-sonnet-4-5-20250929', api_key: ENV['ANTHROPIC_API_KEY']) end, TicketCategory::General => DSPy::Predict.new(SupportPlaybooks::GeneralEnablement).tap.configure do |config| config.lm = DSPy::LM.new('anthropic/claude-haiku-4-5-20251001', api_key: ENV['ANTHROPIC_API_KEY']) end } end def forward(**input_values) classification = classifier.call(**input_values) handler = handlers.fetch(classification.category, handlers[TicketCategory::General]) classified_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: classified_issue.resolution_summary, recommended_steps: classified_issue.recommended_steps, tags: classified_issue.tags ) end endThe lazy initialization pattern keeps all routing logic self-contained: the
classifierandhandlersmethods show exactly which predictors handle each category and which models power them. Because it subclassesDSPy::Module, the router names the root span for every request; Langfuse/Honeycomb/Datadog see a single parent trace, and theRoutedTicketstruct captures which LM actually answered so no span is orphaned.
Because everything is just Ruby, swapping a handler for DSPy’s evaluation modules, attaching tracing subscribers, or injecting feature flags takes minutes.
Observability and tracing benefits
lf traces get <TRACE_ID> -f json (via the open-source langfuse-cli) drops classifier + specialist spans straight into my editor, so we can reason about cost/perf without spelunking dashboards. The November 16, 2025 traces surfaced three fast signals:
- General requests: everything stays on
claude-haiku-4-5-20251001, ~2k tokens, 4.37 s total—cheap tiers cover FAQs. - Technical incidents: Haiku routes (957 tokens / 1.92 s) before escalating to
claude-sonnet-4-5-20250929for the 1,292 token / 12.39 s chain-of-thought hop—expensive capacity only burns when it’s justified. - Billing escalations: still close on Haiku (≈2.1k tokens, 6.56 s end-to-end), so refunds stay on the lightweight tier.
Those traces form a tree you can paste into docs, incidents, or dashboards to explain exactly what ran for each customer request:
Trace abd69193932e86eeb0de30a3ccd72c9e — SupportRouter.forward (category: general; model: anthropic/claude-haiku-4-5-20251001)
message: What limits apply to the new analytics workspace beta?
└── SupportRouter.forward [4.37s]
├── DSPy::Predict.forward [1.71s]
│ └── llm.generate (RouteSupportTicket) → claude-haiku-4-5-20251001 [1.71s]
└── DSPy::Predict.forward [2.65s]
└── llm.generate (SupportPlaybooks::GeneralEnablement) → claude-haiku-4-5-20251001 [2.65s]
Trace fc3cde8c24b24e0d7737603983e45888 — SupportRouter.forward (category: technical; model: anthropic/claude-sonnet-4-5-20250929)
message: Device sensors stopped reporting since last night's deployment. Can you help me roll back?
└── SupportRouter.forward [14.31s]
├── DSPy::Predict.forward [1.92s]
│ └── llm.generate (RouteSupportTicket) → claude-haiku-4-5-20251001 [1.92s]
└── DSPy::ChainOfThought.forward [12.39s]
├── DSPy::Predict.forward [12.39s]
│ └── llm.generate (SupportPlaybooks::Technical) → claude-sonnet-4-5-20250929 [12.39s]
├── chain_of_thought.reasoning_complete (SupportPlaybooks::Technical)
└── chain_of_thought.reasoning_metrics (SupportPlaybooks::Technical)
Trace 20318579a66522710637f10d33be8bee — SupportRouter.forward (category: billing; model: anthropic/claude-haiku-4-5-20251001)
message: My account was charged twice for September and the invoice shows an unfamiliar add-on.
└── SupportRouter.forward [6.56s]
├── DSPy::Predict.forward [2.69s]
│ └── llm.generate (RouteSupportTicket) → claude-haiku-4-5-20251001 [2.68s]
└── DSPy::Predict.forward [3.86s]
└── llm.generate (SupportPlaybooks::Billing) → claude-haiku-4-5-20251001 [3.86s]
Run it locally
echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env
bundle install
bundle exec ruby examples/workflow_router.rb
Sample output (truncated):
🗺️ Routing 3 incoming tickets...
📨 INC-8721 via email
Input: My account was charged twice for September and the invoice shows an unfamiliar add-on.
→ Routed to billing (92.4% confident)
→ Follow-up model: anthropic/claude-haiku-4-5-20251001
Summary: Refund the duplicate charge and confirm whether the add-on was provisioned.
Next steps:
1. Verify September invoices in Stripe...
2. Issue refund if duplicate...
3. Email customer with receipt + policy reminder.
Tags: refund, finance-review
Notice how every branch produces traceable metadata: we know which LM responded, why it was selected, and which next steps were generated. That data is gold for analytics or human-in-the-loop review.
Adapt it to your stack
- Swap the classifier for a lightweight heuristic or a fine-tuned model if you already track intents elsewhere.
- Feed historical tickets into DSPy’s evaluation helpers to benchmark routing accuracy before shipping.
- Attach
DSPy::Callbackssubscribers so each routed request emits spans/metrics to Langfuse, Honeycomb, or Datadog; DSPy.rb modules support Rails-style lifecycle callbacks that wrapforward, letting you keep logging, metrics, context management, and memory operations out of business logic. - Promote a branch to a ReAct agent later without rewriting the classifier—
SupportRouterjust needs a handler that responds tocall.
Routing is a “minimum viable orchestration” pattern: fast to build, cheap to run, and powerful enough to keep your prompts specialized. Grab the example, swap in your own categories, and start measuring the gains before you reach for a full-blown agent.
-
For a comprehensive guide on when to use workflows vs. agents, see Anthropic’s Building Effective Agents. ↩