Feb 2026 - Rails Meetup Kathmandu

The Emperor Has No Clothes

A journey into agentic system within Rails

openai/gpt-5.2-codex Ruby LLM loop Tool Calls Coding Agents Rails Jobs + Turbo

Saugat Khadka

2026

It's the year of the coding agent

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

Thorsten's How to build an agent

ampcode How to build an agent slide title

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?

And what can we learn from it?

Version of ampcode.com/how-to-build-an-agent in ruby

We will build a coding agent ourselves in tiny diffs

  • Start from a blank file.
  • Add one model call.
  • Wrap that in a loop.
  • And build it up from there

Step 0

Blank file -> minimal bootstrapping

before: `agent.rb`

# empty file
                
after: boot and model client

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
                
terminal / setup
$ bundle add ruby-openai
$ touch agent.rb
$ ruby agent.rb
(no output yet)

Step 0.5

Add the Agent class and runner code

before: top-level script only

require "bundler/setup"
require "openai"
require "json"
require "fileutils"

MODEL = "openai/gpt-5.2-codex"
client = OpenAI::Client.new(...)
                
after: class + outer call site

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
                
terminal / first run
$ ruby agent.rb
Chat with GPT (ctrl+c to quit)

Step 1

One model call (no loop yet)

before: run prints only

def run
  puts "Chat with GPT (ctrl+c to quit)"
end
                
after: single user turn, single assistant turn

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
                
terminal / step 1
$ ruby agent.rb
You: explain ruby blocks in one line
Assistant: A block is a chunk of code you pass to a method.
This is already a working LLM app.

Step 2

Wrap that same block in a loop

before: one-shot turn

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
                
after: persistent conversation loop

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
                
terminal / step 2
You: My name is Saugat.
Assistant: Nice to meet you, Saugat.
You: What is my name?
Assistant: Your name is Saugat.

The endpoint is stateless; memory exists because we keep sending the entire conversation each turn.

Step 3

Send one tool schema inline in the model call

before: no tools in params

response = @client.chat.completions.create(
  model: MODEL,
  messages: conversation
)
                
after: include `read_file` schema directly

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"]
        }
      }
    }
  ]
)
                

Important

  • We only declare capability.
  • No execution yet.
  • No planner yet.
Tool calling starts as pure schema + prompt context.

Step 3.1

Move tool schema outside the Agent class

before: inline schema every call

response = @client.chat.completions.create(
  model: MODEL,
  messages: conversation,
  tools: [ ...inline schema... ]
)
                
after: top-level hash constants

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
)
                

Why this refactor now?

  • Keeps `run` readable.
  • Lets us add function handlers next.
  • Matches how Rails `ToolRegistry` will look later.

Tool protocol

What the model expects and returns

request payload (shape)

{
  "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" } } } } }
  ]
}
            
response message (tool request)

{
  "message": {
    "content": null,
    "tool_calls": [
      {
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "read_file",
          "arguments": "{\"path\":\"main.rb\"}"
        }
      }
    ]
  }
}
            
Tool calling is a protocol, not a framework.

Step 4

First, just print `tool_use` requests

before: text only

assistant_blocks.each do |block|
  puts "Assistant: #{block[:text]}" if block[:type] == :text
end
                
after: branch on block type

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
                
terminal / inspect tool request
You: tell me what is in secret-file.txt
tool requested: read_file({"path":"secret-file.txt"})

Step 5

Add `execute_tool` (stub first)

before: only logging tool request

when :tool_use
  puts "tool requested: #{block[:name]}(#{block[:input].to_json})"
                
after: call a stub executor

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
                
terminal / stub phase
tool requested: read_file({"path":"secret-file.txt"})
tool_result: {"type":"tool_result","content":"TODO: run read_file"}

Step 6

Attach real functions to tool hashes

before: schema-only tool hash

READ_FILE = {
  name: "read_file",
  description: "Read file contents",
  input_schema: {
    type: "object",
    properties: { path: { type: "string" } },
    required: ["path"]
  }
}
                
after: add executable function and dispatch map

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
                
terminal / first real execution
tool requested: read_file({"path":"secret-file.txt"})
tool_result: {"content":"what animal is the most disagreeable..."}

Step 7

Send `tool_result` back and continue without user input

before: tool executes, then loop waits for user

when :tool_use
  result = execute_tool(block[:id], block[:name], block[:input])
  puts "tool_result: #{result.to_json}"

# waits for next human input here
                
after: internal loop turn with tool result

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
                
terminal / now it feels agentic
You: solve the riddle in secret-file.txt
tool: read_file({"path":"secret-file.txt"})
tool_result JSON appended to conversation
Assistant: The answer is a horse.
And now you actually have an agent.

Step 8

Let's standardize this for the other tools we will add

before: ad-hoc hashes everywhere

READ_FILE = {
  name: "read_file",
  description: "...",
  input_schema: { ... },
  function: ->(input) do
    ...
  end
}
LIST_FILES = { ... }
EDIT_FILE = { ... }
                
after: one struct + clean definitions

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
)
                

Why this matters

  • Same object holds schema and runtime function.
  • Adding tools becomes copy/paste-safe.
  • This shape maps directly to Rails tool classes next.

Step 9

Add `list_files` and watch tool chaining

before: one tool

tools = [READ_FILE]
                
after: add `list_files`

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
                
terminal / amp-style chaining
You: solve the puzzle in the secret file
tool: list_files({})
tool: read_file({"path":"secret-file.txt"})
Assistant: The answer is a horse.

Step 10

Add `edit_file` and let it modify code

before: read/list only

tools = [READ_FILE, LIST_FILES]
                
after: add edit primitive

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
                
terminal / examples from the Amp post
You: create fizzbuzz.js and run to 100
tool: edit_file({"path":"fizzbuzz.js","old_str":"","new_str":"..."})
You: now edit it to run only to 15
tool: read_file({"path":"fizzbuzz.js"})
tool: edit_file({"path":"fizzbuzz.js","old_str":"fizzBuzz(100)","new_str":"fizzBuzz(15)"})
Aha #4: once edit_file lands, it feels like a coding teammate.

Step 11

That's it. You now have a coding agent.

An LLM loop with read, list, and edit tools is enough to cross from chatbot to coding teammate.

The emperor has no clothes

Part 2

So what about Rails?

A journey into agentic systems in Rails.

Bootstrap Rails

From script to app skeleton

scaffold-level setup

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
            
app/ controllers/ agent_runs_controller.rb messages_controller.rb jobs/ agent_job.rb models/ agent_run.rb message.rb tools/ base_tool.rb read_file_tool.rb list_files_tool.rb edit_file_tool.rb tool_registry.rb

Primitive mapping

Same mental model, Rails-native primitives

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

`gets` becomes browser -> controller -> job

1. UI

Stimulus form posts message content.

->

2. Controller

Create `Message` under `AgentRun`.

->

3. Queue

`AgentJob.perform_later` continues the loop.

->

4. Stream

Turbo/ActionCable updates the thread.

`demo/app/controllers/messages_controller.rb`

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

`loop do` -> recursive background job

before: script loop edge

if tool_results.any?
  conversation << { role: "user", content: tool_results }
  next
end
                
after: Rails loop edge (`demo/app/jobs/agent_job.rb`)

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.

queue timeline
job #1: model -> tool_use
job #1: execute tool + save tool_result
job #1: enqueue job #2
job #2: continue with expanded conversation

Output mapping

`puts` becomes streamed UI updates

before: terminal print

puts "Assistant: #{text_delta}"
                
after: Turbo stream in job + model callback

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
                
http://localhost:3000/agent_runs/12
Summarize this repository.
Thinking...
tool: list_files({})
tool: read_file({"path":"README.md"})
This repo is a Rails agent demo with tool calling and streaming UI...
Output is now data + broadcast, not stdout.

Tool architecture in Rails

Hash definitions -> Ruby classes + registry

before: script hash with lambda

READ_FILE = {
  name: "read_file",
  input_schema: {
    type: "object",
    properties: {
      path: { type: "string" }
    },
    required: ["path"]
  },
  function: ->(input) do
    File.read(input[:path])
  end
}
                
after: tool classes + registry

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
                
demo/app/tools/ base_tool.rb read_file_tool.rb list_files_tool.rb edit_file_tool.rb bash_tool.rb web_fetch_tool.rb tool_registry.rb demo/app/jobs/ agent_job.rb demo/app/services/ openrouter_client.rb

Ride along

Let's follow one request through the Rails stack

execution path
  • Frontend: textarea submit (`message_form_controller.js`) sends POST.
  • Controller: `MessagesController#create` saves user turn, enqueues `AgentJob`.
  • Job: `OpenrouterClient#chat` streams text/tool chunks.
  • Tool path: `ToolRegistry.find(name)` -> `tool.execute(input)`.
  • Loop edge: save tool_result + `perform_later` again when needed.
  • UI: Turbo stream updates browser thread live.
agent run #42
Find all Go files and summarize them.
tool: list_files({"glob":"**/*.go"})
tool: read_file({"path":"main.go"})
I found one Go file. It implements a chat agent with tool handling.
Now create notes.md with that summary.
tool: edit_file({"path":"notes.md","old_str":"","new_str":"..."})
Done. `notes.md` created.

With that, we now have an agent built with Rails

  • Same core loop from the script.
  • Rails adds durability, jobs, streaming UX, and composable tool architecture.
  • And you can use it for anything.

Thank you