A guide for building an AI-powered coding agent using the RubyLLM gem.
Build an interactive command-line coding agent
Goal: Create the self-contained executable script with isolated Ruby/Bundler context
Create Project:
mkdir coding_agent
Create Gemfile:
# Gemfile
source "https://rubygems.org"
gem "dotenv" # environment variables
gem "json" # json utilities
gem "pastel" # text coloring
gem "rspec" # testing framework
gem "ruby_llm" # rubyLLM frameworkInstall gems:
bundle install
Create executable script:
touch run_agent.rb ; chmod +x run_agent.rb
Create run_agent.rb:
#!/usr/bin/env ruby
require 'ruby_llm'
require 'dotenv'
Dotenv.load('.env.local')
welcome_message = ENV.fetch 'WELCOME_MESSAGE'
puts welcome_messageCreate .env.local:
WELCOME_MESSAGE=Hello World
OPENAI_API_KEY=YOUR_OPEN_API_KEY
OPENROUTER_KEY=YOUR_OPENROUTER_KEYRun script:
./run_agent.rb
coding_agent> ./run_agent.rb
Hello World
Goal: Configure RubyLLM with API credentials
Add open router API key:
Replace OPENROUTER_KEY value with your API key from OpenRouter in .env.local
Alternatively configure OPENAI_API_KEY, ANTHROPIC_API_KEY, or any other supported API key you own:
(See https://rubyllm.com/configuration/#provider-configuration)
Add configure block to run_agent.rb:
RubyLLM.configure do |config|
config.openrouter_api_key = ENV.fetch 'OPENROUTER_KEY'
config.openai_api_base = 'https://openrouter.ai/api.v1'
config.default_model = 'anthropic/claude-sonnet-4.5'
config.log_file = './log/ruby_llm.log'
config.log_level = :debug
endReplace previous welcome message with SimpleAgent inside run_agent.rb:
## Remove ##
welcome_message = ENV.fetch 'WELCOME_MESSAGE'
puts welcome_message
## Add ##
require_relative './simple_agent'
SimpleAgent.new.runCreate simple_agent.rb:
# frozen_string_literal: true
require 'ruby_llm'
class SimpleAgent
def initialize
welcome_message = ENV.fetch 'WELCOME_MESSAGE'
puts welcome_message
end
def run
# Create a chat instance
chat = RubyLLM.chat
# Ask a question
response = chat.ask "What is your purpose?"
# Output the response
puts response.content
end
endRun the agent:
./run_agent.rb
Goal: Allow back and forth conversation with model
Remove chat:
## Remove
def run
...
endAdd interactive run loop:
def run
@chat = RubyLLM.chat
puts "Chat with agent, type 'exit' to exit:"
loop do
puts ">>"
input = gets.chomp
break if ['exit', 'quit', 'bye'].include?(input.downcase)
response = @chat.ask(input)
puts "AI:\n#{response.content}"
end
endGoal: Allow AI to list directory contents
More info on rubyLLM tools here: https://rubyllm.com/tools/
Create tools directory:
mkdir tools
Create directory_listing_tool.rb inheriting from RubyLLM::Tool:
# frozen_string_literal: true
require "ruby_llm/tool"
class DirectoryListingTool < RubyLLM::Tool
description "List files and directories in a given path"
param :path, type: :string, desc: "Directory path to list"
def execute(path: "")
working_path = "./" + path
puts " -- executing -- #{self.class} -- #{working_path}"
directory_contents = Dir.glob(File.join(working_path, '*'))
content_data = directory_contents.map do |entry|
{
name: File.basename(entry),
type: File.directory?(entry) ? 'directory' : 'file',
size: File.size(entry)
}
end
content_data.to_json
end
endRegister tool with the chat in simple_agent.rb's run method:
def run
@chat = RubyLLM.chat
@chat.with_tool(DirectoryListingTool)
# other code ....Run agent and ask a question:
> ./run_agent.rb
Hello World
Chat with agent, type 'exit' to exit:
>>
What files are in this directory?
-- executing -- DirectoryListingTool -- ./.
AI:
The current directory contains the following files:
1. **Gemfile** (192 bytes) - Ruby dependency configuration file
2. **Gemfile.lock** (1,387 bytes) - Locked versions of Ruby dependencies
3. **directory_listing_tool.rb** (643 bytes) - Ruby script for directory listing functionality
...Note: The current agent is unable to read the actual content of these files and is just guessing at the purpose of each entry by its filename.
Goal: Allow AI to read file contents
Create read_file_tool.rb:
We can provide longer descriptions than a single line by using <<~EOS strings.
require "ruby_llm/tool"
class ReadFileTool < RubyLLM::Tool
description <<~EOS
Read the contents of a given relative file path.
Use when you want to see what's inside a file.
Do not use this with directory names.
EOS
param :path, type: :string, desc: "Relative path to file in the working directory."
def execute(path:)
puts " -- execute -- ReadFile #{path}"
return { error: "Reading dotfiles is not permitted." } if path.start_with? "."
return { error: "File not found" } unless File.exist?(path)
File.read(path).dump
rescue StandardError => error
{ error: error.message }
end
endRegister new tool in simple_agent.rb:
Add ReadFileTool after our other tool registration.
@chat = RubyLLM.chat
@chat.with_tool(DirectoryListingTool)
@chat.with_tool(ReadFileTool)Ask a question:
>./run_agent.rb
Chat with agent, type 'exit' to exit:
>>
What gems does this project use?
-- executing -- DirectoryListingTool -- ./.
-- execute -- ReadFile Gemfile
AI:
This project uses the following gems:
1. **dotenv** - For managing environment variables
2. **json** - JSON utilities
...
We can also ask What versions am I using? to see a list of each gem's current version.
--
Goal: Display token usage and estimated costs for each request.
Add new display_cost method to simple_agent.rb
def display_cost(response)
puts "---"
model = RubyLLM.models.find(response.model_id)
if model.input_price_per_million && model.output_price_per_million
in_cost = response.input_tokens * model.input_price_per_million / 1_000_000.0
out_cost = response.output_tokens * model.output_price_per_million / 1_000_000.0
puts "Estimated cost: $#{format('%.6f', in_cost + out_cost)}"
end
# Totals so far in the conversation
total = @chat.messages.sum { |m| (m.input_tokens || 0) + (m.output_tokens || 0) }
puts "Conversation total tokens: #{total}"
endAdd call to display_cost in main loop:
response = @chat.ask(input)
puts "AI:\n#{response.content}"
display_cost(response)Goal: Allow AI to modify file contents
edit_file_tool.rb:
require "ruby_llm/tool"
class EditFileTool < RubyLLM::Tool
description <<~DESCRIPTION
Make edits to a text file.
Replace 'old_string' with 'new_string' in the given file.
'old_string' and 'new_string' MUST be different.
If the file specified by 'path' doesn't exist, it will be created.
This tool MUST only update files in the current directory or its subdirectories.
DESCRIPTION
param :path, desc: "The path to the file."
param :old_string, desc: "Text to search for - must match exactly and must only have one match exactly."
param :new_string, desc: "Text to replace old_str with"
def execute(path:, old_string:, new_string:)
puts " -- execute -- EditFile #{path}"
return { error: "Editing dotfiles is not permitted." } if path.start_with? "."
# enure the directory exists
FileUtils.mkdir_p(File.dirname(path))
# create empty content if this is a new file
content = File.exist?(path) ? File.read(path) : ""
File.write(path, content.sub(old_string, new_string))
rescue => e
puts "ERROR: " + e.message
{ error: e.message }
end
endAsk the AI to create a README
> ./run_agent.rb
Chat with agent, type 'exit' to exit:
>>
create a README.md describing the contents on this project
...
Goal: Give AI tool to create git commits
class GitCommit < RubyLLM::Tool
description "Create a git commit with staged changes"
param :message, type: :string, description: "Commit message"
param :files, type: :array, required: false, description: "Files to stage"
def execute(message:, files: nil)
return "Not a git repository" unless Dir.exist?('.git')
# Stage files
if files
system("git add #{files.join(' ')}")
end
# Create commit
result = system("git commit -m '#{message}'")
result ? "Commit created successfully" : "Commit failed"
end
end- Add colorized output (using
colorizegem) - Create configuration file support (~/.coding_agent.yml)
- Test coverage for all tools
- Linter tool
- Gems: https://rubygems.org
- RubyLLM Documentation: https://rubyllm.com/
- RubyLLM GitHub: https://github.com/crmne/ruby_llm
- Bundler: https://bundler.io/
- OpenRouter: https://openrouter.ai/