Skip to content
howdoaiagentswork.com

How to Create an AI Agent

What You'll Build

By the end of this guide you will have a working Python AI agent that can search the web, call simple APIs (including a calculator), and run short code snippets — then return a finished answer. You will wire the classic reason → act → observe loop, add short-term and long-term memory, and deploy the agent as both a FastAPI endpoint and a Streamlit chat UI.

This is intentionally a "from first principles" tutorial. Frameworks are great, but if you only copy a CrewAI template you may never see the loop that makes an agent an agent. Once you understand that loop, every framework becomes easier to evaluate — and easier to debug when something loops forever or burns tokens.

Prerequisites: basic Python (functions, virtualenvs), an OpenAI API key, and comfort running commands in a terminal. You do not need prior LangChain experience. Budget roughly one focused evening for the core loop, plus another for memory and deployment.

Learn the fundamentals first →

Step 1: Choose Your Agent Framework

You can hand-roll an agent loop with the raw OpenAI API (we do that in Step 4), or start from a framework. In 2026 three options cover most builders:

Option A: OpenAI Agents SDK (Beginner-Friendly)

The OpenAI Agents SDK is the fastest path if you already use OpenAI models. It includes tracing, guardrails, and handoffs between agents with minimal boilerplate. Choose it when you want production-shaped defaults and do not need a complex graph of custom state.

Trade-off: you are coupled to OpenAI's abstractions and model ecosystem. For multi-cloud or heavy custom control flow, look at LangGraph.

Option B: LangGraph (Most Popular, Most Flexible)

LangGraph models agents as state machines with nodes, edges, and checkpoints. It is ideal for branching, retries, and human-in-the-loop approvals. LangSmith gives you deep observability for production.

Trade-off: steeper learning curve than CrewAI or the OpenAI SDK. Worth it when the workflow is more than a straight tool loop.

Option C: CrewAI (Best for Multi-Agent Systems)

CrewAI uses roles — researcher, writer, reviewer — that collaborate on tasks. You can stand up a multi-agent prototype in roughly twenty lines. Choose it for demos and content pipelines where personas help.

Trade-off: less fine-grained control than LangGraph for complex stateful systems.

FrameworkDifficultyFlexibilityBest ForGitHub Stars
OpenAI Agents SDKLowMediumFast path on OpenAI models
LangGraphMediumHighStateful production workflows110K+
CrewAILowMediumRole-based multi-agent demos30K

Step 2: Set Up Your Development Environment

Keep the environment minimal. You need the OpenAI client, dotenv for secrets, requests for the demo search tool, FastAPI/Uvicorn for the API deploy path, Streamlit for the chat UI, and Chroma for long-term memory. Pin versions in a real project; for learning, latest wheels are fine.

Create a project folder and virtual environment:

mkdir my-ai-agent && cd my-ai-agent
python3 -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install openai python-dotenv requests fastapi uvicorn streamlit chromadb

Store your API key in a .env file (never commit this file):

OPENAI_API_KEY=sk-your-key-here

Load it at the top of your scripts:

from dotenv import load_dotenv
import os

load_dotenv()
assert os.getenv("OPENAI_API_KEY"), "Set OPENAI_API_KEY in .env"

Step 3: Define Your Agent's Goal and Tools

Write the System Prompt

The system prompt sets personality, limits, and success criteria. Keep it explicit about tools and when to stop:

SYSTEM_PROMPT = """You are a helpful research agent.
You can use tools to search the web, calculate numbers, and run short Python.
Work step by step toward the user's goal.
When the goal is complete, respond with a final answer and do not call more tools.
If a tool fails, explain the error and try a different approach."""

Define Available Tools (Search, Calculator, Code Interpreter)

Define tools as Python functions, then expose JSON schemas to the model:

import json
import ast
import operator
import requests

def web_search(query: str) -> str:
    """Search the web via DuckDuckGo Instant Answer API (demo-friendly)."""
    resp = requests.get(
        "https://api.duckduckgo.com/",
        params={"q": query, "format": "json", "no_html": 1},
        timeout=20,
    )
    resp.raise_for_status()
    data = resp.json()
    abstract = data.get("AbstractText") or data.get("Answer") or ""
    related = [
        f"- {t.get('Text')}" for t in data.get("RelatedTopics", [])[:3]
        if isinstance(t, dict) and t.get("Text")
    ]
    return abstract or "\n".join(related) or "No results found."

OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
}

def _eval(node):
    if isinstance(node, ast.Expression):
        return _eval(node.body)
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp):
        return OPS[type(node.op)](_eval(node.left), _eval(node.right))
    if isinstance(node, ast.UnaryOp):
        return OPS[type(node.op)](_eval(node.operand))
    raise ValueError("Unsupported expression")

def calculator(expression: str) -> str:
    """Safely evaluate a basic arithmetic expression."""
    tree = ast.parse(expression, mode="eval")
    return str(_eval(tree))

def run_python(code: str) -> str:
    """Run a tiny Python snippet with no builtins except print capture."""
    output = []
    def _print(*args, **kwargs):
        output.append(" ".join(str(a) for a in args))
    safe_builtins = {"print": _print, "range": range, "len": len, "sum": sum}
    exec(code, {"__builtins__": safe_builtins}, {})
    return "\n".join(output) if output else "(no output)"

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "web_search",
            "description": "Search the web for current information",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Search query"}
                },
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "Evaluate a math expression like '(12.5 * 4) + 3'",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string"}
                },
                "required": ["expression"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_python",
            "description": "Run a short Python snippet and return printed output",
            "parameters": {
                "type": "object",
                "properties": {
                    "code": {"type": "string"}
                },
                "required": ["code"],
            },
        },
    },
]

TOOL_IMPL = {
    "web_search": web_search,
    "calculator": calculator,
    "run_python": run_python,
}

Step 4: Implement the Agent Loop

This is the heart of every agent: receive a goal, let the model reason, execute any requested tools, feed results back, and loop until the model returns a final message (or you hit a safety limit). Everything else in the ecosystem — LangGraph nodes, CrewAI crews, SDK handoffs — is a structured way to run some version of this loop.

Notice what the model is deciding on each turn: whether to call a tool, which tool, and with what arguments. Your Python code is not hard-coding the plan. It is providing tools and enforcing limits while the LLM chooses the path. That separation is what separates an agent from a scripted pipeline.

from openai import OpenAI

client = OpenAI()
MAX_STEPS = 8

def run_agent(goal: str) -> str:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": goal},
    ]

    for step in range(MAX_STEPS):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = response.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:
            return msg.content or ""

        for call in msg.tool_calls:
            name = call.function.name
            args = json.loads(call.function.arguments or "{}")
            try:
                result = TOOL_IMPL[name](**args)
            except Exception as exc:  # tool failures should not kill the loop
                result = f"ERROR: {exc}"
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": str(result)[:4000],
                }
            )

    return "Stopped: max steps reached without a final answer."

if __name__ == "__main__":
    print(run_agent(
        "Find the population of Tokyo, multiply it by 1.05, "
        "and show the Python code you would use to format it with commas."
    ))

Run it with python agent.py. You should see tool calls in your terminal logs (add prints around TOOL_IMPL if you want visibility) and a final natural-language answer.

Step 5: Add Memory and Context

Short-Term Memory (Conversation History)

Short-term memory is just the messages list. Persist it across turns in a chat session:

class SessionMemory:
    def __init__(self):
        self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]

    def add_user(self, text: str):
        self.messages.append({"role": "user", "content": text})

    def run_turn(self) -> str:
        # reuse the same loop as run_agent, but start from self.messages
        for _ in range(MAX_STEPS):
            response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=self.messages,
                tools=TOOLS,
                tool_choice="auto",
            )
            msg = response.choices[0].message
            self.messages.append(msg)
            if not msg.tool_calls:
                return msg.content or ""
            for call in msg.tool_calls:
                args = json.loads(call.function.arguments or "{}")
                result = TOOL_IMPL[call.function.name](**args)
                self.messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": call.id,
                        "content": str(result)[:4000],
                    }
                )
        return "Stopped: max steps reached."

Long-Term Memory (Vector Database Integration)

Use Chroma to remember facts across sessions. Embed past notes and retrieve the top matches before each new goal:

import chromadb
from chromadb.utils import embedding_functions

chroma = chromadb.Client()
embedder = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small",
)
memory = chroma.get_or_create_collection(
    name="agent_memory",
    embedding_function=embedder,
)

def remember(text: str, doc_id: str):
    memory.upsert(documents=[text], ids=[doc_id])

def recall(query: str, n: int = 3) -> str:
    hits = memory.query(query_texts=[query], n_results=n)
    docs = hits.get("documents", [[]])[0]
    return "\n".join(f"- {d}" for d in docs) if docs else "No long-term memories."

def run_agent_with_memory(goal: str) -> str:
    prior = recall(goal)
    enriched = f"Relevant memories:\n{prior}\n\nUser goal: {goal}"
    answer = run_agent(enriched)
    remember(f"Goal: {goal}\nAnswer: {answer}", doc_id=str(hash(goal)))
    return answer

Step 6: Test and Debug Your Agent

Agents fail differently from normal apps. A unit test can pass while the agent still thrash-loops on a vague goal. Treat evaluation as a product surface: keep a small suite of goals with expected behaviors (must call calculator, must not call search, must finish under N steps).

Three failure modes show up constantly in real agents:

Practical debugging tips: log every tool name and arguments; write unit tests for calculator and run_python; and keep a golden set of five goals you re-run after prompt changes. When costs spike, check whether you accidentally included huge tool payloads in the message history.

For higher-stakes agents, add human approval before irreversible tools (send email, charge a card, delete a file). A one-line confirmation gate prevents most "the agent did what I said but not what I meant" disasters.

Step 7: Deploy Your Agent

Deploy as an API (FastAPI example with real code)

# api.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="My AI Agent")

class AgentRequest(BaseModel):
    goal: str

class AgentResponse(BaseModel):
    result: str

@app.post("/agent", response_model=AgentResponse)
def agent_endpoint(payload: AgentRequest):
    result = run_agent(payload.goal)
    return AgentResponse(result=result)

# Run: uvicorn api:app --host 0.0.0.0 --port 8000

Test with curl -X POST http://localhost:8000/agent -H 'Content-Type: application/json' -d '{"goal":"What is 17*19?"}'.

Deploy as a Chat Interface (Streamlit example with real code)

# app_chat.py
import streamlit as st

st.title("My AI Agent")
if "memory" not in st.session_state:
    st.session_state.memory = SessionMemory()

goal = st.chat_input("What should the agent do?")
for message in st.session_state.memory.messages:
    if message["role"] in {"user", "assistant"} and message.get("content"):
        with st.chat_message(message["role"]):
            st.write(message["content"])

if goal:
    st.session_state.memory.add_user(goal)
    with st.chat_message("user"):
        st.write(goal)
    with st.chat_message("assistant"):
        answer = st.session_state.memory.run_turn()
        st.write(answer)

# Run: streamlit run app_chat.py

Next Steps

You now have a runnable agent loop, tools, memory, and two deployment shapes. From here, swap DuckDuckGo for a proper search API, replace the toy run_python sandbox with a containerized interpreter, and add authentication in front of FastAPI before anything faces the public internet.

Explore real-world use cases next — or revisit the architecture fundamentals if any piece still feels fuzzy. The builders who ship reliable agents are the ones who understand the loop, not just the framework marketing page.

Explore 50+ real-world use cases →Full LangGraph tutorial →Understand the fundamentals first →Prefer a simpler explanation? →

Was this helpful?

Your feedback stays on this page — no tracking.

Share this page