Completing the Tool Use Cycle

Introduction & Overview

In the previous lessons, you learned how to create tool schemas and how to understand Claude's responses when it wants to use tools. You can now recognize when Claude requests tool execution through the stop_reason: "tool_use" signal and how to extract the necessary details from tool use blocks. However, knowing what Claude 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 Claude requests, capturing their results, and sending those results back to Claude in the proper format. By the end of this lesson, you will have a complete tool execution pipeline that can handle Claude'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 Claude 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 Claude with the user's question and available tools using client.messages.create.
  3. Detect tool use requests — Check Claude's response for the "tool_use" stop reason by examining response.stop_reason.to_s. (Just as we saw in the previous unit, we use .to_s because the Anthropic Ruby SDK returns field values as symbol-like objects; converting them ensures a robust comparison against our string literal "tool_use".)
  4. Extract tool information — Iterate through response.content to extract the method name, input parameters, and unique ID from each tool use block.
  5. Execute the requested methods — Use our tool registry hash to retrieve Method objects and call them with Claude's parameters as keyword arguments.
  6. Collect and format tool results — Build an array of Ruby hashes containing all method outputs in the specific format that Claude expects.
  7. Send results back to Claude — Make a second client.messages.create call with the complete conversation, including all tool results as a single user message.
  8. Display the final response — Show Claude's natural language answer that incorporates the tool outputs.

This complete cycle enables Claude 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 Claude'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 Claude uses and our actual Ruby Method objects.

require "json"
require "anthropic"
require_relative "functions"

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

# Create a hash mapping tool names to Method objects
# This is crucial - the keys must match our tool schema names exactly
tools = {
  "sum_numbers" => method(:sum_numbers),
  "multiply_numbers" => method(:multiply_numbers)
}

# Choose a Claude model
model = "claude-sonnet-4-6"

# System prompt with explicit instructions to use tools
system_prompt = (
  "You are a helpful math assistant. " \
  "Always use the available tools to perform calculations accurately."
)

# Load the schemas from JSON file
tool_schemas = JSON.parse(File.read("schemas.json"))

# Create an array of messages to send to Claude
messages = [
  { role: "user", content: "Please calculate 15 + 27" }
]

This setup creates everything we need for tool execution: the tools hash enables dynamic method lookup using Method objects, the system_prompt guides Claude'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 Claude's string-based tool requests. By storing Method objects (created with method(:sum_numbers)), we can later call them dynamically with the parameters that Claude provides.

Sending the Initial Request

With our foundation in place, we can now send the initial request to Claude and immediately prepare for processing its response. This approach ensures that we maintain a proper conversation history from the start.

# Send the messages to Claude with all necessary components
response = client.messages.create(
  model: model,
  max_tokens: 2000,
  messages: messages,       # The initial user message
  system: system_prompt,    # Instructions for Claude's behavior
  tools: tool_schemas       # Available tools and their specifications
)

# Add the assistant's response content to messages immediately
# This maintains conversation history regardless of the response type
messages << {
  role: "assistant",
  content: response.content
}

This API call provides Claude with the complete context: the user's question through messages, behavioral guidance through system, and available capabilities through tools. The Ruby SDK uses keyword arguments for all parameters, making the API call clear and self-documenting. By immediately adding Claude's response to the messages array using the << operator, we ensure proper conversation flow, whether Claude needs tools or can answer directly. Note that we append response.content directly — the SDK returns this as an array of content blocks, which is the format required for maintaining conversation history.

Detecting Tool Use Requests

After receiving Claude's response and adding it to our conversation history, we examine the stop_reason field to determine whether tool execution is needed. When Claude determines that tools are required, it signals this through the "tool_use" stop reason.

# Check if Claude wants to use any methods and execute them
if response.stop_reason.to_s == "tool_use"
  # Define an array to collect all tool results
  tool_results = []
  
  # Execute each tool use by iterating through response content
  response.content.each do |content_item|
    # Check if this content item is a tool use request
    if content_item.type.to_s == "tool_use"
      # Extract the three essential pieces of information
      tool_name = content_item.name    # Which method to call
      tool_input = content_item.input || {}  # Parameters for the method
      tool_id = content_item.id        # Unique identifier for this tool use
      
      # Print the tool execution details for debugging
      puts "Executing: #{tool_name}(#{tool_input})"
    end
  end
end

The response.stop_reason.to_s == "tool_use" condition indicates that Claude has identified tools it wants to execute and is waiting for their results before continuing. Just as we saw with the content item types in the previous unit, we use .to_s because the SDK returns these field values as symbol-like objects. Converting them ensures a robust string comparison against "tool_use".

We initialize a tool_results array to collect all the results from this tool execution cycle. The extraction process identifies each tool use block within Claude's response by checking content_item.type.to_s == "tool_use" and pulls out the three critical pieces of information: the method name, the input parameters (with a fallback to an empty hash if nil), and the unique identifier. The tool_id is particularly important because we will need it to match results back to their corresponding tool requests.

Executing the Requested Methods

With the tool information extracted, we can now execute the actual Ruby methods using our tool registry hash. This is where the tool names from Claude's requests are translated into actual method calls.

if response.stop_reason.to_s == "tool_use"
  # Define an array to collect all tool results
  tool_results = []
  
  response.content.each do |content_item|
    if content_item.type.to_s == "tool_use"
      # Extract tool name, input and id...
      
      # Use begin-rescue to handle both missing tools and execution errors
      result = begin
        # Retrieve the Method object from our tools hash
        callable = tools.fetch(tool_name)
        
        # Normalize input keys to symbols for keyword arguments
        # Claude sends keys as strings, but Ruby methods expect symbols
        kwargs = tool_input.transform_keys(&:to_sym)
        
        # Call the method with keyword arguments
        callable.call(**kwargs)
      rescue KeyError
        # Handle the case where the requested tool doesn't exist
        "Error: Function #{tool_name} not found"
      rescue => e
        # Handle any errors that occur during method execution
        "Error executing #{tool_name}: #{e}"
      end
      
      # Print the result for debugging and verification
      puts "Result: #{result}"
    end
  end
end

Using a begin-rescue block provides comprehensive error handling that catches not only missing tools but also any runtime errors that might occur during method execution. The tools.fetch(tool_name) call retrieves the actual Method object from our registry hash and raises a KeyError if the tool does not exist. (We use .fetch instead of the standard bracket syntax tools[tool_name] because [] would return nil for a missing key, leading to a confusing NoMethodError later. fetch lets us immediately catch the missing tool in our rescue KeyError block.) Before calling the method, we use transform_keys(&:to_sym) to convert the input hash keys from strings (as Claude sends them) to symbols (as Ruby keyword arguments expect them). The & symbol in &:to_sym is a Ruby shorthand that iterates over the hash and applies the to_sym method to each key. The callable.call(**kwargs) syntax unpacks the hash as keyword arguments, allowing dynamic method execution based on Claude's string-based requests. This approach gracefully handles both missing methods and execution failures, ensuring that the conversation can continue even when errors occur.

Collecting and Formatting Tool Results

After executing each tool (or encountering an error), we must format the results in the specific structure that Claude expects and collect them all before sending them back. This ensures that all tool results are sent together as a single message.

if response.stop_reason.to_s == "tool_use"
  # Define an array to collect all tool results
  tool_results = []
  
  response.content.each do |content_item|
    if content_item.type.to_s == "tool_use"
      # Extract tool name, input and id...
      
      result = begin
        # Execute the tool if it exists...
      rescue KeyError
        # Handle missing tools...
      rescue => e
        # Handle execution errors...
      end
      
      # Print the result for debugging and verification
      puts "Result: #{result}"
      
      # Append a properly structured tool_result for this specific tool_use
      tool_results << {
        type: "tool_result",         # Special content type for tool results
        tool_use_id: tool_id,        # Links result to original request
        content: result.to_s         # The actual method output as string
      }
    end
  end

  # Add all tool results as a single user message
  messages << {
    role: "user",                    # Results come from user perspective
    content: tool_results            # Array of all tool results
  }
end

The tool result structure has specific requirements: each result needs a type: "tool_result" field, the tool_use_id must exactly match the id from the original tool use block, and the content must be converted to a string using .to_s. By collecting all results in an array using the << operator and sending them as a single message, we maintain a proper conversation structure while ensuring Claude receives results for all requested tools.

It is critical to provide a tool result for every tool use request that Claude makes. If you skip providing a result for any tool_use_id, your next API call to Claude will fail because Claude expects to receive results for all the tools it requested. This is why we handle both successful executions and errors in the same way; Claude needs to know what happened with each tool request, whether it succeeded or failed.

Getting Claude's Final Response

Once we have executed all requested tools and added their results to the messages array as a single message, we can send the updated conversation back to Claude to obtain the final response that incorporates the tool results.

if response.stop_reason.to_s == "tool_use"
  # Define an array to collect all tool results
  tool_results = []
  
  response.content.each do |content_item|
    if content_item.type.to_s == "tool_use"
      # Extract tool name, input and id...
      
      result = begin
        # Execute the tool if it exists...
      rescue KeyError
        # Handle missing tools...
      rescue => e
        # Handle execution errors...
      end
      
      # Print the result for debugging and verification
      puts "Result: #{result}"
      
      # Append a properly structured tool_result for this specific tool_use
      tool_results << {
        type: "tool_result",
        tool_use_id: tool_id,
        content: result.to_s
      }
    end
  end

  # Add all tool results as a single user message
  messages << {
    role: "user",
    content: tool_results
  }
  
  # Send the complete conversation with tool results back to Claude
  final_response = client.messages.create(
    model: model,
    max_tokens: 2000,
    messages: messages,      # Now includes tool results
    system: system_prompt,
    tools: tool_schemas
  )
  
  # Add Claude's final response to maintain conversation history
  messages << {
    role: "assistant",
    content: final_response.content
  }
  
  # Display Claude's final response to the user
  puts "\nClaude's final response:"
  puts final_response.content.first.text
end

This second API call uses the same parameters as the first, but now the messages array contains the complete conversation history including all tool results in a single message. Claude can now provide a comprehensive answer that incorporates the tool execution results, transforming raw calculation outputs into natural, conversational responses. We access the final text using final_response.content.first.text, which retrieves the text from the first content block in Claude's response.

The complete execution flow produces output similar to this, showing both the tool execution and Claude's final response:

Executing: sum_numbers({:a=>15, :b=>27})
Result: 42

Claude's final response:
The result of 15 + 27 is 42.

This output demonstrates the complete tool execution cycle: our code executes the requested method with the provided parameters and captures the numerical result, and then Claude 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 Claude can answer directly without needing to execute methods, it will respond with a different stop_reason. Since we have already added Claude's response to our messages array, we just need to handle the display of non-tool responses.

if response.stop_reason.to_s == "tool_use"
  # Execute tools and get final response...
else
  # Handle non-tool responses by displaying Claude's direct answer
  puts "Claude did not use any tools:"
  puts response.content.first.text
end

This else block catches any stop_reason that is not "tool_use" and handles it appropriately. For example, if a user asks "What is the capital of France?", Claude would respond directly without needing mathematical calculations. The most common alternative stop_reason you will encounter is "end_turn", which indicates that Claude has finished its response and does not need any tools to answer the user's question.

If you prefer to be more explicit about which stop reasons you are handling, you can replace the general else with a specific condition:

if response.stop_reason.to_s == "tool_use"
  # Execute tools and get final response...
elsif response.stop_reason.to_s == "end_turn"
  # Handle end_turn responses by displaying Claude's direct answer
  puts "Claude answered directly without tools:"
  puts response.content.first.text
end

Both approaches accomplish the same goal, but the explicit elsif makes it clear that you are specifically handling the "end_turn" case. This ensures your application handles both tool-requiring and non-tool scenarios gracefully, maintaining a proper conversation flow in all cases.

Visualizing the Complete Conversation History

Throughout the tool execution cycle, maintaining a proper conversation history enables Claude to understand context and provide coherent responses. Let's examine how the complete conversation flow develops through each step of the process.

# Print the complete messages history to understand the conversation flow
puts "\nMessages history:"

# Loop through each message to see the conversation development
messages.each_with_index do |msg, i|
  role = msg[:role]
  content = msg[:content]
  puts "\nMessage #{i + 1} - Role: #{role}"
  
  # Handle different content formats (string vs array)
  if content.is_a?(String)
    puts "Content: #{content}"
  else
    # For array content, print each item separately
    content.each_with_index do |c, j|
      puts "Content #{j + 1}: #{c}"
    end
  end
end

We check for strings versus arrays because message content can be in either format. As a best practice, use simple strings for your initial user prompts (like our "Please calculate 15 + 27"), but use arrays of objects when constructing complex messages that involve tool uses and results. The SDK will always return Claude's responses as arrays of structured content blocks. Without this check using content.is_a?(String), iterating over a string would print each character separately instead of the full message. The each_with_index iterator provides us with both the message and its index, allowing us to number the messages clearly.

This debugging output reveals the complete conversation structure that enables Claude's tool-using capabilities:

Messages history:

Message 1 - Role: user
Content: Please calculate 15 + 27

Message 2 - Role: assistant
Content 1: {:text=>"Sure! Let me calculate that for you right away.", :type=>:text}
Content 2: {:id=>"toolu_01Jr3rGVTHWwzLBuDNRsVrTj", :caller_=>{:type=>:direct}, :input=>{:a=>15, :b=>27}, :name=>"sum_numbers", :type=>:tool_use}

Message 3 - Role: user
Content 1: {:type=>"tool_result", :tool_use_id=>"toolu_01Jr3rGVTHWwzLBuDNRsVrTj", :content=>"42"}

Message 4 - Role: assistant
Content 1: {:text=>"The result of **15 + 27 = 42**. ✅", :type=>:text}

This conversation history shows the complete tool execution cycle: the user's initial request, Claude's response containing both an explanation and a tool request (with a Ruby hash representation of the content blocks), our tool result with the matching ID, and Claude's final response incorporating the tool output. Note that the Ruby SDK returns content blocks as objects that display as hashes when they are printed, showing fields like :text, :type, :id, :input, and :name. This structure demonstrates how proper message management creates a seamless conversation flow that transforms Claude into a capable agent.

Summary & Practice Preparation

You now understand the complete tool execution workflow that transforms Claude from a text-only assistant into an agent capable of performing actions and calculations. The process involves detecting tool use requests through stop reasons, executing methods through a tool registry hash of Method objects, collecting and formatting all results together, and maintaining a conversation history throughout the entire cycle.

The key components work together to create a robust system: the tool registry hash enables dynamic execution by mapping tool names to Method objects; proper result collection ensures all tools are handled; correct message formatting using Ruby hashes and arrays allows Claude to understand results; and conversation history maintains context across the entire interaction. The Ruby-specific techniques — such as using method(:function_name) to create Method objects, .to_s for stop reason comparison, transform_keys(&:to_sym) for parameter normalization, and begin-rescue blocks for error handling — combine to create a robust implementation that gracefully handles both successful executions and failures.

In the upcoming practice exercises, you will implement this complete workflow yourself, working with different types of tools and handling various scenarios, including multiple tool uses 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