diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..cd92a01
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,156 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+RubyAgent is a Ruby gem framework for building AI agents powered by Claude Code CLI. It provides an event-driven interface to interact with Claude through the Claude Code CLI using stream-json format for bidirectional communication.
+
+## Development Commands
+
+### Testing
+```bash
+# Run all tests
+rake test
+
+# Run specific test
+ruby test/ruby_agent_test.rb --name test_simple_agent_query
+
+# Run full CI suite (linting + tests)
+rake ci
+```
+
+### Linting
+```bash
+# Check for linting issues
+rake ci:lint
+
+# Auto-fix linting issues
+rake ci:lint:fix
+```
+
+### Security
+```bash
+# Run security audit
+rake ci:scan
+```
+
+### Build & Install
+```bash
+# Build gem locally
+rake build
+
+# Install locally built gem
+rake install
+```
+
+### Running Examples
+```bash
+# Run example (requires local build/install first)
+ruby examples/example1.rb
+```
+
+## Architecture
+
+### Core Components
+
+**RubyAgent::Agent** (`lib/ruby_agent/agent.rb`)
+- Main agent class that manages Claude Code CLI subprocess communication
+- Spawns Claude CLI process using `Open3.popen3` with stream-json I/O format
+- Handles bidirectional communication: sends messages via stdin, reads responses via stdout
+- Manages connection lifecycle (connect, ask, close)
+- Supports session resumption via `session_key` parameter
+- Integrates `CallbackSupport` module for event-driven programming model
+
+**RubyAgent::Configuration** (`lib/ruby_agent/configuration.rb`)
+- Global configuration object for default settings
+- Accessible via `RubyAgent.configuration` or `RubyAgent.configure` block
+- Default model: `claude-sonnet-4-5-20250929`
+- Default sandbox: `./sandbox`
+
+**CallbackSupport** (`lib/ruby_agent/callback_support.rb`)
+- Mixin module providing class-level callback registration
+- Supports two callback registration styles:
+ 1. Method name: `on_event :my_handler`
+ 2. Block: `on_event do |event| ... end`
+- Callbacks are inherited through class ancestry chain
+- Events are dispatched during message streaming
+
+### Communication Flow
+
+1. **Connection**: Agent spawns Claude CLI subprocess with specific flags:
+ - `--dangerously-skip-permissions`: Skips permission prompts
+ - `--output-format=stream-json`: JSON streaming output
+ - `--input-format=stream-json`: JSON streaming input
+ - `--system-prompt`: Custom system prompt
+ - `--model`: Specific Claude model
+ - `--mcp-config`: Optional MCP server configuration
+
+2. **Message Sending**: JSON messages sent to stdin with structure:
+ ```ruby
+ { type: "user", message: { role: "user", content: "..." }, session_id: "..." }
+ ```
+
+3. **Response Reading**: Agent reads streaming JSON events from stdout:
+ - `type: "system"`: System messages (ignored)
+ - `type: "assistant"`: Full assistant messages
+ - `type: "content_block_delta"`: Streaming text deltas
+ - `type: "result"`: End of response
+ - `type: "error"`: Error messages
+
+4. **Callbacks**: Events trigger registered callbacks during streaming
+
+### Callback System Design
+
+The callback system uses Ruby's class-level registration pattern:
+- Callbacks registered at class definition time via `on_event`
+- Stored in class instance variable `@on_event_callbacks`
+- Inherited through ancestor chain for subclass support
+- Executed in order during message streaming
+- Can be method names (symbols) or blocks (Procs)
+
+### Subprocess Management
+
+The agent uses `Open3.popen3` to spawn Claude CLI:
+- Runs in bash login shell (`bash -lc`) to inherit environment
+- Changes to sandbox directory before execution
+- Optional TTY mode pipes output through `stream.rb` for debugging
+- Process health checked via `wait_thr.alive?` before operations
+
+### MCP Server Support
+
+Agents can connect to MCP (Model Context Protocol) servers:
+- Configuration passed as JSON via `--mcp-config` flag
+- Server keys transformed from snake_case to kebab-case
+- Example: `headless_browser` becomes `headless-browser` in config
+- MCP servers extend Claude's capabilities with external tools
+
+## Code Style
+
+- Follow Ruby on Rails conventions (Sandi Metz / DHH style)
+- Use RuboCop for linting enforcement
+- Prefer clear, intention-revealing method names
+- Use private methods to hide implementation details
+- Follow POODR principles for object design
+
+## Version Management
+
+Version is defined in `lib/ruby_agent/version.rb`. Bump appropriately:
+- Patch (0.2.1 → 0.2.2): Bug fixes
+- Minor (0.2.1 → 0.3.0): New features
+- Major (0.2.1 → 1.0.0): Breaking changes
+
+Publishing to RubyGems happens automatically via GitHub Actions on merge to `main`.
+
+## Testing Notes
+
+- Tests require Claude Code CLI installed locally
+- Integration test (`test_simple_agent_query`) spawns real Claude CLI subprocess
+- Tests verify callback registration, initialization, ERB template parsing
+- Use Minitest framework with minitest-reporters for output
+
+## Requirements
+
+- Ruby >= 3.2.0
+- Claude Code CLI installed (see README.md for installation)
+- Required gems: dotenv, shellwords (standard library), open3 (standard library), json (standard library), securerandom (standard library)
diff --git a/Gemfile b/Gemfile
index 10ffbb7..1a9d4d8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,6 +6,8 @@ gem "minitest", "~> 5.0"
gem "minitest-reporters", "~> 1.6"
gem "rake", "~> 13.0"
+gem "headless_browser_tool", git: "https://github.com/krschacht/headless-browser-tool.git"
+
group :development do
gem "bundler-audit", "~> 0.9", require: false
gem "rubocop", "~> 1.50", require: false
diff --git a/README.md b/README.md
index b9c1f4a..d5d89bd 100644
--- a/README.md
+++ b/README.md
@@ -37,111 +37,173 @@ gem 'ruby_agent'
```ruby
require 'ruby_agent'
-agent = RubyAgent.new
-agent.on_result { |e, _| agent.exit if e["subtype"] == "success" }
-agent.connect { agent.ask("What is 2+2?") }
-```
+# Option 1: Use global configuration
+RubyAgent.configure do |config|
+ config.system_prompt = "You are a helpful assistant."
+ config.model = "claude-sonnet-4-5-20250929"
+ config.sandbox_dir = "./sandbox"
+end
+
+agent = RubyAgent::Agent.new(name: "MyAgent")
+agent.connect(verbose: false)
-That's it! Three lines to create an agent, ask Claude a question, and exit when done.
+response = agent.ask("What is 1+1?")
+puts response
-### Advanced Example with Callbacks
+agent.close
+```
+
+### Custom Agent with Callbacks
```ruby
require 'ruby_agent'
-agent = RubyAgent.new(
- sandbox_dir: Dir.pwd,
+# Create custom agent class with event callbacks
+class MyAgent < RubyAgent::Agent
+ # Register callback using method name
+ on_event :handle_event
+
+ def handle_event(event)
+ case event['type']
+ # TBD
+ end
+ end
+end
+
+# Initialize and connect
+agent = MyAgent.new(
+ name: "CustomAgent",
system_prompt: "You are a helpful coding assistant",
model: "claude-sonnet-4-5-20250929",
+ sandbox_dir: "./my_sandbox"
+)
+
+agent.connect(
+ timezone: "Eastern Time (US & Canada)",
+ skip_permissions: true,
verbose: true
)
-agent.create_message_callback :assistant_text do |event, all_events|
- if event["type"] == "assistant" && event.dig("message", "content", 0, "type") == "text"
- event.dig("message", "content", 0, "text")
- end
-end
+# Send messages
+agent.ask("Help me write a Ruby function")
+agent.close
+```
-agent.on_system_init do |event, _|
- puts "Session started: #{event['session_id']}"
-end
+### Using Block Callbacks
-agent.on_assistant_text do |text|
- puts "Claude says: #{text}"
-end
+```ruby
+require 'ruby_agent'
-agent.on_result do |event, all_events|
- if event["subtype"] == "success"
- puts "Task completed successfully!"
- agent.exit
- elsif event["subtype"] == "error_occurred"
- puts "Error: #{event['result']}"
- agent.exit
+class MyAgent < RubyAgent::Agent
+ # Register callback using block
+ on_event do |event|
+ # TBD
end
end
-agent.on_error do |error|
- puts "Error occurred: #{error.message}"
-end
-
-agent.connect do
- agent.ask("Write a simple Hello World function in Ruby", sender_name: "User")
-end
+agent = MyAgent.new(name: "BlockAgent")
+agent.connect
+agent.ask("Tell me a joke")
+agent.close
```
### Resuming Sessions
```ruby
-agent = RubyAgent.new(
- session_key: "existing_session_123",
+require 'ruby_agent'
+
+# First session
+agent = RubyAgent::Agent.new(
+ name: "MyAgent",
system_prompt: "You are a helpful assistant"
)
-agent.connect do
- agent.ask("Continue from where we left off", sender_name: "User")
-end
+agent.connect(session_key: "my_session_123")
+agent.ask("Remember that my name is Alice")
+agent.close
+
+# Resume later
+agent = RubyAgent::Agent.new(
+ name: "MyAgent",
+ system_prompt: "You are a helpful assistant"
+)
+
+agent.connect(
+ session_key: "my_session_123",
+ resume_session: true
+)
+agent.ask("What is my name?")
+agent.close
```
-### Using ERB in System Prompts
+### Using MCP Servers
```ruby
-agent = RubyAgent.new(
- system_prompt: "You are <%= role %> working on <%= project_name %>",
- role: "a senior developer",
- project_name: "RubyAgent"
+require 'ruby_agent'
+
+agent = RubyAgent::Agent.new(
+ name: "MCPAgent",
+ system_prompt: "You are a helpful assistant with web browsing capabilities"
+)
+
+# Connect with MCP server configuration
+agent.connect(
+ mcp_servers: {
+ headless_browser: {
+ type: :http,
+ url: "http://localhost:4567/mcp"
+ }
+ }
)
+
+agent.ask("Browse to example.com and summarize the page")
+agent.close
```
-## Event Callbacks
+### Interactive Example
+
+See `examples/example1.rb` for a complete interactive example with multiline input:
-RubyAgent supports dynamic event callbacks using `method_missing`. You can create callbacks for any event type:
+```bash
+# Start MCP server (if using browser tool)
+bundle exec hbt start --no-headless --be-human --single-session
+
+# Run example
+ruby examples/example1.rb
+```
-- `on_message` (alias: `on_event`) - Triggered for every message
-- `on_assistant` - Triggered when Claude responds
-- `on_system_init` - Triggered when a session starts
-- `on_result` - Triggered when a task completes
-- `on_error` - Triggered when an error occurs
-- `on_tool_use` - Triggered when Claude uses a tool
-- `on_tool_result` - Triggered when a tool returns results
+## Event Callbacks
+
+The callback system allows you to react to events during Claude's response streaming:
-You can also create custom callbacks with specific subtypes like `on_system_init`, `on_error_timeout`, etc.
+### Event Types
-## Custom Message Callbacks
+- `type: "system"` - System messages
+- `type: "assistant"` - Complete assistant messages
+- `type: "content_block_delta"` - Streaming text chunks (contains `delta.text`)
+- `type: "result"` - Conversation completion
+- `type: "error"` - Error messages
-Create custom message processors that filter and transform events:
+### Callback Registration
```ruby
-agent.create_message_callback :important_messages do |message, all_messages|
- if message["type"] == "assistant"
- message.dig("message", "content", 0, "text")
+class MyAgent < RubyAgent::Agent
+ # Method 1: Using method name
+ on_event :my_handler
+
+ def my_handler(event)
+ # Process event
end
-end
-agent.on_important_messages do |text|
- puts "Important: #{text}"
+ # Method 2: Using block
+ on_event do |event|
+ # Process event
+ end
end
```
+Callbacks are executed in registration order and inherited through subclasses
+
## API
### RubyAgent.new(options)
@@ -149,26 +211,14 @@ end
Creates a new RubyAgent instance.
**Options:**
-- `sandbox_dir` (String) - Working directory for the agent (default: `Dir.pwd`)
-- `timezone` (String) - Timezone for the agent (default: `"UTC"`)
-- `skip_permissions` (Boolean) - Skip permission prompts (default: `true`)
-- `verbose` (Boolean) - Enable verbose output (default: `false`)
-- `system_prompt` (String) - System prompt for Claude (default: `"You are a helpful assistant"`)
-- `model` (String) - Claude model to use (default: `"claude-sonnet-4-5-20250929"`)
-- `mcp_servers` (Hash) - MCP server configuration (default: `nil`)
-- `session_key` (String) - Resume an existing session (default: `nil`)
-- Additional keyword arguments are passed to the ERB template in `system_prompt`
+
### Instance Methods
-- `connect(&block)` - Connect to Claude and execute the block
+- `connect()` - Connect to Claude
- `ask(text, sender_name: "User", additional: [])` - Send a message to Claude
-- `send_system_message(text)` - Send a system message
-- `interrupt` - Interrupt Claude's current operation
-- `exit` - Close the connection to Claude
-- `on_message(&block)` - Register a callback for all messages
+- `on_event(&block)` - Register a callback for all messages
- `on_error(&block)` - Register a callback for errors
-- Dynamic `on_*` methods for specific event types
## Error Handling
@@ -180,9 +230,8 @@ RubyAgent defines three error types:
```ruby
begin
- agent.connect do
- agent.ask("Hello", sender_name: "User")
- end
+ agent.connect
+ agent.ask("Hello", sender_name: "User")
rescue RubyAgent::ConnectionError => e
puts "Connection failed: #{e.message}"
rescue RubyAgent::AgentError => e
@@ -208,8 +257,9 @@ rake ci:test # Run test suite
rake ci:lint # Run RuboCop linter
rake ci:lint:fix # Auto-fix linting issues
rake ci:scan # Run security audit
+rake build # Add locally to run examples
+rake install
```
-
5. Commit your changes: `git commit -am 'Add some feature'`
6. Push to your fork: `git push origin my-new-feature`
7. Create a Pull Request against the `main` branch
diff --git a/examples/example1.rb b/examples/example1.rb
new file mode 100644
index 0000000..9db7546
--- /dev/null
+++ b/examples/example1.rb
@@ -0,0 +1,85 @@
+require "dotenv/load"
+require "reline"
+
+# Before running:
+# start the hbt server:
+# bundle exec hbt start --no-headless --be-human --single-session --session-id=amazon
+# claude mcp add --transport http headless-browser http://localhost:4567/mcp
+# claude --dangerously-skip-permissions
+
+# Load local development version instead of installed gem
+$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
+require "ruby_agent"
+
+class MyAgent < RubyAgent::Agent
+ on_event :my_handler
+
+ def my_handler(event)
+ puts "Event triggered"
+ puts "Received event: #{event.dig('message', 'id')}"
+ puts "Received event type: #{event['type']}"
+ end
+
+ # Or using a block:
+ #
+ # on_event do |event|
+ # puts "Block event triggered"
+ # puts "Received event in block: #{event.dig("message", "id")}"
+ # end
+end
+
+DONE = %w[done end eof exit].freeze
+
+def prompt_for_message
+ puts "\n(multiline input; type 'end' on its own line when done. or 'exit' to exit)\n\n"
+
+ user_message = Reline.readmultiline("User message: ", true) do |multiline_input|
+ last = multiline_input.split.last
+ DONE.include?(last)
+ end
+
+ return :noop unless user_message
+
+ lines = user_message.split("\n")
+
+ if lines.size > 1 && DONE.include?(lines.last)
+ # remove the "done" from the message
+ user_message = lines[0..-2].join("\n")
+ end
+
+ return :exit if DONE.include?(user_message.downcase)
+
+ user_message
+end
+
+begin
+ RubyAgent.configure do |config|
+ config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil) # Not strictly necessary with claude installed
+ config.system_prompt = "You are a helpful AI news assistant."
+ config.model = "claude-sonnet-4-5-20250929"
+ config.sandbox_dir = "./news_sandbox"
+ end
+
+ agent = MyAgent.new(name: "News-Agent").connect(mcp_servers: { headless_browser: { type: :http,
+ url: "http://0.0.0.0:4567/mcp" } })
+
+ puts "Welcome to your Claude assistant!"
+
+ loop do
+ user_message = prompt_for_message
+
+ case user_message
+ when :noop
+ next
+ when :exit
+ break
+ end
+
+ puts "Asking Claude..."
+ puts agent.ask(user_message)
+ end
+rescue Interrupt
+ puts "\nExiting..."
+ensure
+ agent&.close
+end
diff --git a/lib/ruby_agent.rb b/lib/ruby_agent.rb
index cdab81c..8e9b2a4 100644
--- a/lib/ruby_agent.rb
+++ b/lib/ruby_agent.rb
@@ -1,428 +1,22 @@
-require_relative "ruby_agent/version"
+require "dotenv/load"
require "shellwords"
require "open3"
-require "erb"
require "json"
+require "fileutils"
+require "securerandom"
-class RubyAgent
- class AgentError < StandardError; end
- class ConnectionError < AgentError; end
- class ParseError < AgentError; end
-
- DEBUG = false
-
- attr_reader :sandbox_dir, :timezone, :skip_permissions, :verbose, :system_prompt, :model, :mcp_servers
-
- def initialize(
- sandbox_dir: Dir.pwd,
- timezone: "UTC",
- skip_permissions: true,
- verbose: false,
- system_prompt: "You are a helpful assistant",
- model: "claude-sonnet-4-5-20250929",
- mcp_servers: nil,
- session_key: nil,
- **additional_context
- )
- @sandbox_dir = sandbox_dir
- @timezone = timezone
- @skip_permissions = skip_permissions
- @verbose = verbose
- @model = model
- @mcp_servers = mcp_servers
- @session_key = session_key
- @system_prompt = parse_system_prompt(system_prompt, additional_context)
- @on_message_callback = nil
- @on_error_callback = nil
- @dynamic_callbacks = {}
- @custom_message_callbacks = {}
- @stdin = nil
- @stdout = nil
- @stderr = nil
- @wait_thr = nil
- @parsed_lines = []
- @parsed_lines_mutex = Mutex.new
- @pending_ask_after_interrupt = nil
- @pending_interrupt_request_id = nil
- @deferred_exit = false
-
- return if @session_key
-
- inject_streaming_response({
- type: "system",
- subtype: "prompt",
- system_prompt: @system_prompt,
- timestamp: Time.now.utc.iso8601(6),
- received_at: Time.now.utc.iso8601(6)
- })
- end
-
- def create_message_callback(name, &processor)
- @custom_message_callbacks[name.to_s] = {
- processor: processor,
- callback: nil
- }
- end
-
- def on_message(&block)
- @on_message_callback = block
- end
-
- alias on_event on_message
-
- def on_error(&block)
- @on_error_callback = block
- end
-
- def method_missing(method_name, *args, &block)
- if method_name.to_s.start_with?("on_") && block_given?
- callback_name = method_name.to_s.sub(/^on_/, "")
-
- if @custom_message_callbacks.key?(callback_name)
- @custom_message_callbacks[callback_name][:callback] = block
- else
- @dynamic_callbacks[callback_name] = block
- end
- else
- super
- end
- end
-
- def respond_to_missing?(method_name, include_private = false)
- method_name.to_s.start_with?("on_") || super
- end
-
- def connect(&block)
- command = build_claude_command
-
- spawn_process(command, @sandbox_dir) do |stdin, stdout, stderr, wait_thr|
- @stdin = stdin
- @stdout = stdout
- @stderr = stderr
- @wait_thr = wait_thr
-
- begin
- block.call if block_given?
- receive_streaming_responses
- ensure
- @stdin = nil
- @stdout = nil
- @stderr = nil
- @wait_thr = nil
- end
- end
- rescue StandardError => e
- trigger_error(e)
- raise
- end
-
- def ask(text, sender_name: "User", additional: [])
- formatted_text = if sender_name.downcase == "system"
- <<~TEXT.strip
-
- #{text}
-
- TEXT
- else
- "#{sender_name}: #{text}"
- end
- formatted_text += extra_context(additional, sender_name:)
-
- inject_streaming_response({
- type: "user",
- subtype: "new_message",
- sender_name:,
- text:,
- formatted_text:,
- timestamp: Time.now.utc.iso8601(6)
- })
-
- send_message(formatted_text)
- end
-
- def ask_after_interrupt(text, sender_name: "User", additional: [])
- @pending_ask_after_interrupt = { text:, sender_name:, additional: }
- end
-
- def send_system_message(text)
- ask(text, sender_name: "system")
- end
-
- def receive_streaming_responses
- @stdout.each_line do |line|
- next if line.strip.empty?
-
- begin
- json = JSON.parse(line)
-
- all_lines = nil
- @parsed_lines_mutex.synchronize do
- @parsed_lines << json
- all_lines = @parsed_lines.dup
- end
-
- trigger_message(json, all_lines)
- trigger_dynamic_callbacks(json, all_lines)
- trigger_custom_message_callbacks(json, all_lines)
- rescue JSON::ParserError
- warn "Failed to parse line: #{line}" if DEBUG
- end
- end
-
- puts "→ stdout closed, waiting for process to exit..." if DEBUG
- exit_status = @wait_thr.value
- puts "→ Process exited with status: #{exit_status.success? ? 'success' : 'failure'}" if DEBUG
- unless exit_status.success?
- stderr_output = @stderr.read
- raise ConnectionError, "Claude command failed: #{stderr_output}"
- end
-
- @parsed_lines
- end
-
- def inject_streaming_response(event_hash)
- stringified_event = stringify_keys(event_hash)
- all_lines = nil
- @parsed_lines_mutex.synchronize do
- @parsed_lines << stringified_event
- all_lines = @parsed_lines.dup
- end
-
- trigger_message(stringified_event, all_lines)
- trigger_dynamic_callbacks(stringified_event, all_lines)
- trigger_custom_message_callbacks(stringified_event, all_lines)
- end
-
- def interrupt
- raise ConnectionError, "Not connected to Claude" unless @stdin
- raise ConnectionError, "Cannot interrupt - stdin is closed" if @stdin.closed?
-
- @request_counter ||= 0
- @request_counter += 1
- request_id = "req_#{@request_counter}_#{SecureRandom.hex(4)}"
-
- @pending_interrupt_request_id = request_id if @pending_ask_after_interrupt
- if DEBUG
- puts "→ Sending interrupt with request_id: #{request_id}, pending_ask: #{@pending_ask_after_interrupt ? true : false}"
- end
-
- control_request = {
- type: "control_request",
- request_id: request_id,
- request: {
- subtype: "interrupt"
- }
- }
-
- inject_streaming_response({
- type: "control",
- subtype: "interrupt",
- timestamp: Time.now.utc.iso8601(6)
- })
-
- @stdin.puts JSON.generate(control_request)
- @stdin.flush
- rescue StandardError => e
- warn "Failed to send interrupt signal: #{e.message}"
- raise
- end
-
- def exit
- return unless @stdin
-
- if @pending_interrupt_request_id
- puts "→ Deferring exit - waiting for interrupt response (request_id: #{@pending_interrupt_request_id})" if DEBUG
- @deferred_exit = true
- return
- end
-
- puts "→ Exiting Claude (closing stdin)" if DEBUG
-
- begin
- @stdin.close unless @stdin.closed?
- puts "→ stdin closed" if DEBUG
- rescue StandardError => e
- warn "Error closing stdin during exit: #{e.message}"
- end
- end
-
- private
-
- def spawn_process(command, sandbox_dir, &)
- Open3.popen3("bash", "-lc", command, chdir: sandbox_dir, &)
- end
-
- def build_claude_command
- cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json --verbose"
- cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}"
- cmd += " --model #{Shellwords.escape(@model)}"
-
- if @mcp_servers
- mcp_config = build_mcp_config(@mcp_servers)
- cmd += " --mcp-config #{Shellwords.escape(mcp_config.to_json)}"
- end
-
- cmd += " --setting-sources \"\""
- cmd += " --resume #{Shellwords.escape(@session_key)}" if @session_key
- cmd
- end
-
- def build_mcp_config(mcp_servers)
- servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") }
- { mcpServers: servers }
- end
-
- def parse_system_prompt(template_content, context_vars)
- if Dir.exist?(@sandbox_dir)
- Dir.chdir(@sandbox_dir) do
- parse_system_prompt_in_context(template_content, context_vars)
- end
- else
- parse_system_prompt_in_context(template_content, context_vars)
- end
- end
-
- def parse_system_prompt_in_context(template_content, context_vars)
- erb = ERB.new(template_content)
- binding_context = create_binding_context(**context_vars)
- result = erb.result(binding_context)
-
- raise ParseError, "There was an error parsing the system prompt." if result.include?("<%=") || result.include?("%>")
-
- result
- end
-
- def create_binding_context(**vars)
- context = Object.new
- vars.each do |key, value|
- context.instance_variable_set("@#{key}", value)
- context.define_singleton_method(key) { instance_variable_get("@#{key}") }
- end
- context.instance_eval { binding }
- end
-
- def extra_context(additional = [], sender_name:)
- raise "additional is not an array" unless additional.is_a?(Array)
-
- return "" if additional.empty?
-
- <<~CONTEXT
-
-
- #{additional.join("\n\n")}
-
- CONTEXT
- end
-
- def send_message(content, session_id = nil)
- raise ConnectionError, "Not connected to Claude" unless @stdin
-
- message_json = {
- type: "user",
- message: { role: "user", content: content },
- session_id: session_id
- }.compact
-
- @stdin.puts JSON.generate(message_json)
- @stdin.flush
- rescue StandardError => e
- trigger_error(e)
- raise
- end
-
- def trigger_message(message, all_messages)
- @on_message_callback&.call(message, all_messages)
- end
-
- def trigger_dynamic_callbacks(message, all_messages)
- type = message["type"]
- subtype = message["subtype"]
-
- return unless type
-
- if type == "control_response"
- puts "→ Received control_response: #{message.inspect}" if DEBUG || @pending_interrupt_request_id
- if @pending_interrupt_request_id
- response = message["response"]
- if response&.dig("subtype") == "success" && response&.dig("request_id") == @pending_interrupt_request_id
- puts "→ Interrupt confirmed, executing queued ask" if DEBUG
- @pending_interrupt_request_id = nil
- if @pending_ask_after_interrupt
- pending = @pending_ask_after_interrupt
- @pending_ask_after_interrupt = nil
- begin
- ask(pending[:text], sender_name: pending[:sender_name], additional: pending[:additional])
- rescue IOError, Errno::EPIPE => e
- warn "Failed to send queued ask after interrupt (stream closed): #{e.message}"
- end
- end
-
- if @deferred_exit
- puts "→ Executing deferred exit" if DEBUG
- @deferred_exit = false
- exit
- end
- elsif DEBUG
- puts "→ Control response didn't match pending interrupt: #{response.inspect}"
- end
- end
- end
-
- if subtype
- specific_callback_key = "#{type}_#{subtype}"
- specific_callback = @dynamic_callbacks[specific_callback_key]
- if specific_callback
- puts "→ Triggering callback for: #{specific_callback_key}" if DEBUG
- specific_callback.call(message, all_messages)
- end
- end
-
- general_callback = @dynamic_callbacks[type]
- if general_callback
- puts "→ Triggering callback for: #{type}" if DEBUG
- general_callback.call(message, all_messages)
- end
-
- check_nested_content_types(message, all_messages)
- end
-
- def check_nested_content_types(message, all_messages)
- return unless message["message"].is_a?(Hash)
-
- content = message.dig("message", "content")
- return unless content.is_a?(Array)
-
- content.each do |content_item|
- next unless content_item.is_a?(Hash)
-
- nested_type = content_item["type"]
- next unless nested_type
-
- callback = @dynamic_callbacks[nested_type]
- if callback
- puts "→ Triggering callback for nested type: #{nested_type}" if DEBUG
- callback.call(message, all_messages)
- end
- end
- end
-
- def trigger_custom_message_callbacks(message, all_messages)
- @custom_message_callbacks.each_value do |config|
- processor = config[:processor]
- callback = config[:callback]
-
- next unless processor && callback
-
- result = processor.call(message, all_messages)
- callback.call(result) if result && !result.to_s.empty?
- end
- end
+require_relative "ruby_agent/version"
+require_relative "ruby_agent/configuration"
+require_relative "ruby_agent/agent"
+require_relative "ruby_agent/callback_support"
- def trigger_error(error)
- @on_error_callback&.call(error)
+module RubyAgent
+ class << self
+ attr_accessor :configuration
end
- def stringify_keys(hash)
- hash.transform_keys(&:to_s)
+ def self.configure
+ self.configuration ||= Configuration.new
+ yield(configuration)
end
end
diff --git a/lib/ruby_agent/agent.rb b/lib/ruby_agent/agent.rb
new file mode 100644
index 0000000..32c5d61
--- /dev/null
+++ b/lib/ruby_agent/agent.rb
@@ -0,0 +1,274 @@
+require_relative "callback_support"
+
+module RubyAgent
+ class Agent
+ include CallbackSupport
+
+ class ConnectionError < StandardError; end
+
+ attr_reader :name, :sandbox_dir, :timezone, :skip_permissions, :verbose,
+ :system_prompt, :mcp_servers, :model, :session_key,
+ :context, :conversation_history
+
+ # Configure parameters for the Agent(s) like this or when initializing:
+ #
+ # RubyAgent.configure do |config|
+ # config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] # Not strictly necessary with Claude SDK
+ # config.system_prompt = "You are a helpful AI human resources assistant."
+ # config.model = "claude-sonnet-4-5-20250929"
+ # config.sandbox_dir = "./hr_sandbox"
+ # end
+
+ # Users can register callbacks in two ways:
+ #
+ # class MyAgent < RubyAgent::Agent
+ # # Using a method name
+ # on_event :my_handler
+ #
+ # def my_handler(event)
+ # text = event.dig("delta", "text")
+ # # Process the streaming text
+ # end
+ # end
+ #
+ # class MyAgent < RubyAgent::Agent
+ # # Using a block
+ # on_event do |event|
+ # text = event.dig("delta", "text")
+ # # Process the streaming text
+ # end
+ # end
+
+ def initialize(name: "MyName", system_prompt: nil, model: nil, sandbox_dir: nil)
+ @name = name
+ @system_prompt = system_prompt || config.system_prompt
+ @model = model || config.model
+ @sandbox_dir = sandbox_dir || config.sandbox_dir
+ @stdin = nil
+ @stdout = nil
+ @stderr = nil
+ @wait_thr = nil
+ @parsed_lines = []
+ @parsed_lines_mutex = Mutex.new
+
+ return unless @session_key.nil?
+
+ inject_streaming_response({
+ type: "system",
+ subtype: "prompt",
+ system_prompt: @system_prompt,
+ timestamp: Time.now.iso8601(6),
+ received_at: Time.now.iso8601(6)
+ })
+ end
+
+ def config
+ RubyAgent.configuration ||= RubyAgent::Configuration.new
+ end
+
+ def connect(
+ timezone: "Eastern Time (US & Canada)",
+ skip_permissions: true,
+ verbose: true,
+ mcp_servers: nil,
+ session_key: nil,
+ resume_session: false,
+ **additional_context
+ )
+ @timezone = timezone
+ @skip_permissions = skip_permissions
+ @verbose = verbose
+ @mcp_servers = mcp_servers
+ @session_key = session_key
+ @resume_session = resume_session
+ @context = additional_context
+ @conversation_history = []
+
+ ensure_sandbox_exists
+
+ command = build_claude_command
+
+ @stdin, @stdout, @stderr, @wait_thr = spawn_process(command, @sandbox_dir)
+
+ sleep 0.5
+ unless @wait_thr.alive?
+ error_output = @stderr.read
+ raise ConnectionError, "Claude process failed to start. Error: #{error_output}"
+ end
+
+ puts "Claude process started successfully (PID: #{@wait_thr.pid})"
+ self
+ end
+
+ def ask(message)
+ return if message.nil? || message.strip.empty?
+
+ send_message(message)
+ read_response
+ rescue StandardError
+ raise
+ end
+
+ def close
+ return unless @stdin
+
+ @stdin.close unless @stdin.closed?
+ @stdout.close unless @stdout.closed?
+ @stderr.close unless @stderr.closed?
+ @wait_thr&.join
+ ensure
+ @stdin = nil
+ @stdout = nil
+ @stderr = nil
+ @wait_thr = nil
+ end
+
+ def inject_streaming_response(event_hash)
+ stringified_event = event_hash.transform_keys(&:to_s)
+ all_lines = nil
+ @parsed_lines_mutex.synchronize do
+ @parsed_lines << stringified_event
+ all_lines = @parsed_lines.dup
+ end
+
+ # TODO: event handling(?)
+ # trigger_event(stringified_event, all_lines)
+ # trigger_dynamic_callbacks(stringified_event, all_lines)
+ # trigger_custom_event_callbacks(stringified_event, all_lines)
+ end
+
+ private
+
+ def ensure_sandbox_exists
+ return if File.directory?(@sandbox_dir)
+
+ puts "Creating sandbox directory: #{@sandbox_dir}"
+ FileUtils.mkdir_p(@sandbox_dir)
+ end
+
+ def build_claude_command
+ puts "Building Claude command..."
+
+ cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json"
+ cmd += " --verbose" if @verbose
+ cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}"
+ cmd += " --model #{Shellwords.escape(@model)}"
+
+ if @mcp_servers
+ mcp_config_json = build_mcp_config(@mcp_servers).to_json
+ cmd += " --mcp-config #{Shellwords.escape(mcp_config_json)}"
+ end
+
+ cmd += ' --setting-sources ""'
+ cmd += " --resume #{Shellwords.escape(@session_key)}" if @resume_session && @session_key
+ cmd
+ end
+
+ def build_mcp_config(mcp_servers)
+ servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") }
+ { mcpServers: servers }
+ end
+
+ def spawn_process(command, sandbox_dir)
+ puts "Spawning process with command: #{command}"
+
+ command_to_run = if $stdout.tty? && File.exist?("./stream.rb")
+ "#{command} | tee >(ruby ./stream.rb >/dev/tty)"
+ else
+ command
+ end
+
+ stdin, stdout, stderr, wait_thr = Open3.popen3("bash", "-lc", command_to_run, chdir: sandbox_dir)
+ [stdin, stdout, stderr, wait_thr]
+ end
+
+ def send_message(content, session_id = nil)
+ raise ConnectionError, "Not connected to Claude" unless @stdin
+
+ unless @wait_thr&.alive?
+ error_output = @stderr&.read || "Unknown error"
+ raise ConnectionError, "Claude process has died. Error: #{error_output}"
+ end
+
+ message_json = {
+ type: "user",
+ message: { role: "user", content: content },
+ session_id: session_id
+ }.compact
+
+ @stdin.puts JSON.generate(message_json)
+ @stdin.flush
+ rescue StandardError
+ raise
+ end
+
+ def read_response
+ response_text = ""
+
+ loop do
+ unless @wait_thr.alive?
+ error_output = @stderr.read
+ raise ConnectionError, "Claude process died while reading response. Error: #{error_output}"
+ end
+
+ ready = IO.select([@stdout, @stderr], nil, nil, 0.1)
+
+ next unless ready
+
+ if ready[0].include?(@stderr)
+ error_line = @stderr.gets
+ warn error_line if error_line
+ end
+
+ next unless ready[0].include?(@stdout)
+
+ line = @stdout.gets
+ break unless line
+
+ line = line.strip
+ next if line.empty?
+
+ begin
+ message = JSON.parse(line)
+
+ case message["type"]
+ when "system"
+ next
+ when "assistant"
+ if message.dig("message", "content")
+ content = message["message"]["content"]
+ if content.is_a?(Array)
+ content.each do |block|
+ if block["type"] == "text" && block["text"]
+ text = block["text"]
+ response_text += text
+ end
+ end
+ elsif content.is_a?(String)
+ response_text += content
+ end
+ end
+ when "content_block_delta"
+ if message.dig("delta", "text")
+ text = message["delta"]["text"]
+ response_text += text
+ print text
+ end
+ when "result"
+ break
+ when "error"
+ puts "[ERROR] #{message['message']}"
+ break
+ end
+ run_callbacks(message)
+ rescue JSON::ParserError
+ warn "Failed to parse JSON: #{line[0..100]}"
+ next
+ end
+ end
+
+ puts
+ response_text
+ end
+ end
+end
diff --git a/lib/ruby_agent/callback_support.rb b/lib/ruby_agent/callback_support.rb
new file mode 100644
index 0000000..26a3656
--- /dev/null
+++ b/lib/ruby_agent/callback_support.rb
@@ -0,0 +1,32 @@
+module CallbackSupport
+ def self.included(base)
+ base.extend ClassMethods
+ end
+
+ module ClassMethods
+ def on_event(method_name = nil, &block)
+ @on_event_callbacks ||= []
+ @on_event_callbacks << (method_name || block)
+ end
+
+ def on_event_callbacks
+ callbacks = []
+ ancestors.each do |ancestor|
+ if ancestor.instance_variable_defined?(:@on_event_callbacks)
+ callbacks.concat(ancestor.instance_variable_get(:@on_event_callbacks))
+ end
+ end
+ callbacks
+ end
+ end
+
+ def run_callbacks(event_data)
+ self.class.on_event_callbacks.each do |callback|
+ if callback.is_a?(Proc)
+ instance_exec(event_data, &callback)
+ else
+ send(callback, event_data)
+ end
+ end
+ end
+end
diff --git a/lib/ruby_agent/configuration.rb b/lib/ruby_agent/configuration.rb
new file mode 100644
index 0000000..1085d94
--- /dev/null
+++ b/lib/ruby_agent/configuration.rb
@@ -0,0 +1,12 @@
+module RubyAgent
+ class Configuration
+ attr_accessor :anthropic_api_key, :system_prompt, :model, :sandbox_dir
+
+ def initialize
+ @anthropic_api_key = nil # Not necessarily required with Claude SDK
+ @system_prompt = "You are a helpful AI assistant."
+ @model = "claude-sonnet-4-5-20250929"
+ @sandbox_dir = "./sandbox"
+ end
+ end
+end
diff --git a/lib/ruby_agent/version.rb b/lib/ruby_agent/version.rb
index 080916d..a6efcbe 100644
--- a/lib/ruby_agent/version.rb
+++ b/lib/ruby_agent/version.rb
@@ -1,3 +1,3 @@
-class RubyAgent
- VERSION = "0.2.2".freeze
+module RubyAgent
+ VERSION = "0.2.3".freeze
end