A tiny Ruby LLM agent (101 lines, no deps)
I wanted a better understanding of how LLM agents work under the hood, so I wrote a tiny terminal coding agent in plain Ruby: no external deps. The whole thing lands at 101 lines (including comments and new lines). Below we’ll walk through the pieces, with the complete script at the end.
You’ll need an OPENAI_API_KEY in your environment. The script calls OpenAI’s Responses API.
Start with the HTTP request skeleton:
require "uri"
require "net/http"
require "json"
uri = URI.parse("https://api.openai.com/v1/responses")
request = Net::HTTP::Post.new(uri, {
"Authorization" => "Bearer #{ENV['OPENAI_API_KEY']}",
"Content-Type" => "application/json"
})Nothing fancy here - a POST to the Responses endpoint with a bearer token. We’ll fill in request.body each successive turn.
Next, we declare a tool. OpenAI uses “tools” as an umbrella term; ours is a function - when the model wants to use it, the response includes a function_call. Either way, a tool isn’t something the model runs itself. It’s an action you’ve told the model it can ask for. Your code decides whether to honor that request and, if so, actually runs it.
The definition is just a JSON schema that tells OpenAI what the tool is called, what it does, and what arguments it expects:
local_shell_tool = {
type: "function",
name: "run_local_command",
description: "Run a shell command on the user's machine to inspect or change real state - e.g. list files, read file contents, check git status, or run tests. Use this to get ground truth instead of guessing about the local environment.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "The exact terminal shell command to execute. For example, 'ls' to see files, or 'pwd' to see directory path."
}
},
required: ["command"]
}
}The description matters more than it looks. This is how the model decides when to request the tool and what to put in command. Later we’ll wire up the part that actually runs the shell command.
Now the outer chat loop - one user turn at a time:
puts "Hello! How can I help you today?"
previous_response_id = nil
loop do
print "> "
next_input = [{ role: "user", content: gets.chomp }]
# ... call the API, handle tool calls, print the reply ...
endrole: "user" labels the message as coming from the human, so the model can tell it apart from assistant replies and tool results.
previous_response_id lets OpenAI keep track of the conversation so far, so we don’t have to ship the full transcript back on every request. It’s nil on the first turn; after that we chain each request to the last response id the API returned.
You could manage that history yourself by appending each user message, tool call, and reply to an array and resend it as input each time - but chaining response ids keeps this script small.
After each user message, we build the request body and fire it off:
request.body = {
model: "gpt-5-mini",
instructions: "You are a coding assistant running in a terminal on the user's real machine. Your `run_local_command` shell tool is how you get work done - use it both to inspect state (list/read files, git status, run tests) and to take action (create or edit files, run scripts). When the user asks you to do something, actually do it by running the command(s): don't paste code or describe steps instead of executing them, and don't ask whether to proceed - every command is approved by the user before it runs. Write files with shell redirection like a heredoc (`cat > path <<'EOF' … EOF`). Prefer ground truth over guessing, never fabricate file contents or output, and only ask a clarifying question when the request is genuinely ambiguous - otherwise choose sensible defaults and proceed.",
input: next_input,
tools: [local_shell_tool],
previous_response_id: previous_response_id # nil on the first turn -> dropped by compact
}.compact.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(request)
endA few details worth noticing:
instructions(also known as the system prompt) steers behavior for the whole session (act via the shell tool, don’t just describe steps, etc).toolsregistersrun_local_command..compactdropsprevious_response_idwhen it’snil, so the first request stays clean. OpenAI’s response includes anid; we’ll save that and pass it asprevious_response_idon later turns.
After receiving the response we parse it and chain the id:
unless response.code == "200"
puts "\nAPI Error #{response.code}: #{response.body}"
break
end
response_data = JSON.parse(response.body, symbolize_names: true)
# Chain the next request to this response so the server keeps the history.
previous_response_id = response_data[:id]
output_blocks = response_data[:output] || []output is where things branch. If the model asked for a tool, stop and ask before running anything. This is the critical safety step: the command came from the model, and a “yes” means it runs on your machine with your permissions.
if function_call = output_blocks.find { |block| block[:type] == "function_call" }
function_call => { call_id:, name: tool_name }
arguments = JSON.parse(function_call[:arguments], symbolize_names: true)
if tool_name == "run_local_command"
command = arguments[:command]
print "\n[Agent requested shell command]: `#{command}`\nAllow execution? (y/n): "
if ["y", "yes"].include?(gets.chomp.strip.downcase)
puts "Executing command..."
shell_result = `#{command} 2>&1`
else
puts "Command execution denied by user."
shell_result = "Error: System command execution was explicitly denied by the user."
end
# Send ONLY the tool result back. The function_call itself is already stored server-side via previous_response_id.
next_input = [{
type: "function_call_output",
call_id: call_id,
output: shell_result
}]
end
next
endDon’t skip this prompt or auto-approve in a learning script like this. When you deny, we still send a result - the model needs to know the tool failed so it can adapt. The next sends us around again: call the API with the tool result, and the model could request another command or decide it has enough context.
If there’s no function call, the reply is plain text - print it and we’re done with this turn:
text = output_blocks
.select { |item| item[:type] == "message" }
.flat_map { |item| item[:content] || [] }
.select { |part| part[:type] == "output_text" }
.map { |part| part[:text] }
.join
puts text unless text.empty?Using pseudo code, the nested loops look like this:
outer loop (each user turn)
get user message
loop
call the API (passing along all available functions/tools and their descriptions)
if function_call
approve/run → send result back to API
(loop again - another tool call is possible)
else
print text reply
breakAnd that’s it - an agent is a chat loop with tools in the middle. Once you’ve built one like this, the bigger frameworks are easier to reason about and understand.
One thing worth repeating: this is a learning script, not a robust agent. The shell tool is like a very sharp knife, incredibly useful but also dangerous. Every approved command runs on your machine with your permissions. Read and understand each command before you respond with “yes”.
Here’s the complete script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
require "uri"
require "net/http"
require "json"
uri = URI.parse("https://api.openai.com/v1/responses")
request = Net::HTTP::Post.new(uri, {
"Authorization" => "Bearer #{ENV['OPENAI_API_KEY']}",
"Content-Type" => "application/json"
})
local_shell_tool = {
type: "function",
name: "run_local_command",
description: "Run a shell command on the user's machine to inspect or change real state - e.g. list files, read file contents, check git status, or run tests. Use this to get ground truth instead of guessing about the local environment.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "The exact terminal shell command to execute. For example, 'ls' to see files, or 'pwd' to see directory path."
}
},
required: ["command"]
}
}
puts "Hello! How can I help you today?"
previous_response_id = nil
loop do
print "> "
next_input = [{ role: "user", content: gets.chomp }]
loop do
request.body = {
model: "gpt-5-mini",
instructions: "You are a coding assistant running in a terminal on the user's real machine. Your `run_local_command` shell tool is how you get work done - use it both to inspect state (list/read files, git status, run tests) and to take action (create or edit files, run scripts). When the user asks you to do something, actually do it by running the command(s): don't paste code or describe steps instead of executing them, and don't ask whether to proceed - every command is approved by the user before it runs. Write files with shell redirection like a heredoc (`cat > path <<'EOF' … EOF`). Prefer ground truth over guessing, never fabricate file contents or output, and only ask a clarifying question when the request is genuinely ambiguous - otherwise choose sensible defaults and proceed.",
input: next_input,
tools: [local_shell_tool],
previous_response_id: previous_response_id # nil on the first turn -> dropped by compact
}.compact.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(request)
end
unless response.code == "200"
puts "\nAPI Error #{response.code}: #{response.body}"
break
end
response_data = JSON.parse(response.body, symbolize_names: true)
# Chain the next request to this response so the server keeps the history.
previous_response_id = response_data[:id]
output_blocks = response_data[:output] || []
if function_call = output_blocks.find { |block| block[:type] == "function_call" }
function_call => { call_id:, name: tool_name }
arguments = JSON.parse(function_call[:arguments], symbolize_names: true)
if tool_name == "run_local_command"
command = arguments[:command]
# Prompt the user for permission
print "\n[Agent requested shell command]: `#{command}`\nAllow execution? (y/n): "
if ["y", "yes"].include?(gets.chomp.strip.downcase)
puts "Executing command..."
shell_result = `#{command} 2>&1`
else
puts "Command execution denied by user."
shell_result = "Error: System command execution was explicitly denied by the user."
end
# Send ONLY the tool result back. The function_call itself is already stored server-side via previous_response_id.
next_input = [{
type: "function_call_output",
call_id: call_id,
output: shell_result
}]
end
next
else
text = output_blocks
.select { |item| item[:type] == "message" }
.flat_map { |item| item[:content] || [] }
.select { |part| part[:type] == "output_text" }
.map { |part| part[:text] }
.join
puts text unless text.empty?
break
end
end
end