How to Build an AI Agent in Ruby
Everyone is familiar with LLMs by now. They carry an incredible amount of knowledge picked up during training, but that knowledge is frozen at a cutoff date. And yet, when you ask ChatGPT about today's weather, it answers.
That's possible because modern assistants aren't just language models anymore: they're agents. The model is wrapped in a loop that lets it request outside information, receive the results, and reason over them before replying. To see how that works end to end, we'll build a small agent of our own that will be able to answer one very important question: "what's the weather in Paris?".
1. Define a tool
The model itself has no way to look up today's weather so we need to give it a way to ask us for live data. That's what a "tool" is: a small piece of code the model can request by name, with arguments it chooses.
For the model to pick the right tool and fill in the right arguments, it needs a precise description of what each tool does and what inputs it expects. That description is a JSON schema, and in this article we'll use OpenAI's tool-calling format, which has become the de facto standard and works with most models on the market.
module Weather
SCHEMA = {
type: "function",
function: {
name: "weather",
description: "Get current weather for a place.",
parameters: {
type: "object",
properties: { location: { type: "string" } },
required: ["location"]
}
}
}
def self.call(location:)
# ...hit a weather API, return a hash
{ location: location, temperature_c: 12 }
end
end
2. Register tools
A single weather tool is enough to demonstrate the idea, but a real agent quickly grows a collection of them - one to search the web, one to read files, one to run shell commands, and so on. We centralize everything in a registry: a hash that maps a tool's name to the module that implements it.
REGISTRY = { "weather" => Weather }
SCHEMAS = REGISTRY.values.map { |t| t::SCHEMA }
3. Dispatch a tool call
When the model decides it needs a tool, it doesn't execute anything itself - it simply returns a structured request containing the tool's name and a JSON string of arguments. It's our job to intercept that request, find the matching tool in the registry, parse the arguments, and actually run the code. Once the tool finishes, we'll hand the result back to the model so it can continue reasoning with the new information.
def run(tool_call)
name = tool_call["function"]["name"]
args = JSON.parse(tool_call["function"]["arguments"])
REGISTRY[name].call(**args.transform_keys(&:to_sym))
end
4. Call the LLM
Here we send the full conversation history plus the tool schemas to the model in a single HTTP request. The schemas tell the model which tools are available and how to call them, but they don't force its hand: the model is free to respond with plain text when it has enough information, or to emit one or more tool calls when it needs outside data.
def chat(messages)
post_json(API_URL,
body: { model: MODEL, messages: messages, tools: SCHEMAS })
.dig("choices", 0, "message")
end
5. The agent loop
The heart of our agent is a loop that alternates between asking the model what to do and feeding it the results of whatever it asked for. We append that message to the conversation straight away so the history stays intact for the next turn.
This of course creates a situation where an infinite loop is possible - a misbehaving model could keep requesting tools forever - but for our simple example I'll skip this problem for now.
def ask(messages, user_input)
messages << { role: "user", content: user_input }
loop do
msg = chat(messages) # ask the model
messages << msg # remember its reply
# no tool calls? we're done - return the answer
return msg["content"] if msg["tool_calls"].nil?
# otherwise run each tool and feed results back
msg["tool_calls"].each do |tc|
messages << {
role: "tool",
tool_call_id: tc["id"],
content: JSON.dump(Tools.run(tc))
}
end
# loop again - model now sees the tool results
end
end
The flow in one picture
user: "weather in Paris?"
│
▼
LLM.chat ──► model says: call weather(location: "Paris")
│
▼
Tools.run ──► { temperature_c: 12, ... }
│
▼
LLM.chat ──► model says: "It's 12°C in Paris."
│
▼
return to user
The loop is complete, and what's remarkable is how little code it actually took: a schema, a registry, a dispatcher, a chat call, and a loop. Everything people describe as "agentic" behavior - reasoning, planning, using external data, iterating on results - emerges from that small feedback cycle between the model and the tools we hand it.
To see how this simple terminal agent works in more detail, the full source code is available on GitHub: https://github.com/visualitypl/weather-ai-agent