Skip to content
>_ abagas
All Docs
8 min read

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.

  1. 01 Goal A task plus whatever context you hand it — files, history, system prompt.
  2. 02 Decide The model picks the next action: call a tool, or answer and stop.
  3. 03 Act Your harness executes the call. This is your code, not the model’s.
  4. 04 Observe The result is appended to the conversation and becomes new context.
The 4-step cycle every agent framework is a wrapper around. The interesting engineering is in what "Act" is allowed to do, and when the loop is allowed to end.

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.

— trace · one task, 5 turns —
  1. 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

  2. 02 Find where the number is produced.

    grep("applyDiscount", src/)

    3 matches. Two call sites, one definition in pricing.ts.

    +900 tokens of context

  3. 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

  4. 04 Make the smallest change that could work.

    edit("src/pricing.ts", round-after-sum)

    Applied. One hunk, four lines.

    +700 tokens of context

  5. 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

context used after turn 1 2,400 / 40,000

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.

Most useful systems live at L2–L3. L4 is a deployment decision, not an architectural one — it is the same loop with the gates removed.
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:

Complexity
Is the task multi-step and hard to fully specify up front? "Turn this spec into a PR" qualifies. "Extract the title from this PDF" does not.
Value
Does the outcome justify the latency and token cost of a loop that may run for twenty turns?
Viability
Is the model actually good at this task shape today? Enthusiasm is not a capability assessment.
Cost of error
Can a wrong answer be caught and undone — by tests, review, a git revert? If not, the loop needs a human in it.

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.