How to build a coding agent
A useful coding agent can fit in one Ruby file.
Give the model a terminal chat loop, keep the conversation in memory, and expose a few file tools. That is enough for it to inspect a project and make targeted edits.
This walkthrough builds that agent in Ruby, step by step.
OpenAI recommends the Responses API for new projects. This walkthrough uses Chat Completions because the tool-calling loop is easier to see in a small teaching example: messages go in, tool calls come back, your code runs them, tool outputs are appended, and the loop continues.
Here is what you need:
- Ruby
- Bundler
- an
OPENAI_API_KEYenvironment variable
Start the project
Create a new project:
mkdir code-editing-agent
cd code-editing-agent
bundle init
touch main.rb
Update Gemfile:
source "https://rubygems.org"
gem "openai", "~> 0.57.0"
Install dependencies:
bundle install
This version keeps everything in main.rb so the full loop stays easy to see. Put this into the file:
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/setup"
require "openai"
BLUE = "\e[94m"
YELLOW = "\e[93m"
RESET = "\e[0m"
MODEL = "gpt-5.4"
WORKSPACE_ROOT = "."
def build_system_prompt(workspace_root)
<<~PROMPT
You are a coding agent running in a terminal.
Always use the available tools to inspect files before answering questions about repository content.
The workspace root is: `#{workspace_root}`.
Be concise and factual in your final answer.
PROMPT
end
if ENV["OPENAI_API_KEY"].to_s.empty?
warn "Error: OPENAI_API_KEY is not set"
exit 1
end
client = OpenAI::Client.new
conversation = [
{
role: :system,
content: build_system_prompt(WORKSPACE_ROOT)
}
]
puts "Chat with OpenAI (workspace: #{WORKSPACE_ROOT}, ctrl-c to quit)"
loop do
print "#{BLUE}You#{RESET}: "
user_input = $stdin.gets
break if user_input.nil?
user_input = user_input.chomp
next if user_input.strip.empty?
conversation << { role: :user, content: user_input }
completion = client.chat.completions.create(
model: MODEL,
messages: conversation,
temperature: 0
)
message = completion[:choices][0][:message]
conversation << { role: :assistant, content: message[:content] }
if message[:content] && !message[:content].empty?
puts "#{YELLOW}Agent#{RESET}: #{message[:content]}"
end
end
OpenAI::Client.new uses OPENAI_API_KEY from the environment, so there is no extra setup beyond exporting the key.
This is already the core chat loop. The program reads a line from stdin, appends it to the conversation, sends the full message array to the model, appends the assistant reply, prints it, and repeats.
The conversation array is the context. There is no hidden session state beyond what your process keeps in memory.
Run it:
export OPENAI_API_KEY="replace-me"
ruby main.rb
At this point you have a terminal chat app.
Add the first tool
A chat app becomes an agent when the model can do something outside the prompt itself.
With function calling, the loop is simple:
- You send tool definitions with the request.
- The model can return one or more tool calls.
- Your code executes those tools.
- You send the tool outputs back.
- The model continues.
In Ruby you do not need a dedicated tool class for a demo like this. A plain hash with four parts is enough:
namedescriptionparametersas a JSON schemahandleras a lambda that executes the tool
Add these requires and constants near the top of the file:
require "json"
require "fileutils"
GREEN = "\e[92m"
Now add a few helpers for parsing tool input and keeping file access inside the workspace:
def parse_json(arguments_json)
input = JSON.parse(arguments_json)
raise "tool arguments must be a JSON object" unless input.is_a?(Hash)
input
rescue JSON::ParserError, TypeError
raise "tool arguments must be valid JSON"
end
def require_string(input, key)
value = input[key]
raise "#{key} must be a string" unless value.is_a?(String)
value
end
def resolve_workspace_path(workspace_root, raw_path)
raise "path must not be empty" if raw_path.strip.empty?
raise "path must be relative to workspace root" if raw_path.start_with?("/")
root = File.expand_path(workspace_root)
candidate = File.expand_path(raw_path, root)
unless candidate == root || candidate.start_with?("#{root}#{File::SEPARATOR}")
raise "path escapes the workspace root"
end
candidate
end
That last helper is worth having even in a small demo. It keeps the agent inside the workspace.
Start with read_file:
def build_tools(workspace_root)
[
{
name: "read_file",
description: "Read the UTF-8 contents of a given relative file path. Use this when you need to inspect a file. Do not use this with directories.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
path: {
type: "string",
description: "The relative path of a file in the workspace."
}
},
required: ["path"]
},
handler: lambda do |arguments_json|
input = parse_json(arguments_json)
path = require_string(input, "path")
file_path = resolve_workspace_path(workspace_root, path)
raise "path is a directory, not a file: #{path}" if File.directory?(file_path)
File.read(file_path)
end
}
]
end
Then create a helper that sends the tool definitions with the request:
def run_inference(client, model, conversation, tools)
completion = client.chat.completions.create(
model: model,
messages: conversation,
tools: tools.map do |tool|
{
type: :function,
function: {
name: tool[:name],
description: tool[:description],
parameters: tool[:parameters]
}
}
end,
temperature: 0
)
message = completion[:choices] && completion[:choices][0] && completion[:choices][0][:message]
raise "OpenAI returned no choices" if message.nil?
message
end
And initialize the tools:
tools = build_tools(WORKSPACE_ROOT)
Replace the direct API call inside the loop with this:
message = run_inference(client, MODEL, conversation, tools)
conversation << { role: :assistant, content: message[:content] }
At this point the model knows that read_file exists, but the program still ignores tool calls. The next step is to wire up the execution loop.
Handle tool calls
Add these helpers:
def assistant_message_to_hash(message)
result = { role: :assistant }
result[:content] = message[:content] unless message[:content].nil?
if message[:tool_calls]
result[:tool_calls] = message[:tool_calls].map do |tool_call|
{
id: tool_call[:id],
type: tool_call[:type],
function: {
name: tool_call[:function][:name],
arguments: tool_call[:function][:arguments]
}
}
end
end
result
end
def execute_tool(tools, name, arguments_json)
tool = tools.find { |candidate| candidate[:name] == name }
return "tool not found" if tool.nil?
tool[:handler].call(arguments_json)
rescue StandardError => e
e.message
end
Now replace the main loop with this version:
read_user_input = true
loop do
if read_user_input
print "#{BLUE}You#{RESET}: "
user_input = $stdin.gets
break if user_input.nil?
user_input = user_input.chomp
next if user_input.strip.empty?
conversation << { role: :user, content: user_input }
end
message = run_inference(client, MODEL, conversation, tools)
conversation << assistant_message_to_hash(message)
if message[:content] && !message[:content].empty?
puts "#{YELLOW}Agent#{RESET}: #{message[:content]}"
end
tool_calls = Array(message[:tool_calls])
if tool_calls.empty?
read_user_input = true
next
end
tool_calls.each do |tool_call|
tool_name = tool_call[:function][:name]
arguments_json = tool_call[:function][:arguments]
puts "#{GREEN}tool#{RESET}: #{tool_name}(#{arguments_json})"
conversation << {
role: :tool,
tool_call_id: tool_call[:id],
content: execute_tool(tools, tool_name, arguments_json)
}
end
read_user_input = false
end
That is the whole loop.
When the model returns tool calls, the program executes them locally, appends their outputs as role: :tool messages, and loops again without reading more user input. If there are no tool calls, control goes back to the user.
With that in place, the model can decide to call read_file before answering questions about files in the project.
Add list_files
Reading one file is useful, but an editing agent also needs a way to inspect the working tree.
Add this helper:
def walk_files(current_path, base_path, files)
Dir.children(current_path).sort.each do |entry|
full_path = File.join(current_path, entry)
relative_path = full_path.delete_prefix("#{base_path}/").tr("\\", "/")
if File.directory?(full_path)
files << "#{relative_path}/"
walk_files(full_path, base_path, files)
else
files << relative_path
end
end
end
Then add a second tool entry inside build_tools:
{
name: "list_files",
description: "List files and directories at a given path. If no path is provided, lists files in the current directory.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
path: {
type: "string",
description: "Optional relative path to list files from. Defaults to current directory if not provided."
}
}
},
handler: lambda do |arguments_json|
input = parse_json(arguments_json)
dir = input.key?("path") ? require_string(input, "path") : "."
dir_path = resolve_workspace_path(workspace_root, dir)
raise "path is not a directory: #{dir}" unless File.directory?(dir_path)
files = []
walk_files(dir_path, dir_path, files)
JSON.generate(files.sort)
end
}
Now the model can inspect the directory tree and then decide which files to read.
That is enough for questions like:
- "What Ruby files are in this project?"
- "What is in
main.rb?" - "What files are under
lib/?"
The important part is that you do not hardcode any of that behavior. You expose tools, and the model decides when it needs them.
Add edit_file
The last tool turns this from a read-only assistant into a code-editing agent.
Add this helper first:
def create_new_file(file_path, content, display_path)
FileUtils.mkdir_p(File.dirname(file_path))
File.write(file_path, content)
"Successfully created file #{display_path}"
end
Then add the edit_file tool to build_tools:
{
name: "edit_file",
description: "Make edits to a text file. Replace 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. If the file specified with path doesn't exist, it will be created.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
path: {
type: "string",
description: "The path to the file"
},
old_str: {
type: "string",
description: "Text to search for - must match exactly and must only have one match exactly"
},
new_str: {
type: "string",
description: "Text to replace old_str with"
}
},
required: ["path", "old_str", "new_str"]
},
handler: lambda do |arguments_json|
input = parse_json(arguments_json)
path = require_string(input, "path")
old_str = require_string(input, "old_str")
new_str = require_string(input, "new_str")
raise "invalid input parameters" if path.empty? || old_str == new_str
file_path = resolve_workspace_path(workspace_root, path)
begin
old_content = File.read(file_path)
rescue Errno::ENOENT
raise unless old_str.empty?
next create_new_file(file_path, new_str, path)
end
raise "old_str must not be empty for existing files" if old_str.empty?
match_count = old_content.scan(Regexp.new(Regexp.escape(old_str))).length
raise "old_str not found in file" if match_count.zero?
raise "old_str must match exactly once" if match_count > 1
File.write(file_path, old_content.sub(old_str, new_str))
"OK"
end
}
This tool is still intentionally simple. It does exact string replacement instead of syntax-aware editing, but that is enough to demonstrate the full loop clearly: inspect files, decide on a change, request the edit, then continue from the result.
The exact-once match check is deliberate. It avoids the easiest class of accidental wide replacements while keeping the implementation small.
The guardrails matter:
- paths stay inside the workspace root
- invalid JSON is rejected
- required fields must be strings
- existing files require a non-empty
old_str old_strmust match exactly once before the file is changed
That keeps the demo small without making it completely loose.
What this gives you
At this point the one-file main.rb agent contains:
- a terminal chat loop
- local conversation state
- a small system prompt
- Chat Completions tool calling
read_filelist_filesedit_file- workspace path confinement
That is the core of the agent.
The rest is engineering around it: better prompts, better edit operations, command execution, retries, test feedback, and a more polished interface. But the central idea is already here.
The useful thing to take away is how small the first version can be. If the model can inspect the working tree, read the right files, and make targeted edits, it can already do meaningful work.