Anatomy of an agentic AI system
What an agent actually is under the marketing: a loop, a tool surface, a context budget, and a stop condition.
- #ai
- #agents
- #architecture
“Agentic AI” has been stretched to cover everything from a chat window with a search box to a fully autonomous engineer, which makes it a bad word for planning and a worse one for budgeting. Underneath the term there is a specific and fairly small architecture, and it is worth being able to draw it: a model that chooses actions, a harness that executes them, a conversation that accumulates, and a rule for stopping. Everything else — memory, planning, subagents, evals — is an elaboration on those four things.
The loop is the whole idea
A single model call maps one input to one output. A workflow chains several of those calls in an order you decide. An agent hands the ordering decision to the model: it looks at the state, picks the next action, sees the result, and decides again.
- 01 Goal A task plus whatever context you hand it — files, history, system prompt.
- 02 Decide The model picks the next action: call a tool, or answer and stop.
- 03 Act Your harness executes the call. This is your code, not the model’s.
- 04 Observe The result is appended to the conversation and becomes new context.
That control inversion is the entire distinction. It is also the entire risk: you no longer know in advance how many calls will happen, which tools will run, or in what order. You are trading determinism for the ability to handle tasks you couldn’t fully specify.
In code, it is smaller than you’d expect
Stripped of frameworks, the loop is a while over one field of the response. Here it is against the Claude API, with a tool the harness executes locally:
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const tools: Anthropic.Tool[] = [
{
name: 'read_file',
description: 'Read a UTF-8 file from the project. Call this before editing anything.',
input_schema: {
type: 'object',
properties: { path: { type: 'string', description: 'Repo-relative path' } },
required: ['path'],
},
},
];
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: 'Why does the payments test fail?' },
];
while (true) {
const response = await client.messages.create({
model: 'claude-opus-4-8',
max_tokens: 16000,
thinking: { type: 'adaptive' },
tools,
messages,
});
// Append the WHOLE assistant turn — the tool_use blocks have to survive.
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') break;
const calls = response.content.filter(
(block): block is Anthropic.ToolUseBlock => block.type === 'tool_use'
);
// Every result goes back in ONE user message, each keyed to its tool_use_id.
messages.push({
role: 'user',
content: await Promise.all(
calls.map(async (call) => ({
type: 'tool_result' as const,
tool_use_id: call.id,
content: await runTool(call.name, call.input),
}))
),
});
}
In production you would reach for the SDK’s tool runner (client.beta.messages.toolRunner) rather than hand-rolling this, since it drives the same cycle and still gives you per-turn hooks for approval gates and result rewriting. Write the loop once anyway — it is the difference between debugging an agent and debugging a framework.
Four things you actually design
The loop is fixed. What you choose is everything it operates on.
The tool surface is the real API of your agent, and its shape decides what your harness can enforce. A single bash tool gives maximum reach and an opaque string you cannot inspect; a dedicated send_email tool gives you a typed call you can gate, log, and render. Start broad, then promote an action to its own tool the moment you need to intercept it.
The context is a budget, not a container. Every observation is appended, so a long run spends most of its tokens re-reading its own history. Compaction summarizes it, context editing prunes stale tool results, and a plain memory file carries facts across sessions — but the first lever is returning less from each tool.
The stop condition is the part most people leave implicit. “Done when pnpm test passes” is a condition the agent can check; “improve the code” is a vibe it will keep pursuing until it runs out of tokens.
The feedback loop determines whether any of this is self-correcting. An agent with a fast test suite fixes most of its own mistakes before you see them. An agent without one reports success with total confidence and no evidence.
What a turn actually looks like
Reading a real trace does more than any diagram. Step through this one — note that the useful work is four tool calls, and roughly a third of the context is spent on a file the agent needed to read once and then carried for the rest of the run.
01 Reproduce the failure before changing anything.
bash("pnpm test payments")1 failing — expected 1200, received 1188. Rounding, not logic.
+2,400 tokens of context
02 Find where the number is produced.
grep("applyDiscount", src/)3 matches. Two call sites, one definition in pricing.ts.
+900 tokens of context
03 Read the definition before editing it.
read("src/pricing.ts", lines 40-96)Math.round applied per line item, then summed. Drift compounds.
+3,100 tokens of context
04 Make the smallest change that could work.
edit("src/pricing.ts", round-after-sum)Applied. One hunk, four lines.
+700 tokens of context
05 Check the work instead of claiming it.
bash("pnpm test payments")All 42 passing. Stop condition met — end the loop.
+2,600 tokens of context
Turn 1 of 5. 2,400 of 40,000 context tokens used.
Autonomy is a dial
“Is it an agent?” is the wrong question. Every system sits somewhere on a range, and the rung you pick is a statement about what you’re willing to have happen unsupervised.
| Rung | Shape | Human in the loop | Blast radius |
|---|---|---|---|
| L0 | Completion | Reads and applies every suggestion | None — nothing runs |
| L1 | Single tool call | Approves each call | One action, reviewed before it happens |
| L2 | Bounded loop | Reviews the diff at the end | Whatever the tool surface allows |
| L3 | Loop with gates | Approves only the irreversible steps | Reversible actions run unattended |
| L4 | Unattended run | Reads the report afterwards | Everything, until something external stops it |
The jump worth thinking hardest about is L2 to L3, because it is where reversibility starts carrying the weight. Local edits in a git-tracked repo are cheap to undo. A database write, an outbound request, a git push — those deserve an explicit stop, not because the model is unreliable but because the cost of being wrong is asymmetric.
Should you build one at all
Four questions, and a “no” to any of them means a simpler tier is the better engineering:
Where it still breaks
The failure mode that survives all of this is context that lives outside the loop: a constraint enforced three services away, a convention nobody wrote down, a reason the obvious fix is wrong here. The agent cannot see it, will not ask about it, and will produce something plausible instead. That is not a prompting problem you can engineer away yet — it is the argument for bounded tasks, tight feedback, and reading the diff.
Used inside those limits, the loop is a genuine step change in what you can delegate. Outside them, it is a very efficient way to generate a confident, wrong answer that takes longer to unpick than the work would have taken to do.