The short version: An AI agent is an LLM autonomously using tools in a loopAnthropic's own definition, and the only one worth building against. There is no framework you have to adopt first. The runtime is a while-loop of about 40 lines: call the model, run whatever tools it asked for, hand the results back, call it again, stop when it stops asking. Everything that decides whether your agent is good lives in three places you control directly — the loop's correctness, your context budget, and your tool descriptions. This is a from-scratch tutorial for all three, ending with the same tool exposed over MCP so anything can call it.

One decision comes first, because it saves most people the whole exercise: if your task has known steps, build a workflow — plain code that calls the model at fixed points — not an agent. A workflow is cheaper, faster, and you debug it like normal code. Reach for a real agent only when the branch space is genuinely open and the model has to decide what to do next. The comparison table above lays out where the line is.

1. The loop — the whole runtime is ~40 lines#

Here is a correct, minimal agent against a raw model API (Python, Anthropic Messages API shape; OpenAI and Gemini differ only in field names). Read it before you import anything, because every framework is a wrapper around exactly this:

def run_agent(user_input, tools, tool_impls, model=MODEL, max_turns=10):
    messages = [{"role": "user", "content": user_input}]
    for _ in range(max_turns):
        resp = client.messages.create(              # 1. MODEL CALL
            model=model, max_tokens=4096,
            tools=tools, messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":          # 4. no tool asked → done
            return resp

        results = []
        for block in resp.content:                  # 2. RUN THE TOOL(S)
            if block.type == "tool_use":
                try:
                    out = tool_impls[block.name](**block.input)   # 3. OBSERVE
                    results.append({"type": "tool_result",
                                    "tool_use_id": block.id, "content": out})
                except Exception as e:              # feed errors back — don't crash
                    results.append({"type": "tool_result",
                                    "tool_use_id": block.id,
                                    "content": f"Error: {e}", "is_error": True})
        messages.append({"role": "user", "content": results})     # ...and loop
    raise RuntimeError("hit max_turns without finishing")

Four rules in that code are load-bearing, and every one of them is a bug if you get it wrong:

An agent is not a product category. It's a control-flow pattern: an LLM choosing its next action from the result of its last one.

That's the entire engine. What's left is not more engine — it's managing what goes into it.

2. Context engineering — the part that actually decides quality#

Context is a finite, degrading resource. As the window fills, quality drops — Anthropic calls it context rot, and a long-running agent that never trims its own history gets slower, more expensive, and measurably dumber over a session. Context engineering is the discipline of keeping the smallest high-signal token set that gets the next decision right. Three moves do most of the work, in order of leverage:

  1. Clear stale tool results. This is the lightest-touch compaction and the biggest cheap win: an old search that returned 4KB of JSON five turns ago is dead weight the model still pays to re-read every turn. Drop the content of tool results you're past needing (keep a stub so the transcript stays coherent).
  2. Compact when you near the limit. When history approaches the window, summarize it and reinitialize a fresh window from the summary. The craft, per Anthropic: maximize recall first (capture everything that might matter), then improve precision (drop the rest). A lossy summary that forgets the user's actual goal is worse than the bloated history it replaced.
  3. Retrieve just-in-time. Don't front-load documents into the prompt "in case." Keep lightweight identifiers — file paths, IDs, query strings — in context and pull the full content only when the model reaches for it. The model's context stays small; the data stays retrievable.

A useful fourth, once you're past the basics: give sub-tasks their own clean context. A sub-agent that researches one question in an isolated window and returns only a distilled answer keeps the coordinator's context uncluttered — the same reason you delegate.

What it means: if you only do one thing beyond the loop, clear stale tool results. It's a few lines, it costs nothing, and it's the difference between an agent that stays sharp over 30 turns and one that's confused by turn 12.

3. Tool descriptions are prompts — write them like one#

The single highest-leverage prompt in an agent is not the system prompt — it's the tool description, because it decides whether the model reaches for the right tool at the right moment. Anthropic's guidance treats the agent-tool interface as a first-class design surface, and the practical rule is short: **say when to call the tool, not just what it does.**

tools = [{
    "name": "search_orders",
    # BAD:  "Searches orders."
    # GOOD: tells the model the trigger condition, not just the capability.
    "description": "Look up a customer's orders by email or order ID. "
                   "Call this whenever the user asks about order status, "
                   "shipping, refunds, or 'where is my order' — do not guess.",
    "input_schema": {
        "type": "object",
        "properties": {
            "email":    {"type": "string", "description": "Customer email"},
            "order_id": {"type": "string", "description": "Order ID, e.g. ord_9f3k2"},
            "status":   {"type": "string", "enum": ["open", "shipped", "refunded"]},
        },
        "required": [],
    },
}]

On newer models — which reach for tools more conservatively than the 2024-era ones — an explicit trigger condition in the description measurably raises the correct call-rate. Two more rules that pay off: give every parameter a description (an undescribed status field is a coin flip), and use enum for fixed value sets so the model can't invent "in_transit" when your code expects "shipped". And resist the urge to armor your system prompt with CRITICAL: YOU MUST scaffolding — recent models over-trigger on it. State the role, the tools and when to use each, the hard stop conditions, and the output format, then stop.

4. Hang the tool off MCP so anything can call it#

Your search_orders tool works in your loop. To let other agents call it — Claude Desktop, an IDE, a teammate's bot — you expose it over the Model Context Protocol. The good news: it's the same object. An MCP tool is just a name, a description, and an inputSchema:

{
  "name": "search_orders",
  "description": "Look up a customer's orders by email or order ID. Call this whenever the user asks about order status, shipping, or refunds.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "email":    { "type": "string", "description": "Customer email" },
      "order_id": { "type": "string", "description": "Order ID, e.g. ord_9f3k2" }
    },
    "required": []
  }
}

A client lists tools (server/discover under the new spec), the model picks one, the client sends tools/call with { "name": "search_orders", "arguments": {...} }, and your server returns a content array. The agent loop from Section 1 doesn't change at all — the tool just lives behind a protocol instead of inside your process.

What did change, as of the 2026-07-28 stateless spec, is the transport: there's no session and no initialize handshake anymore, so each tools/call is a self-contained HTTP request you can put behind a plain round-robin load balancer — the cheapest infrastructure a solo team can run. If you're wiring a client to the new spec, we wrote the exact header-by-header migration in How to Migrate Your MCP Client to the 2026-07-28 Stateless Core, and the background on why the session died in MCP Goes Stateless.

Ship this, in order#

  1. Write the loop from Section 1 against your model's raw API. Confirm it calls one tool and stops. ~40 lines, one afternoon.
  2. Add one real tool with a when-to-call description. Watch the transcript to see if the model reaches for it correctly.
  3. Add stale-tool-result clearing before you add anything else. It's the cheapest quality you'll ever buy.
  4. Only then reach for a framework — and you'll know exactly which of its features you actually need, because you'll have hit the wall it's built for.

The framework you didn't adopt on day one is the one you'll understand on day thirty. Build the loop first.