Part 1: Tool calls intro

Before building the full coding agent, we need to understand how tool calls work with the OpenAI Responses API. We start by asking the model questions without tools, then add tools one at a time, and generalize the pattern into a loop.

Tools

Tools are functions the agent can call to interact with the world. Without tools, the model only sees the prompt and can only guess. With tools, it can inspect files, run commands, and use the results before answering.

The flow looks like this:

  • The user gives input
  • The agent sees the input, its instructions, available tools, and message history
  • The agent decides: respond directly, or call a tool
  • If it calls a tool, we execute it, add the result back to the conversation, and continue
  • When it has enough information, it sends the final answer

This is why tools matter for coding agents. A coding agent should not only talk about code. It should be able to inspect a real project, read files, make changes, and verify the result.

Later in this workshop, our coding agent will use five core tools:

  1. read_file
  2. write_file
  3. see_file_tree
  4. execute_bash_command
  5. search_in_files

For now, we start with two of them.

Asking questions without tools

First, ask the model a question without any tools:

system_prompt = "You are a helpful coding assistant."
user_prompt = "What's in this folder?"

chat_messages = [
    {"role": "developer", "content": system_prompt},
    {"role": "user", "content": user_prompt}
]

response = openai_client.responses.create(
    model=model,
    input=chat_messages,
)

print(response.output_text)

Without tools, the model can only guess. Now we add tools one by one.

Defining the first tool

Define see_file_tree as a normal Python function:

import os

def see_file_tree(root_dir: str = ".") -> list[str]:
    tree = []

    for dirpath, dirnames, filenames in os.walk(root_dir):
        dirnames[:] = [d for d in dirnames if d not in {".git", ".venv", "__pycache__", ".ipynb_checkpoints"}]

        for name in sorted(dirnames + filenames):
            full_path = os.path.join(dirpath, name)
            tree.append(os.path.relpath(full_path, root_dir))

    return tree

Test it directly:

see_file_tree(".")

This is just a regular Python function. The LLM still does not know it exists. We need to pass a tool description that tells the model the tool name, what it does, which arguments it accepts, and which are required:

see_file_tree_description = {
    "type": "function",
    "name": "see_file_tree",
    "description": "List files and directories in the current project.",
    "parameters": {
        "type": "object",
        "properties": {
            "root_dir": {
                "type": "string",
                "description": "Directory to list. Defaults to the current directory.",
            }
        },
        "required": [],
        "additionalProperties": False,
    },
}

Using the first tool

Now include the tool in the request:

response = openai_client.responses.create(
    model=model,
    input=chat_messages,
    tools=[see_file_tree_description]
)

Look at the structured output. In a tool-calling response, one of the items can be a function_call:

response.output

Find the first tool call:

call = next(item for item in response.output if item.type == "function_call")
call

The important fields are type (message or function call), name (which tool), arguments (JSON arguments), and call_id (the identifier for sending the result back). The model does not execute the function on its own. It is telling us which tool it wants to call and with which arguments.

Parse the arguments and run the Python function:

import json

args = json.loads(call.arguments)
result = see_file_tree(**args)
result

This is the key idea: the model decides which tool to use, our Python code executes the tool, then we send the result back.

Add the tool result to the conversation. The model is stateless, so we maintain the history ourselves:

chat_messages.extend(response.output)

chat_messages.append({
    "type": "function_call_output",
    "call_id": call.call_id,
    "output": json.dumps(result),
})

The call_id tells the model which tool call this output belongs to. Now call the model one more time to see the answer.

Adding the second tool

To let the model go beyond the directory tree and inspect actual files, we need a read_file tool:

def read_file(filepath: str) -> str:
    try:
        if filepath == 'uv.lock':
            return "you're not allowed to read this file"

        with open(filepath, "r", encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        return f"Error: file '{filepath}' not found."

And its description for the model:

read_file_description = {
    "type": "function",
    "name": "read_file",
    "description": "Read the contents of a file in the current project.",
    "parameters": {
        "type": "object",
        "properties": {
            "filepath": {
                "type": "string",
                "description": "Path to the file to read.",
            }
        },
        "required": ["filepath"],
        "additionalProperties": False,
    },
}

Now ask a question that should make the agent inspect project files, such as "What dependencies does this project have?":

user_prompt = "What dependencies does this project have?"

chat_messages = [
    {"role": "developer", "content": system_prompt},
    {"role": "user", "content": user_prompt}
]

response = openai_client.responses.create(
    model=model,
    input=chat_messages,
    tools=[see_file_tree_description, read_file_description]
)

Go through the response and execute tool calls:

chat_messages.extend(response.output)

for item in response.output:
    if item.type == 'function_call':
        f_name = item.name
        args = json.loads(item.arguments)
        print(f'tool_call: {f_name}({args})')

        if f_name == 'see_file_tree':
            result = see_file_tree(**args)
        elif f_name == 'read_file':
            result = read_file(**args)
        else:
            result = {'error': f'unknown function {f_name}'}

        chat_messages.append({
            "type": "function_call_output",
            "call_id": item.call_id,
            "output": json.dumps(result),
        })
    elif item.type == 'message':
        print(item.content[0].text)

If the model made tool calls but has not answered yet, call it again with the updated history. The model will see the tool results and produce a final answer.

Generalizing to a while loop

In the previous example we manually repeated cells until getting a response. We can replace that with a while loop that iterates until there are no tool calls:

chat_messages = [
    {"role": "developer", "content": system_prompt},
    {"role": "user", "content": user_prompt}
]

while True:
    response = openai_client.responses.create(
        model=model,
        input=chat_messages,
        tools=[see_file_tree_description, read_file_description]
    )

    has_tool_calls = False
    chat_messages.extend(response.output)

    for item in response.output:
        if item.type == 'function_call':
            has_tool_calls = True
            f_name = item.name
            args = json.loads(item.arguments)
            print(f'tool_call: {f_name}({args})')

            if f_name == 'see_file_tree':
                result = see_file_tree(**args)
            elif f_name == 'read_file':
                result = read_file(**args)
            else:
                result = {'error': f'unknown function {f_name}'}

            chat_messages.append({
                "type": "function_call_output",
                "call_id": item.call_id,
                "output": json.dumps(result),
            })
        elif item.type == 'message':
            print(item.content[0].text)

    if not has_tool_calls:
        break

The structure stays the same: ask the model, inspect response.output, execute the requested tool, append function_call_output, ask again.

Outer Q&A loop

To continue the conversation, add an outer loop that reads user input:

First, set up the outer conversation loop and read user input:

chat_messages = [
    {"role": "developer", "content": system_prompt}
]

while True:
    user_prompt = input(">> ").strip()
    if user_prompt.lower() == "stop":
        print("Chat ended.")
        break

    chat_messages.append({"role": "user", "content": user_prompt})

Inside that loop, nest the tool-call loop from earlier:

    while True:
        response = openai_client.responses.create(
            model=model,
            input=chat_messages,
            tools=[see_file_tree_description, read_file_description]
        )

        has_tool_calls = False

        for item in response.output:
            chat_messages.append(item)

            if item.type == 'function_call':
                has_tool_calls = True
                f_name = item.name
                args = json.loads(item.arguments)
                print(f'tool_call: {f_name}({args})')

                if f_name == 'see_file_tree':
                    result = see_file_tree(**args)
                elif f_name == 'read_file':
                    result = read_file(**args)
                else:
                    result = {'error': f'unknown function {f_name}'}

                chat_messages.append({
                    "type": "function_call_output",
                    "call_id": item.call_id,
                    "output": json.dumps(result),
                })
            elif item.type == 'message':
                print(item.content[0].text)

        if not has_tool_calls:
            break

Now we have two loops: the inner loop handles tool calls until the model is ready to answer, and the outer loop keeps the conversation going until the user types stop. This is the basic shape of an interactive agent.

Try it with: "explore the repo and tell me what you find".

Continue with Part 2: The coding agent to build the full coding agent with ToyAIKit.

Questions & Answers

Sign in to ask questions