You can build a working AI agent without a framework. At its core, an
agent is a language model that can use tools and remember context.
Frameworks like LangChain or
AutoGPT get you started
quickly, but their layers of abstraction make it harder to see what's
actually happening, and harder to customize the agent for your use case.
In this tutorial we build an agent from scratch with direct API calls to
GPT-4o. The agent queries a PostgreSQL database, looks things up on
Wikipedia, and keeps conversation context in PostgreSQL. The whole thing
comes down to three parts: tool definitions for OpenAI function calling, a
loop that feeds tool results back to the model, and a small memory layer.
By the end you'll know what an agent is, how it works and how to build one.
If you still decide to use a framework afterwards, that's fine. You'll just
have a much better idea of what it does under the hood.
Tools (sometimes called "functions" or "capabilities") are functions the
agent can call to interact with the outside world. In Python, a tool is a
plain function with:
A clear description of what it does
Defined input parameters
Structured output
Here's a concrete example of a tool:
1def search_wikipedia(query: str) -> str:
2 """
3 Searches Wikipedia and returns the first paragraph of the most relevant article.
4
5 Args:
6 query: The search term to look up on Wikipedia
7
8 Returns:
9 str: The first paragraph of the most relevant Wikipedia article
10 """
11 return wikipedia.summary(query, sentences=3)
12
13# This is how we describe the tool to the LLM
14wikipedia_tool_description = """
15Tool: search_wikipedia
16Description: Searches Wikipedia and returns a summary of the topic
17Input: A search query string
18Example: To learn about Python programming, use: search_wikipedia("Python programming language")
19"""
Giving the agent this tool is the same as telling it: "You can search
Wikipedia by calling this function." Based on the user's question, the LLM
then decides whether it needs the tool to give a good answer. "The LLM
decides" means we prompt the LLM with the list of available tools and ask
it which one to use. The model simply returns a text response with the
tool name.
Common examples of tools:
Database queries
Web searches
API calls
File operations
Calculator functions
Sending emails
Put agents and tools together and there's no magic left. It's model calls
and a bunch of Python functions. In pseudo-code, an AI agent looks like
this:
1# This is what an agent interaction basically looks like
13 Decide if you need to use a tool to answer. If yes, specify which tool and how to use it."""
14
15 # 2. The LLM decides what to do
16 llm_response = gpt4_call(prompt)
17
18 # 3. We execute the tool if needed and get the final answer
19 if "use query_database" in llm_response:
20 # Execute database query and get result
21 data = execute_query(extract_query(llm_response))
22 return get_final_answer(data)
23
24 return llm_response
That is the whole concept of an agent. Complex planning, multiple tool
calls and sophisticated memory systems are extensions of this basic
pattern.
Some common misconceptions about agents. Agents don't need:
Complex orchestration frameworks
To run autonomously (though they can)
Sophisticated planning algorithms (though these can help for complex
tasks)
What makes an agent effective is not the framework. It comes down to:
Clear prompts that help the LLM understand its capabilities
Reliable tools the agent can use
Relevant context kept in some form of memory storage
In the following sections we implement this pattern with real code: an
agent that actually queries databases and searches Wikipedia. You'll see
that the complexity comes from the requirements of your use case, not from
the agent architecture.
Frameworks like LangChain and AutoGPT are often recommended for building
AI agents. They're useful for quick prototypes, but there are good reasons
to skip them.
Compare an error from a framework with one from your own code:
1# Framework error:
2AgentError: Tool execution failed: SQLToolkit.query failed with error:
3Invalid chain configuration...
4
5# Without framework:
6try:
7 data = execute_query(query)
8except SQLException as e:
9 print(f"SQL error: {e}") # Clear what went wrong
Note: This is probably the strongest reason to build agents without
frameworks. Frameworks abstract away the core of any AI agent: the LLM
interaction. Unless you add extra infrastructure, you don't see the exact
prompts, the LLM's answers or the tools it called. That makes debugging
a nightmare.
Note: Raw performance is arguably not the most important thing for
AI agents, because the model calls themselves are slow. Loading a few
extra modules doesn't add up to much.
For production systems, or whenever you need full control, a direct
implementation is often the better choice. (If you do want a framework,
smolagents is one of the more
minimal ones.)
Now let's build the agent and see how straightforward the direct
implementation is.
The agent has four parts: the tools, the agent class with its tool-call
loop, an optional memory layer, and the code that uses the agent. We start
with the tools.
A PostgreSQL query tool that lets the agent query your database and
search for information stored in it.
A Wikipedia search tool that lets the agent look up information on
Wikipedia.
The two tools do very different things, so the agent has to decide, based
on the user's question, which one to use.
For OpenAI's function calling, each tool needs three components:
A schema that tells the LLM what the tool does and how to use it. This
is a JSON description of the Python function; the example below makes it
clear.
The actual implementation of the tool (the Python function)
Error handling so the agent behaves gracefully when things go wrong
The schema matters most, because it is the LLM's documentation. Think of
it as writing API docs: the better you describe your tools, the better the
LLM uses them. The important fields are name (which should match the
function name), description (what the tool does) and parameters (the
values the function needs).
For the database tool, we:
Allow only SELECT queries for safety
Return results as JSON for easy parsing
Handle database connection errors
For the Wikipedia tool, we:
Limit results to three sentences to keep responses short
Handle disambiguation pages and missing articles
Return structured error messages when searches fail
Here's the implementation of both tools:
1import json
2import psycopg2
3import wikipedia
4from typing import Dict, List, Any
5from openai import OpenAI
6
7# First, we define how the LLM should understand our tools
8tools = [
9 {
10 "type": "function",
11 "function": {
12 "name": "query_database",
13 "description": """Execute a PostgreSQL SELECT query and return the results.
14 Only SELECT queries are allowed for security reasons.
15 Returns data in JSON format.""",
16 "parameters": {
17 "type": "object",
18 "properties": {
19 "query": {
20 "type": "string",
21 "description": """The SQL SELECT query to execute.
22 Must start with SELECT and should not
23 contain any data modification commands."""
24 }
25 },
26 "required": ["query"],
27 "additionalProperties": False
28 },
29 "strict": True
30 }
31 },
32 {
33 "type": "function",
34 "function": {
35 "name": "search_wikipedia",
36 "description": """Search Wikipedia and return a concise summary.
37 Returns the first three sentences of the most
38 relevant article.""",
39 "parameters": {
40 "type": "object",
41 "properties": {
42 "query": {
43 "type": "string",
44 "description": "The topic to search for on Wikipedia"
45 }
46 },
47 "required": ["query"],
48 "additionalProperties": False
49 },
50 "strict": True
51 }
52 }
53]
54
55# Now implement the actual tool functions
56def query_database(query: str) -> str:
57 """
58 Execute a PostgreSQL query and return results as a JSON string.
59 Includes safety checks and error handling.
60 """
61 # Security check: Only allow SELECT queries
62 if not query.lower().strip().startswith('select'):
63 return json.dumps({
64 "error": "Only SELECT queries are allowed for security reasons."
65 })
66
67 try:
68 # You should use environment variables for these in production
11└─► Final Response: "There are 1,234 users in the database"
This design gives us:
Clear separation of concerns
Easy debugging and monitoring
Simple extension with new tools
Robust error handling
A maintainable codebase
The agent runs as a loop: we send the results of the tool calls back to
the LLM, which then decides whether to write a final answer or call
another tool. The implementation below caps the loop at five iterations.
105 # Add the model's thinking to conversation history
106 self.messages.append(response_message)
107
108 # Process all tool calls
109 for tool_call in response_message.tool_calls:
110 try:
111 print("Tool call:", tool_call)
112 result = self.execute_tool(tool_call)
113 print("Tool executed......")
114 except Exception as e:
115 print("Execution failed......")
116 result = json.dumps({
117 "error": f"Tool execution failed: {str(e)}"
118 })
119
120 print(f"Tool result custom: {result}")
121
122 self.messages.append({
123 "role": "tool",
124 "tool_call_id": tool_call.id,
125 "content": str(result)
126 })
127 print("Messages:", self.messages)
128
129 # If we've reached max iterations, return a message indicating this
130 max_iterations_message = {
131 "role": "assistant",
132 "content": "I've reached the maximum number of tool calls (5) without finding a complete answer. Here's what I know so far: " + response_message.content
Note: Consider whether you need memory at all. Does your application
really need to store and retrieve conversation histories? More often than
not, the answer is no. But if you do need memory, here's how to add it.
Memory in AI agents is often overcomplicated. Here's what we're actually
trying to achieve.
Interested in building high-quality AI agent systems?
We prepared a comprehensive guide based on cutting-edge research for how to build robust, reliable AI agent systems that actually work in production. This guide covers:
Understanding the 14 systematic failure modes in multi-agent systems
Evidence-based best practices for agent design
Structured communication protocols and verification mechanisms
17 chat_with_agent("How many users do we have in our database?")
18 chat_with_agent("What's the average age of users from New York?")
19 chat_with_agent("Compare our user base in California with PostgreSQL's popularity there")
20 chat_with_agent("What were the numbers you just mentioned?")
21 chat_with_agent("What are the most interesting trends you can find in our user data?")
That's it. The agent will:
Use the database when needed
Search Wikipedia when appropriate
Remember previous context
Create summaries automatically
Handle the complexity behind the scenes, while still giving us direct
debugging access
The output looks clean and natural:
1User: How many users do we have in our database?
2Assistant: According to the database, we currently have 10,432 registered users.
3
4User: What's the average age of users from New York?
5Assistant: I've queried the database and found that users from New York have an average age of 31.5 years.
6
7User: Compare our user base in California with PostgreSQL's popularity there
8Assistant: Let me check both sources. According to our database, we have 2,345 users from California. From Wikipedia, PostgreSQL is particularly popular in the Silicon Valley region, being used by companies like Apple, Instagram, and Reddit. In fact, many of our California users (43%) are in the tech industry, which aligns with PostgreSQL's strong presence in the region.
The PostgreSQL tool above is a little too simple, isn't it? How would the
model know which tables and columns exist? It wouldn't. We left this
detail out to keep the first version readable. The improved database tool
puts the schema information into the tool description, so the LLM knows
what data is available and how to query it correctly:
1def get_database_schema() -> str:
2 """Retrieve the database schema information"""
3 schema_query = """
4 SELECT
5 t.table_name,
6 array_agg(
7 c.column_name || ' ' ||
8 c.data_type ||
9 CASE
10 WHEN c.is_nullable = 'NO' THEN ' NOT NULL'
11 ELSE ''
12 END
13 ) as columns
14 FROM information_schema.tables t
15 JOIN information_schema.columns c
16 ON c.table_name = t.table_name
17 WHERE t.table_schema = 'public'
18 AND t.table_type = 'BASE TABLE'
19 GROUP BY t.table_name;
20 """
21
22 try:
23 with psycopg2.connect("dbname=your_db user=your_user password=your_pass") as conn:
24 with conn.cursor() as cur:
25 cur.execute(schema_query)
26 schema = cur.fetchall()
27
28 # Format schema information
29 schema_str = "Database Schema:\n"
30 for table_name, columns in schema:
31 schema_str += f"\n{table_name}\n"
32 for col in columns:
33 schema_str += f" - {col}\n"
34
35 return schema_str
36 except Exception as e:
37 return f"Error fetching schema: {str(e)}"
38
39# Updated tool definition with schema information;
40# Note that we could also dynamically fetch the schema when needed
41tools = [
42 {
43 "type": "function",
44 "function": {
45 "name": "query_database",
46 "description": """Execute a PostgreSQL SELECT query and return the results.
47 Available tables and their schemas:
48
49 users
50 - id SERIAL PRIMARY KEY
51 - email VARCHAR(255) NOT NULL
52 - age INTEGER
53 - location VARCHAR(100)
54 - signup_date DATE NOT NULL
55 - last_login TIMESTAMP WITH TIME ZONE
56 - job_industry VARCHAR(100)
57
58 user_activity
59 - id SERIAL PRIMARY KEY
60 - user_id INTEGER REFERENCES users(id)
61 - activity_date DATE NOT NULL
62 - activity_type VARCHAR(50) NOT NULL
63 """,
64 "parameters": {
65 "type": "object",
66 "properties": {
67 "query": {
68 "type": "string",
69 "description": "The SQL SELECT query to execute. Must start with SELECT for security."
70 }
71 },
72 "required": ["query"],
73 "additionalProperties": False
74 },
75 "strict": True
76 }
77 }
78]
79
80# Updated database query function
81def query_database(query: str) -> str:
82 """
83 Execute a PostgreSQL query with schema awareness
84 """
85 if not query.lower().strip().startswith('select'):
86 return json.dumps({
87 "error": "Only SELECT queries are allowed for security reasons.",
88 "schema": get_database_schema() # Return schema for reference
89 })
90
91 try:
92 with psycopg2.connect("dbname=your_db user=your_user password=your_pass") as conn:
93 with conn.cursor() as cur:
94 cur.execute(query)
95
96 # Get column names from cursor description
97 columns = [desc[0] for desc in cur.description]
98
99 # Fetch results and convert to list of dictionaries
100 results = []
101 for row in cur.fetchall():
102 results.append(dict(zip(columns, row)))
103
104 return json.dumps({
105 "success": True,
106 "data": str(results),
107 "row_count": len(results),
108 "columns": columns
109 })
110
111 except Exception as e:
112 return json.dumps({
113 "error": str(e),
114 "schema": get_database_schema() # Return schema on error for help
115 })
116
117# Usage example:
118agent = Agent()
119
120# The agent now knows about the schema and can write better queries
121response = agent.process_query(
122 "What's the age distribution of users by location and industry?"
123)
What changed in the schema-aware tool:
Schema information in the tool description
Column types and constraints included
Schema returned on errors for better error handling
Detailed result structure with column names
With these changes, the LLM can:
Write correct queries on the first try
Understand the available data
Handle errors better
Give more detailed answers
The result is a complete agent in plain Python: two tools, a tool-call
loop, optional PostgreSQL memory and a schema-aware database tool. To make
it plan and handle multi-step tasks, continue with
building a smarter, multi-step AI agent.
Writing a JSON schema by hand for every tool gets tedious once there are
more than a few. langchain-autotools generates
tool definitions from existing Python SDKs, and wrapping your tools in an
MCP server built with FastMCP makes
them usable from any MCP client.
Andreas has spent more than eight years building data applications. At Pondhouse Data he designs and ships AI systems and integrates them into complex, real-world environments.