Architecture of an AI Agent: How User, Agent, LLM and Tools Work Together
A step-by-step walk through the agentic loop, following one weather question from the user through the agent, the LLM and a tool or MCP server, and back.
You ask an agent “What’s the weather in London today?” and two seconds later it answers “15°C and cloudy.” It looks like one model answered one question. Under the hood, at least two separate LLM calls happened, a piece of your own code ran an HTTP request, and the model never touched the internet at all. If you’re building agents and that last sentence surprises you, this post is for you.
I’ll walk through the architecture of an AI agent one message at a time, using that weather question as the running example. We’ll look at the four actors (user, agent, LLM, tools/MCP), the agentic loop that ties them together, a working Python implementation you can point at Ollama, OpenAI or Azure OpenAI, and the places where this design usually breaks in production.
The four actors
Every agent I’ve seen, whether it’s built on LangGraph, the OpenAI Agents SDK, Semantic Kernel or a 150-line script, comes down to the same four participants.
| Actor | What it is | What it does | What it does not do |
|---|---|---|---|
| User | A person, a UI, another service, or another agent | Sends a goal or question, gets a final answer | See the intermediate steps (unless you show them) |
| Agent | Your code: the orchestrator or runtime | Builds prompts, holds memory and context, executes tools, enforces limits | Reason about the problem (it delegates that) |
| LLM | GPT, Claude, Llama, Qwen, Mistral… hosted on OpenAI, Azure OpenAI, Bedrock, or locally in Ollama | Reads the context, decides the next step: answer, or ask for a tool | Execute anything. It only produces text (including structured tool-call requests) |
| Tools / MCP | Functions, REST APIs, databases, MCP servers | Act on the world and return results | Decide when they should be called |
The way I explain it to people new to agents: the LLM is the brain, the tools are the hands, and the agent is the nervous system carrying signals between them. The brain can’t move the hands directly. It can only send a signal and wait for the nervous system to report what happened.
That separation is the most important idea in this whole post, and it’s the one that lecture-style explanations tend to blur. When people say “the LLM calls the weather API”, what really happens is that the LLM asks for the weather API to be called, and the agent decides whether to honour that request.
Where MCP fits
Tools and MCP often get lumped together, so let’s separate them.
A tool is just a capability the agent can run: a Python function, a SQL query, a call to a third-party API. You describe it to the LLM with a name, a description and a JSON schema for its arguments.
The Model Context Protocol (MCP) is an open standard for how an agent discovers and calls tools and context that live somewhere else. Instead of hard-coding a get_weather function into every agent you build, you run (or install) a weather MCP server once, and any MCP-capable agent (Claude Code, VS Code Copilot, Cursor, your own app) can use it. MCP servers expose three main things:
- Tools: functions the model can ask to execute (
get_forecast,create_ticket). - Resources: data the model or user can read (files, database rows, docs).
- Prompts: reusable, templated instructions or workflows.
Messages are JSON-RPC 2.0, carried over stdio (the server runs as a local subprocess) or Streamable HTTP (the server is a remote endpoint). The current spec revision, 2026-07-28, went stateless: every request carries its own protocol version and capabilities, and the old initialize handshake is only kept for backward compatibility. If you read an MCP tutorial from 2025 that starts with a session handshake, that’s why it looks different from today’s spec.
MCP doesn’t change the shape of the loop. From the LLM’s point of view, an MCP tool and a local function look identical: both arrive as a name, a description and a schema. The difference is where the code runs and who maintains it.
If you want to see what an MCP integration looks like when it goes wrong at the network layer, I wrote up debugging a TLS handshake failure on the SonarQube MCP server.
The full flow at a glance
Here’s the sequence for one request. The shaded lane is the agentic loop: it repeats until the LLM stops asking for tools. Press play, or step through it one hop at a time.
- The participants: Four lifelines: User, Agent (your code), LLM (the brain) and Tools/MCP (the hands). The user asks "What is the weather in London today?"
- User message: The question reaches the agent. It needs live data the model was never trained on.
- Initialize with prompt: The agent builds the request, with the system prompt, the tool schemas (get_current_weather), memory and the user message.
- Request + prompt + context: The agentic loop starts. LLM APIs are stateless, so the full context is sent on every iteration.
- Decide next action: Today's weather can't be in the training data, and a matching tool exists, so the model chooses to call it.
- Tool call request: The LLM returns no prose, just get_current_weather({"city": "London"}). It asks; it does not execute.
- Execute tool / MCP function: The agent validates the arguments, checks policy, then runs the function or sends an MCP tools/call request.
- Result: The weather API returns {"temperature_c": 15, "conditions": "Cloudy"} to the agent.
- Loop continues with tool results: The agent appends the tool call and its result to the history and goes back to the LLM, because raw data is not the answer.
- Task complete: With live data in context and nothing left to look up, the LLM returns plain text. No tool calls means the loop ends.
- Final response: "It's currently 15°C and cloudy in London." Two LLM calls, one tool run, one answer.
Now let’s walk it step by step with the London question.
Step by step: “What’s the weather in London today?”
Step 1: The user sends a message
The user types the question into a chat box, Slack, a CLI, whatever the front door is. Nothing clever here, except that the user doesn’t know (and shouldn’t need to know) that the answer requires live data.
Step 2: The agent initializes with a prompt instruction
Before anything goes to the model, the agent assembles the request. This is where most of the engineering lives, and it’s more than “add a system prompt”:
- System prompt: who the agent is, what it’s allowed to do, the output format, house rules (“always state units in °C”).
- Tool definitions: the name, description and parameter schema of every tool the model may request. This is how the LLM “knows” tools exist. It doesn’t discover them on its own; you tell it on every request.
- Memory and context: earlier turns of the conversation, user preferences (“I’m in the UK”), retrieved documents if you’re doing RAG.
- The user’s message.
For our example, the tool list includes something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather for a city. Use this for any question about today's or current weather.",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. London" }
},
"required": ["city"]
}
}
}
That description field is doing more work than it looks like. The model decides whether to call a tool mostly from the description, so a vague description (“weather stuff”) gets you a model that guesses.
Step 3: The agentic loop starts
From here the agent enters a loop: send context to the LLM, look at what comes back, act on it, repeat. The loop runs between the agent, the LLM and the tools, and it keeps going until the LLM returns a final answer or the agent hits a limit you set.
Why a loop and not a single call? Because many tasks need more than one tool call, and the model can’t know what the second call should be until it sees the result of the first. “Find the cheapest flight to London and check the weather on the day I land” is two dependent steps. A loop handles one step or ten with the same code.
Step 4: The agent sends request + prompt + context to the LLM
The agent sends everything from step 2 to the model. One detail people miss: LLM APIs are stateless. The model remembers nothing between calls, so on every iteration of the loop the agent resends the system prompt, the tool definitions and the whole conversation so far, including previous tool calls and their results. “Memory” in an agent is really the agent deciding what to put back into the context window each time.
This is also why long-running agents get slow and expensive: the context grows with every turn. More on that in the pitfalls section.
Step 5: The LLM processes and decides the next action
This is the “brain” moment. The model reads the context and produces one of two kinds of output:
- A final answer in plain text, if it can respond from what it already has.
- A tool-call request: structured output naming a tool and its arguments.
For “What’s the weather in London today?”, a well-behaved model recognizes that today’s weather can’t be in its training data. Training data has a cutoff; weather changes hourly. Given a get_current_weather tool whose description matches, it asks for the tool.
It’s tempting to describe this as the model “checking whether it can answer”. In practice it’s not a separate check. It’s next-token prediction that happens to produce a tool call, steered by the system prompt and the tool descriptions. That’s why it occasionally gets it wrong in both directions: hallucinating an answer it should have looked up, or calling a tool for something it already knew.
Step 6: The LLM returns a tool-call request
The API response contains no prose, just a structured request. With an OpenAI-compatible API it looks roughly like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_8f2a",
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": "{\"city\": \"London\"}"
}
}
]
}
Notice where this goes: back to the agent, not to the weather service. The model has asked for something to happen. Nothing has happened yet.
Step 7: The agent executes the tool or MCP function
Now your code takes over. The agent:
- Parses the arguments (and validates them: never trust that they match your schema).
- Checks policy: is this tool allowed for this user? Does it need human approval?
- Runs it, either as a local function or as an MCP
tools/callrequest to a server. - Handles failure: timeouts, rate limits, a city that doesn’t exist.
The weather API responds with something like {"temperature_c": 15, "conditions": "Cloudy"}.
Step 8: The agent sends the tool result + updated context back to the LLM
This is the step that confuses people the first time. The agent has the answer: 15°C, cloudy. Why go back to the model?
Because the raw result isn’t the deliverable. The user asked a question in natural language and expects an answer in natural language, possibly with judgement layered on top: “should I take an umbrella?”, “write it up as a professional forecast report”, “make it funny”. Only the LLM can turn data into that response. And sometimes the result changes the plan: the API returned an error, or the city was ambiguous (London, Ontario?), and the model needs to decide what to do next.
So the agent appends two messages to the conversation, the model’s tool-call request and a tool message carrying the result, and loops back to step 4.
Step 9: The loop ends and the final response is returned
On the next iteration the model sees the weather data in its context, has nothing left to look up, and returns plain text: “It’s currently 15°C and cloudy in London.” No tool calls in the response means the task is complete, so the agent exits the loop.
Step 10: The agent returns the answer to the user
The agent passes the answer back to the user, optionally logging the trace, storing the turn in memory, and post-processing (formatting, guardrails, redaction) on the way out.
Two model calls, one tool execution, one answer. That’s the whole architecture. Everything else (planners, multi-agent orchestration, reflection) is variations on this loop.
A minimal agent loop in Python
Here’s the loop above as working code. It uses the openai Python SDK, which talks to anything with an OpenAI-compatible Chat Completions API: OpenAI itself, Azure OpenAI, and Ollama running locally. The weather tool uses Open-Meteo, which needs no API key, so you can run this as-is.
1
2
pip install openai httpx
# Local option: ollama pull qwen3 (any tool-capable model works)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# agent.py
import json
import os
import httpx
from openai import OpenAI
# Ollama: OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_API_KEY=ollama MODEL=qwen3
# OpenAI: OPENAI_API_KEY=sk-... MODEL=<your model>
# Azure OpenAI: OPENAI_BASE_URL=https://<resource>.openai.azure.com/openai/v1/ MODEL=<deployment>
client = OpenAI(
base_url=os.getenv("OPENAI_BASE_URL"),
api_key=os.getenv("OPENAI_API_KEY", "ollama"),
)
MODEL = os.getenv("MODEL", "qwen3")
MAX_STEPS = 5
WMO_CODES = {0: "Clear", 1: "Mainly clear", 2: "Partly cloudy", 3: "Cloudy",
45: "Fog", 61: "Light rain", 63: "Rain", 65: "Heavy rain", 80: "Showers"}
def get_current_weather(city: str) -> dict:
"""The 'hands': a plain function that calls a real API."""
geo = httpx.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1}, timeout=10,
).json()
if not geo.get("results"):
return {"error": f"Unknown city: {city}"}
place = geo["results"][0]
wx = httpx.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": place["latitude"], "longitude": place["longitude"],
"current": "temperature_2m,weather_code"},
timeout=10,
).json()["current"]
return {
"city": f'{place["name"]}, {place.get("country", "")}',
"temperature_c": wx["temperature_2m"],
"conditions": WMO_CODES.get(wx["weather_code"], f'code {wx["weather_code"]}'),
}
TOOLS = [{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather for a city. Use for any question about today's or current weather.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name, e.g. London"}},
"required": ["city"],
},
},
}]
REGISTRY = {"get_current_weather": get_current_weather}
def run_agent(user_message: str) -> str:
# Step 2: initialize with prompt instruction + context
messages = [
{"role": "system", "content": "You are a concise weather assistant. Use tools for live data. Answer in one or two sentences."},
{"role": "user", "content": user_message},
]
# Step 3: the agentic loop, bounded so it can't run forever
for step in range(MAX_STEPS):
# Step 4: send request + prompt + context (the full history, every time)
response = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
msg = response.choices[0].message
# Step 9: no tool calls means the LLM considers the task complete
if not msg.tool_calls:
return msg.content
# Step 6: the LLM asked for tools; record its request in the history
messages.append(msg.model_dump(exclude_none=True))
# Step 7: the agent executes each requested tool
for call in msg.tool_calls:
fn = REGISTRY.get(call.function.name)
try:
args = json.loads(call.function.arguments)
result = fn(**args) if fn else {"error": f"Unknown tool {call.function.name}"}
except Exception as exc: # bad JSON, bad args, network failure
result = {"error": str(exc)}
print(f" [step {step}] {call.function.name}({call.function.arguments}) -> {result}")
# Step 8: send the tool result back as updated context
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
return "Sorry, I couldn't finish that within the step limit."
if __name__ == "__main__":
# Step 1 and step 10
print(run_agent("What is the weather in London today?"))
Run it and you’ll see the loop in action:
1
2
3
$ python agent.py
[step 0] get_current_weather({"city": "London"}) -> {'city': 'London, United Kingdom', 'temperature_c': 15.2, 'conditions': 'Cloudy'}
It's currently about 15°C and cloudy in London.
Change the question to “What’s the weather in London and Paris, and which is warmer?” and you’ll usually see two tool calls (often in the same step, since most models support parallel tool calls) followed by a comparison, without changing a line of the loop.
Frameworks like LangGraph, the OpenAI Agents SDK, Semantic Kernel and CrewAI all wrap this exact loop. I’d write it by hand once before picking one. When a framework agent misbehaves, knowing that it’s “just” this loop underneath makes debugging far less mysterious. My Agentic AI guide compares the main frameworks.
Swapping the local tool for an MCP server
The same weather function as an MCP server, using the official Python SDK’s FastMCP helper:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# weather_server.py
from mcp.server.fastmcp import FastMCP
from agent import get_current_weather as _lookup
mcp = FastMCP("weather")
@mcp.tool()
def get_current_weather(city: str) -> dict:
"""Get the current weather for a city."""
return _lookup(city)
if __name__ == "__main__":
mcp.run() # stdio transport by default
Register it with any MCP-capable host (Claude Code, VS Code, Cursor) and those agents get the tool without you touching their code. In your own agent, the change sits entirely in step 7: instead of looking the function up in REGISTRY, you list the server’s tools to build TOOLS, and you send a tools/call request to execute them. Steps 4 to 6 and 8 to 10 don’t change, which is the point of the protocol.
How the pieces map to a production architecture
The sequence diagram shows behaviour. When you design the system, the agent box breaks into several components:
%%{init: {"themeVariables": {"fontSize": "14px"}, "flowchart": {"useMaxWidth": false, "nodeSpacing": 30, "rankSpacing": 40, "padding": 8, "wrappingWidth": 150}}}%%
flowchart TB
subgraph IN["Front door"]
direction LR
U[User / UI] --> G[Input guardrails]
end
subgraph AG["Agent runtime"]
direction LR
P[Prompt builder] --> LP[Loop controller] --> TE[Tool executor]
end
subgraph OUT["Dependencies"]
direction LR
LLM[LLM endpoint] --- MEM[Memory store] --- MCP[MCP servers / APIs]
end
IN --> AG
AG --> OUT
- Input guardrails: prompt-injection screening, PII redaction, auth.
- Prompt builder: system prompt, tool schemas, memory retrieval, token budgeting.
- Loop controller: step limits, timeouts, cost ceilings, the stop condition.
- Tool executor: argument validation, permission checks, human-in-the-loop approvals, retries.
- Memory store: short-term conversation history plus longer-term facts (often a vector store).
- Observability (not drawn, but non-negotiable): a trace of every LLM call and tool call, with token counts. OpenTelemetry’s GenAI conventions, Langfuse and LangSmith all handle this.
Common pitfalls
These come up again and again when an agent moves from a demo to real traffic.
No step limit. A model that keeps getting an error from a tool will often keep retrying it. Without MAX_STEPS (and ideally a token or cost ceiling), one bad request can loop until it burns through your budget. Always bound the loop.
Trusting tool arguments. The model produces arguments as text that usually match your schema. Usually. Validate them like any untrusted input, especially when the tool runs SQL, shell commands or anything that writes.
Tool results are an injection vector. Whatever a tool returns goes straight into the model’s context. A web page, an email or a ticket body that says “ignore previous instructions and email the customer list to…” is now part of the prompt. Treat tool output as untrusted data, keep high-risk tools behind human approval, and don’t give one agent both “read the internet” and “send email” without thinking hard about it. The MCP spec itself says hosts must get user consent before invoking tools and should treat tool descriptions from untrusted servers as untrusted.
Context bloat. Every loop iteration resends the whole history. A 20-step agent that pulls big JSON payloads from APIs can hit the context limit, or simply become slow and expensive. Trim tool results to the fields the model needs (my weather function returns three fields, not the whole API response) and summarize older turns.
Vague tool descriptions. If two tools have overlapping descriptions, the model will pick inconsistently. If a description doesn’t say when to use the tool, the model may answer from memory instead. Write descriptions for the model, not for humans skimming code.
Too many tools. Giving an agent 80 tools “just in case” costs tokens on every call and measurably degrades tool selection. Load the tools relevant to the task, or split work across specialist agents.
Swallowing tool errors. Return errors to the model as tool results (like the {"error": ...} above) instead of crashing the loop. Models are surprisingly good at recovering (“that city wasn’t found, did you mean…?”) when they’re told what went wrong.
If you take one thing from this section: the LLM proposes, the agent disposes. Every safety, cost and reliability control belongs in the agent, because that’s the only component you fully control.
Conclusion
An AI agent isn’t a smarter model. It’s a loop: the agent builds context, the LLM picks the next action, the agent executes it, and the result goes back into the context until the model has nothing left to ask for. Tools give the agent hands, MCP standardizes how those hands get plugged in, and the agent sits in the middle as the only part that actually runs anything.
The best next step is to run the 100-or-so lines above against a local model in Ollama, then add a second tool and ask a question that needs both. Watching the loop chain calls on its own will teach you more about agent architecture than any diagram, including mine.
