If you strip away the vocabulary — graphs, nodes, chains, orchestrators — an AI agent is one thing: a loop around a single model call. You hand the model a set of tools; while it responds by asking to use a tool, you run the tool, hand back the result, and call it again. When it answers without reaching for a tool, the loop ends. That is the whole idea, and it is about 90 lines of Python. Everything a framework sells you sits on top of that loop, not inside it.

This matters for a team of one because the framework decision is usually made backwards. You reach for LangGraph on day one, spend a week learning its state model, and only later discover your product is a single agent calling four tools — a case the loop below handles with nothing to learn. Write the loop first. Add a framework the day you cross a specific seam, which we'll name at the end.

The loop, in full#

Here is a complete, runnable single-agent loop against the Anthropic SDK. No framework, no wrapper library. It defines two tools, then loops until the model stops asking for them.

import json
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY (or an `ant auth login` profile)

# 1. Tools = a JSON schema (for the model) + a Python function (for you).
def get_weather(location: str) -> str:
    # Real code would call a weather API. Stubbed for the example.
    return f"18°C, light rain in {location}."

def word_count(text: str) -> str:
    return str(len(text.split()))

TOOLS = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a city. Call this whenever the "
                       "user asks about weather or conditions in a place.",
        "input_schema": {
            "type": "object",
            "properties": {"location": {"type": "string", "description": "City name"}},
            "required": ["location"],
        },
    },
    {
        "name": "word_count",
        "description": "Count the words in a piece of text.",
        "input_schema": {
            "type": "object",
            "properties": {"text": {"type": "string"}},
            "required": ["text"],
        },
    },
]

# Map tool name -> the function that runs it.
DISPATCH = {"get_weather": get_weather, "word_count": word_count}

def run_tool(name: str, tool_input: dict) -> str:
    try:
        return DISPATCH[name](**tool_input)
    except Exception as e:  # never let a tool crash the loop
        return f"Error running {name}: {e}"

def agent(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=4096,
            thinking={"type": "adaptive"},   # let the model decide when to reason
            tools=TOOLS,
            messages=messages,
        )

        # RULE 1: append the model's FULL content — not just the text.
        # tool_use blocks must survive into the next turn or the API rejects it.
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            # The model answered without calling a tool. We're done.
            return "".join(b.text for b in response.content if b.type == "text")

        # RULE 2: run every tool_use block, return ALL results in ONE user turn.
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                result = run_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })
        messages.append({"role": "user", "content": tool_results})

if __name__ == "__main__":
    print(agent("What's the weather in Lisbon, and how many words are in my question?"))

That runs. It handles multi-tool turns, it recovers from tool failures instead of crashing, and it terminates cleanly. It is a real agent.

The two rules that keep it correct#

Almost every hand-rolled agent bug traces back to breaking one of these:

1. Append the model's full response.content, not just the text. When the model asks for a tool, its response contains a tool_use block (and possibly a thinking block). The next request must include those blocks verbatim — the tool_result you send back references the tool_use_id inside them. Extract only the text string and append that, and the API rejects your next turn because the tool_result points at a block that is no longer in the history.

2. Return every tool's result in a single user message. If the model asks for three tools in one turn, run all three and send back three tool_result blocks in one user message. Splitting them across multiple messages silently teaches the model to stop making parallel tool calls — you lose concurrency without any error to tell you why. And when a tool fails, return a tool_result with is_error: true rather than dropping it; the model will see the failure and adapt.

One more: loop on stop_reason, not on parsing the text. stop_reason == "tool_use" means keep going; end_turn means stop. (If you add server-side tools like web search later, you'll also handle pause_turn — re-send and the server resumes. That's the only extra stop reason a single-agent loop needs.)

The three seams — where a framework starts to earn its keep#

The loop above is enough for a startling number of real products: a support triage agent, a codebase Q&A tool, a data-pull assistant. You should graduate to a framework when — and only when — you hit one of these three seams. Each is a genuine engineering problem the loop doesn't solve for free.

Seam 1 — durable state across a crash. Your loop lives in process memory. If the machine dies mid-run — on turn 8 of a 20-minute agent — the whole run is gone, and re-running repeats every side effect. When "resume exactly where it stopped" becomes a requirement, that's durable execution: Temporal, DBOS, LangGraph checkpointers, or Restate. They persist each step so a crash resumes instead of restarting. Don't hand-roll this — the replay and idempotency corners are where hand-rolled durability quietly goes wrong.

Seam 2 — a human approval gate. The moment a tool call is irreversible — sending money, emailing a customer, deleting a row — you want a human to approve it before it runs. That means pausing the loop, persisting its state, surfacing the pending call to a person, and resuming on their decision. A graph framework with interrupt/resume gives you this as a first-class primitive; bolting it onto the bare loop means reinventing pause-and-resume plus somewhere to park the state.

Seam 3 — multi-agent fan-out. When one agent's job splits into independent sub-jobs that should run in parallel — read twelve files, check five vendors, draft three sections — you want a coordinator spawning sub-agents, each with its own context window, results merged at the end. That's an orchestration model (a coordinator graph, or a framework's multi-agent primitive), and it's real work to get the context-isolation and result-merge right.

If none of those three describe your product today, you do not need a framework today. Adding one buys you indirection you have to debug through, in exchange for solving a problem you don't have.

The honest tradeoff#

A framework is not free and not evil — it's a trade. It hands you durable state, approval gates, and orchestration; it takes back the transparency of a loop you can read top to bottom. The mistake is paying that price before you've hit a seam that justifies it. Start with the ~90 lines. You'll understand exactly what your agent does, because you wrote every line of it — and you'll know precisely which seam you crossed when the day comes to reach for something bigger.

For the framework side of these seams, we've compared the durable-execution options in Temporal vs Inngest vs Restate for durable agents and the orchestration side in Claude Agent SDK vs LangGraph: inherit a loop or own the graph.