Completing the Tool Use Cycle

Introduction & Overview

In the previous lessons, you learned how to create tool schemas and how to understand GPT-5's responses when it wants to use tools. You can now recognize when GPT-5 requests function execution through function_call items in response.output, and how to extract the name, arguments, and call_id from each. However, knowing what GPT-5 wants to do is only half the story — you still need to actually execute those tools and complete the conversation cycle.

In this lesson, you will learn how to bridge that gap by executing the methods GPT-5 requests, capturing their results, and sending those results back to GPT-5 in the proper format. By the end of this lesson, you will have a complete tool execution pipeline that can handle GPT-5's tool requests from start to finish, maintaining a proper conversation flow throughout the entire process.

The Complete Tool Execution Flow

Before we dive into the implementation, let's understand the complete workflow we will be building in this lesson. Here is the step-by-step process that transforms GPT-5 from a simple chatbot into a capable agent:

  1. Set up the foundation — Create a tool registry hash mapping tool names to Ruby Method objects, load tool schemas from JSON, and prepare initial conversation messages.
  2. Send the initial request — Make the first API call to GPT-5 with the user's question and available tools using client.responses.create.
  3. Detect function calls — Filter response.output for items whose type equals "function_call".
  4. Append function call items to messages — Add each function call back into your conversation array so GPT-5 has a complete view of what it asked for.
  5. Execute the requested methods — Parse the JSON arguments, use the tool registry to retrieve the right Method object, and call it with keyword arguments.
  6. Collect and format outputs — For each call, build a function_call_output item that includes the matching call_id and the result encoded as a JSON string.
  7. Send results back to GPT-5 — Make a second client.responses.create call with the complete conversation, including the function call items and their outputs.
  8. Display the final response — Use final_response.output_text to show GPT-5's natural language answer that incorporates the tool outputs.

This complete cycle enables GPT-5 to seamlessly use tools as part of its reasoning process, transforming raw method outputs into conversational responses that directly answer user questions.

Setting Up the Foundation

Before diving into tool execution, we need to establish the foundation that connects GPT-5's tool requests to our actual Ruby methods. As we covered in previous lessons, this involves creating a tool registry hash and preparing our tool schemas and initial messages. The critical component here is the tool registry hash — this serves as the bridge between the tool names GPT-5 uses and our actual Ruby Method objects.

require "json"
require "openai"
require_relative "functions"

def text_message(role, text)
  {
    role: role,
    content: [
      { type: "input_text", text: text }
    ]
  }
end

# Initialize the OpenAI client
client = OpenAI::Client.new

# Choose a model
model = "gpt-5"

# Developer instructions for the assistant
developer_prompt = (
  "You are a helpful math assistant. " \
  "Always use the available tools to perform calculations accurately. " \
  "Prefer calling tools for any arithmetic."
)

# Load tool definitions
tool_schemas = JSON.parse(File.read("schemas.json"))

# Map tool names to actual Ruby functions we can execute
tools = {
  "sum_numbers" => method(:sum_numbers),
  "multiply_numbers" => method(:multiply_numbers)
}

# Start with a user message asking for a calculation
messages = [
  text_message("user", "Please calculate 15 + 27")
]

This setup creates everything we need for tool execution: the tools hash enables dynamic method lookup using Method objects, the developer_prompt guides GPT-5's behavior, the tool_schemas provide technical specifications, and the messages array starts the conversation. The tool registry is particularly important because it allows our code to execute the correct method based on GPT-5's string-based tool requests.

Sending the Initial Request

With our foundation in place, we can now send the initial request to GPT-5. We use client.responses.create, passing the developer message and our user messages in the input array along with the available tools:

# STEP 1: Send the user's request to GPT-5 with tools available
response = client.responses.create(
  model: model,
  input: [
    text_message("developer", developer_prompt),
    *messages
  ],
  tools: tool_schemas,
  reasoning: { effort: "low" },
  store: false
)

A couple of new details here:

  • We pass reasoning: { effort: "low" }. GPT-5 is a reasoning model, and this option controls how much thinking it does before responding. "low" is a good default for fast, cost-effective interactions; "high" is appropriate for more complex problems.
  • store: false tells OpenAI not to persist the response on its server. We'll be managing the conversation state ourselves on the client side.

Detecting Function Calls

After receiving GPT-5's response, we filter for function_call items in the output. If there's at least one, we know GPT-5 wants us to execute tools.

# STEP 2: Look for function calls in the response
function_calls = response.output.select { |item| item.type.to_s == "function_call" }

if function_calls.any?
  puts "Executing function calls..."
  # ... handle function calls
else
  puts "GPT-5 reply (no tools):"
  puts response.output_text
end

We use .to_s when comparing item.type because the OpenAI Ruby SDK returns enum-like values that need conversion to strings for robust comparison. The function_calls array contains zero or more ResponseFunctionToolCall objects, each with a name, arguments, and call_id.

If function_calls is empty, GPT-5 has answered directly — we use response.output_text to print the answer and we're done. Otherwise, we proceed to execute the tools.

Appending Function Call Items to the Conversation

For GPT-5 to understand the conversation history correctly, we need to add each function_call item it produced back into our messages array. These are added as plain item hashes (not wrapped in a role/content message):

# STEP 3: Add the function call items to the conversation
function_calls.each do |function_call|
  messages << {
    type: "function_call",
    name: function_call.name,
    arguments: function_call.arguments,
    call_id: function_call.call_id
  }
end

This is a key conceptual difference from a typical chat-style API: in the Responses API, function_call and function_call_output items live alongside regular role-based messages as separate entries in the input array. They are not nested under an "assistant" message.

Executing the Requested Methods

Now we execute each function call. The arguments come as a JSON string, so we parse them into a Ruby hash and then call our method with keyword arguments.

# STEP 4: Execute each function call that the model requested
function_outputs = []

function_calls.each do |function_call|
  name = function_call.name
  call_id = function_call.call_id
  args = JSON.parse(function_call.arguments || "{}")

  result = begin
    callable = tools.fetch(name)
    callable.call(**args.transform_keys(&:to_sym))
  rescue KeyError
    "Error: Function '#{name}' not found"
  rescue => e
    "Error executing #{name}: #{e}"
  end

  puts "#{name}(#{args}) = #{result}"

  function_outputs << {
    type: "function_call_output",
    call_id: call_id,
    output: JSON.generate(result: result)
  }
end

A few important things happen here:

  • JSON.parse(function_call.arguments || "{}") converts the JSON-encoded arguments string into a Ruby hash with string keys.
  • args.transform_keys(&:to_sym) converts those string keys to symbols so we can use them as keyword arguments. The & symbol in &:to_sym is a Ruby shorthand that applies the to_sym method to each key.
  • tools.fetch(name) looks up the corresponding Method object. We use .fetch (instead of tools[name]) so that a missing tool raises a KeyError, which we can rescue cleanly. If we used the bracket syntax, a missing tool would return nil and cause a confusing NoMethodError later.
  • The begin...rescue block catches both missing-tool errors and any runtime exceptions during execution, converting them into error strings so the model can see what went wrong.
  • For each call, we build a function_call_output item containing the matching call_id and the result encoded as a JSON string via JSON.generate(result: result). The output field must be a string — wrapping the result in a JSON object is a clean convention.

It's critical to provide a function_call_output for every function_call GPT-5 produced. If you skip one, your next API call will fail because GPT-5 expects to see results for all the calls it made.

Getting GPT-5's Final Response

Once we've executed all the tools and collected their outputs, we add them to messages and send everything back to GPT-5 for the final answer:

# STEP 5: Add function outputs and send everything back to the model
messages.concat(function_outputs)

final_response = client.responses.create(
  model: model,
  input: [
    text_message("developer", developer_prompt),
    *messages
  ],
  tools: tool_schemas,
  store: false
)

puts "\nFinal answer:"
puts final_response.output_text

This second API call uses the same parameters as the first, but now the input array contains the full history: the developer prompt, the user's original message, the function call items, and the function output items. GPT-5 has everything it needs to produce a natural, conversational answer that incorporates the tool results.

We use final_response.output_text to get the model's text answer. This SDK helper conveniently concatenates the text from all message items in the output, saving us from manually iterating through content blocks.

The complete execution flow produces output similar to this:

Executing function calls...
sum_numbers({"a"=>15, "b"=>27}) = 42

Final answer:
15 + 27 = 42.

This output demonstrates the complete tool execution cycle: our code executes the requested method with the provided arguments and captures the numerical result, and then GPT-5 transforms that raw output into a natural, conversational response that directly answers the user's original question.

Handling Non-Tool Responses

Not every user request will require tool usage. When GPT-5 can answer directly without needing to execute methods, it will simply produce a message item (and maybe reasoning) — but no function_call. Our code already handles this in the else branch:

else
  puts "GPT-5 reply (no tools):"
  puts response.output_text
end

This ensures your application handles both tool-requiring and direct-answer scenarios gracefully.

Summary & Practice Preparation

You now understand the complete tool execution workflow that transforms GPT-5 from a text-only assistant into an agent capable of performing actions and calculations. The process involves detecting function_call items, executing methods through a tool registry hash of Method objects, formatting results as function_call_output items with matching call_ids, and sending the complete conversation back for a final answer.

The key Ruby techniques to remember:

  • Use method(:function_name) to create Method objects and store them in a Hash registry.
  • Use .to_s for safe comparison against API enum values.
  • Parse JSON-encoded arguments strings with JSON.parse.
  • Convert hash keys with transform_keys(&:to_sym) before calling Ruby keyword-argument methods.
  • Wrap tool execution in begin...rescue blocks to handle missing tools and runtime errors gracefully.
  • Use JSON.generate(result: result) to encode tool outputs as JSON strings for the API.

In the upcoming practice exercises, you will implement this complete workflow yourself, working with different scenarios including multiple function calls and error handling. Happy coding!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal