Build a CLI Agent with OpenAI Function Calling from Scratch

TL;DR: Most agent frameworks abstract away the tool-calling loop. This tutorial peels back the abstraction — 60 lines of Python, the openai package, and JSON schemas. By the end you’ll have a CLI agent that can calculate, read files, and fetch URLs, all by implementing the 5-step function calling protocol directly.
Goal
Build a command-line agent that:
- Accepts natural language commands
- Uses tools (calculator, file reader, URL fetcher) to answer questions
- Runs multiple tool calls in a single turn
- Handles errors gracefully
- Prints a clean chat transcript
All without LangChain, CrewAI, or any agent framework. Just OpenAI’s chat completions API and the function calling protocol.
Prerequisites
- Python 3.10+
- An OpenAI API key with access to
gpt-4oorgpt-4.1 pip install openai
1. The Function Calling Protocol in 5 Steps
Before writing code, understand the protocol. Every tool-using agent follows this loop [1]:
- Send the user message + tool definitions to the model
- Receive tool call requests (name + JSON arguments)
- Execute your local functions with those arguments
- Send the results back as tool response messages
- Repeat until the model returns a plain text response
That’s it. No graph, no runtime, no abstractions. The model tells you what to call and when — you execute and return results.
2. Define Your Tools
Every tool needs a JSON Schema that tells the model what arguments it accepts [1]:
tools = [
{
"type": "function",
"function": {
"name": "calculator",
"description": "Evaluate a mathematical expression",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression e.g. '2 ** 10' or 'sqrt(144)'"
}
},
"required": ["expression"],
"additionalProperties": False
},
"strict": True
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read contents of a file on disk",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute or relative path to file"
}
},
"required": ["path"],
"additionalProperties": False
},
"strict": True
}
},
{
"type": "function",
"function": {
"name": "fetch_url",
"description": "Fetch text content from a URL",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "HTTP or HTTPS URL to fetch"
}
},
"required": ["url"],
"additionalProperties": False
},
"strict": True
}
}
]
Key rules for good tool schemas [1]:
- Write clear descriptions — the model reads these to decide when to call
- Use
strict: true— forces the model to produce valid JSON matching your schema - Set
additionalProperties: false— prevents hallucinated parameters - Use enums for constrained inputs (not shown here, but good for limited option sets)
3. Implement the Tool Handlers
These are plain Python functions that do the actual work:
import math, urllib.request, json, os
def handle_tool_call(tc):
name = tc.function.name
args = json.loads(tc.function.arguments)
if name == "calculator":
# SAFE: only allow math module functions and basic ops
allowed = {k: v for k, v in math.__dict__.items() if not k.startswith("_")}
allowed.update({"abs": abs, "round": round, "min": min, "max": max})
try:
result = eval(args["expression"], {"__builtins__": {}}, allowed)
return str(result)
except Exception as e:
return f"Error: {e}"
elif name == "read_file":
path = args["path"]
if not os.path.isfile(path):
return f"Error: file not found at {path}"
try:
with open(path) as f:
return f.read()[:2000]
except Exception as e:
return f"Error reading file: {e}"
elif name == "fetch_url":
try:
req = urllib.request.Request(
args["url"],
headers={"User-Agent": "CLI-Agent/1.0"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read().decode("utf-8")[:2000]
except Exception as e:
return f"Error fetching URL: {e}"
return f"Unknown tool: {name}"
Security note: The calculator tool uses eval() with a restricted namespace — only math functions and basic builtins. This prevents arbitrary code execution. In production, use a proper expression parser.
4. The Agent Loop
This is the core — the while loop that implements the 5-step protocol:
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
messages = [{"role": "system", "content": "You are a helpful CLI agent. Use tools when needed. Be concise."}]
def agent_loop(user_input):
messages.append({"role": "user", "content": user_input})
while True:
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
# No tool calls — final answer
return msg.content
for tc in msg.tool_calls:
result = handle_tool_call(tc)
print(f" 🛠 {tc.function.name}({tc.function.arguments}) → {result[:80]}...")
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result
})
The loop:
- Appends the user message
- Calls the API with the full message history + tool definitions
- If the model returns tool calls → executes them, appends results, loops
- If no tool calls → the model’s text is the final answer
The tool_choice="auto" parameter lets the model decide when to call tools. To force a tool call, use tool_choice="required" [1].
5. The CLI Entry Point
def main():
print("CLI Agent (type 'exit' to quit)")
print("-" * 40)
while True:
user_input = input("\n> ")
if user_input.lower() in ("exit", "quit"):
break
print()
result = agent_loop(user_input)
print(f"\n 🤖 {result}")
if __name__ == "__main__":
main()
6. Run It
export OPENAI_API_KEY="sk-..."
python3 agent.py
Output:
CLI Agent (type 'exit' to quit)
----------------------------------------
> what's 2 ** 16 + 144?
🛠 calculator({"expression":"2 ** 16 + 144"}) → 65680...
🤖 2 ** 16 = 65536, plus 144 gives 65680.
> read my todo.txt and summarize it
🛠 read_file({"path":"./todo.txt"}) → Buy groceries...
🛠 calculator({"expression":"3 + 2 + 5"}) → 10...
🤖 Your todo.txt has 3 items: groceries, fix the leak, and email Carol.
That's 10 total small tasks across all categories.
> fetch https://example.com
🛠 fetch_url({"url":"https://example.com"}) → <!doctype html>...
🤖 example.com is a simple page: "This domain is for use in illustrative examples."
Notice the parallel tool calls — when reading a file, the model also calculated a count at the same time. OpenAI’s API supports parallel function calling by default [1].
Verification
Run these test inputs to confirm your agent works:
| Input | Expected behavior |
|---|---|
what's 15 * 37? |
Calls calculator, returns 555 |
read the file data.txt |
Calls read_file, returns content or error |
fetch google.com |
Calls fetch_url, returns HTML preview |
who won the world cup in 2018? |
No tool call, answers from training data |
calculate 10! and read my config |
Parallel tool calls, both execute |
To disable parallel calls (zero or one tool per turn):
response = client.chat.completions.create(
...,
parallel_tool_calls=False
)
Production Hardening
For a production agent, add:
- Rate limiting — per-IP, per-key usage tracking
- Timeout per tool call — wrap handlers with
concurrent.futures.TimeoutError - Token budget — track
response.usage.total_tokensand cut the loop when approaching limits - Retry with backoff — handle 429/500 API errors
- Context pruning — summarize old messages when the message list grows past N turns
Summary
In ~60 lines of Python you built a multi-tool agent that:
- Decides when to call tools based on the user’s intent
- Executes calculator, file, and web fetch operations
- Handles parallel tool calls in a single turn
- Reports errors back to the model for recovery
No frameworks. No SDKs beyond openai. Just the function calling protocol, implemented directly. Every agent framework you’ve used — LangChain, CrewAI, AutoGen — wraps this same 5-step loop with abstractions.
What to build next: Add a search_web tool using the SerpAPI or Tavily API, or turn this into a Slack bot by swapping the CLI input for a websocket handler.
Sources
[1] OpenAI Function Calling documentation. https://platform.openai.com/docs/guides/function-calling
[2] OpenAI API Reference — Chat Completions. https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
📖 Related Reads
- NoCode Insider — AI workflow automation with no-code tools, agents, and APIs
- CodeIntel Log — code quality, debugging, and software engineering benchmarks
Cross-links automatically generated from NiteAgent.
← Back to all posts

