The word “agent” collected a lot of mystique, so I did the thing that usually dissolves mystique: built one from scratch, no framework, and watched what was actually required. The answer is almost anticlimactic. An agent is a loop plus a capability table — about 35 lines of control flow around a model that can only talk. Every “agentic” system, however elaborate, is this core with a bigger tool list. Here’s the whole thing, and why each piece maps to a systems idea you already know.

The Loop

The model itself does nothing but produce text (or a structured request). It can’t read a file or call an API. What makes it an agent is a loop that lets it request actions and feeds back results:

flowchart TD
    U[user question + system prompt] --> M[call the model]
    M --> D{model output}
    D -- tool request --> T[your code runs the tool]
    T --> F[append result to transcript]
    F --> M
    D -- final answer --> E[return to user, stop]

In code, minus error handling:

python
messages = [system_prompt, user_message]
for _ in range(MAX_ITERATIONS):
    resp = model(messages, tools=TOOL_SCHEMAS)
    if resp.tool_calls:
        for call in resp.tool_calls:
            result = TOOLS[call.name](**call.args)   # YOUR code executes
            messages.append(tool_result(call.id, result))
    else:
        return resp.content                          # model chose to stop

That’s it. The profound-sounding parts demystify one by one:

  • The model is a control plane, not an executor. It only ever names a tool and supplies JSON arguments. Your code decides whether and how to run it. The model talks; your loop acts. Every security property flows from this: the model never touches your system directly, so the trust boundary is your tool implementations, and giving an agent a run_shell tool is precisely as dangerous as it sounds because now it can act.
  • Tools are a capability table. Swap the table, keep the loop, and the same agent explores a codebase, triages logs, or queries a database — I demonstrated exactly this: identical control loop, three different read-only tool sets, three different agents. The loop is generic; the capabilities are the product.
  • MAX_ITERATIONS is a TTL. It’s the hop count that stops a runaway loop — the model can keep requesting tools forever (retrying a failing call, chasing its tail), and the iteration cap is the same defensive bound as a packet TTL or a retry limit. First thing you add after the naive version.
  • The transcript is the entire state. The model is stateless between calls (see my inference post); “memory” is just the growing messages list re-sent each iteration. Which immediately surfaces the real engineering constraint below.

Where the 35 Lines Meet Reality

The loop is trivial; making it robust is where actual systems work lives, and it’s all recognizable:

  • Context is a resource budget. The transcript grows every iteration and the context window is finite, so long agent runs force the same decisions as any bounded buffer: summarize old turns, evict tool outputs you no longer need, page detail in and out. A 20-tool-call debugging session overflows naive context; managing it is most of what “agent memory” means.
  • Tools fail, and the model must hear about it. Feed errors back as tool results ("error: file not found") rather than crashing — the model can often recover, retry differently, or report honestly. Your tool layer is an API boundary with an untrusted, occasionally confused caller: validate arguments, sandbox side effects, make destructive tools require confirmation, and never assume the arguments are well-formed.
  • Determinism is gone. The same input can take different tool paths on different runs. Testing shifts from asserting exact outputs to evaluating behavior over a fixed task set (LLM-as-judge, success metrics) — closer to testing a flaky distributed system than a pure function.
  • Multi-agent is just tools that are agents. A “supervisor” orchestrating specialists is the same loop where some tools happen to invoke other loops. Delegation is a tool call; there’s no new primitive, just composition — which is the honest antidote to a lot of multi-agent-framework marketing.

The reason I find this clarifying rather than deflating: it means agents are amenable to ordinary engineering. The loop is a state machine, tools are an API surface with a hostile caller, context is a cache-eviction problem, iteration caps are TTLs, and orchestration is composition. Frameworks (LangChain and friends) package retries, parsing, and memory so you don’t rewrite them — genuinely useful — but they’re conveniences over these 35 lines, not magic you couldn’t write. Knowing that changes how you debug: when an agent misbehaves, you read the transcript and the tool results, because that’s the whole state.

Takeaways

  • An agent = a loop that calls the model, executes any tool the model requests, feeds results back, and stops when the model stops — ~35 lines.
  • The model is a control plane that only names actions; your code executes them, so the trust boundary and all real capability live in your tools.
  • Swap the capability table to get a different agent; MAX_ITERATIONS is a TTL; the transcript is the entire state.
  • The hard parts are ordinary systems work: context as a resource budget, tools as a hostile-caller API, non-determinism forcing eval-based testing.
  • Multi-agent is tools-that-are-agents — composition, not a new primitive; frameworks package the plumbing, not the magic.