back to blogs

Building AI Agents from Scratch

8 Jul 2026·10 min read·...

Most agent tutorials start with a framework. That is fine for shipping, but it hides the part worth understanding: the loop.

Building an agent from scratch is not about replacing frameworks. It is about learning what they wrap. Once the loop is clear, tool calls, memory, stop conditions, and model switching stop feeling like magic and start looking like normal software decisions.


What is an AI Agent?#

An AI agent is a program that receives a goal, asks an LLM what to do next, executes that action (either a tool call or a final output), feeds the result back to the LLM, and repeats until done. The LLM is the brain, the tools are the hands, and the loop is what makes it autonomous.

Every framework in this space, whether it's LangGraph, LangChain, or Smolagents, is a variation of that same pattern. The differences are in how they handle structured outputs, error recovery, and multi-agent coordination, but underneath, it's always the same loop. That's the only part that matters. Everything else is a tool definition or a message format.

The agent loop: goal flows into LLM, LLM calls a tool, result feeds back, repeats until done


The Loop#

Imagine you hired someone to research a topic and write a report. They don't do it all in one shot. They search for something, read what comes back, decide what to search next, maybe open a document, take notes, and keep going until they have enough to write the report. They're running a loop in their head: observe, decide, act, repeat.

An agent does exactly the same thing. The difference is that the "deciding" step is done by an LLM, and the "acting" step is calling a function in your code. Every iteration, the agent gets a little more context about the problem, and that context informs what it does next.

Here's what that looks like as code:

python
# Adapted from the OpenAI function calling guide
# https://platform.openai.com/docs/guides/function-calling
def run_agent(goal: str, tools: dict, tool_schemas: list, max_steps: int = 8):
messages = [{"role": "user", "content": goal}]
for _ in range(max_steps):
message = llm_call(messages, tool_schemas)
messages.append(message)
if not message.tool_calls:
return message.content
for tool_call in message.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
result = tools[name](**args)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
})
return messages[-1].content

The interesting part is what the model does with accumulated context. By step 5, it's reading its own previous tool calls and results, and that history is what stops it from calling the same tool with the same arguments twice. The messages list is doing the work here, not the loop. The loop just keeps feeding that history back in.

How the messages list grows each step, showing user, assistant, and tool roles

max_steps is just a stop condition, the same kind of guard used in any loop or if statement. Eight steps is enough for a small example and cheap enough to run safely. Without a limit, a confused model can call the same tool again and again, each call costing tokens. When the limit is hit, messages[-1].content is returned, which is whatever the model last said. Real output, not a hardcoded error string.

Everything else in this post is just filling in the details of that skeleton.


The LLM Call#

The model needs to return a structured decision about which tool to call and with what arguments. There are three useful layers to understand here: raw function calling, a model adapter like OpenRouter, and an Agent SDK that owns the loop for you.

First, the raw function-calling pattern. Rather than parsing free text to infer intent, the model signals intent through a structured channel. Free text parsing is fragile. Function calling is not.

python
# From the OpenAI function calling guide
# https://platform.openai.com/docs/guides/function-calling
from openai import OpenAI
import json
client = OpenAI()
messages = [{"role": "user", "content": "What is the latest Python release?"}]
# 1. Send the request with tools defined
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOL_SCHEMAS,
tool_choice="auto",
)
messages.append(response.choices[0].message)
# 2. Execute each tool call and feed the result back
for tool_call in response.choices[0].message.tool_calls or []:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id, # required: links result to call
"content": str(TOOLS[name](**args)),
})
# 3. Get the final response
final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOL_SCHEMAS,
)
print(final.choices[0].message.content)

The API returns a list of tool_calls, not a single call. Most tutorials iterate over one. That's a bug. The tool_call_id field is how the model matches each result back to the specific call it made, and without it the API will reject the message.

Second, the model adapter. OpenRouter gives access to many models through a single OpenAI-compatible endpoint. One client, one base URL, and the model is just a string passed in per call:

python
# From the OpenRouter quickstart
# https://openrouter.ai/docs/quickstart
import os
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)
MODEL_FAST = "google/gemini-3.5-flash"
MODEL_SLOW = "anthropic/claude-sonnet-4.5"
def llm_call(messages: list, tools: list, model: str = MODEL_FAST):
response = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
tool_choice="auto",
)
return response.choices[0].message

MODEL_FAST for routine tool calls, MODEL_SLOW when a step needs deeper reasoning. The tradeoff is latency. OpenRouter adds a hop. For most agent workloads that hop is negligible because the bottleneck is the model thinking, not the network. For latency-critical applications, calling the provider directly is the better choice.

Third, the Agent SDK. This is the production-shaped version of the same idea. Instead of manually wiring the message loop, tool execution, and stop conditions, callModel owns the loop. Tools are defined with tool(), and stop conditions like stepCountIs and maxCost replace the manual max_steps check:

typescript
// From the OpenRouter Agent SDK documentation
// https://openrouter.ai/docs/agent-sdk/overview
import { callModel, tool, stepCountIs, maxCost } from "@openrouter/agent";
import { z } from "zod";
const searchTool = tool({
name: "search_web",
description: "Search the web for current information.",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => {
return { results: ["..."] }; // plug in Tavily, Serper, etc.
},
});
const result = await callModel({
model: "anthropic/claude-sonnet-4.6",
messages: [
{
role: "user",
content: "What is the latest Python release?",
},
],
tools: [searchTool],
stopWhen: [stepCountIs(8), maxCost(0.50)],
});
const text = await result.getText();

The SDK sends the prompt, receives tool calls, executes them, feeds results back, and loops until a stop condition is met. stepCountIs(8) is the same idea as max_steps; maxCost(0.50) adds a spend cap. Less control, much less wiring. The manual loop stays in this post because the goal is to see what the SDK is abstracting away.


The Tools#

Tools are just functions. They can call external APIs, query databases, read files, trigger workflows, or hit services like Firecrawl, Tavily, or Serper. You define the function, describe it in a schema, and pass that schema to the model. From there, the model picks which tool to call and the result goes back into the message history.

python
# Tool schema format from the OpenAI function calling reference
# https://platform.openai.com/docs/guides/function-calling#defining-functions
def search_web(query: str) -> str:
# Swap this with Firecrawl, Tavily, Serper, or an internal API.
return f"Search results for: {query}"
TOOLS = {"search_web": search_web} # name -> callable registry
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "search_web",
"description": (
"Search the web for current information. "
"Use when the goal needs facts not in training data."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A concise, specific search query",
}
},
"required": ["query"],
"additionalProperties": False,
},
"strict": True,
},
},
# add more tools following the same shape
]

The description field is the only thing the model sees when deciding which tool to call. A vague description like "search the web" is weaker than "search the web for current facts not available in the model's training data." OpenAI's own function calling guide puts it well: write descriptions like API documentation for an intern who has never seen your codebase.


Memory#

Short-term memory is already solved. The messages list is the agent's memory for the current run, and every tool call and its result gets appended so the model sees the full history on every step.

Long-term memory, meaning remembering things across separate runs, is a different problem. It requires embeddings.

python
# From the OpenAI embeddings reference
# https://platform.openai.com/docs/guides/embeddings
def embed(text: str) -> list[float]:
response = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return response.data[0].embedding

The pattern is to embed important observations from a run and store them in a vector database like Pinecone, Qdrant, or Chroma. At the start of the next run, the current goal is embedded, the closest past observations are retrieved, and they're injected into the system prompt. The temptation is to inject everything. Don't. A system prompt stuffed with 50 retrieved observations is worse than an empty one, because the model spends its attention budget reading context instead of solving the problem. Three relevant observations beat thirty irrelevant ones.

Short-term memory lives in messages[], long-term memory lives in a vector database


Putting It Together#

python
# Full example: loop skeleton + OpenRouter adapter + tools
# OpenAI function calling:
# https://platform.openai.com/docs/guides/function-calling
# OpenRouter quickstart: https://openrouter.ai/docs/quickstart
import json
import os
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)
MODEL_FAST = "google/gemini-3.5-flash"
def run_agent(goal: str, max_steps: int = 8) -> str:
messages = [
{
"role": "system",
"content": (
"Use tools to accomplish the goal. "
"When done, respond without calling a tool."
),
},
{"role": "user", "content": goal},
]
for step in range(max_steps):
message = client.chat.completions.create(
model=MODEL_FAST,
messages=messages,
tools=TOOL_SCHEMAS,
tool_choice="auto",
).choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content
for tool_call in message.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
try:
result = TOOLS[name](**args)
except Exception as e:
result = f"Error: {e}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
})
return messages[-1].content
if __name__ == "__main__":
print(run_agent(
"Search for the latest Python release "
"and summarise it in one paragraph."
))

This is the whole agent. The system prompt is one sentence. The loop is six lines. The tool execution is eight lines. Everything else is message plumbing. Run it and it searches, reads, writes, recovers from errors, and decides when it's done, all driven by the model.


When to Use a Framework#

Building an agent from scratch is the best way to learn what is happening underneath. It is also useful when you want to experiment with new features, build a custom workflow, or plug in your own tools and APIs without fighting an abstraction. The search_web tool in this post could call Firecrawl, Tavily, Serper, an internal search service, or anything else. The loop does not care. It only needs a function, a schema, and a result to feed back to the model.

Production agents are much more complex than this example. They need tracing, retries, budgets, permissions, evaluations, queues, human checkpoints, observability, and safe deployment paths. That is why agent SDKs and frameworks exist, and why so many people are actively working on them. If the goal is to ship a production system, using an SDK like the OpenRouter Agent SDK, LangGraph, Smolagents, or another mature framework is usually the better choice.

The point of this post is not that every agent should be written from scratch. The point is that writing one from scratch makes the abstraction obvious. Once the loop is clear, frameworks stop feeling like magic and start feeling like what they are: production-ready infrastructure around the same core idea.

And this is only the beginning. Tools can search the web, read files, manipulate the DOM, generate custom UI based on user interactions, call internal APIs, or retrieve information from a knowledge base. Agents can become retrieval-based systems, multi-agent workflows, browser automation layers, or product interfaces that adapt in real time. This post is the tip of the iceberg. If you want the deeper version, sign up for the newsletter.

More posts

Get notified when a new post drops. It's free, no spam.