Creating Networks¶
Networks orchestrate multiple robots using SimpleFlow pipelines with DAG-based execution and optional task activation.
Basic Network¶
Create a network with a sequential pipeline:
network = RobotLab.create_network(name: "pipeline") do
task :analyzer, analyzer_robot, depends_on: :none
task :writer, writer_robot, depends_on: [:analyzer]
task :reviewer, reviewer_robot, depends_on: [:writer]
end
result = network.run(message: "Analyze this document")
Network Properties¶
Name¶
Identifies the network for logging and debugging:
Concurrency¶
Control parallel execution mode:
network = RobotLab.create_network(name: "parallel", concurrency: :threads) do
# :auto (default), :threads, or :async
end
Shared Memory¶
Networks provide a shared memory accessible to all robots:
network = RobotLab.create_network(name: "pipeline") do
task :first, robot1, depends_on: :none
end
# Pre-populate shared memory
network.memory[:project] = "Q4 Report"
network.memory[:user_id] = 123
Adding Tasks¶
Sequential Tasks¶
Each task depends on the previous:
network = RobotLab.create_network(name: "pipeline") do
task :first, robot1, depends_on: :none
task :second, robot2, depends_on: [:first]
task :third, robot3, depends_on: [:second]
end
Parallel Tasks¶
Tasks with the same dependencies run in parallel:
network = RobotLab.create_network(name: "parallel_analysis") do
task :fetch, fetcher, depends_on: :none
# These run in parallel after :fetch
task :sentiment, sentiment_bot, depends_on: [:fetch]
task :entities, entity_bot, depends_on: [:fetch]
task :keywords, keyword_bot, depends_on: [:fetch]
# This waits for all three to complete
task :merge, merger, depends_on: [:sentiment, :entities, :keywords]
end
This shared-depends_on form is the supported way to fan out.
The parallel Block¶
Network#parallel(name = nil, depends_on: :none, &block) forwards to the underlying SimpleFlow pipeline's parallel group.
[!WARNING]
parallelcannot currently register robot tasks. Its block isinstance_eval'd bySimpleFlow::Pipeline::ParallelBlock, which definesstep, nottask— writingtask :a, robotinside raisesNoMethodError: undefined method 'task' for an instance of SimpleFlow::Pipeline::ParallelBlock. AndstepbypassesNetwork#task, so the robot is never added tonetwork.robots, never wired to the bus poller, and never wrapped in aTask. Use the shared-depends_onform above instead; it produces the same parallel execution through the supported path.
Concurrency Cap¶
When a network fans out to many parallel robots, each makes a simultaneous LLM API call. With no limit this can exhaust API rate-limit quotas or database connection pools under load. Set max_concurrent_robots: on a RunConfig to cap how many robot tasks run at once — the rest queue behind an Async::Semaphore and start as slots open:
config = RobotLab::RunConfig.new(max_concurrent_robots: 4)
network = RobotLab.create_network(name: "launch_assessment", config: config) do
# All six declared parallel, but at most 4 LLM calls in-flight simultaneously
task :market, market_robot, depends_on: :none
task :competitive, comp_robot, depends_on: :none
task :tech, tech_robot, depends_on: :none
task :risk, risk_robot, depends_on: :none
task :financial, financial_robot, depends_on: :none # queues until a slot opens
task :legal, legal_robot, depends_on: :none # queues until a slot opens
task :director, director_robot, depends_on: [:market, :competitive, :tech,
:risk, :financial, :legal]
end
nil (the default) means unlimited — identical to pre-existing behavior. For Rails deployments, size the cap to match your database connection pool and API rate tier. See examples/31_launch_assessment.rb for a working demo.
[!WARNING] The cap is enforced by an
Async::Semaphore, so it only applies on the async execution path —concurrency: :async, orconcurrency: :autowhen theasyncgem is available (both the default). Withconcurrency: :threadsthe value is accepted but ignored: every parallel task starts immediately. Do not rely onmax_concurrent_robots:for rate limiting in a thread-mode network.
Optional Tasks¶
Optional tasks only run when explicitly activated by a preceding robot:
network = RobotLab.create_network(name: "router") do
task :classifier, classifier_robot, depends_on: :none
task :billing, billing_robot, depends_on: :optional
task :technical, technical_robot, depends_on: :optional
task :general, general_robot, depends_on: :optional
end
Per-Task Configuration¶
Tasks can have individual context and configuration that is deep-merged with the network's run parameters:
# technical_robot was built with local_tools: [DebugTool, LogTool, TraceTool] —
# attached as classes, so the task's allowlist names them as classes too.
network = RobotLab.create_network(name: "support") do
task :classifier, classifier_robot, depends_on: :none
task :billing, billing_robot,
context: { department: "billing", escalation_level: 2 },
depends_on: :optional
task :technical, technical_robot,
context: { department: "technical" },
tools: [DebugTool, LogTool],
depends_on: :optional
end
Task Options¶
| Option | Description |
|---|---|
context |
Hash merged with run params (task values override) |
mcp |
MCP servers for this task (:none, :inherit, or array) |
tools |
Tools available to this task (:none, :inherit, or array) |
memory |
Task-specific memory |
config |
Per-task RunConfig (merged on top of network's config) |
depends_on |
:none, [:task1], or :optional |
poller_group |
Bus delivery group label (:default, :slow, etc.) |
[!IMPORTANT]
tools:andmcp:both default to:none, and:nonemeans "send zero tools this turn" — not "fall back to whatever the robot was built with". A task that omitstools:runs its robot with no tools at all, even if the robot was constructed withlocal_tools:. Writetools: :inheriton the task to send the robot's attached tools.One exception: a non-
:nonetools:/mcp:set by an earlier task carries forward through the shared run params to later tasks — see Network-Wide Tool and MCP Defaults.An explicit array is an allowlist over the tools the robot already has — it selects, it does not add.
tools: [DebugTool, LogTool]on a robot built without those tools resolves to an empty set. Attach the tools withlocal_tools:at build time and use the task'stools:to narrow.Entries must match how the tool was attached. The filter compares each entry against
tool.name, andClass#nameis notRubyLLM::Tool#name. A tool attached as a class (local_tools: [DebugTool]) is named"DebugTool"; the same tool attached as an instance (local_tools: [DebugTool.new]) is named"debug". Verified:
attached as allowlist entry result class [DebugTool]matches class %w[debug]no match instance %w[debug]matches instance [DebugTool]no match Mixing the two forms is the usual cause of a task that silently ends up with zero tools.
tools: :inheritsidesteps the question entirely — it sends every attached tool without filtering.MCP needs both values:
mcp: :inherittriggers the connection, andtools: :inheritis additionally required for the MCP tools to reach the model.
Conditional Routing¶
Use optional tasks with custom Robot subclasses for intelligent routing:
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)
# Activate appropriate specialist based on classification
category = robot_result.last_text_content.to_s.strip.downcase
case category
when /billing/ then new_result.activate(:billing)
when /technical/ then new_result.activate(:technical)
else new_result.activate(:general)
end
end
end
classifier = ClassifierRobot.new(
name: "classifier",
system_prompt: "Classify as: billing, technical, or general. Respond with one word."
)
network = RobotLab.create_network(name: "support") do
task :classifier, classifier, depends_on: :none
task :billing, billing_robot, depends_on: :optional
task :technical, technical_robot, depends_on: :optional
task :general, general_robot, depends_on: :optional
end
Poller Groups¶
Each network maintains a shared BusPoller that serializes TypedBus deliveries on a per-robot basis: if a robot is already processing a message, new deliveries are queued and drained after the current one completes. This prevents re-entrancy without blocking other robots.
[!NOTE] Despite the name,
BusPollerruns no background thread.startandstopare no-ops,running?is hard-codedtrue, andenqueueprocesses and drains inline in the caller's own execution context (Async fiber or OS thread). All it owns is a mutex plus a per-robot queue. Don't expect deliveries to make progress on their own while the calling fiber is parked.
Named poller groups let you label tasks so slow robots are identifiable in logs and monitoring without needing separate infrastructure:
network = RobotLab.create_network(name: "mixed_speed") do
# Fast robots on the default group
task :fetcher, fetcher_robot, depends_on: :none
task :summarize, summarizer, depends_on: [:fetcher]
# Slow robots with expensive LLM calls — label them :slow
task :analyst, analyst_robot, depends_on: [:fetcher], poller_group: :slow
task :writer, writer_robot, depends_on: [:analyst], poller_group: :slow
end
Group labels are informational — there is no separate queue per group. In Async execution, robots naturally yield during LLM HTTP calls, so fast and slow robots interleave without explicit isolation.
Running Networks¶
Basic Run¶
result = network.run(message: "Help me with my order")
# Get the final response
puts result.value.last_text_content
With Additional Context¶
Accessing Task Results¶
result = network.run(message: "Process this")
# Access individual robot results — keyed by the ROBOT's name
classifier_result = result.context[:classifier]
billing_result = result.context[:billing]
# Original run parameters
original_params = result.context[:run_params]
[!IMPORTANT]
result.contextis keyed by the robot'sname:, not by the task label. The defaultRobot#callwritesresult.with_context(@name.to_sym, robot_result). The lookups above only work because each task label matches its robot's name —task :classifier, RobotLab.build(name: "classifier", ...). If they differ, you must index by the robot name. Keeping the two identical is the simplest way to avoid the mismatch. (network.robots, by contrast, is keyed by task name.)
SimpleFlow::Result¶
Networks return a SimpleFlow::Result object:
result = network.run(message: "Hello")
result.value # The final task's output (RobotResult)
result.context # Hash of all robot results and metadata
result.continue? # true while execution is proceeding; false once a robot called halt
result.activated_steps # Symbols of the :optional tasks that were activated
result.errors # Accumulated errors
The full public API is exactly activate, activated_steps, context, continue, continue?, errors, halt, value, with_context, with_error.
[!WARNING]
result.halted?andresult.continued?do not exist — calling either raisesNoMethodError. There is a single predicate,continue?; a halted result is simplycontinue? == false.
result.with_valueis private (public_method_defined?(:with_value)isfalse), so calling it from a robot raisesNoMethodErrortoo. Usecontinue(new_value)to carry a new value forward.
Broadcasting¶
Networks support a broadcast channel for network-wide announcements:
# Register a broadcast handler
network.on_broadcast do |message|
case message[:payload][:event]
when :pause
puts "Pausing: #{message[:payload][:reason]}"
when :phase_complete
puts "Phase complete: #{message[:payload][:phase]}"
end
end
# Send broadcasts during execution
network.broadcast(event: :phase_complete, phase: "analysis")
Patterns¶
Classifier Pattern¶
Route to specialists based on classification:
class SupportClassifier < 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)
category = robot_result.last_text_content.to_s.strip.downcase
new_result.activate(category.to_sym)
end
end
network = RobotLab.create_network(name: "support") do
task :classifier, SupportClassifier.new(name: "classifier", template: :classifier),
depends_on: :none
task :billing, billing_robot, depends_on: :optional
task :technical, technical_robot, depends_on: :optional
task :general, general_robot, depends_on: :optional
end
Pipeline Pattern¶
Process through sequential stages:
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
Fan-Out/Fan-In Pattern¶
Parallel processing with aggregation:
network = RobotLab.create_network(name: "multi_analysis") do
task :prepare, preparer, depends_on: :none
# Fan-out: parallel analysis
task :sentiment, sentiment_analyzer, depends_on: [:prepare]
task :topics, topic_extractor, depends_on: [:prepare]
task :entities, entity_recognizer, depends_on: [:prepare]
# Fan-in: aggregate results
task :aggregate, aggregator, depends_on: [:sentiment, :topics, :entities]
end
Pipeline Error Resilience¶
When a robot raises an exception during pipeline execution, the error is caught and wrapped in a RobotResult with the error message as content. This ensures one failing robot does not crash the entire network:
# If billing_robot raises an error, the network continues
# The error is available in the result context, keyed by the robot's name:
result = network.run(message: "Process this")
billing_result = result.context[:billing]
if billing_result&.last_text_content&.start_with?("Error:")
puts "Billing failed: #{billing_result.last_text_content}"
puts "Took: #{billing_result.duration}s"
end
Each robot's RobotResult includes a duration field (elapsed seconds) that is set automatically during pipeline execution, even for errored results.
Conditional Continuation¶
A robot can halt execution 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")
# Stop the pipeline
result.halt(robot_result)
else
# Continue to next task
result
.with_context(@name.to_sym, robot_result)
.continue(robot_result)
end
end
end
Data Passing Between Tasks¶
Access previous task results via context:
class ResponderRobot < RobotLab::Robot
def call(result)
# Get classifier's output — the key is the classifier ROBOT's name
classification = result.context[:classifier]&.last_text_content
context = extract_run_context(result)
message = context.delete(:message)
# Use classification in the message or context
robot_result = run(
"Classification: #{classification}\n\nUser message: #{message}",
**context
)
result.with_context(@name.to_sym, robot_result).continue(robot_result)
end
end
Visualization¶
ASCII Visualization¶
Mermaid Diagram¶
DOT Format (Graphviz)¶
Execution Plan¶
Network Introspection¶
network.name # => "support"
network.robots # => Hash of key => Robot (see the key caveat below)
network.robot(:billing) # => Robot instance
network["billing"] # => Robot instance (alias)
network.available_robots # => Array of Robot instances
network.crew # => Array of Robot instances (Runnable protocol)
network.memory # => Memory instance (shared)
network.to_h # => Hash representation
network.add_robot(extra_robot) # add without a pipeline task -> self
network.remove_robot(:extra_robot) # remove by name -> the removed Robot, or nil
[!WARNING]
network.robotsuses two different key conventions.task :alpha, botregisters the robot under the task name ("alpha"), whilenetwork.add_robot(bot)registers it underbot.name.network.robot(...)andnetwork[...]inherit the same split. When task label and robot name differ, a lookup by robot name will miss a task-registered robot.
add_robotalso raisesArgumentErrorif the key is already taken:Robot 'x' already exists in network 'n'. Because the key conventions differ, adding a robot whosenamematches an existing task label collides, while adding one whose name matches a task-registered robot'snamedoes not.
remove_robot only drops the robot from the crew — it doesn't touch the pipeline, so don't remove a robot that's still a depends_on target of a task.
Configuration Inheritance¶
Networks accept a config: parameter, but its reach is much narrower than a general "network-wide defaults" mechanism.
[!WARNING] A network-level (or per-task)
RunConfigpropagates onlymcpandtoolsto member robots, and only for robots that opt in with:inherit. LLM fields (model,temperature,top_p,max_tokens, …) and callbacks (on_content,on_tool_call,on_tool_result) are never inherited from a network — each robot reads those from its own config at construction time. Settingmodel:on a network config has no effect on any robot.Verified: a robot in a network configured with
RunConfig.new(model: "claude-haiku-4-5-20251001", temperature: 0.11)still runs with the global default model and aniltemperature.
The one field the network consumes for itself is max_concurrent_robots (see Concurrency Cap).
Network-Wide Tool and MCP Defaults¶
Reaching a robot from a network config: takes two opt-ins, because
resolution runs in two passes — first the robot's build-time value against the
network config, then the task's runtime value against that result:
- the robot is built with
tools: :inherit(and/ormcp: :inherit), which is what pulls the network config's list down to the robot level, and - the task passes
tools: :inherit(and/ormcp: :inherit), which is what actually sends them for that turn.
shared = RobotLab::RunConfig.new(
tools: [SearchTool, CalculatorTool],
mcp: [{ name: "fs", transport: { type: "stdio", command: "mcp-server-filesystem" } }]
)
# Build-time :inherit is the opt-in to `shared`. The robot must still *attach*
# the tools — the network list narrows what it already has, it cannot add.
analyzer_robot = RobotLab.build(
name: "analyzer",
system_prompt: "...",
local_tools: [SearchTool, CalculatorTool, DraftTool],
tools: :inherit,
mcp: :inherit
)
network = RobotLab.create_network(name: "pipeline", config: shared) do
task :analyzer, analyzer_robot, depends_on: :none, tools: :inherit, mcp: :inherit
task :writer, writer_robot, depends_on: [:analyzer]
end
Verified outcomes for the :analyzer task above:
robot build tools: |
task tools: |
tools sent |
|---|---|---|
:inherit |
:inherit |
[:search, :calculator] — shared applied, DraftTool filtered out |
| unset | :inherit |
[:search, :calculator, :draft] — shared never reached the robot |
:inherit |
omitted (:none) |
[] |
Same for MCP: with mcp: :inherit at build time the fs server connection is
attempted (it shows up in robot.failed_mcp_server_names if it fails); without
it, the network's mcp: list never reaches the robot and nothing is attempted.
[!IMPORTANT] A task's
tools:/mcp:carries forward to later tasks.Taskwrites them into the sharedrun_params, and the next task deep-merges that hash as its base — so:writerabove ends up withtools: :inherittoo, inherited from:analyzer, even though its owntaskline says nothing about tools. Verified: put:writerfirst in the pipeline and it resolves to[]; put it after an:analyzerthat passestools: :inheritand it resolves to[:search, :calculator].Writing
tools: :noneon the downstream task does not undo it —Taskskips writing:noneintorun_params(run_params[:tools] = @tools unless @tools == :none), so the inherited value survives untouched. Use an empty array instead, which is written and is treated as an explicit zero:task :writer, writer_robot, depends_on: [:analyzer], tools: [] # verified -> [] task :writer, writer_robot, depends_on: [:analyzer], tools: :none # verified -> still [:search, :calculator][!NOTE] This is the one place where build-time
:inheritis correct. For a standalone robot the parent level is the global:none, so build-time:inheritthere resolves to an allowlist of["none"]and matches nothing — see Building Robots: Adding Tools.
To give every robot the same model, set it on each robot at construction:
MODEL = "claude-sonnet-4"
analyzer_robot = RobotLab.build(name: "analyzer", system_prompt: "...", model: MODEL, temperature: 0.5)
writer_robot = RobotLab.build(name: "writer", system_prompt: "...", model: MODEL, temperature: 0.5)
Per-Task config:¶
A per-task config: is merged into the network config that reaches the robot, so it carries the same mcp/tools-only limitation:
# writer_robot built with local_tools: [SearchTool, CalculatorTool, DraftTool]
# and tools: :inherit — the same two opt-ins as above.
network = RobotLab.create_network(name: "pipeline", config: shared) do
task :analyzer, analyzer_robot, depends_on: :none, tools: :inherit
task :writer, writer_robot,
config: RobotLab::RunConfig.new(tools: [DraftTool]), # tools/mcp only
tools: :inherit,
depends_on: [:analyzer]
end
The task config: replaces the network's list for that task, so :writer
resolves to [:draft] while :analyzer resolves to [:search, :calculator].
Inheritance Chain¶
Per robot, the cascade runs least- to most-specific — note that template front matter is the base, not an override:
template front matter (lowest — the starting point)
-> config: RunConfig
-> constructor kwargs (highest — always wins)
Constructor kwargs always win. Network and task config: sit outside this chain and contribute only mcp/tools, and only where a robot or task asks for :inherit.
Two further front-matter caveats: only model and temperature are actually applied from front matter — top_p, top_k, max_tokens, presence_penalty, frequency_penalty, and stop are parsed and silently dropped. The same six do work as constructor kwargs.
Best Practices¶
1. Keep Robots Focused¶
Each robot should have a single responsibility:
# Good: focused robots
task :classify, classifier, depends_on: :none
task :respond, responder, depends_on: [:classify]
# Bad: one robot doing everything
task :do_everything, mega_robot, depends_on: :none
2. Use Per-Task Context¶
Pass task-specific configuration through context:
task :billing, billing_robot,
context: { department: "billing", max_refund: 500 },
depends_on: :optional
3. Handle Missing Results¶
Guard against missing optional task results:
def call(result)
# Check if the optional task ran (key = that robot's name)
if result.context[:validator]
# Use validator result
else
# Handle missing validation
end
end
4. Reset Memory Between Runs¶
If reusing a network, reset shared memory between runs:
Next Steps¶
- Using Tools - Add capabilities to robots
- Memory Guide - Shared memory patterns
- API Reference: Network - Complete API