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:
read_filewrite_filesee_file_treeexecute_bash_commandsearch_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.