Feb 2026 - Rails Meetup Kathmandu
A journey into agentic system within Rails
Saugat Khadka
2026
Assistants are moving from autocomplete to multi-step collaborators that can read, run, and ship code.
It is worth looking inside these systems that have shaped our work so much
ampcode.com/how-to-build-an-agent
This is around the time when coding agents finally started to take off. And this December, we saw them as general agents.
How does it work though?
Version of ampcode.com/how-to-build-an-agent in ruby
Step 0
# empty file
require "bundler/setup"
require "openai" # NEW
require "json" # NEW
require "fileutils" # NEW
MODEL = "openai/gpt-5.2-codex" # NEW
client = OpenAI::Client.new( # NEW
api_key: ENV.fetch("OPENROUTER_API_KEY"), # NEW
base_url: "https://openrouter.ai/api/v1" # NEW
) # NEW
Step 0.5
require "bundler/setup"
require "openai"
require "json"
require "fileutils"
MODEL = "openai/gpt-5.2-codex"
client = OpenAI::Client.new(...)
class Agent
def initialize(client:, tools: []) # NEW
@client = client # NEW
@tools = tools # NEW
end # NEW
def run # NEW
puts "Chat with GPT (ctrl+c to quit)" # NEW
end # NEW
end
agent = Agent.new(client: client, tools: []) # NEW
agent.run # NEW
Step 1
def run
puts "Chat with GPT (ctrl+c to quit)"
end
def run
puts "Chat with GPT (ctrl+c to quit)"
print "You: "
user_input = $stdin.gets.to_s.strip
conversation = [{ role: "user", content: user_input }] # NEW
response = @client.chat.completions.create(model: MODEL, messages: conversation) # NEW
assistant_text = response.dig("choices", 0, "message", "content").to_s # NEW
puts "Assistant: #{assistant_text}" # NEW
end
Step 2
def run
print "You: "
user_input = $stdin.gets.to_s.strip
conversation = [{ role: "user", content: user_input }]
response = @client.chat.completions.create(model: MODEL, messages: conversation)
puts "Assistant: #{response.dig("choices", 0, "message", "content")}"
end
def run
conversation = [] # NEW
loop do # NEW
print "You: "
user_input = $stdin.gets.to_s.strip
break if user_input.empty? # NEW
conversation << { role: "user", content: user_input } # NEW
response = @client.chat.completions.create(model: MODEL, messages: conversation)
assistant_text = response.dig("choices", 0, "message", "content").to_s
conversation << { role: "assistant", content: assistant_text } # NEW
puts "Assistant: #{assistant_text}"
end # NEW
end
The endpoint is stateless; memory exists because we keep sending the entire conversation each turn.
Step 3
response = @client.chat.completions.create(
model: MODEL,
messages: conversation
)
response = @client.chat.completions.create(
model: MODEL,
messages: conversation,
tools: [ # NEW
{ # NEW
type: "function", # NEW
function: {
name: "read_file",
description: "Read file contents by relative path.",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"]
}
}
}
]
)
Step 3.1
response = @client.chat.completions.create(
model: MODEL,
messages: conversation,
tools: [ ...inline schema... ]
)
READ_FILE_TOOL = { # NEW
type: "function",
function: {
name: "read_file",
description: "Read file contents by relative path.",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"]
}
}
}
TOOLS = [READ_FILE_TOOL].freeze # NEW
response = @client.chat.completions.create(
model: MODEL,
messages: conversation,
tools: TOOLS # NEW
)
Tool protocol
{
"model": "openai/gpt-5.2-codex",
"messages": [
{ "role": "user", "content": "What is in main.rb?" }
],
"tools": [
{ "type": "function", "function": { "name": "read_file", "parameters": { "type": "object", "properties": { "path": { "type": "string" } } } } }
]
}
{
"message": {
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\"main.rb\"}"
}
}
]
}
}
Step 4
assistant_blocks.each do |block|
puts "Assistant: #{block[:text]}" if block[:type] == :text
end
assistant_blocks.each do |block|
case block[:type]
when :text
puts "Assistant: #{block[:text]}"
when :tool_use
puts "tool requested: #{block[:name]}(#{block[:input].to_json})" # NEW
end
end
Step 5
when :tool_use
puts "tool requested: #{block[:name]}(#{block[:input].to_json})"
when :tool_use
result = execute_tool(block[:id], block[:name], block[:input]) # NEW
puts "tool_result: #{result.to_json}" # NEW
def execute_tool(id, name, input) # NEW
{ type: "tool_result", tool_use_id: id, content: "TODO: run #{name}", is_error: false } # NEW
end # NEW
Step 6
READ_FILE = {
name: "read_file",
description: "Read file contents",
input_schema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"]
}
}
READ_FILE = {
name: "read_file",
description: "Read file contents",
input_schema: {
type: "object",
properties: {
path: { type: "string" }
},
required: ["path"]
},
function: ->(input) do # NEW
File.read(input[:path]) # NEW
end # NEW
}
tools = [READ_FILE] # NEW
agent = Agent.new(client: client, tools: tools) # NEW
def execute_tool(id, name, input)
tool = @tools.find { |t| t[:name] == name } # NEW
return { type: "tool_result", tool_use_id: id, content: "Tool not found: #{name}", is_error: true } unless tool # NEW
output = tool[:function].call(input) # NEW
{ type: "tool_result", tool_use_id: id, content: output, is_error: false } # NEW
rescue => e # NEW
{ type: "tool_result", tool_use_id: id, content: "Error: #{e.message}", is_error: true } # NEW
end
Step 7
when :tool_use
result = execute_tool(block[:id], block[:name], block[:input])
puts "tool_result: #{result.to_json}"
# waits for next human input here
read_user_input = true # NEW
loop do
if read_user_input # NEW
print "You: " # NEW
conversation << { role: "user", content: $stdin.gets.to_s.strip } # NEW
end # NEW
response = run_inference(conversation)
blocks = extract_blocks(response)
tool_results = []
blocks.each do |block|
case block[:type]
when :text then puts "Assistant: #{block[:text]}"
when :tool_use then tool_results << execute_tool(block[:id], block[:name], block[:input])
end
end
if tool_results.any?
conversation << { role: "user", content: tool_results } # NEW
read_user_input = false # NEW
next # NEW
end
read_user_input = true # NEW
end
Step 8
READ_FILE = {
name: "read_file",
description: "...",
input_schema: { ... },
function: ->(input) do
...
end
}
LIST_FILES = { ... }
EDIT_FILE = { ... }
ToolDefinition = Struct.new(
:name,
:description,
:input_schema,
:function,
keyword_init: true
)
READ_FILE = ToolDefinition.new(
name: "read_file",
description: "Read file contents by relative path",
input_schema: {
type: "object",
properties: {
path: { type: "string" }
},
required: ["path"]
},
function: ->(input) do
File.read(input[:path])
end
)
Step 9
tools = [READ_FILE]
LIST_FILES = ToolDefinition.new(
name: "list_files",
description: "List files and directories in a path",
input_schema: {
type: "object",
properties: {
path: { type: "string" }
},
required: []
},
function: ->(input) do
dir = input[:path].to_s.empty? ? "." : input[:path]
entries = Dir.glob(File.join(dir, "**/*"))
entries.map { |path| File.directory?(path) ? "#{path}/" : path }.to_json
end
)
tools = [READ_FILE, LIST_FILES] # NEW
Step 10
tools = [READ_FILE, LIST_FILES]
EDIT_FILE = ToolDefinition.new(
name: "edit_file",
description: "Create file if missing (old_str empty) or replace old_str with new_str",
input_schema: {
type: "object",
properties: {
path: { type: "string" },
old_str: { type: "string" },
new_str: { type: "string" }
},
required: ["path", "old_str", "new_str"]
},
function: ->(input) do
path = input[:path]
old_str = input[:old_str].to_s
new_str = input[:new_str].to_s
raise "path is required" if path.to_s.empty?
raise "old_str and new_str must differ" if old_str == new_str
unless File.exist?(path)
raise "old_str must be empty when creating" unless old_str.empty?
dir = File.dirname(path)
FileUtils.mkdir_p(dir) unless dir == "."
File.write(path, new_str)
next "Created file #{path}"
end
content = File.read(path)
raise "old_str not found" unless content.include?(old_str)
new_content = content.sub(old_str, new_str)
File.write(path, new_content)
"OK"
end
)
tools = [READ_FILE, LIST_FILES, EDIT_FILE] # NEW
Step 11
Part 2
A journey into agentic systems in Rails.
Bootstrap Rails
rails new demo
cd demo
bin/rails g scaffold AgentRun status:string llm_model:string provider:string working_directory:string
bin/rails g scaffold Message agent_run:references role:string content:text
bin/rails db:migrate
Primitive mapping
| Ruby script primitive | Rails primitive |
|---|---|
| `conversation = []` | `AgentRun has_many :messages` |
| `gets` | `textarea + POST /agent_runs/:id/messages` |
| `loop do` | `AgentJob.perform_later` recursion |
| `puts` | Turbo stream broadcast to subscribed browser |
Input path
Stimulus form posts message content.
Create `Message` under `AgentRun`.
`AgentJob.perform_later` continues the loop.
Turbo/ActionCable updates the thread.
def create
@agent_run = AgentRun.find(params[:agent_run_id])
@agent_run.add_message!(role: "user", content: params[:content])
@agent_run.update!(status: "running")
AgentJob.perform_later(@agent_run.id, model: @agent_run.llm_model)
head :ok
end
Loop mapping
if tool_results.any?
conversation << { role: "user", content: tool_results }
next
end
if tool_results.any?
agent_run.add_message!(role: "user", content: tool_results, broadcast: false)
AgentJob.perform_later(agent_run.id, model: model) # NEW loop continuation
else
agent_run.update!(status: "waiting_for_input")
end
Same behavior as `next`, but now durable, retryable, and observable.
Output mapping
puts "Assistant: #{text_delta}"
broadcast_stream(stream_name, :replace, "#{msg_dom_id}_text", render_streaming_text(msg_dom_id, accumulated_text))
class Message < ApplicationRecord
after_create_commit :broadcast_message, unless: :skip_broadcast # NEW
def broadcast_message
broadcast_append_to("agent_run_#{agent_run_id}", target: "messages", partial: "messages/message", locals: { message: self }) # NEW
end
end
Tool architecture in Rails
READ_FILE = {
name: "read_file",
input_schema: {
type: "object",
properties: {
path: { type: "string" }
},
required: ["path"]
},
function: ->(input) do
File.read(input[:path])
end
}
class ReadFileTool < BaseTool
def self.tool_name
"read_file"
end
def self.input_schema
{
type: "object",
properties: {
path: { type: "string" }
},
required: ["path"]
}
end
def call(input)
File.read(resolve_path(input[:path]))
end
end
class ToolRegistry
TOOLS = [ReadFileTool, ListFilesTool, EditFileTool, BashTool, WebFetchTool]
def self.definitions
TOOLS.map(&:to_definition)
end
def self.find(name)
TOOLS.find { |tool| tool.tool_name == name }
end
end
Ride along