Agentic Workflows
Compose agents and AI operations with ordinary Ruby methods, from sequential steps to routing and parallel work.
After reading this guide, you will know:
- How to drive the agentic loop yourself, one move at a time.
- Why the loop is safe to interrupt and resume between moves.
- How to implement common workflow patterns with plain Ruby classes.
- How to compose sequential, routing, parallel, and fan-in workflows.
- When to reach for an evaluator loop to iterate on output quality.
A workflow is orchestration code that coordinates one or more agents. In practice, this is often a small Ruby class with a single public method, plus a loop you control. Before you build a multi-agent workflow, it helps to understand how a single agent’s turn-by-turn loop runs, so you can pause it, persist it, and resume it on demand.
When you want to see the whole operation as one unit in your observability stack, wrap that Ruby code with RubyLLM.workflow and mark meaningful regions with workflow.step. These wrappers only add correlation to instrumentation; they do not introduce a workflow DSL or take over execution.
Driving the Loop Yourself
ask sends your message and runs the conversation to completion: it calls the model, runs any tools the model asks for, and calls the model again until it answers without a tool. When you need to control that loop, for example to run each turn as its own background job, set an iteration budget, or stop and resume on another machine, Chat exposes it as named verbs:
generateruns the model once and appends its response (the model’s move). It returns the response.run_toolsexecutes the tool calls the model asked for and appends their results (your move). It makes no model call and returns the chat.stepdoes whichever move is next:run_toolsif tools are pending, otherwisegenerate. It returns the message that move produced, andnilonce there is nothing left or the loop is parked for approval.completesteps until done. This is whataskcalls, and it returns the final response.complete?is true when the model answered without calling a tool.
So the agentic loop is step until complete?:
chat = RubyLLM.chat.with_tools(Weather).ask_later("What's the weather in Paris?")
chat.step until chat.complete? || chat.waiting?
chat.messages.last.content # => "It's 15°C and partly cloudy in Paris."
ask_later stages the question without sending it, so ask is ask_later then complete.
With Rails persistence, each method reads the saved transcript and skips tool calls with saved results. Work interrupted before its result is saved may run again. See Durable Agents for job retries and idempotent tools.
Stop scheduling work while waiting? is true: the chat waits on something outside it. awaiting_approval? tells you it waits on an approval, so record one or a denial, then resume the loop. awaiting_input? and awaiting_tasks? cover MCP servers asking for input and running tasks.
Batches are the same idea at scale: a batch is generate deferred for many chats at once, with run_tools run locally between rounds.
Workflow Patterns
With the loop in hand, you can compose agents into larger systems. Each pattern below uses ordinary Ruby methods to coordinate RubyLLM agents.
Sequential Workflow
Use this pattern when each step depends on the previous one.
class ResearchAgent < RubyLLM::Agent
model "gemini-3.7-flash"
instructions "Given a topic, return concise, reliable key facts."
end
class WriterAgent < RubyLLM::Agent
model "claude-sonnet-5"
instructions "Given research notes, write a clear article."
end
class ResearchWriterWorkflow
def create_article(topic)
research = ResearchAgent.new.ask(topic).content
WriterAgent.new.ask(research).content
end
end
workflow = ResearchWriterWorkflow.new
article = workflow.create_article("Ruby 3.3 features")
Routing Workflow
Use this pattern when requests fall into clear categories that benefit from specialized agents or models.
class CodeAgent < RubyLLM::Agent
model "gpt-5.6"
instructions "You are a coding assistant. Be precise and practical."
end
class CreativeAgent < RubyLLM::Agent
model "claude-opus-5"
instructions "You are a creative writing assistant."
end
class FactualAgent < RubyLLM::Agent
model "gemini-3.7-flash"
instructions "You are a factual assistant. Prioritize accuracy."
end
class TaskClassifierAgent < RubyLLM::Agent
model "gpt-5.6-terra"
instructions "Classify the request as one word only: code, creative, or factual."
end
class ModelRouterWorkflow
def call(query)
agent_for(query).new.ask(query).content
end
private
def agent_for(query)
case classify(query)
when :code then CodeAgent
when :creative then CreativeAgent
when :factual then FactualAgent
else FactualAgent
end
end
def classify(query)
TaskClassifierAgent.new.ask(query).content.downcase.to_sym
end
end
workflow = ModelRouterWorkflow.new
response = workflow.call("Write a Ruby function to parse JSON")
Agent Handoffs
Routing picks an agent up front. A handoff goes further: it hands the same ongoing conversation to a different agent, so the specialist sees the full history and the user keeps one continuous thread. Agent.find(chat_id) is what makes this work. It loads a persisted chat and applies a different agent’s instructions and tools at runtime, leaving the messages intact, so whichever agent takes over inherits the whole conversation.
The simplest version is a single turn. Give a router an inline schema so it answers with the specialist to hand to, then let that specialist continue the persisted chat:
class SupportRouter < RubyLLM::Agent
schema do
string :specialist, enum: %w[billing technical]
end
instructions "Pick the specialist best suited to the latest message."
end
SPECIALISTS = { "billing" => BillingAgent, "technical" => TechnicalAgent }
def handle(chat_id, message)
specialist = SupportRouter.new.ask(message).parsed["specialist"]
SPECIALISTS.fetch(specialist).find(chat_id).ask(message)
end
The router responds immediately with its choice (no tool round-trip), and the specialist takes over the same chat with full history. Outside Rails, pass the chat object with SpecialistAgent.new(chat: chat) instead of find(chat_id).
If you would rather the agent decide mid-conversation, give it a single handoff tool that returns the specialist to switch to. Drive the loop a step at a time, and when a tool result names an agent, hand off:
class Handoff < RubyLLM::Tool
description "Hand the conversation to the specialist who should answer next"
parameter :specialist, description: "billing or technical"
def execute(specialist:) = specialist
end
class SupportRouter < RubyLLM::Agent
chat_model Chat
instructions "Call handoff with the specialist who should take over."
tools Handoff
end
AGENTS = { "billing" => BillingAgent, "technical" => TechnicalAgent }
agent = SupportRouter.find(chat_id)
agent.ask_later(message)
until agent.complete?
agent.step
last = agent.messages.reload.last
if last.role == "tool" && (specialist = AGENTS[last.content])
agent = specialist.find(chat_id)
end
end
The tool returns the specialist name, and the loop switches agents. Give specialists the same Handoff tool if they should be able to pass the conversation onward.
(A tool cannot reconfigure the chat it runs inside, since its execute never receives the chat, so the switch belongs in the loop, not in the tool.)
Parallel Workflow
Use this pattern when independent analyses can run at the same time.
require 'async'
class SentimentAgent < RubyLLM::Agent
instructions "Given text, return one word sentiment: positive, negative, or neutral."
end
class SummaryAgent < RubyLLM::Agent
instructions "Given text, summarize it in one concise sentence."
end
class KeywordAgent < RubyLLM::Agent
instructions "Given text, extract exactly 5 relevant keywords."
end
class ParallelAnalyzer
def analyze(text)
Async do |task|
sentiment = task.async { SentimentAgent.new.ask(text).content }
summary = task.async { SummaryAgent.new.ask(text).content }
keywords = task.async { KeywordAgent.new.ask(text).content }
{
sentiment: sentiment.wait,
summary: summary.wait,
keywords: keywords.wait
}
end.wait
end
end
analyzer = ParallelAnalyzer.new
insights = analyzer.analyze("Your text here...")
Fan-Out/Fan-In Workflow
Use this pattern when multiple specialists produce outputs that are later synthesized.
require 'async'
class SecurityReviewAgent < RubyLLM::Agent
model "claude-sonnet-5"
instructions "Given code, review it for security issues."
end
class PerformanceReviewAgent < RubyLLM::Agent
model "gpt-5.6"
instructions "Given code, review it for performance issues."
end
class StyleReviewAgent < RubyLLM::Agent
model "gpt-5.6-terra"
instructions "Given code, review style against Ruby conventions."
end
class ReviewSynthesizerAgent < RubyLLM::Agent
instructions "Given multiple code review reports, summarize prioritized findings."
end
class CodeReviewSystem
def review_code(code)
Async do |task|
security = task.async { SecurityReviewAgent.new.ask(code).content }
performance = task.async { PerformanceReviewAgent.new.ask(code).content }
style = task.async { StyleReviewAgent.new.ask(code).content }
ReviewSynthesizerAgent.new.ask(
"security: #{security.wait}\n\n" \
"performance: #{performance.wait}\n\n" \
"style: #{style.wait}"
).content
end.wait
end
end
reviewer = CodeReviewSystem.new
summary = reviewer.review_code("def calculate(x); x * 2; end")
Evaluation Loop (Evaluator-Optimizer)
Use this pattern when you have clear quality criteria and want iterative refinement.
class DraftAgent < RubyLLM::Agent
instructions "Given a task, produce the best possible draft response."
end
class CriticAgent < RubyLLM::Agent
schema do
string :verdict, enum: ["pass", "revise"], description: "Whether the draft passes or needs changes"
string :feedback, description: "Specific feedback for improvement"
end
instructions "Review the draft against the task and return a verdict and specific feedback."
end
class EvaluatorOptimizerWorkflow
MAX_ROUNDS = 3
def call(task)
draft = DraftAgent.new.ask(task).content
MAX_ROUNDS.times do
verdict, feedback = review(task:, draft:)
return draft if verdict == "pass"
draft = revise(task:, draft:, feedback:)
end
draft
end
private
def review(task:, draft:)
result = CriticAgent.new.ask("Task:\n#{task}\n\nDraft:\n#{draft}").parsed
[result.fetch("verdict"), result.fetch("feedback")]
end
def revise(task:, draft:, feedback:)
DraftAgent.new.ask("Task:\n#{task}\n\nCurrent draft:\n#{draft}\n\nFeedback:\n#{feedback}").content
end
end
workflow = EvaluatorOptimizerWorkflow.new
final = workflow.call("Write a concise onboarding email for a new API customer")
Combining Conversations with Other Operations
Workflows can include any RubyLLM operation. This one turns a recording into a spoken summary:
RubyLLM.workflow("Summarize recording") do |workflow|
transcript = workflow.step("Transcribe") { RubyLLM.transcribe("meeting.wav") }
summary = workflow.step("Summarize") do
RubyLLM.chat.ask "Summarize the main decisions:\n#{transcript.text}"
end
workflow.step("Narrate") do
RubyLLM.speak(summary.content).save("summary.mp3")
end
end
Each step returns its block’s value. RubyLLM.workflow adds correlation to the instrumentation events, so you can follow the three calls as one operation.
Retrieval-Augmented Generation
Retrieve relevant documents and pass them to an answering agent. See RAG for a retrieval tool, and Reranking for ordering candidate documents before the model reads them.
Error Handling
Handle failures at the step that can recover from them:
- Return
{ error: "description" }for recoverable errors the LLM might fix - Raise exceptions for unrecoverable errors (missing config, service down)
- Use the retry middleware for transient failures
See the Error Handling section in Tools for detailed patterns.
Next Steps
- Retrieval-Augmented Generation (RAG) - Ground answers in your own documents.
- Agents - Define reusable agent classes.
- Tools - Add capabilities and external actions.
- Scale with Async - Run concurrent workflow steps.
- Error Handling - Build resilient systems.