Skip to content

How to Build a Fully Autonomous AI Agent from Scratch

Purpose

This post demonstrates how to build a fully autonomous AI agent from scratch. I’ll show you how to wire together orchestration, memory, and observability using LangChain, CrewAI, or PydanticAI.

The real challenge isn’t building the agent - it’s keeping it running when tokens expire at 2am.

Environment

  • Python 3.11+
  • LangChain 0.3
  • CrewAI 0.28
  • PydanticAI 0.0.10
  • Mem0 0.1
  • LangSmith API access

The Three Pillars

An autonomous AI agent needs three components:

Agent Architecture
[User Request] -> [Orchestration Layer (LangChain/CrewAI/PydanticAI)]
|
v
[Tool Selection] -> [External APIs/Services]
|
v
[Memory Layer (Mem0)] <-> [Persistent Storage]
|
v
[Observability (LangSmith/Helicone)]
|
v
[Response/Action]

I’ll show you how each framework handles these pillars.

Framework Comparison

FrameworkBest ForKey Feature
LangChainMaximum customization200+ integrations, LangGraph
CrewAIMulti-agent teamsDeclarative roles, collaboration
PydanticAIType-safe outputsPydantic validation, FastAPI-like

I’ve used all three in production. Here’s what I found.

Building with LangChain

LangChain gives you maximum control. I use it when I need fine-grained tool orchestration.

Basic Agent with Memory

langchain_agent.py
from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
store.put(("users",), "user_123", {"preferences": "dark mode"})
@tool
def get_user_info(runtime) -> str:
"""Look up user info from memory."""
store = runtime.store
user_info = store.get(("users",), runtime.context.user_id)
return str(user_info.value) if user_info else "Unknown"
agent = create_agent(
model="claude-sonnet-4-5",
tools=[get_user_info],
store=store,
memory=True
)

The key parts:

  • InMemoryStore holds user context between turns
  • The @tool decorator exposes functions to the agent
  • memory=True enables conversation history

Memory Scoping

Memory isn’t one-size-fits-all. I use different scopes for different needs:

ScopeUse CasePersistence
user_idUser preferencesPermanent
run_idSession contextTemporary
agent_idAgent-specific knowledgePermanent
memory_scopes.py
# User-level: persists across sessions
store.put(("users", "preferences"), "user_123", {"theme": "dark"})
# Run-level: clears when session ends
store.put(("runs", "context"), "run_456", {"current_task": "summarize"})
# Agent-level: shared across users
store.put(("agents", "knowledge"), "agent_789", {"common_patterns": [...]})

Building with CrewAI

CrewAI shines when you need multiple agents working together. I use it for research and writing workflows.

Multi-Agent Research Team

crew_research.py
from crewai import Agent, Crew, Task
from crewai_tools import SerperDevTool
search_tool = SerperDevTool()
researcher = Agent(
role="Research Analyst",
goal="Find accurate information",
backstory="Expert researcher with 10 years experience",
tools=[search_tool],
memory=True,
max_iter=25
)
writer = Agent(
role="Content Writer",
goal="Create engaging content",
backstory="Former journalist turned AI assistant",
memory=True
)
research_task = Task(
description="Research the latest AI agent frameworks",
agent=researcher
)
writing_task = Task(
description="Write a blog post based on research",
agent=writer
)
crew = Crew(
agents=[researcher, writer],
tasks=[research_task, writing_task]
)
result = crew.kickoff()

The agents share memory and coordinate through the crew. Each agent has a defined role that shapes how it approaches tasks.

Why CrewAI Works for Teams

I found CrewAI useful when:

  • Tasks have distinct phases (research → write → edit)
  • Each phase needs different expertise
  • Agents need to pass context to each other

The declarative syntax keeps the agent definitions readable.

Building with PydanticAI

PydanticAI is the new player. I use it when I need guaranteed structured outputs.

Type-Safe Agent Responses

pydantic_agent.py
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class SupportResponse(BaseModel):
advice: str = Field(description='Advice for customer')
escalate: bool = Field(description='Whether to escalate')
risk_level: int = Field(ge=0, le=10)
agent = Agent('openai:gpt-4', output_type=SupportResponse)
result = agent.run_sync("Customer complaint about billing")
# Type-safe access - IDE autocomplete works
print(result.output.advice)
print(result.output.escalate)
print(result.output.risk_level)

The output_type parameter forces the LLM to return valid Pydantic models. If the model returns invalid data, PydanticAI retries automatically.

When PydanticAI Wins

I use PydanticAI for:

  • Customer support classification
  • Data extraction pipelines
  • Any task needing validated JSON output

The validation saves me from writing parsing code.

Adding Memory with Mem0

All three frameworks benefit from external memory. I use Mem0 for persistent user context.

mem0_example.py
from mem0 import MemoryClient
mem0 = MemoryClient()
# Store conversation context
mem0.add([
{"role": "user", "content": "I prefer morning workouts"},
{"role": "assistant", "content": "Noted. I'll schedule for mornings."}
], user_id="user_123")
# Retrieve relevant memories later
memories = mem0.search("workout schedule", user_id="user_123", limit=3)
for memory in memories:
print(memory["memory"])

Mem0 handles:

  • Semantic search over past conversations
  • User-specific memory isolation
  • Long-term storage

I’ve found it useful for agents that need to remember user preferences across sessions.

Error Handling in Production

Here’s the part most tutorials skip. Building an agent is easy. Keeping it running is hard.

Token Expiration at 2am

I learned this the hard way. One expired token broke five workflows at 2am.

retry_handler.py
from tenacity import retry, stop_after_attempt, wait_exponential
import logging
logger = logging.getLogger(__name__)
class TokenExpiredError(Exception):
pass
class RateLimitError(Exception):
def __init__(self, retry_after: int):
self.retry_after = retry_after
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10)
)
def invoke_agent_with_retry(agent, message):
try:
result = agent.invoke(message)
return result
except TokenExpiredError:
logger.warning("Token expired, refreshing credentials")
refresh_credentials()
raise # Let retry handle it
except RateLimitError as e:
logger.warning(f"Rate limited, retry after {e.retry_after}s")
raise
except Exception as e:
logger.error(f"Unexpected error: {e}")
raise

The retry decorator handles transient failures. But you also need:

health_check.py
import asyncio
from datetime import datetime
async def credential_watcher():
"""Check credentials every 5 minutes"""
while True:
try:
await check_all_tokens()
await check_all_api_keys()
except ExpiredCredential as e:
alert_oncall(f"Credential expired: {e}")
await refresh_credentials()
await asyncio.sleep(300) # 5 minutes
async def check_all_tokens():
for service in registered_services:
if service.token_expires_soon():
raise ExpiredCredential(service.name)

I run this watcher as a background task alongside my agents.

Observability with LangSmith

When something goes wrong, you need to see what happened. LangSmith gives you visibility into agent execution.

observability.py
import langsmith as ls
# Trace agent invocations
with ls.tracing_context("agent_invocation"):
result = agent.invoke({"messages": [user_message]})
# Log custom metrics
ls.log_metric("response_time_ms", response_time)
ls.log_metric("tokens_used", token_count)

In the LangSmith dashboard, I can see:

  • Every tool call the agent made
  • Token usage per request
  • Where the agent got stuck
  • Failed reasoning chains

What to Monitor

I track these metrics:

key_metrics.txt
┌─────────────────────┬────────────────────────────────┐
│ Metric │ Alert Threshold │
├─────────────────────┼────────────────────────────────┤
│ Token usage │ > 80% of daily limit │
│ Error rate │ > 5% in 5-minute window │
│ Response latency │ > 10s p99 │
│ Retry count │ > 3 per request │
│ Tool failures │ Any tool failure │
└─────────────────────┴────────────────────────────────┘

The Real Lessons

After running agents in production for months, here’s what I learned:

Building is the easy part. I assembled my first autonomous agent in an afternoon using LangChain and Mem0.

Reliability is the hard part. Token expiration, rate limits, API changes - these will break your agent. Plan for them from day one.

Memory matters more than you think. Without persistent memory, your agent forgets user context between sessions. Users hate repeating themselves.

Observability saves debugging time. When your agent makes 20 tool calls and fails on the 19th, you need to see every step. LangSmith or Helicone are worth the cost.

Start simple, add complexity later. I began with a single agent, then added multi-agent orchestration only when needed. Don’t over-engineer upfront.

Summary

In this post, I showed how to build autonomous AI agents using LangChain, CrewAI, and PydanticAI. The key point is that building the agent takes an afternoon, but production reliability requires proper error handling, memory management, and observability.

Choose LangChain for maximum control, CrewAI for multi-agent teams, or PydanticAI for type-safe outputs. Add Mem0 for memory and LangSmith for debugging. Then spend your time on error handling - that’s what will wake you up at 2am.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments