How to Build AI Agents From Scratch: A Blueprint

Every framework I have used in production, I have eventually gutted. LangChain, CrewAI, even parts of LangGraph. Not because they are bad, but because when an agent fails at 3 a.m. and you are staring at a stack trace inside three layers of abstractions you did not write, you start thinking: this loop is maybe 200 lines. Why do I need 40 dependencies to run it?
This is the architecture I actually use to ship autonomous multi-agent systems, the ones that publish content, hit APIs, and run unattended for weeks. No framework religion. Just the parts that survive real traffic.
The agent loop is smaller than you think
An agent is a while loop around an LLM call that can invoke tools and decide when to stop. That is the whole thing. Everything else (memory, planning, reflection, multi-agent choreography) is a variation on that core.
Here is the minimal loop I start every new agent from. Python, no framework, uses the Anthropic SDK but the shape is identical for OpenAI:
def run_agent(task: str, tools: dict, max_steps: int = 20):
messages = [{"role": "user", "content": task}]
for step in range(max_steps):
response = client.messages.create(
model="claude-sonnet-4-5",
system=SYSTEM_PROMPT,
tools=[t.schema for t in tools.values()],
messages=messages,
max_tokens=4096,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
return extract_text(response)
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = tools[block.name].run(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result),
"is_error": isinstance(result, Exception),
})
messages.append({"role": "user", "content": tool_results})
raise MaxStepsExceeded(f"Agent did not finish in {max_steps} steps")
That is roughly 30 lines and it will run a real agent. Everything I am about to describe is layered on top of this without changing the shape of the loop.
Two things I have learned to bake in from day one: a hard step cap, and structured tool_result blocks with an is_error flag. Without the step cap, one bad prompt burns $40 in a night. Without the error flag, the model quietly hallucinates that failed calls succeeded.
Tool design is where agents actually live or die
The model is only as smart as the tools you give it. In production, 80% of my debugging time is not prompt tuning, it is tool interface work. Ambiguous tool names, missing error messages, and untyped inputs cause more agent failures than any model weakness.
The rules I follow, without exception:
- Every tool is a pure function with a typed schema. No hidden state. If the tool needs context, pass it in.
- Tool names are verbs.
search_docs, notdocs_util. The model prompts itself with the name. - Errors are returned, not raised. Every tool returns
{"ok": bool, "data": ..., "error": ...}. The model can then react. - Descriptions include when NOT to use the tool. This alone cut wrong-tool-selection rates by half in one of my content agents.
- Idempotency where possible. Retries are cheap when the tool is safe to call twice.
Here is a tool description snippet I have shipped:
{
"name": "publish_post",
"description": "Publish a finalized post to the CMS. Only call this AFTER `validate_post` returns ok=true. Do NOT call this to preview or draft. This action is irreversible for 5 minutes.",
"input_schema": { ... }
}
That last line, "irreversible for 5 minutes", changes model behavior meaningfully. Claude and GPT-4-class models actually respect it. They pause and often call validate_post first, even when I have not explicitly told them to in the system prompt.
Memory: three layers, not one blob
The most common mistake I see in home-rolled agents is treating memory as "just append to the context window". That works for 5 turns. It falls apart at 50, and it becomes unaffordable at 500.
I split memory into three layers, each with its own retention policy:
| Layer | Contents | Retention | Storage |
|---|---|---|---|
| Working | Current task messages, tool calls | Current run | In-memory |
| Episodic | Past task summaries, outcomes | 30-90 days | Postgres |
| Semantic | Facts, entities, learned patterns | Indefinite | pgvector + Postgres |
In one of my recent posts I broke down how I cut a 3.26M token conversation down to 118K by moving from a naive append-everything design to this layered model. The key insight: the model does not need history, it needs the right slice of history.
For semantic memory I use pgvector with hybrid search (BM25 + vector, fused with RRF). Pure vector search misses exact-match queries ("what is the SKU for product X"), pure keyword misses semantic queries ("similar customer complaints"). Hybrid wins in every eval I have ever run.
For working memory, I aggressively summarize. Every 10 turns, a small model (Haiku or GPT-4o-mini) compresses the last 10 messages into 200 tokens of "here is what happened and what state we are in". The full log stays in the database for debugging. The context stays lean.
Orchestration: single agent, then supervisor, then graph
Do not start with a multi-agent system. I know it is the fashionable thing. Start with one agent and a lot of tools. Escalate only when you hit a real limit.
Here is the progression I actually use:
Level 1: Single agent, many tools. Works up to about 15-20 tools before the model starts confusing them. Cheap, easy to debug, testable.
Level 2: Supervisor plus workers. A planning agent decides which specialist to call. Specialists are themselves single agents with narrower tool sets. This is how BizFlowAI ContentStudio is built: a research agent, a writing agent, an SEO agent, a publishing agent, orchestrated by a supervisor that owns the workflow state.
Level 3: Graph / DAG. Only when you have deterministic branching, parallel work, and cycles that would be wasteful to let an LLM figure out on its own. This is where LangGraph earns its keep, and honestly, if I am at this level, I sometimes reach for it rather than rolling my own state machine.
The heuristic: if you can draw the workflow on a napkin and it does not need to change per input, the orchestration should be code, not an LLM decision. Every decision you outsource to the model is latency, cost, and a failure mode. Reserve LLM decisions for genuinely ambiguous branches.
Failure handling is 40% of production agents
The demo works. The production version has to survive rate limits, tool timeouts, malformed JSON, hallucinated tool names, infinite loops, context overflow, and the occasional model outage. In my experience, roughly 40% of the code in a production agent is failure handling, not "agent logic".
The layers I always put in:
- Retry with backoff on transient errors. Model APIs 529, tool network calls, everything.
- Structured output validation. If a tool expects JSON, validate it against a Pydantic schema and hand the model back the validation error on failure. The model will fix it in one turn 90% of the time.
- Loop detection. I hash the last three tool calls plus arguments. If the same hash repeats, I inject a system message: "You just repeated the same action. Reconsider your approach." That alone kills most infinite loops.
- Budget caps per run. Max steps, max tokens, max wall clock, max dollar. Any breach halts the run and logs it.
- Observability from day one. Every step is written to a trace store with inputs, outputs, latency, cost. I use a lightweight Postgres schema for this, not a hosted tool, so I can query traces with SQL. When something breaks at scale, you need to be able to ask "show me every run where the SEO agent called publish_post before validate_post".
Here is the pattern I use for tool retries:
def run_tool_safely(tool, args, max_retries=2):
for attempt in range(max_retries + 1):
try:
result = tool.run(**args)
return {"ok": True, "data": result}
except TransientError as e:
if attempt == max_retries:
return {"ok": False, "error": f"Transient error after {max_retries} retries: {e}"}
time.sleep(2 ** attempt)
except ValidationError as e:
return {"ok": False, "error": f"Bad arguments: {e}. Please correct and retry."}
except Exception as e:
return {"ok": False, "error": f"Tool failed: {e}"}
Notice the model sees the error text. That is deliberate. The model is your last line of recovery, not a helpless victim of a stack trace.
Roll your own vs LangGraph vs CrewAI
I get this question weekly. Here is the honest answer.
Roll your own if:
- Your agent has fewer than about 5 distinct roles
- You need tight control over cost, latency, and error surface
- You want to actually understand what your agent does when it misbehaves
- You are shipping to production and will maintain it for years
Use LangGraph if:
- You have genuine graph structure: parallel branches, cycles, checkpointing across a long-running workflow
- You want first-class human-in-the-loop primitives without building them
- Your team already knows the LangChain ecosystem
Use CrewAI if:
- You are prototyping a multi-agent concept quickly and the exact wiring does not matter yet
- Honestly, I have not shipped anything on CrewAI to production. It is fine for demos. I have always ended up rewriting.
The tradeoff nobody talks about: frameworks lock in a mental model. Once your team thinks in "Crews and Tasks" or "StateGraph nodes", it is very hard to think outside that model when you need to. Rolling your own means the mental model is: functions, state, loops. Universal.
For reference, the 200-line loop I showed at the top has been the base of every production agent I have shipped in the last two years, including the content pipeline that publishes across multiple sites unattended. I have never regretted starting small.
What I would do if starting a new agent today
- Write the tools first. Type them, test them without an LLM. If your tools are broken, no model saves you.
- Start with the 30-line loop. Get one end-to-end run working with a real task before you add anything.
- Add the trace store. Postgres table, columns for run_id, step, role, content, tokens, cost, latency. Query with SQL.
- Add budget caps. Steps, tokens, dollars. Non-negotiable.
- Add loop detection and structured error returns. These two catch 80% of production weirdness.
- Only then, if the task actually needs it, split into supervisor and workers.
- Only then, if you have real graph structure, consider a framework.
The teams that ship fast are the ones that resist the urge to adopt heavy abstractions before they have felt the pain the abstraction solves. Every abstraction you adopt without earning it becomes a debugging tax later.
Closing
Production agents are not a framework problem. They are a systems problem: tool design, memory layering, orchestration discipline, and relentless failure handling. Get those right and a 200-line loop will outperform a 10,000-line framework wrapper you do not understand.
If you are building an agent that has to actually run in production and you want a second pair of eyes on the architecture, get in touch at lazar-milicevic.com/#contact. Or read more of what I have shipped and learned on the blog.
Frequently asked questions
Do I need a framework like LangChain or CrewAI to build production AI agents?
No, and in my experience most frameworks become liabilities in production. An agent is fundamentally a while loop around an LLM call that can invoke tools and decide when to stop, which takes roughly 30 lines of Python using the Anthropic or OpenAI SDK directly. I have gutted LangChain, CrewAI, and parts of LangGraph from production systems because debugging failures through three layers of abstractions I did not write is far more painful than owning the 200-line loop myself. Start framework-free, and only add abstractions when you hit a concrete limit you cannot solve with plain code.
What is the minimum viable structure of an AI agent loop?
The minimum is a loop that sends messages to an LLM, checks the stop reason, executes any tool calls the model requested, appends the results back into messages, and repeats until the model signals it is done. On top of that bare loop I always bake in two things from day one: a hard step cap (so one bad prompt does not burn $40 overnight) and structured tool_result blocks with an is_error flag (so the model does not hallucinate that failed calls succeeded). Everything else, including memory, planning, reflection, and multi-agent orchestration, is a variation layered on top of this core without changing its shape.
How should I design tools for an AI agent so it actually works reliably?
Tool interface design is where 80% of my production debugging time goes, so I follow strict rules. Every tool is a pure function with a typed schema and no hidden state, tool names are verbs like search_docs rather than nouns, and errors are returned as structured objects like {ok, data, error} instead of raised so the model can react. I also make tool descriptions explicit about when NOT to use the tool, which alone cut wrong-tool-selection rates by half in one of my agents, and I make tools idempotent wherever possible so retries are safe. Adding constraints like 'this action is irreversible for 5 minutes' to descriptions measurably changes model behavior for the better.
How should memory be structured in an AI agent instead of just appending to context?
Treating memory as one growing blob works for about 5 turns, breaks at 50, and becomes unaffordable at 500. I split memory into three layers: working memory (current task messages and tool calls, in-memory, current run only), episodic memory (past task summaries and outcomes, stored in Postgres for 30-90 days), and semantic memory (facts, entities, and learned patterns, stored indefinitely in pgvector plus Postgres). I also summarize working memory every 10 turns using a small model like Haiku or GPT-4o-mini, compressing 10 messages into about 200 tokens. This layered approach let me cut a 3.26M token conversation down to 118K because the model does not need full history, it needs the right slice of it.
Should I use hybrid search or pure vector search for agent semantic memory?
Hybrid search wins in every evaluation I have run, so I use it by default. I combine BM25 keyword search with vector search and fuse the results using Reciprocal Rank Fusion (RRF), typically on top of pgvector in Postgres. Pure vector search reliably misses exact-match queries like 'what is the SKU for product X', while pure keyword search misses semantic queries like 'similar customer complaints'. Hybrid retrieval covers both failure modes with almost no added complexity, and it is the single highest-leverage change most teams can make to their agent's memory layer.
Building something hard with AI or automation? I am open to talk.
Get in touch