Part 1: Tool calls intro

Before building the full coding agent, we need to understand tool calls in the OpenAI Responses API. We start by asking the model questions without tools. Then we 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 read 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 read a real project, change files, 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. 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 the function directly:

see_file_tree(".")

This is just a regular Python function. The LLM still does not know it exists.

Pass a tool description with the tool name, what it does, and its arguments.

Mark the required arguments too:

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

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, and then we send the result back.

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

Append the tool call and result like this:

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 read 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,
    },
}

Ask a question that should make the agent read project files.

Use this example question:

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.

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.
  • Look at 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. 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