Building an Autonomous Claude Agent

Introduction & Overview

Throughout this course, you have mastered the fundamentals of tool integration with Claude: creating tool schemas, understanding Claude's tool use responses, and executing single tool calls. However, the approach you've learned so far has a significant limitation — it handles only one tool call per conversation turn. While this works perfectly for simple tasks, many real-world problems require multiple sequential steps, and often the number and nature of these steps cannot be determined in advance.

In this lesson, we'll work together to transform Claude from a single-turn tool user into an autonomous agent capable of iterative problem-solving. We'll build an Agent class that can call tools, analyze results, decide what to do next, and continue this process until complex multi-step tasks are completed. This represents a fundamental shift from reactive tool usage to proactive, intelligent problem-solving that mirrors how humans approach complex challenges.

The Action-Feedback Loop Concept

Before we start coding, let's understand how autonomous agents operate through action-feedback loops in which each tool execution provides information that influences the next decision. This iterative process mirrors human problem-solving: we take an action, observe the result, decide what to do next, and repeat until we reach our goal. The action-feedback loop consists of four key phases that repeat until task completion:

  1. Decision Phase: Claude analyzes the current situation and determines the next action, which may include calling one or more tools.
  2. Action Phase: Our agent executes the requested tool(s) based on Claude's instructions.
  3. Feedback Phase: The results from the tool execution(s) are captured and added to the conversation history.
  4. Evaluation Phase: Claude reviews the new information, decides whether the task is complete, or if additional steps are needed, and the loop continues.

This loop structure enables complex problem-solving because each iteration builds upon previous results. For example, when solving a quadratic equation, Claude might first calculate the discriminant, then use that result to determine if real solutions exist, then calculate the square root of the discriminant, and finally compute the two solutions. The key insight is that Claude doesn't need to plan all steps in advance — it can adapt its approach based on intermediate results, just like a human mathematician working through a problem.

Now let's start building our agent class to make this iterative process possible.

Building Our Agent Class Foundation

Let's begin by creating the foundation of our autonomous agent. We need to establish the core structure that will manage extended conversations, tool execution, and decision-making loops. We'll start with the class definition and constructor:

Ruby
require "anthropic"

class Agent
  # Base system prompt to be used for all agents
  BASE_SYSTEM_PROMPT = (
    "You are an autonomous agent that can take multiple tool-calling steps when helpful. " \
    "The user only sees your response when you stop using tools, not your tool usage or reasoning steps. " \
    "When you provide your answer without calling tools, make it complete and standalone.\n" \
    "Additional instructions:\n"
  )

  def initialize(
    name:,
    system_prompt: "You are a helpful assistant.",
    model: "claude-sonnet-4-6",
    tools: nil,
    tool_schemas: nil,
    max_turns: 10
  )
    @client = Anthropic::Client.new
    @name = name
    @model = model
    @system_prompt = BASE_SYSTEM_PROMPT + system_prompt
    @max_turns = max_turns

    # Avoid shared mutable defaults and protect against external mutation
    @tools = tools ? tools.dup : {}                      # name -> Ruby callable
    @tool_schemas = tool_schemas ? tool_schemas.dup : [] # list of {name, description, input_schema}
  end
end

Our agent's foundation relies on key design decisions that enable autonomous behavior while maintaining flexibility for different use cases:

  • BASE_SYSTEM_PROMPT: Explicitly tells Claude that it can make multiple tool calls and that users won't see the intermediate steps — only the final result. We're combining this with a custom system_prompt to allow for domain-specific instructions while maintaining the autonomous behavior.

  • Constructor parameters: Provide flexibility for different scenarios while ensuring some safe defaults:

    • name:: Provides a clear identifier for the agent, which is useful for debugging, logging, and working with multiple agents in complex systems.
    • system_prompt:: Allows customization for specific domains like math or data analysis.
    • model:: Specifies which Claude model to use for agent interactions (defaulting to "claude-sonnet-4-6").
    • tools: and tool_schemas::
      • Defaults to nil to avoid shared mutable defaults.
      • Are duplicated via .dup so that each agent gets its own independent registry and schema list, preventing later external mutations from affecting the agent.
      • Accepts any hash/array inputs for convenience.
    • max_turns:: Prevents infinite loops by limiting the number of iterative steps.
  • Tools structure: In Ruby, our @tools hash maps tool names (strings) to callable objects like Method instances obtained from method(:function_name). This allows us to dynamically invoke the correct Ruby method when Claude requests a tool.

This architecture separates concerns cleanly while preparing us to implement the core functionality that will make our agent truly autonomous.

Adding Helper Methods for State Management

As our agent works through complex problems, we need to manage conversation state properly. Let's add two essential helper methods that will support our main loop:

Ruby
def extract_text(content)
  content
    .select { |block| block.type.to_s == "text" }
    .map(&:text)
    .join
end

def build_request_args(messages)
  # Create a hash with the basic request arguments
  args = {
    model: @model,
    system: @system_prompt,
    messages: messages,
    max_tokens: 8000
  }

  # Add tool schemas only if they exist
  args[:tools] = @tool_schemas unless @tool_schemas.empty?

  # Return the complete set of arguments to use for the API call
  args
end

These helper methods might seem simple, but they're essential for maintaining clean separation between the complex orchestration logic we're about to write and the details of message handling:

  • extract_text: Safely extracts and combines text blocks from Claude's responses, which could contain one or more text blocks mixed with other content like tool use blocks. We filter the content array by checking whether each block's type equals "text", then map to extract the text content and join everything into a single string. This ensures we return clean, readable final responses to users.

  • build_request_args: Centralizes how we construct API requests, ensuring consistent parameters across all agent interactions. Notice how we conditionally include tool schemas using unless @tool_schemas.empty? — this prevents API errors when we create agents without tools while simultaneously supporting full tool integration when needed.

These helper methods provide a clean foundation for the complex orchestration logic we're about to implement.

Implementing Tool Execution

Now let's add the method that handles individual tool executions within our agent loop. This method needs to be robust because tool failures shouldn't break our entire autonomous process:

Ruby
def call_tool(tool_use)
  # Get the tool name, input, and id
  tool_name = tool_use.name
  tool_input = tool_use.input || {}
  tool_use_id = tool_use.id

  # Display which tool is being called
  puts "🔧 Tool called: #{tool_name}(#{tool_input})"

  result = begin
    callable = @tools.fetch(tool_name)
    kwargs = tool_input.transform_keys(&:to_sym)
    callable.call(**kwargs).to_s
  rescue KeyError
    "Error: Tool #{tool_name} not found"
  rescue => e
    "Error: #{e}"
  end

  # Return the tool result
  {
    type: "tool_result",
    tool_use_id: tool_use_id,
    content: result
  }
end

This method handles the individual tool executions occurring within our larger iterative loop. Here's how it manages the execution flow:

  1. Extracts tool information: Gets the tool_name, input parameters, and unique tool_use_id from Claude's tool use request.
  2. Debug tracking: Prints which tool is being called with what parameters using puts — which is invaluable for debugging and understanding how our agent thinks.
  3. Executes with comprehensive error handling:
    • Uses a begin...rescue block to handle errors gracefully.
    • rescue KeyError catches cases where the tool doesn't exist in our registry (when fetch fails).
    • rescue => e catches any other execution failures.
    • Transforms string keys from the API into Ruby symbols via transform_keys(&:to_sym) so that they work with our keyword argument methods.
    • Calls the tool with callable.call(**kwargs) to properly expand the hash into keyword arguments.
  4. Returns structured results: Converts both successful results and errors into properly formatted tool_result hashes.

The error handling ensures that tool failures don't break the entire agent loop. Instead, errors are converted into tool results that Claude can understand and potentially work around. This robustness allows our agent to continue operating even when individual tools encounter problems, making the whole system much more resilient.

Building the Core Loop - Part 1: Understanding Stateless Design

Now we're ready to implement the heart of our autonomous agent: the run method. This method will manage the iterative loop that enables multi-step problem-solving. Let's start by understanding how our agent handles conversation state:

Ruby
def run(input_messages)
  # Create a copy of the input messages to avoid modifying the original
  messages = input_messages.map(&:dup)
end

The input_messages.map(&:dup) call is important because it ensures our agent remains stateless. Similar to normal LLM API calls where you pass the complete conversation history each time, our agent doesn't store any conversation state between calls. Each time you call agent.run, you provide the full context through input_messages, and the agent processes only that specific conversation without any memory of previous interactions.

By creating a shallow copy of each message hash instead of modifying them directly, we preserve the original conversation and allow the same agent instance to handle multiple independent conversations. Note that this is a shallow copy — the message hashes themselves are duplicated, but any nested objects within them are still shared references. This design also gives you complete control over context management — you can decide exactly what conversation history to include, filter out irrelevant messages, or combine conversations as needed before passing them to the agent.

Building the Core Loop - Part 2: Setting Up the Iteration

Now let's add the basic loop structure that will enable our agent's iterative problem-solving:

Ruby
def run(input_messages)
  # Create a copy of the input messages to avoid modifying the original
  messages = input_messages.map(&:dup)

  # Initialize turn counter to track iterations
  turn = 0

  # Loop until the model returns a final answer or the max turns is reached
  while turn < @max_turns
    # Increment the turn
    turn += 1

    # Ask the model for a response
    response = @client.messages.create(**build_request_args(messages))

    # Add Claude's response to messages exactly as returned (text + tool_use blocks)
    messages << { role: "assistant", content: response.content }
  end
end

We're starting with a controlled loop that will continue until Claude provides a final answer or we reach our maximum turn limit. Each iteration represents one complete action-feedback cycle where Claude makes a decision (potentially including tool calls), and we capture that decision in our conversation history. The turn counter prevents infinite loops while allowing sufficient iterations for complex problems.

Notice that we use the double-splat operator ** to expand our build_request_args hash into keyword arguments for the create method, following Ruby's idiomatic approach to method calls.

Building the Core Loop - Part 3: Handling Tool Calls

Now let's add the logic for handling tool calls within our loop. This is where the magic of autonomous behavior happens:

Ruby
def run(input_messages)
  # Create a copy of the input messages to avoid modifying the original...

  # Loop until the model returns a final answer or the max turns is reached
  while turn < @max_turns
    # Increment the turn...
    # Ask the model for a response...
    # Add Claude's response to messages...
    
    # Check if Claude wants to use any tools
    if response.stop_reason.to_s == "tool_use"
      # Initialize a list to store tool results
      tool_results = []

      # Execute each tool use
      response.content.each do |content_item|
        # Check if the content item is a tool use
        next unless content_item.type.to_s == "tool_use"

        # Execute the tool with the given input
        tool_results << call_tool(content_item)
      end

      # Add all tool results to messages
      messages << { role: "user", content: tool_results }
    end
  end
end

When Claude decides to use tools, we handle the execution through a systematic process:

  1. Detect tool usage: We check whether response.stop_reason.to_s == "tool_use" to determine if Claude wants to call tools. As we learned earlier, we convert to string for robust comparison since the SDK returns symbol-like values.

  2. Execute all requested tools: Claude might call multiple tools in a single turn, and we need to execute each one to gather all the information it needs for its next decision. We iterate through the response.content array and process each tool_use block.

  3. Collect results systematically: Using next unless to skip non-tool-use blocks, we execute each tool while collecting the tool_results in an array.

  4. Feed results back as user message: By adding the tool results as a "user" message using messages <<, we maintain the proper conversation flow that Claude expects, ensuring the results become part of the context for the next iteration.

Each tool result influences Claude's subsequent reasoning, allowing it to build upon what it just learned and make more informed decisions in the next turn.

Building the Core Loop - Part 4: Managing Flow Control

Finally, let's complete our loop with the logic for handling final responses and error conditions:

Ruby
def run(input_messages)
  # Create a copy of the input messages to avoid modifying the original...

  # Loop until the model returns a final answer or the max turns is reached
  while turn < @max_turns
    # Increment the turn...
    # Ask the model for a response...
    # Add Claude's response to messages...
    
    # Check if Claude wants to use any tools
    if response.stop_reason.to_s == "tool_use"
      # Execute each tool use and add results to messages...
    else
      # Extract the text from the response
      response_text = extract_text(response.content)

      # Return the agent history and final output
      return [messages, response_text]
    end
  end

  # If the max turns is reached, raise an exception
  raise "Max turns reached"
end

When Claude reaches a final answer, we handle the completion using a structured return process:

  1. Detect completion: When Claude doesn't want to use tools (indicated by a different stop_reason), it signals that it has reached a final answer and no further iterations are needed.

  2. Extract clean response: We use our extract_text helper to pull out the readable text content from Claude's response, filtering out any non-text blocks.

  3. Return complete state: We return both the full conversation history (messages) and the final response text (response_text) as an array to maintain our stateless design — the caller receives everything needed to understand what happened and can use the conversation history for follow-up questions or multi-turn interactions.

  4. Safety net for runaway loops: The raise statement for reaching max turns prevents infinite loops if something goes wrong. You can control this limit through the @max_turns parameter, or alternatively implement a mechanism to force Claude to provide a final answer when approaching the limit rather than raising an exception.

This dual return approach reinforces our stateless architecture by giving the caller complete control over conversation state while providing both the detailed interaction history for continued conversations and the clean final answer for immediate use.

Complete Run Method

Here's how our complete run method looks when put together:

Ruby
def run(input_messages)
  # Create a copy of the input messages to avoid modifying the original
  messages = input_messages.map(&:dup)
  # Initialize turn counter to track iterations
  turn = 0

  # Loop until the model returns a final answer or the max turns is reached
  while turn < @max_turns
    # Increment the turn
    turn += 1

    # Ask the model for a response
    response = @client.messages.create(**build_request_args(messages))

    # Add Claude's response to messages exactly as returned (text + tool_use blocks)
    messages << { role: "assistant", content: response.content }

    # Check if Claude wants to use any tools
    if response.stop_reason.to_s == "tool_use"
      # Initialize a list to store tool results
      tool_results = []

      # Execute each tool use
      response.content.each do |content_item|
        # Check if the content item is a tool use
        next unless content_item.type.to_s == "tool_use"

        # Execute the tool with the given input
        tool_results << call_tool(content_item)
      end

      # Add all tool results to messages
      messages << { role: "user", content: tool_results }
    else
      # Extract the text from the response
      response_text = extract_text(response.content)

      # Return the agent history and final output
      return [messages, response_text]
    end
  end

  # If the max turns is reached, raise an exception
  raise "Max turns reached"
end

Testing Our Autonomous Agent

Now let's put our agent to work! We'll create a math-focused autonomous agent and see how it handles a complex quadratic equation. We will provide additional math tools following the same pattern used throughout the course. These new methods (subtract_numbers, divide_numbers, power, and square_root) are designed to take keyword arguments (a: and b:, or just a: for square_root) to match the inputs Claude provides.

For example, the square_root method looks like this:

Ruby
def square_root(a:)
  raise ArgumentError, "a must be non-negative" if a < 0
  Math.sqrt(a)
end

And its corresponding JSON schema defines a as the only required parameter.

Let's set up the agent with these tools:

Ruby
require "json"
require_relative "agent"
require_relative "functions"

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

# Create a hash mapping tool names to Method objects
tools = {
  "sum_numbers" => method(:sum_numbers),
  "multiply_numbers" => method(:multiply_numbers),
  "subtract_numbers" => method(:subtract_numbers),
  "divide_numbers" => method(:divide_numbers),
  "power" => method(:power),
  "square_root" => method(:square_root)
}

# Create a stateless autonomous agent
agent = Agent.new(
  name: "math_assistant",
  system_prompt: "You are a helpful math assistant.",
  tools: tools,
  tool_schemas: tool_schemas,
  max_turns: 15
)

# Initialize conversation with user message
messages = [
  { role: "user", content: "Solve this equation: 2x² - 7x + 3 = 0" }
]

# Send message to the stateless agent
messages, result = agent.run(messages)

# Display the response
puts "\nFinal response:"
puts result

When we run this code, our agent demonstrates sophisticated autonomous reasoning:

text
🔧 Tool called: power({:a=>-7, :b=>2})
🔧 Tool called: multiply_numbers({:a=>4, :b=>6})
🔧 Tool called: multiply_numbers({:a=>2, :b=>2})
🔧 Tool called: subtract_numbers({:a=>49, :b=>24})
🔧 Tool called: square_root({:a=>25})
🔧 Tool called: sum_numbers({:a=>7, :b=>5})
🔧 Tool called: subtract_numbers({:a=>7, :b=>5})
🔧 Tool called: divide_numbers({:a=>12, :b=>4})
🔧 Tool called: divide_numbers({:a=>2, :b=>4})

Final response:
---

## ✅ Solution

The two solutions are:

$$x_1 = \frac{7 + 5}{4} = \frac{12}{4} = \boxed{3}$$

$$x_2 = \frac{7 - 5}{4} = \frac{2}{4} = \boxed{\frac{1}{2}}$$

### Verification:
- **x = 3:** 2(9) - 7(3) + 3 = 18 - 21 + 3 = **0** ✓
- **x = ½:** 2(¼) - 7(½) + 3 = 0.5 - 3.5 + 3 = **0** ✓

Our agent systematically applied the quadratic formula by calculating b² ((-7)²), computing 4ac (4×2×3 and 2×2 for 2a), finding the discriminant (49-24), taking the square root (√25), and finally calculating both solutions through the complete quadratic formula. Each tool call built upon previous results, demonstrating true autonomous reasoning. The agent made 9 tool calls across multiple conversation turns, yet the user only sees the final, complete answer with verification.

Summary & Practice Preparation

Together, we've successfully built an autonomous agent capable of complex, multi-step problem-solving. Our Agent class encapsulates conversation management, tool execution, and iterative decision-making in a reusable structure that can tackle problems requiring dozens of sequential operations.

The architecture we created enables Claude to operate as a true autonomous agent: it can assess situations, make decisions, execute tools, learn from results, and continue iterating until complex tasks are completed. This represents a fundamental advancement from simple tool usage to intelligent, adaptive problem-solving.

In the upcoming practice exercises, you'll implement your own autonomous agents, experiment with different system_prompt and tool combinations, and tackle increasingly complex multi-step problems. You'll gain hands-on experience with the debugging and optimization techniques needed for production agent systems, building upon the solid foundation we've created together.

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