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 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 network's MCP servers
temperature: 0.7 # Inference parameter
)
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"):
With runtime overrides:
With streaming:
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 |
Hash |
Runtime data (accessible via 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 30 seconds
# 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, ...]
result.tool_calls # => [ToolResultMessage, ...]
result.stop_reason # => "stop"
result.created_at # => Time
result.id # => UUID string
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¶
RobotLab uses MywayConfig for configuration. There is no RobotLab.configure block. Configuration is loaded from:
- Bundled defaults (
lib/robot_lab/config/defaults.yml) - Environment-specific overrides
- XDG config files (
~/.config/robot_lab/config.yml) - Project config (
./config/robot_lab.yml) - Environment variables (
ROBOT_LAB_*prefix)
Access via RobotLab.config:
RobotLab.config.ruby_llm.model # => "claude-sonnet-4"
RobotLab.config.ruby_llm.request_timeout # => 120
Configuration Hierarchy¶
Tools and MCP servers use a cascading configuration system:
RobotLab.config (global)
|
+-- mcp: [server1, server2]
+-- tools: [tool1, tool2]
|
+-- Network
| |
| +-- mcp: :inherit | :none | [servers]
| +-- tools: :inherit | :none | [tools]
| |
| +-- Task (per-step config)
| | +-- context: { department: "billing" }
| | +-- mcp: :none | :inherit | [servers]
| | +-- tools: :none | :inherit | [tools]
| |
| +-- Robot (build-time config)
| |
| +-- mcp: :inherit | :none | [servers]
| +-- tools: :inherit | :none | [tools]
| |
| +-- run() call (runtime config)
| +-- mcp: :none | [servers]
| +-- tools: :none | [tools]
Resolution order: runtime > robot build-time > task > network > global config.
The :inherit value pulls from the parent level. :none explicitly disables.
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. Messages are delivered asynchronously via the async gem's fiber scheduler.
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!.
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)
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
Next Steps¶
- Robot Execution - Detailed execution flow
- Network Orchestration - Multi-robot coordination
- Using Tools - Creating and using tools