Core Concepts¶
This page provides an in-depth look at RobotLab's fundamental building blocks.
Robot¶
A Robot is the primary unit of computation in RobotLab. It is a subclass of RubyLLM::Agent that wraps a persistent @chat with:
- A unique identity (name, description)
- A personality (system prompt and/or template)
- Capabilities (tools, MCP connections)
- Model, provider, and inference configuration
- Inherent memory (key-value store)
Robot Anatomy¶
robot = RobotLab.build(
name: "support_agent", # Unique identifier
description: "Handles support requests", # Used for routing hints
model: "claude-sonnet-4", # LLM model
system_prompt: <<~PROMPT, # Inline system prompt
You are a friendly customer support agent for Acme Corp.
Always be polite and helpful. If you don't know something,
say so honestly.
PROMPT
local_tools: [OrderLookup, RefundProcessor], # RubyLLM::Tool subclasses
mcp: :inherit, # Use the network's MCP servers
temperature: 0.7 # Inference parameter
)
The build-time mcp: :inherit above assumes this robot will be added to a
network whose config: supplies an mcp: list. The parent is resolved on every
run, so :inherit picks up whatever the enclosing network provides. For a robot
that will run standalone, :inherit resolves against the global default
:none and yields nothing — give it an explicit array instead.
Or with a template:
robot = RobotLab.build(
name: "support_agent",
template: :support, # Loads prompts/support.md
context: { company: "Acme Corp" }, # Template variables
local_tools: [OrderLookup]
)
Robot Lifecycle¶
stateDiagram-v2
[*] --> Created: RobotLab.build / Robot.new
Created --> Running: robot.run("message")
Running --> ToolLoop: tool_call from LLM
ToolLoop --> Running: tool result sent back
Running --> Completed: final text response
Completed --> Running: robot.run("next message")
Completed --> [*]: robot.disconnect
The persistent @chat maintains conversation history across multiple run calls, making the robot stateful.
Robot Properties¶
| Property | Type | Description |
|---|---|---|
name |
String |
Unique identifier within network |
description |
String, nil |
What the robot does |
model |
String |
LLM model ID (resolved from chat) |
template |
Symbol, nil |
Prompt template identifier |
system_prompt |
String, nil |
Inline system prompt |
local_tools |
Array |
Locally defined tools |
mcp_clients |
Hash |
Connected MCP clients by server name |
mcp_tools |
Array |
Tools discovered from MCP servers |
memory |
Memory |
Inherent key-value memory |
bus |
TypedBus::MessageBus, nil |
Message bus instance |
outbox |
Hash |
Sent messages tracked with status and replies |
mcp_config |
Symbol, Array |
Build-time MCP configuration |
tools_config |
Symbol, Array |
Build-time tools configuration |
Running a Robot¶
The primary method is robot.run("message"):
run defaults to mcp: :none, tools: :none. Those defaults are explicit "send nothing this turn" values, not "unset" — so a plain run connects no MCP servers and sends the LLM zero tools even when local_tools:/mcp: were supplied at build time. Opt in per run:
robot.run("...", tools: :inherit) # send the attached local tools
robot.run("...", mcp: :inherit, tools: :inherit) # connect MCP servers and send their tools
With other runtime overrides:
With streaming — the block receives a RubyLLM::Chunk, whose text is in content:
An on_content: callback (constructor kwarg or RunConfig field) fires on every run. When both an on_content callback and a block are supplied, both fire, stored callback first.
Tool¶
Tools give robots the ability to interact with external systems. There are two patterns for defining tools.
RubyLLM::Tool Subclass (Primary)¶
class GetWeather < RubyLLM::Tool
description "Get current weather for a location"
param :location, type: "string", desc: "City name"
param :unit, type: "string", desc: "celsius or fahrenheit"
def execute(location:, unit: "celsius")
WeatherAPI.current(location, unit: unit)
end
end
Tools defined as RubyLLM::Tool subclasses are passed to robots via local_tools::
robot = RobotLab.build(
name: "weather_bot",
system_prompt: "You provide weather information.",
local_tools: [GetWeather]
)
RobotLab::Tool.create Factory¶
For simpler tools that do not need a class:
tool = RobotLab::Tool.create(
name: "get_time",
description: "Get the current time"
) { |_args| Time.now.to_s }
With parameter schema:
tool = RobotLab::Tool.create(
name: "get_weather",
description: "Get weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" }
},
required: ["location"]
}
) { |args| WeatherAPI.current(args[:location]) }
Tool Execution¶
When an LLM decides to use a tool:
- LLM generates a tool call with tool name and arguments
@chat(RubyLLM) identifies the tool from its registered tools- Calls the
executemethod with keyword arguments - Result is sent back to the LLM for continued processing
- Loop repeats until the LLM produces a final text response
Error Handling¶
Tool errors are captured and returned to the LLM:
def execute(order_id:)
order = ORDERS[order_id]
if order
order
else
{ error: "Order not found" }
end
end
Memory¶
Memory is a reactive key-value store used by robots and networks.
Standalone vs Network Memory¶
- Standalone: Each robot has its own inherent
Memoryinstance (robot.memory) - In a Network: All robots share the network's
Memoryinstance
# Standalone memory
robot.memory[:user_id] = 123
robot.memory[:user_id] # => 123
# Network memory is passed automatically
network = RobotLab.create_network(name: "pipeline") do
task :robot_a, robot_a, depends_on: :none
task :robot_b, robot_b, depends_on: [:robot_a]
end
# Both robot_a and robot_b share network.memory during execution
Reserved Keys¶
| Key | Type | Description |
|---|---|---|
:data |
StateProxy |
Runtime data (hash-style memory.data[:key] and method-style memory.data.key_name) |
:results |
Array |
Accumulated robot results |
:messages |
Array |
Conversation history |
:session_id |
String |
Session identifier |
:cache |
Module |
Semantic cache (RubyLLM::SemanticCache) |
Reactive Features¶
Memory supports pub/sub semantics for inter-robot communication:
# Write a value (notifies subscribers, wakes waiters)
memory.set(:sentiment, { score: 0.8 })
# Read a value (non-blocking)
memory.get(:sentiment) # => { score: 0.8 } or nil
# Blocking read (waits until value exists)
memory.get(:sentiment, wait: true) # Blocks indefinitely
memory.get(:sentiment, wait: 30) # Blocks up to 30s, then raises RobotLab::AwaitTimeout
# Subscribe to changes
memory.subscribe(:sentiment) do |change|
puts "#{change.key} = #{change.value} (written by #{change.writer})"
end
Message Types¶
RobotLab uses a type hierarchy for messages:
classDiagram
Message <|-- TextMessage
Message <|-- ToolCallMessage
Message <|-- ToolResultMessage
class Message {
+String type
+String role
+content
+String stop_reason
+text?()
+tool_call?()
+tool_result?()
+stopped?()
}
class TextMessage {
+String content
}
class ToolCallMessage {
+Array~ToolMessage~ tools
}
class ToolResultMessage {
+ToolMessage tool
+Hash content
+success?()
+error?()
}
Message Roles¶
| Role | Description |
|---|---|
user |
Input from the user |
assistant |
Response from the LLM |
system |
System instructions |
tool_result |
Tool execution result |
Stop Reasons¶
| Reason | Description |
|---|---|
stop |
Natural completion |
tool |
Tool call requested |
RobotResult¶
The output from a robot execution:
result = robot.run("Hello!")
result.robot_name # => "support_agent"
result.output # => [TextMessage] built from the final response text
result.tool_calls # => [] — see note below
result.stop_reason # => nil — always (see below)
result.created_at # => Time
result.id # => UUID string
result.input_tokens # => Integer
result.output_tokens # => Integer
result.duration # => Float, nil (set by Robot#call during pipeline execution)
result.checksum # => "sha256-hex"
stop_reason is always nil on a Robot#run result — build_result only copies it when the response responds to stop_reason, and RubyLLM::Message does not. The table above describes Message::VALID_STOP_REASONS, which applies to message objects you construct yourself, not to RobotResult. As a consequence result.stopped? reduces to !result.has_tool_calls?.
output is always a single TextMessage synthesized from the final response text — it is not a transcript of the turn. tool_calls is read off that final assistant message, which no longer carries tool calls once RubyLLM's tool loop has finished, so in practice it is empty; use the on_tool_call/on_tool_result callbacks or the tool hooks to observe tool usage.
Accessing Response Content¶
# Get last text response (most common)
text = result.last_text_content
# Check if tools were called
has_tools = result.has_tool_calls?
# Check if execution completed naturally
result.stopped?
# Serialization
result.export # => Hash (excludes debug fields)
result.to_h # => Hash (includes debug fields)
result.to_json # => JSON string
Configuration¶
Global configuration uses MywayConfig. Sources are layered lowest to highest:
- Bundled defaults (
lib/robot_lab/config/defaults.yml) - Environment-specific overrides (development / test / production)
- XDG config file (
~/.config/robot_lab/robot_lab.yml— note the filename repeats the app name;config.ymlis never read, and this loader does not run ERB) - Project config (
./config/robot_lab.yml— ERB is evaluated here) - Environment variables (
ROBOT_LAB_*prefix, double underscore for nesting) - Constructor params
The two file layers treat top-level wrappers differently:
- XDG file (
~/.config/robot_lab/robot_lab.yml) — a section named for the current environment is honoured. The loader checksparsed.key?(env)first (env comes fromAnyway::Settings.current_environment, thenRAILS_ENV, thenRACK_ENV, defaulting to"development") and falls back to the root when no such key exists. Sodevelopment:works, a flat file works, andproduction:is simply skipped while you are in development. Only adefaults:wrapper is meaningless — it is not an environment name, so the whole hash is read as flat config anddefaultsis an unknown key. - Project file (
./config/robot_lab.yml) — outside Rails this must be flat; every wrapper,defaults:and environment names alike, is ignored. Inside Rails,anyway_configsetscurrent_environmenttoRails.env, which makes this file environmental: a flat project file is then ignored and keys must be nested underdevelopment:/test:/production:.
Read via RobotLab.config, or set values with the RobotLab.configure block, which yields the same Config object:
RobotLab.config.ruby_llm.model # => "claude-sonnet-4"
RobotLab.config.ruby_llm.request_timeout # => 120
RobotLab.configure do |c|
c.logger = Logger.new($stdout)
end
RunConfig¶
Global Config is distinct from RunConfig, the per-run settings object that flows RobotLab.config → Network → Task → Robot → template front matter → constructor kwargs. Its fields are:
- LLM:
model,temperature,top_p,top_k,max_tokens,presence_penalty,frequency_penalty,stop - Capabilities (
TOOL_FIELDS):mcp,tools - Callbacks:
on_tool_call,on_tool_result,on_content - Infrastructure (
INFRA_FIELDS):bus,enable_cache,max_tool_rounds,token_budget,cost_budget,ractor_pool_size,max_concurrent_robots,doom_loop_threshold,auto_compact,compact_threshold,max_tools
TOOL_FIELDS is exactly [:mcp, :tools] — those are the two a network propagates. max_tools is an infrastructure field, so it is not inherited that way.
Per robot, template front matter is the base, a config: RunConfig merges over it, and constructor kwargs always win. A network-level config: propagates only mcp and tools down to member robots (and only when a robot opts in with :inherit); LLM fields and callbacks are never inherited from a network. max_concurrent_robots is the one field the network itself consumes.
Configuration Hierarchy¶
Tools and MCP servers use a cascading configuration system:
RobotLab.config (global)
|
+-- mcp: [server1, server2]
+-- tools: [tool1, tool2]
|
+-- Network config: (RunConfig)
| |
| +-- mcp: :inherit | :none | [servers]
| +-- tools: :inherit | :none | [tools]
| |
| +-- Robot (build-time config)
| |
| +-- mcp: :inherit | :none | [servers]
| +-- tools: :inherit | :none | [tools]
| |
| +-- Task (per-task) / run() call -- the RUNTIME level
| +-- mcp: :none (default) | :inherit | [servers]
| +-- tools: :none (default) | :inherit | [tools]
Resolution order: task/runtime > robot build-time > network > global config.
A Task's mcp:/tools: are not a separate tier between network and robot. The task injects them into run_params, and Robot#call pulls them out and passes them to run — so they arrive as the runtime value and are resolved against the robot's build-time value.
The :inherit value pulls from the parent level. :none explicitly disables. An explicit array is a filter over the already-attached tools, not a local-vs-MCP switch.
Three consequences worth internalizing:
run()defaults both to:none, so the runtime level is a deliberate "send nothing this turn" unless you override it. Passtools: :inherit(andmcp: :inherit) to use what the robot was built with.- Build-time
:inheritdepends on there being a parent to inherit from. The parent is recomputed on every run (network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools), so inside a network whoseconfig:setstools:/mcp:it is exactly the right way to opt in. For a robot that runs standalone the parent is the global:none, so build-time:inheritmatches nothing — leavetools:unset there and opt in at run time. - An explicit array is compared with
tool.name.to_s, andClass#namediffers fromRubyLLM::Tool#name. A tool attached as a class matches[RefundTool]("RefundTool"); the same tool attached as an instance matches%w[refund]. Both forms work — but the array must be written in the same form the tool was attached in, or it filters everything out. Note that the build-timetools:kwarg is validated (validate_tools_filter!) and accepts only Strings/Symbols; the class form is available only at the task/runtime level, which is not validated. See Network Orchestration.
Message Bus¶
The Message Bus provides bidirectional, cyclic communication between robots, independent of the Network pipeline. While Networks enforce DAG-based (acyclic) execution, the bus enables negotiation loops, convergence patterns, and multi-turn dialogues.
How It Works¶
Robots connect to a shared TypedBus::MessageBus via the bus: parameter. Each robot gets a typed channel (accepting only RobotMessage objects) named after its name. Delivery is routed through a shared BusPoller, which runs the handler in the caller's execution context (Async fiber or OS thread) rather than on a background thread of its own. A mutex serializes deliveries per robot: if a robot is already processing a message, later ones are queued and drained after the current one returns.
bus = TypedBus::MessageBus.new
bob = RobotLab.build(name: "bob", system_prompt: "You tell jokes.", bus: bus)
alice = RobotLab.build(name: "alice", system_prompt: "You evaluate jokes.", bus: bus)
RobotMessage¶
RobotMessage is an immutable Data.define value object used as the typed envelope:
| Field | Type | Description |
|---|---|---|
id |
Integer |
Per-robot sequential counter |
from |
String |
Sender's robot name (= channel name) |
content |
String, Hash |
Message payload |
in_reply_to |
String, nil |
Composite key of the original message (e.g., "alice:1") |
Methods: key returns "from:id" composite identity; reply? returns true when in_reply_to is set.
Sending and Receiving¶
# Send a message to another robot
alice.send_message(to: :bob, content: "Tell me a joke.")
# Handle incoming messages with auto-ack (1 arg)
bob.on_message do |message|
joke = bob.run(message.content.to_s).last_text_content
bob.send_reply(to: message.from.to_sym, content: joke, in_reply_to: message.key)
end
Block arity controls delivery handling: 1 argument auto-acks; 2 arguments give manual control over delivery.ack!/delivery.nack!.
send_message/send_reply synchronize the per-robot message counter and outbox with an internal mutex, so concurrent sends from multiple threads and reply correlation can't clobber each other — the bus is safe to send on from more than one thread at a time. (There is no poller thread doing the correlating: as described above, BusPoller#enqueue processes deliveries inline in the caller's context.)
For the common case of a robot that should simply answer whatever tasks arrive on the bus, respond_to_tasks/serve do the on_message wiring above in one call:
respond_to_tasks takes a block instead when the reply needs post-processing, and both ignore inbound messages that are themselves replies, so two robots calling serve on each other don't loop. See Auto-Responding to Bus Tasks.
Dynamic Spawning¶
Robots can create new robots at runtime using spawn. The bus is created lazily — no upfront wiring required:
dispatcher = RobotLab.build(name: "dispatcher", system_prompt: "You delegate work.")
# spawn creates a child on the same bus (bus created automatically)
helper = dispatcher.spawn(name: "helper", system_prompt: "You answer questions.")
# The child can immediately communicate with the parent
answer = helper.run("What is 2+2?").last_text_content
helper.send_message(to: :dispatcher, content: answer)
The spawned robot inherits its parent's model/provider, so a dispatcher running on a local provider (e.g. Ollama) spawns specialists targeting that same model instead of falling back to the global default (which would fail without cloud credentials). Explicit model:/provider: passed to spawn still override.
Robots can also join a bus after creation using with_bus:
bot = RobotLab.build(name: "late_joiner", system_prompt: "Hello.")
bot.with_bus(existing_bus) # now connected to the bus
Fan-out messaging: Multiple robots with the same name all subscribe to the same channel. Messages sent to that name are delivered to all subscribers:
worker1 = dispatcher.spawn(name: "worker", system_prompt: "Worker 1")
worker2 = dispatcher.spawn(name: "worker", system_prompt: "Worker 2")
dispatcher.send_message(to: :worker, content: "Do this task")
# Both worker1 and worker2 receive the message
Bus vs Network¶
| Feature | Network | Message Bus |
|---|---|---|
| Execution model | DAG (acyclic) | Cyclic, bidirectional |
| Communication | Sequential pipeline | Pub/sub channels |
| Memory | Shared network memory | Independent per-robot |
| Use case | Linear workflows | Negotiation, convergence |
The bus is purely additive — robots without bus: work exactly as before.
Network¶
A Network orchestrates multiple robots in a pipeline workflow using SimpleFlow:
network = RobotLab.create_network(name: "support") do
task :classifier, classifier_robot, depends_on: :none
task :billing, billing_robot, depends_on: :optional
task :technical, technical_robot, depends_on: :optional
end
result = network.run(message: "I need help with billing")
Networks provide:
- DAG-based execution via SimpleFlow with
depends_on:for sequencing - Parallel execution for tasks with the same dependencies
- Optional tasks activated dynamically by classifier robots
- Shared memory for inter-robot communication
- Per-task configuration via the
Taskwrapper - Broadcast messaging for network-wide announcements
Robots can be added to a network without a pipeline task via add_robot, and removed again with remove_robot(name) (returns the removed robot, or nil if absent). remove_robot only drops the robot from the crew — it doesn't rewrite the pipeline, so avoid removing a robot that's still referenced by a task's depends_on.
Runnable Protocol¶
RobotLab::Runnable is a shared interface implemented by both Robot and Network, so callers can treat either uniformly instead of branching on is_a?(RobotLab::Network):
def summarize(runnable)
runnable.crew.each { |r| puts r.name }
puts "chief: #{runnable.chief.name}"
puts runnable.network? ? "network of #{runnable.robot_count}" : "single robot"
end
summarize(robot) # crew: [robot], chief: robot, "single robot"
summarize(network) # crew: network.robots.values, chief: crew.first, "network of N"
| Method | Robot | Network |
|---|---|---|
crew |
[self] |
robots.values (pipeline order) |
chief |
self |
crew.first |
robot_count |
1 |
crew.size |
network? |
false |
true |
single? |
true |
false |
run(message = nil, **opts) |
accepts a positional message (already did) | now also accepts a positional message, folded into message: — the keyword form still works |
crew is the only method implementers must define themselves; chief, robot_count, and single? all derive from it, and network? defaults to false unless overridden (as Network does).
Next Steps¶
- Robot Execution - Detailed execution flow
- Network Orchestration - Multi-robot coordination
- Using Tools - Creating and using tools