Building Your First AI Agent in Python

Building Your First AI Agent in Python

There is a difference between building something that talks and building something that acts. Chatbots belong to the first category. AI agents belong to the second.

An AI agent takes a goal, decides which tools to use, executes them, observes the results, and decides what to do next — looping until the task is complete or a stopping condition is triggered. That single design shift — from one-shot response to goal-directed loop — is why companies like Klarna, Uber, LinkedIn, and JPMorgan are running agents in production rather than chatbots.

By early 2026, over 65% of enterprise software teams are shipping at least one AI agent into production. The Python ecosystem is where almost all of that work happens. This guide walks you through building your first Python AI agent from scratch — not a toy demo, but a real, runnable research agent you can extend and deploy.

By the end, you will understand the three core layers of every Python AI agent: the LLM backbone, the tool layer, and the memory layer. You will have working code at each step.

Why Python Dominates AI Agent Development

Python did not become the language of AI agents by accident. Every major agentic framework — LangGraph, CrewAI, Pydantic AI, AutoGen — ships Python as its primary runtime. LangChain alone integrates with more than 700 third-party connectors as of mid-2026, the largest integration library in the ecosystem.

LangGraph reached v1.0 in late 2025 and is now the most widely adopted Python framework for building production AI agents. The GitHub repository has exceeded 33,900 stars and 34.5 million monthly downloads. The broader LangChain ecosystem — core package plus langchain-core, community, and integration packages — exceeds 220 million monthly downloads monthly.

The market around these tools is scaling just as fast. The global AI agents market is projected to reach $47.1 billion by 2030, growing at a CAGR of 44.8%. Python is the language doing most of that work.

What You Are Building

By the end of this guide, you will have a working research agent that:

  • Takes a research question as input
  • Searches the web for relevant information
  • Reads and processes the results
  • Decides whether it has enough information to answer
  • Returns a structured summary if it does, or searches again if it does not

This is a real agentic loop, not a single LLM call with a fancy wrapper. Let’s build it step by step.

Step 1: Environment Setup

Use Python 3.11 or higher. Create a fresh virtual environment and install the 2026 stack:

bash

python -m venv agent-env
source agent-env/bin/activate   # Windows: agent-env\Scripts\activate

pip install langgraph==0.3.34 \
            langchain-anthropic \
            langchain-community==0.3.19 \
            tavily-python==0.5.0 \
            python-dotenv==1.1.0

Pin your versions in production to avoid breaking changes. LangGraph follows semantic versioning post-1.0 — always check pip index versions langgraph for the latest stable release before starting a new project.

Create a .env file in your project root:

ANTHROPIC_API_KEY=your_anthropic_key_here
TAVILY_API_KEY=your_tavily_key_here

Why Tavily? Tavily is a search engine built specifically for AI agents. It returns clean, structured results rather than raw HTML, which means your agent spends tokens on reasoning instead of parsing. It offers 1,000 free API requests per month — more than enough for development.

Never hardcode API keys. Load them with python-dotenv:

python

from dotenv import load_dotenv
import os

load_dotenv()
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
TAVILY_API_KEY    = os.getenv("TAVILY_API_KEY")

Step 2: Understanding the Agent Loop

Before writing code, understand what LangGraph actually does. Every LangGraph agent is a directed state graph:

  • State — a Python dictionary that flows through the entire graph. Every node reads from it and writes to it. Think of it as the agent’s working memory.
  • Nodes — individual functions or actions (call the LLM, execute a tool, format output).
  • Edges — transitions between nodes. Conditional edges route based on runtime state.

The agent loop looks like this:

User Query → [Reason] → should I use a tool?
                           ↓ Yes         ↓ No
                        [Act]        [Respond]
                           ↓
                    [Back to Reason]

LangGraph manages this loop, state persistence, retries, and human-in-the-loop interrupts as first-class primitives — not workarounds you bolt on after the fact.

Step 3: Define Your Agent State

The state schema is the most important design decision in any LangGraph agent. Get this right and everything downstream is simpler.

Create a file called agent.py:

python

from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages

class AgentState(TypedDict):
    # The conversation history — add_messages appends instead of replacing
    messages: Annotated[list, add_messages]
    # Raw search results from Tavily
    search_results: list[str]
    # The final structured answer
    final_answer: str
    # How many search iterations the agent has run
    iteration_count: int

The Annotated[list, add_messages] pattern tells LangGraph to append new messages to the list rather than overwrite it. This preserves the full conversation history across multiple reasoning steps — which is what makes the agent stateful.

Step 4: Configure the LLM and Tools

python

from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.tools import tool

# LLM: Claude Sonnet 4.6 — reliable reasoning, fast response
llm = ChatAnthropic(
    model="claude-sonnet-4-6",
    temperature=0,           # deterministic reasoning
    max_tokens=4096
)

# Web search tool — returns up to 5 structured results per query
search_tool = TavilySearchResults(
    max_results=5,
    search_depth="advanced",   # deeper crawl, better results
    include_answer=True        # Tavily's own summary alongside raw results
)

# Custom tool example — wrap any Python function as an agent tool
@tool
def calculate_reading_time(text: str) -> str:
    """Estimates reading time for a given block of text."""
    word_count = len(text.split())
    minutes = round(word_count / 200)   # average adult reading speed
    return f"Estimated reading time: {minutes} minute(s) ({word_count} words)"

# Bind tools to the LLM — this tells the model what it can call
tools = [search_tool, calculate_reading_time]
llm_with_tools = llm.bind_tools(tools)

The @tool decorator is LangChain’s way of turning a plain Python function into a tool the agent can discover and call. The docstring becomes the tool description — write it clearly because the LLM reads it to decide when to use the tool.

Step 5: Build the Agent Nodes

Nodes are the functions your agent executes. Each node receives the current state and returns a dictionary of state updates.

python

from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
import json

# Node 1: The reasoning step — the LLM decides what to do next
def reason(state: AgentState) -> dict:
    """LLM reviews the state and decides: answer now or use a tool."""
    response = llm_with_tools.invoke(state["messages"])
    return {
        "messages": [response],
        "iteration_count": state.get("iteration_count", 0)
    }

# Node 2: The action step — execute whatever tool the LLM requested
def act(state: AgentState) -> dict:
    """Execute the tool calls the LLM requested."""
    last_message = state["messages"][-1]
    tool_results = []
    search_results = list(state.get("search_results", []))

    for tool_call in last_message.tool_calls:
        tool_name = tool_call["name"]
        tool_args = tool_call["args"]

        # Route to the right tool
        if tool_name == "tavily_search_results_json":
            result = search_tool.invoke(tool_args)
            # Extract clean text from Tavily's structured response
            for item in result:
                search_results.append(item.get("content", ""))
            result_str = json.dumps(result, indent=2)

        elif tool_name == "calculate_reading_time":
            result_str = calculate_reading_time.invoke(tool_args["text"])

        else:
            result_str = f"Unknown tool: {tool_name}"

        # Return the tool result to the LLM as a ToolMessage
        tool_results.append(
            ToolMessage(
                content=result_str,
                tool_call_id=tool_call["id"]
            )
        )

    return {
        "messages": tool_results,
        "search_results": search_results,
        "iteration_count": state.get("iteration_count", 0) + 1
    }

Step 6: Add Routing Logic

The routing function decides which node to visit next. This is the conditional edge — the decision point that makes the graph an agent rather than a static pipeline.

python

def should_continue(state: AgentState) -> str:
    """
    Decide what to do after the reasoning step.
    Returns: 'act' | 'end' | 'max_iterations'
    """
    last_message = state["messages"][-1]
    iteration_count = state.get("iteration_count", 0)

    # Safety ceiling — prevent infinite loops
    if iteration_count >= 5:
        return "max_iterations"

    # If the LLM requested tool calls, go to act
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        return "act"

    # Otherwise the LLM produced a final answer — we are done
    return "end"

The iteration_count ceiling is not optional. Without it, a misconfigured agent can loop indefinitely, burning API credits and never returning. Five iterations is a sensible default for a research agent — adjust based on your task complexity.

Step 7: Assemble the Graph

python

from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import InMemorySaver

def build_agent():
    graph = StateGraph(AgentState)

    # Add nodes
    graph.add_node("reason", reason)
    graph.add_node("act", act)

    # Set the entry point
    graph.set_entry_point("reason")

    # Conditional routing after the reason node
    graph.add_conditional_edges(
        "reason",
        should_continue,
        {
            "act": "act",
            "end": END,
            "max_iterations": END
        }
    )

    # After acting, always return to reason
    graph.add_edge("act", "reason")

    # In-memory checkpointer for state persistence within a session
    checkpointer = InMemorySaver()

    return graph.compile(checkpointer=checkpointer)

agent = build_agent()

Production note: InMemorySaver loses state when the process restarts. For production, swap it for PostgresSaver from langgraph-checkpoint-postgres. The graph code stays identical — only the checkpointer changes. This is one of LangGraph’s best design decisions.

Step 8: Run Your Agent

python

from langchain_core.messages import HumanMessage
import uuid

def run_agent(question: str) -> str:
    """Run the research agent and return its final answer."""

    # Each conversation needs a unique thread_id for state isolation
    config = {"configurable": {"thread_id": str(uuid.uuid4())}}

    initial_state = {
        "messages": [HumanMessage(content=question)],
        "search_results": [],
        "final_answer": "",
        "iteration_count": 0
    }

    # Stream events for visibility into the agent's reasoning
    print(f"\n🔍 Research question: {question}\n")

    for event in agent.stream(initial_state, config=config):
        for node_name, node_output in event.items():
            if node_name == "reason":
                last_msg = node_output["messages"][-1]
                if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
                    for tc in last_msg.tool_calls:
                        print(f"  → Using tool: {tc['name']}")
                        if "query" in tc["args"]:
                            print(f"    Query: {tc['args']['query']}")
            elif node_name == "act":
                iter_count = node_output.get("iteration_count", "?")
                print(f"  ✓ Tool executed (iteration {iter_count})")

    # Retrieve the final state
    final_state = agent.get_state(config)
    final_messages = final_state.values.get("messages", [])

    # The last AIMessage without tool_calls is the final answer
    for message in reversed(final_messages):
        if (hasattr(message, "content") and
            isinstance(message.content, str) and
            not getattr(message, "tool_calls", None)):
            return message.content

    return "Agent did not produce a final answer."


# Run it
if __name__ == "__main__":
    answer = run_agent(
        "What are the main benefits of AI agents for enterprise businesses in 2026?"
    )
    print(f"\n📋 Final Answer:\n{answer}")

Run it:

bash

python agent.py

You will see the agent reason through the question, trigger a Tavily search, read the results, and synthesize a structured answer. The iteration counter in the output tells you exactly how many tool calls it made.

Step 9: Add Persistent Memory Across Sessions

The agent above forgets everything between runs. For production agents that serve repeat users, that is a problem. Add Mem0 to give your agent persistent, cross-session memory in under 20 lines.

bash

pip install mem0ai

python

from mem0 import MemoryClient

memory = MemoryClient(api_key=os.getenv("MEM0_API_KEY"))

def reason_with_memory(state: AgentState) -> dict:
    """Reasoning node with persistent user memory."""
    user_id = state.get("user_id", "default_user")
    current_query = state["messages"][-1].content

    # Retrieve relevant memories for this user
    memories = memory.search(
        query=current_query,
        user_id=user_id,
        limit=5
    )

    # Inject memories into the system context
    if memories:
        memory_text = "\n".join([f"- {m['memory']}" for m in memories])
        system_context = f"What you know about this user:\n{memory_text}"
    else:
        system_context = "No prior context for this user."

    # Add memory context to the messages
    from langchain_core.messages import SystemMessage
    messages_with_context = [SystemMessage(content=system_context)] + state["messages"]

    response = llm_with_tools.invoke(messages_with_context)

    # Store the current interaction as a new memory
    memory.add(
        messages=[{"role": "user", "content": current_query}],
        user_id=user_id
    )

    return {"messages": [response]}

Persistent memory reduces token usage by 30–60% for repeated tasks by replacing verbose context reconstruction with targeted memory retrieval. For agents serving the same users repeatedly — customer support, research assistants, personalised tutors — this is the single highest-impact optimisation available.

Step 10: Add Observability

You cannot improve what you cannot see. Add LangSmith tracing to get full visibility into every reasoning step, tool call, token count, and cost:

bash

pip install langsmith

python

from langsmith import traceable

@traceable(name="research_agent_run", project_name="databusinesscentral-agents")
def run_agent_traced(question: str, user_id: str = "default") -> str:
    return run_agent(question)

Set these environment variables:

LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your_langsmith_key
LANGCHAIN_PROJECT=databusinesscentral-agents

Every agent run now appears in your LangSmith dashboard with a full execution trace. You can see exactly which tools were called, in what order, with what arguments, and at what token cost. This is not optional for production agents — 47% of stalled agent programs have no automated evaluation running at month 12, and programs without continuous evaluation lose 14 to 23 percentage points of accuracy over 18 months.

The Complete File Structure

After following this guide, your project should look like this:

my-first-agent/
├── .env                   # API keys — never commit this
├── .gitignore             # include .env
├── requirements.txt       # pinned dependencies
├── agent.py               # main agent logic
└── README.md

Your requirements.txt:

langgraph==0.3.34
langchain-anthropic
langchain-community==0.3.19
tavily-python==0.5.0
python-dotenv==1.1.0
mem0ai
langsmith

What to Build Next

Once this agent is running, three natural extensions will take it to production quality:

Swap the checkpointer for PostgreSQL. Replace InMemorySaver with PostgresSaver from langgraph-checkpoint-postgres. Your agent now survives process restarts and supports time-travel debugging.

Add structured output validation. Wrap your agent in Pydantic AI to guarantee that every LLM response conforms to a defined schema. No more silent failures from malformed LLM output corrupting downstream systems.

Add NeMo Guardrails. Define topic restrictions, output validation rules, and jailbreak resistance policies before your agent goes anywhere near real users. 88% of enterprises deploying AI agents report security incidents — guardrails are the most systematically underinvested layer of the Python AI agent stack.

The agent you built in this guide is not a demo. It is the core architecture that Klarna, Uber, and LinkedIn are running at scale, compressed into a beginner-accessible tutorial. The framework, the reasoning loop, and the state management pattern are identical — only the tools and the LLM differ.

The agentic AI market is growing at 44.8% annually. The gap between developers who can build agents and those who cannot is widening at exactly the same pace. You have closed that gap today.

Table of Contents

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top