When developers first hear the word “AI agent,” it can sound like a new magical runtime. In practice, the core mechanism is much simpler:
An agent is usually an LLM wrapped in a loop that can decide whether to call tools, execute those tools, observe the results, and continue until it can produce a final answer.
Modern frameworks like OpenAI Agents SDK and LangChain create_agent hide much of this loop from you. But underneath, the mental model is still close to a manual loop:
User input
-> LLM call
-> LLM emits tool call
-> application executes tool
-> tool result is appended back to context
-> LLM call again
-> final answer
OpenAI’s function calling documentation describes this pattern directly: you provide tools to the model, the model may return a tool call, and your application sends the tool output back to the model with the corresponding call ID. (OpenAI Developers)
In this article, we will build the same small credit-card recommendation agent in three ways:
- Manual Responses API + tool-calling loop
- OpenAI Agents SDK with
@function_tool - LangChain
create_agent
The goal is not to build a production credit-card engine. The goal is to understand what agent frameworks are actually abstracting away.
The Toy Domain: Credit Card Recommendation
We will use two simple tools:
get_card_catalog(spending_category)
estimate_annual_rewards(card_name, annual_spend)
The user asks something like:
I spend about $12,000 per year on dining and travel.
I prefer annual fee under $100. Which card should I consider?
The agent should call tools to inspect mock product data and estimate rewards before giving a recommendation.
1. Manual Agent Loop with OpenAI Responses API
This is the lowest-level version. We manually define:
- Tool schemas
- Python tool implementations
- Tool registry
- LLM call
- Function-call detection
- Tool execution
- Function-call-output append
- Step limit
This is the most educational version because it shows what agent frameworks are doing behind the scenes.
# manual_agent.py
import json
import os
from typing import Any
from openai import OpenAI
client = OpenAI()
def get_card_catalog(spending_category: str) -> dict[str, Any]:
"""Mock: search credit card catalog by spending category."""
cards = {
"dining": [
{
"name": "Citi Premier",
"annual_fee": 95,
"reward": "3x dining",
},
{
"name": "Custom Cash",
"annual_fee": 0,
"reward": "5% top eligible category",
},
],
"travel": [
{
"name": "Citi Premier",
"annual_fee": 95,
"reward": "3x travel",
},
{
"name": "Premium Travel Card",
"annual_fee": 395,
"reward": "5x flights",
},
],
"costco": [
{
"name": "Costco Anywhere Visa",
"annual_fee": 0,
"reward": "4% gas, 3% travel/dining, 2% Costco",
},
],
}
return {
"category": spending_category,
"cards": cards.get(spending_category.lower(), []),
}
def estimate_annual_rewards(card_name: str, annual_spend: float) -> dict[str, Any]:
"""Mock: estimate annual reward value."""
multiplier = {
"Citi Premier": 0.03,
"Custom Cash": 0.05,
"Premium Travel Card": 0.05,
"Costco Anywhere Visa": 0.02,
}.get(card_name, 0.01)
return {
"card_name": card_name,
"annual_spend": annual_spend,
"estimated_reward_value": round(annual_spend * multiplier, 2),
}
TOOLS = [
{
"type": "function",
"name": "get_card_catalog",
"description": "Search available credit cards by spending category.",
"parameters": {
"type": "object",
"properties": {
"spending_category": {
"type": "string",
"description": "Spending category such as dining, travel, costco, gas, grocery.",
}
},
"required": ["spending_category"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "estimate_annual_rewards",
"description": "Estimate annual reward value for a card and annual spend amount.",
"parameters": {
"type": "object",
"properties": {
"card_name": {
"type": "string",
"description": "Name of the credit card.",
},
"annual_spend": {
"type": "number",
"description": "Estimated annual spend in USD.",
},
},
"required": ["card_name", "annual_spend"],
"additionalProperties": False,
},
},
]
TOOL_IMPLS = {
"get_card_catalog": get_card_catalog,
"estimate_annual_rewards": estimate_annual_rewards,
}
def run_agent(user_message: str, max_steps: int = 5) -> str:
input_items: list[dict[str, Any]] = [
{
"role": "user",
"content": user_message,
}
]
for step in range(max_steps):
response = client.responses.create(
model=os.environ.get("OPENAI_MODEL", "gpt-5.4"),
instructions=(
"You are a credit card recommendation agent. "
"Use tools when product or reward data is needed. "
"Do not recommend applying for a card without explaining tradeoffs. "
"If information is insufficient, ask a concise follow-up question."
),
tools=TOOLS,
input=input_items,
)
tool_calls = [
item for item in response.output
if item.type == "function_call"
]
if not tool_calls:
return response.output_text
# Preserve the model's tool-call request in the conversation trajectory.
input_items.extend(response.output)
for tool_call in tool_calls:
tool_name = tool_call.name
tool_args = json.loads(tool_call.arguments)
if tool_name not in TOOL_IMPLS:
tool_result = {"error": f"Unknown tool: {tool_name}"}
else:
try:
tool_result = TOOL_IMPLS[tool_name](**tool_args)
except Exception as exc:
tool_result = {
"error": f"Tool execution failed: {type(exc).__name__}: {exc}"
}
input_items.append(
{
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": json.dumps(tool_result),
}
)
return "I could not complete the task within the step limit."
if __name__ == "__main__":
answer = run_agent(
"I spend about $12,000 per year on dining and travel. "
"I prefer annual fee under $100. Which card should I consider?"
)
print(answer)
Run it:
export OPENAI_API_KEY="your-api-key"
export OPENAI_MODEL="gpt-5.4"
python manual_agent.py
This version is close to the “bare metal” agent loop. OpenAI’s documentation defines tool calls as requests from the model to use tools and tool-call outputs as the application-generated outputs sent back to the model. (OpenAI Developers)
The key thing to notice is that the model does not execute Python functions by itself. It emits a structured request. Your application executes the function and sends the result back.
What This Manual Version Teaches
This manual version makes the agent architecture visible:
| Agent concept | Manual code |
|---|---|
| Tool definition | TOOLS JSON schema |
| Tool registry | TOOL_IMPLS |
| Model call | client.responses.create(...) |
| Tool call detection | item.type == "function_call" |
| Argument parsing | json.loads(tool_call.arguments) |
| Tool execution | TOOL_IMPLS[tool_name](**tool_args) |
| Observation / tool result | function_call_output |
| Agent loop | for step in range(max_steps) |
| Stop condition | no tool calls returned |
This is why I like building a manual version first. It removes the mystery. Agent frameworks are not magic. They mostly package this loop into a reusable runtime.
2. OpenAI Agents SDK Version
Now let’s build the same thing using the OpenAI Agents SDK.
OpenAI’s Agents SDK defines an Agent as an LLM configured with instructions, tools, and optional runtime behavior like handoffs, guardrails, and structured outputs. The SDK uses the Responses API by default for OpenAI models, and Agent plus Runner lets the SDK manage turns, tools, guardrails, handoffs, and sessions. (OpenAI GitHub Pages)
Install:
pip install openai-agents
Code:
# openai_agents_demo.py
import os
from typing import Any
from agents import Agent, Runner, function_tool
@function_tool
def get_card_catalog(spending_category: str) -> dict[str, Any]:
"""Search available credit cards by spending category."""
cards = {
"dining": [
{
"name": "Citi Premier",
"annual_fee": 95,
"reward": "3x dining",
},
{
"name": "Custom Cash",
"annual_fee": 0,
"reward": "5% top eligible category",
},
],
"travel": [
{
"name": "Citi Premier",
"annual_fee": 95,
"reward": "3x travel",
},
{
"name": "Premium Travel Card",
"annual_fee": 395,
"reward": "5x flights",
},
],
"costco": [
{
"name": "Costco Anywhere Visa",
"annual_fee": 0,
"reward": "4% gas, 3% travel/dining, 2% Costco",
},
],
}
return {
"category": spending_category,
"cards": cards.get(spending_category.lower(), []),
}
@function_tool
def estimate_annual_rewards(card_name: str, annual_spend: float) -> dict[str, Any]:
"""Estimate annual reward value for a card and annual spend amount."""
multiplier = {
"Citi Premier": 0.03,
"Custom Cash": 0.05,
"Premium Travel Card": 0.05,
"Costco Anywhere Visa": 0.02,
}.get(card_name, 0.01)
return {
"card_name": card_name,
"annual_spend": annual_spend,
"estimated_reward_value": round(annual_spend * multiplier, 2),
}
card_agent = Agent(
name="Credit Card Recommendation Agent",
model=os.environ.get("OPENAI_MODEL", "gpt-5.4"),
instructions=(
"You are a credit card recommendation agent. "
"Use tools when product or reward data is needed. "
"Do not recommend applying for a card without explaining tradeoffs. "
"If information is insufficient, ask a concise follow-up question."
),
tools=[
get_card_catalog,
estimate_annual_rewards,
],
)
if __name__ == "__main__":
result = Runner.run_sync(
card_agent,
(
"I spend about $12,000 per year on dining and travel. "
"I prefer annual fee under $100. Which card should I consider?"
),
)
print(result.final_output)
Run it:
export OPENAI_API_KEY="your-api-key"
export OPENAI_MODEL="gpt-5.4"
python openai_agents_demo.py
The important difference is that we no longer manually write the TOOLS JSON schema. The @function_tool decorator wraps Python functions as tools. OpenAI’s Agents SDK documentation explicitly supports wrapping Python functions as function-calling tools. (OpenAI GitHub Pages)
So this:
@function_tool
def get_card_catalog(spending_category: str) -> dict[str, Any]:
"""Search available credit cards by spending category."""
...
roughly replaces the manual work of writing this:
{
"type": "function",
"name": "get_card_catalog",
"description": "Search available credit cards by spending category.",
"parameters": {
...
},
}
And Runner.run_sync(...) replaces the manual loop.
Conceptually:
Manual version:
You own the loop.
OpenAI Agents SDK:
Agent + Runner own the loop.
3. LangChain create_agent Version
Now let’s build the same agent using LangChain.
LangChain’s create_agent combines a language model with tools. Its documentation says agents reason about tasks, decide which tools to use, and iteratively work toward solutions. It also states that create_agent provides a production-ready agent implementation and runs tools in a loop until a stop condition is met. (LangChain Docs)
Install:
pip install -U langchain langchain-openai
Code:
# langchain_create_agent_demo.py
import os
from typing import Any
from langchain.agents import create_agent
from langchain.tools import tool
@tool
def get_card_catalog(spending_category: str) -> dict[str, Any]:
"""Search available credit cards by spending category."""
cards = {
"dining": [
{
"name": "Citi Premier",
"annual_fee": 95,
"reward": "3x dining",
},
{
"name": "Custom Cash",
"annual_fee": 0,
"reward": "5% top eligible category",
},
],
"travel": [
{
"name": "Citi Premier",
"annual_fee": 95,
"reward": "3x travel",
},
{
"name": "Premium Travel Card",
"annual_fee": 395,
"reward": "5x flights",
},
],
"costco": [
{
"name": "Costco Anywhere Visa",
"annual_fee": 0,
"reward": "4% gas, 3% travel/dining, 2% Costco",
},
],
}
return {
"category": spending_category,
"cards": cards.get(spending_category.lower(), []),
}
@tool
def estimate_annual_rewards(card_name: str, annual_spend: float) -> dict[str, Any]:
"""Estimate annual reward value for a card and annual spend amount."""
multiplier = {
"Citi Premier": 0.03,
"Custom Cash": 0.05,
"Premium Travel Card": 0.05,
"Costco Anywhere Visa": 0.02,
}.get(card_name, 0.01)
return {
"card_name": card_name,
"annual_spend": annual_spend,
"estimated_reward_value": round(annual_spend * multiplier, 2),
}
agent = create_agent(
model=f"openai:{os.environ.get('OPENAI_MODEL', 'gpt-5.4')}",
tools=[
get_card_catalog,
estimate_annual_rewards,
],
system_prompt=(
"You are a credit card recommendation agent. "
"Use tools when product or reward data is needed. "
"Do not recommend applying for a card without explaining tradeoffs. "
"If information is insufficient, ask a concise follow-up question."
),
)
if __name__ == "__main__":
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": (
"I spend about $12,000 per year on dining and travel. "
"I prefer annual fee under $100. Which card should I consider?"
),
}
]
}
)
final_message = result["messages"][-1]
print(final_message.content)
Run it:
export OPENAI_API_KEY="your-api-key"
export OPENAI_MODEL="gpt-5.4"
python langchain_create_agent_demo.py
In LangChain, the @tool decorator converts a normal Python function into a tool object that agents can use. LangChain’s tools documentation shows that tool return values may be strings or structured objects such as dictionaries; structured objects are serialized and sent back as tool output for the model to inspect. (LangChain Docs)
LangChain v1 also positions create_agent as the standard way to build agents, replacing the older langgraph.prebuilt.create_react_agent interface with a simpler agent entry point. (LangChain Docs)
Comparing the Three Versions
1. Manual Responses API Version
The manual version gives you maximum control.
You explicitly control:
- Tool schema
- Tool registry
- Tool execution
- Tool-call parsing
- Tool-call output format
- Agent loop
- Max steps
- Error handling
- Conversation trajectory
This is useful when you want to deeply understand how tool calling works, or when your system has strict requirements that do not fit a framework abstraction.
The downside is that you must implement a lot of runtime behavior yourself.
2. OpenAI Agents SDK Version
The OpenAI Agents SDK removes boilerplate.
You define:
- Agent instructions
- Python tools
- Optional model settings
The SDK handles:
- Tool schema generation
- Tool-call loop
- Tool execution
- Turn management
- Optional sessions
- Optional guardrails
- Optional handoffs
The OpenAI Agents SDK is a good fit when your application primarily uses OpenAI models and you want a clean, official orchestration layer.
3. LangChain create_agent Version
LangChain gives you a broader provider-agnostic abstraction.
You define:
- Model provider string
- Tools
- System prompt
LangChain handles:
- Tool wrapping
- Agent loop
- Provider-specific model/tool formatting
- Middleware
- LangGraph-based execution
- LangSmith tracing integration
LangChain’s docs state that create_agent builds a graph-based agent runtime using LangGraph, where the agent moves through nodes such as the model node, tools node, and middleware. (LangChain Docs)
LangChain is a better fit when you want to switch across model providers, add middleware, build more complex workflows, or eventually move into LangGraph-style orchestration.
The Core Mental Model
All three versions implement the same conceptual loop.
1. Send user input and available tools to the model.
2. Model decides whether it can answer directly.
3. If not, model emits a tool call.
4. Application/framework executes the tool.
5. Tool result is sent back to the model.
6. Model continues until it emits a final answer.
The main difference is who owns the loop.
| Version | Who owns the loop? | Who defines tool schema? | Best for |
|---|---|---|---|
| Manual Responses API | You | You | Learning, maximum control, custom runtime |
| OpenAI Agents SDK | OpenAI Runner |
@function_tool helps generate it |
OpenAI-native agent apps |
LangChain create_agent |
LangChain/LangGraph runtime | @tool helps generate it |
Multi-provider agent workflows |
“Is This ReAct?”
Yes, but with a modern caveat.
The original ReAct pattern was often shown as:
Thought -> Action -> Observation -> Thought -> Action -> Observation -> Final Answer
Modern tool-calling agents usually do not need to expose this exact text format. Instead, the loop is structured:
Model output -> tool call
Tool execution -> tool result
Model output -> final answer
So it is better to call this a ReAct-style tool loop rather than assuming the model literally emits Thought, Action, and Observation strings.
Why Agent Frameworks Exist
The manual loop is easy to understand but quickly becomes hard to maintain.
A production agent runtime usually needs:
- Tool argument validation
- Tool timeout handling
- Tool retry logic
- Tool permission checks
- Human approval gates
- Memory/session management
- Conversation summarization
- Token budget control
- Tool selection/gating
- Streaming
- Tracing
- Evaluation
- Structured final output validation
- Multi-agent handoffs
This is why frameworks exist.
OpenAI Agents SDK is useful when you want OpenAI-native ergonomics around agents, tools, guardrails, handoffs, and sessions. LangChain is useful when you want a provider-agnostic agent abstraction with middleware and LangGraph underneath.
But the underlying idea remains simple:
The model decides what tool it wants. The runtime executes that tool. The result goes back into the model. Repeat until done.
Final Takeaway
If you are learning agentic AI, start with the manual version.
It teaches you the real mechanics:
LLM + tools + loop + state = agent runtime
Then move to OpenAI Agents SDK or LangChain once you understand what they abstract away.
My personal recommendation:
Use the manual version to learn.
Use OpenAI Agents SDK for simple OpenAI-native production agents.
Use LangChain / LangGraph when you need provider flexibility, middleware, durable state, or more complex workflows.
The frameworks are helpful, but they are not magic. They are structured ways to implement the same loop you can write by hand.
—proudly generated by my best buddy ChatGPT:)