Multi-Robot Network¶
Customer service system with intelligent routing using SimpleFlow pipelines.
Overview¶
This example demonstrates a multi-robot network where a classifier routes customer inquiries to specialized support robots using SimpleFlow's optional task activation.
The runnable version is
examples/03_network.rb;
the snippets here expand it with a fourth specialist and per-task configuration.
[!IMPORTANT] Two names are in play and they are easy to confuse.
result.activate(:x)takes a task name (the first argument totask), whileresult.with_context(@name.to_sym, ...)keys the context by the robot's name. Activating a task name that was never declared raisesArgumentErrorand aborts the run — it is not a silent no-op. Give each robot the same name as its task and both problems disappear.
Complete Example¶
#!/usr/bin/env ruby
require "bundler/setup"
require "robot_lab"
# Custom classifier that routes to specialists
class ClassifierRobot < RobotLab::Robot
def call(result)
context = extract_run_context(result)
message = context.delete(:message)
robot_result = run(message, **context)
new_result = result
.with_context(@name.to_sym, robot_result)
.continue(robot_result)
# Route based on classification
category = robot_result.last_text_content.to_s.strip.downcase
case category
when /billing/ then new_result.activate(:billing_agent)
when /technical/ then new_result.activate(:tech_agent)
when /account/ then new_result.activate(:account_agent)
else new_result.activate(:general_agent)
end
end
end
# Classifier robot
classifier = ClassifierRobot.new(
name: "classifier",
description: "Classifies customer inquiries",
system_prompt: <<~PROMPT,
You are a customer inquiry classifier. Analyze the customer's message
and respond with exactly ONE of these categories:
- BILLING (payment issues, invoices, refunds, subscriptions)
- TECHNICAL (bugs, errors, how-to questions, feature requests)
- ACCOUNT (login issues, profile changes, security concerns)
- GENERAL (everything else)
Respond with ONLY the category name, nothing else.
PROMPT
model: "claude-sonnet-4"
)
# Billing specialist
billing_agent = RobotLab.build(
name: "billing_agent",
description: "Handles billing inquiries",
system_prompt: <<~PROMPT,
You are a billing support specialist. You help customers with:
- Payment issues and refunds
- Invoice questions
- Subscription management
- Pricing inquiries
Be helpful, empathetic, and provide clear next steps.
PROMPT
model: "claude-sonnet-4"
)
# Technical support
tech_agent = RobotLab.build(
name: "tech_agent",
description: "Handles technical issues",
system_prompt: <<~PROMPT,
You are a technical support specialist. You help customers with:
- Bug reports and troubleshooting
- Feature explanations
- Integration questions
- Best practices
Ask clarifying questions when needed. Provide step-by-step solutions.
PROMPT
model: "claude-sonnet-4"
)
# Account specialist
account_agent = RobotLab.build(
name: "account_agent",
description: "Handles account issues",
system_prompt: <<~PROMPT,
You are an account support specialist. You help customers with:
- Login and authentication issues
- Profile and settings changes
- Security concerns
- Account recovery
Prioritize security while being helpful.
PROMPT
model: "claude-sonnet-4"
)
# General support
general_agent = RobotLab.build(
name: "general_agent",
description: "Handles general inquiries",
system_prompt: <<~PROMPT,
You are a general support agent. You help customers with:
- Product information
- General questions
- Feedback collection
- Routing to appropriate departments
Be friendly and informative.
PROMPT
model: "claude-sonnet-4"
)
# Create the network with optional task routing
network = RobotLab.create_network(name: "customer_service") do
task :classifier, classifier, depends_on: :none
task :billing_agent, billing_agent, depends_on: :optional
task :tech_agent, tech_agent, depends_on: :optional
task :account_agent, account_agent, depends_on: :optional
task :general_agent, general_agent, depends_on: :optional
end
# Run the support system
puts "Customer Service System"
puts "=" * 50
puts
test_inquiries = [
"I was charged twice for my subscription last month",
"How do I reset my password?",
"The app crashes when I try to upload photos",
"What features are included in the pro plan?"
]
test_inquiries.each do |inquiry|
puts "Customer: #{inquiry}"
puts "-" * 50
result = network.run(message: inquiry)
# Show classification
if result.context[:classifier]
puts "Classification: #{result.context[:classifier].last_text_content}"
end
# Show specialist response
if result.value.is_a?(RobotLab::RobotResult)
puts "Handled by: #{result.value.robot_name}"
puts "Response: #{result.value.last_text_content[0..200]}..."
end
puts
puts "=" * 50
puts
end
ClassifierRobot Pattern¶
The key to conditional routing is overriding the call method. The base Robot#call extracts the message from the SimpleFlow::Result and calls run. A classifier overrides this to inspect the output and activate the appropriate optional task:
class ClassifierRobot < RobotLab::Robot
def call(result)
# 1. Extract run context from the SimpleFlow result
context = extract_run_context(result)
# 2. Pull out the message (it's a key in the context hash)
message = context.delete(:message)
# 3. Run the robot to get a classification
robot_result = run(message, **context)
# 4. Store our result and continue the pipeline
new_result = result
.with_context(@name.to_sym, robot_result)
.continue(robot_result)
# 5. Activate the appropriate optional task based on output.
# These symbols MUST match the task names declared in create_network.
category = robot_result.last_text_content.to_s.strip.downcase
case category
when /billing/ then new_result.activate(:billing_agent)
when /technical/ then new_result.activate(:tech_agent)
else new_result.activate(:general_agent)
end
end
end
[!WARNING]
activatevalidates its argument and raises. Activating:billingwhen the network declaredtask :billing_agent, ...aborts the run withArgumentError: Step :classifier attempted to activate unknown step :billing. Activating a task that was not declareddepends_on: :optionalraises too:ArgumentError: Step :classifier attempted to activate non-optional step :billing_agent.Useresult.activated_stepsto inspect what routing actually did.
extract_run_context
The extract_run_context(result) method is a protected helper on Robot. It extracts run_params from the SimpleFlow result context, handles value propagation from previous steps, and separates robot-specific params (mcp:, tools:, memory:, network_memory:) from the message and other context.
With Context Passing¶
Enhanced version where the classifier passes additional context to specialists:
class ContextAwareClassifier < RobotLab::Robot
def call(result)
context = extract_run_context(result)
message = context.delete(:message)
robot_result = run(message, **context)
# Store classification and original message in context for specialist
new_result = result
.with_context(@name.to_sym, robot_result)
.with_context(:classification, robot_result.last_text_content.strip)
.with_context(:original_message, result.context[:run_params][:message])
.continue(robot_result)
category = robot_result.last_text_content.to_s.downcase
case category
when /billing/ then new_result.activate(:billing_agent)
when /technical/ then new_result.activate(:tech_agent)
else new_result.activate(:general_agent)
end
end
end
# Specialist can access shared context
class BillingAgent < RobotLab::Robot
def call(result)
# Access context from classifier
classification = result.context[:classification]
original_message = result.context[:original_message]
context = extract_run_context(result)
message = context.delete(:message)
robot_result = run(message, **context)
result.with_context(@name.to_sym, robot_result).continue(robot_result)
end
end
Per-Task Configuration¶
Tasks can carry their own context, a tool allowlist, and MCP servers:
# Tools must already be ATTACHED to the robot...
billing_agent = RobotLab.build(
name: "billing_agent",
system_prompt: "You handle billing.",
local_tools: [RefundTool, InvoiceTool, AuditTool]
)
network = RobotLab.create_network(name: "support") do
task :classifier, classifier, depends_on: :none
task :billing_agent, billing_agent,
context: { department: "billing", escalation_level: 2 },
tools: [RefundTool, InvoiceTool], # ...and this SELECTS from them
depends_on: :optional
task :tech_agent, tech_agent,
context: { department: "technical" },
mcp: [filesystem_server],
tools: :inherit, # send every discovered MCP tool
depends_on: :optional
end
[!WARNING] Per-task
tools:is an allowlist over tools the robot already has — it never attaches anything. Give a tasktools: [RefundTool]for a robot whoselocal_toolsare empty and it sends zero tools.Matching is by string comparison against each attached tool's
name, so the two sides must agree.local_tools: [RefundTool](the class) matchestools: [RefundTool], butlocal_tools: [RefundTool.new](an instance, whose name is"refund") does not — usetools: %w[refund]there.Omitting
tools:from a task means:none: the robot sends no tools at all. Usetools: :inheritfor "send everything attached".
The Task wrapper deep-merges per-task context with the network's run params before delegating to the robot's call method.
Pipeline Pattern¶
Sequential processing pipeline where each robot depends on the previous:
extractor = RobotLab.build(
name: "extractor",
system_prompt: "Extract key information from documents."
)
analyzer = RobotLab.build(
name: "analyzer",
system_prompt: "Analyze extracted data and provide insights."
)
formatter = RobotLab.build(
name: "formatter",
system_prompt: "Format analysis results into a clear report."
)
network = RobotLab.create_network(name: "document_processor") do
task :extract, extractor, depends_on: :none
task :analyze, analyzer, depends_on: [:extract]
task :format, formatter, depends_on: [:analyze]
end
result = network.run(message: "Process this document")
puts result.value.last_text_content
Parallel Analysis Pattern¶
Fan-out / fan-in pattern where multiple robots analyze in parallel and a synthesizer merges results:
# Robot names deliberately match their task names -- result.context is keyed
# by the ROBOT's name, so mismatched names produce nil lookups below.
sentiment = RobotLab.build(name: "sentiment", system_prompt: "Score sentiment.")
entities = RobotLab.build(name: "entities", system_prompt: "Extract entities.")
keywords = RobotLab.build(name: "keywords", system_prompt: "Extract keywords.")
network = RobotLab.create_network(name: "multi_analysis") do
task :prepare, preparer, depends_on: :none
# These run in parallel (all depend on :prepare)
task :sentiment, sentiment, depends_on: [:prepare]
task :entities, entities, depends_on: [:prepare]
task :keywords, keywords, depends_on: [:prepare]
# Waits for all three to complete
task :summarize, summarizer, depends_on: [:sentiment, :entities, :keywords]
end
result = network.run(message: "Analyze this text")
# Access parallel results from context (keyed by robot name)
puts "Sentiment: #{result.context[:sentiment].last_text_content}"
puts "Entities: #{result.context[:entities].last_text_content}"
puts "Keywords: #{result.context[:keywords].last_text_content}"
puts "Summary: #{result.value.last_text_content}"
[!WARNING]
result.contextis keyed by@nameof the robot that wrote it, not by the task name. Had these robots kept names likesentiment_analyzer, the lookups above would returnnileven though every task ran. Keep robot name and task name identical.
Shared Memory in Networks¶
Networks provide a shared Memory instance that all robots can read and write. This is especially useful for parallel robots that need to coordinate:
class AnalysisRobot < RobotLab::Robot
def initialize(memory_key:, **opts)
super(**opts)
@memory_key = memory_key
end
def call(result)
context = extract_run_context(result)
network_memory = context.delete(:network_memory)
message = context.delete(:message)
robot_result = run(message, network_memory: network_memory, **context)
# Write parsed results to shared memory
if network_memory
network_memory.current_writer = @name
network_memory.set(@memory_key, robot_result.last_text_content)
end
result.with_context(@name.to_sym, robot_result).continue(robot_result)
end
end
Conditional Halting¶
Use result.halt to stop the pipeline early:
class ValidatorRobot < RobotLab::Robot
def call(result)
context = extract_run_context(result)
message = context.delete(:message)
robot_result = run(message, **context)
if robot_result.last_text_content.include?("INVALID")
# Halt the pipeline early
result.halt(robot_result)
else
result.with_context(@name.to_sym, robot_result).continue(robot_result)
end
end
end
network = RobotLab.create_network(name: "validated_pipeline") do
task :validate, validator, depends_on: :none
task :process, processor, depends_on: [:validate] # Only runs if not halted
end
result = network.run(message: "Process this")
if result.continue?
puts "Processing complete: #{result.value.last_text_content}"
else
puts "Validation failed: #{result.value.last_text_content}"
end
[!WARNING]
SimpleFlow::Resulthas nohalted?orcontinued?predicate — calling either raisesNoMethodError. The predicate iscontinue?, which returnsfalseafterhalt. The full public API is exactlyactivate,activated_steps,context,continue,continue?,errors,halt,value,with_context, andwith_error.with_valueis private — calling it raisesNoMethodError; usecontinue(value)orhalt(value)to set the value.
Running¶
export ANTHROPIC_API_KEY="your-key"
# Classifier + three specialists with optional-task routing
ruby examples/03_network.rb
# Shared reactive memory across concurrent robots
ruby examples/07_network_memory.rb
# Network visualization and introspection (no LLM calls)
ruby examples/11_network_introspection.rb
Key Concepts¶
- SimpleFlow Pipeline: DAG-based execution with dependency management via
depends_on:. The DSL method istask— there is nostep, norouter:kwarg, and noRouterclass - Optional Tasks: Use
depends_on: :optionalfor tasks activated dynamically by classifiers - Robot#call Override: Routing is written in Ruby — subclass
Robot, overridecall, and callresult.activate(:task_name) - extract_run_context: Helper method to extract message and params from
SimpleFlow::Result - Context Flow:
result.contextis keyed by the robot's name (with_context(@name.to_sym, ...)), whileactivatetakes a task name — keep the two identical - Result predicates:
continue?(nothalted?), plusactivated_stepsto see what routing actually did - Parallel Execution: Tasks with the same dependencies run concurrently; cap in-flight LLM calls with
RunConfig.new(max_concurrent_robots: N) - Shared Memory: Network memory (
network_memory:) enables inter-robot communication - Per-Task Configuration: Each task can carry its own context, MCP servers, and a
tools:allowlist over the robot's already-attached tools