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_KEY environment 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:

  1. You send tool definitions with the request.
  2. The model can return one or more tool calls.
  3. Your code executes those tools.
  4. You send the tool outputs back.
  5. 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:

  • name
  • description
  • parameters as a JSON schema
  • handler as 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_str must 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_file
  • list_files
  • edit_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.