An AI agent is not a chatbot. It is a deterministic loop wrapped around a non-deterministic model β and every architectural decision you make determines whether that loop terminates cleanly or silently drains your budget. Here is how the runtime actually works.
ββββββ βββββββ ββββββββββββ ββββββββββββββββββββ ββββββββββββββββ βββββββββββββ ββββββββββββββββββββ βββββββββββ ββββββββββ ββββββ βββ βββ ββββββββ βββββββββββ βββββββββ ββββββββββ βββ ββββββββ βββ βββββββββββββββββββββββ ββββββ βββ ββββββββ βββ βββ βββββββ βββββββββββ βββββ βββ ββββββββ
The word "agent" gets used to describe everything from a glorified fetch() call to full autonomous systems that spawn sub-agents, manage external state, and take actions in the real world. The architecture underneath all of them is simpler than the hype suggests β and understanding it at the protocol level is what separates a reliable agent from one that silently burns your token budget.
An agent is a model running in a loop with tools. The model receives messages and tool results, decides what to do next, and either calls another tool or returns a final answer. Your application code drives the loop. The model is stateless between calls β all state lives in the message array you maintain.
tool_use response or a final text answer. It has no persistent state.ToolLoopAgent) that drives iterations: send β parse β execute β append β repeat until done or capped.
The canonical pattern is called ReAct β Reason and Act. The model reasons about the current state, selects an action (tool call), observes the result, and reasons again. Concretely, this maps to the stop_reason field on the model response.
stop_reason === "tool_use", one or more tool call blocks are in the response content. If end_turn, the model is done β return the text.Every model call is independent. The model has no memory of previous calls unless you include them in the message array you send. This means the entire agent "memory" grows linearly in the message thread on every step β and every subsequent call is more expensive than the last because the context is larger.
Tools are the extension points that allow an agent to interact with the world beyond text generation. Architecturally, there are two fundamentally different execution models β and confusing them is a common source of bugs.
Defined by you. The model returns a tool_use block; your application executes the function and returns a tool_result. Full control, full responsibility.
β stop_reason: "tool_use" β your code runs
Defined by the provider. Anthropic executes them on their infrastructure. Results come back in the same response β no round-trip to your code unless mixed with client tools.
β Results embedded in response β no round-trip needed
// A well-defined tool schema β the description is load-bearing
{
"name": "charge_customer",
"description": "Initiates an M-Pesa STK Push payment request to a customer phone number.
Only call this after confirming the amount with the user. This action
moves real money β never call it speculatively.",
"input_schema": {
"type": "object",
"properties": {
"phone_number": { "type": "string", "description": "E.164 format, e.g. +254712345678" },
"amount_kes": { "type": "number", "description": "Amount in Kenyan Shillings, integer only" },
"reference": { "type": "string", "description": "Idempotency key β reuse for retries" }
},
"required": ["phone_number", "amount_kes", "reference"]
}
}
The model uses the description field β not the name β to decide when to call a tool. A vague description leads to wrong tool selection, wasted steps, and incorrect behavior. Treat tool descriptions with the same care you give API contracts: they are the interface between natural language intent and deterministic code execution.
The model can return multiple tool_use blocks in a single response. These should be executed in parallel when they have no data dependency β reading three database records simultaneously is correct. Updating a record based on a read from the same step is not. The model decides whether to parallelize; your execution layer must handle concurrent dispatch correctly and return all tool_result blocks in a single user turn.
| Tool Type | Executes Where | Round-trip to App? | Use Case |
|---|---|---|---|
| Client / User-Defined | Your application | Yes β stop_reason: tool_use | DB, APIs, business logic |
| Server / web_search | Anthropic infra | No β embedded in response | Real-time web lookup |
| Server / code_execution | Anthropic infra | No β embedded in response | Python data analysis, math |
| MCP Tools | MCP server process | Via MCP protocol layer | Shared tools across agents |
| Harness Tools (bash, editor) | Host machine | Via harness loop | Claude Code, Codex-style |
"Memory" in an agent is not a single thing. It is a stack of layers with different scope, latency, and cost characteristics. Getting the layer boundaries wrong is the most common architectural mistake in agent systems.
Everything the agent has seen this run: the original task, all tool calls, all results, all model reasoning. This is the most immediate memory layer β the model reads it on every call. The cost is that it grows linearly: a 10-step agent might send 50k tokens on step 10 even if the original task was 500 tokens. The context window is the hard ceiling on how long an agent can run without external memory.
Server-side state that should not be placed in the prompt. The Vercel AI SDK formalizes this as two objects: runtimeContext (shared across the agent loop β tenant ID, request ID, escalation state, progress flags) and toolsContext (scoped per-tool β API keys, account IDs, permissions). The model never sees this data; only your tool execution code does.
// runtimeContext flows through the entire agent loop
// toolsContext is scoped: each tool only sees its own slice
const result = await agent.generate({
prompt: "Find open billing tickets for account acct_123",
runtimeContext: { requestId: "req_abc", escalated: false },
toolsContext: {
searchTickets: { apiKey: process.env.SUPPORT_API_KEY, accountId: "acct_123" }
}
});
MCP servers expose tools that any agent framework can consume β Anthropic API, Vercel AI SDK, OpenAI, LangChain β without re-implementing the integration. A single supabase-mcp server gives every agent on the host access to database queries without each agent embedding a Supabase client. MCP decouples tool implementation from agent implementation: update the MCP server, every agent picks up the change automatically.
Databases, vector stores, and caches that survive across agent runs and sessions. An agent that needs to remember what a user prefers across conversations must write those preferences to Supabase (or similar) at the end of a run and read them back at the start of the next. Without this layer, every agent run starts from zero β which is the correct behavior for stateless tasks but the wrong behavior for anything user-specific.
Every byte in Layer 1 costs tokens on every subsequent model call. A common mistake is appending full API responses to the message thread when only a summary is needed. Compress tool results before appending β extract only what the model needs to reason about next β or move durable state to Layer 4 and retrieve it via a tool when needed.
Three levels of abstraction for building agent loops, each with different trade-offs between control, boilerplate, and feature surface. The right choice depends on whether you need multi-provider flexibility, built-in tool ecosystems, or full protocol visibility.
| Approach | Control | Boilerplate | Multi-provider | Built-in tools | Best for |
|---|---|---|---|---|---|
| Raw @anthropic-ai/sdk | Full | ~50 lines per loop | No | No | Learning the protocol; bespoke loops |
| Vercel AI SDK ToolLoopAgent | High | ~10 lines | Yes | No (you define) | Next.js apps; multi-provider |
| Claude Agent SDK | Lower | ~5 lines | No | Yes (file, bash, web, MCP) | Ops/coding agents with guardrails |
| HarnessAgent (AI SDK) | Minimal | 2 lines | Via harness | Harness-provided | Running Claude Code, Codex as an agent |
As of 2026, the Vercel AI SDK has replaced the deprecated maxSteps pattern with the ToolLoopAgent class. You define the agent once β model, instructions, tools, stop conditions β and call .generate() or .stream(). The class manages the message array, step counting, and context passing internally. Key advantages: reusable across API routes, type-safe tool definitions with Zod schemas, and built-in support for runtimeContext and toolsContext.
import { ToolLoopAgent, tool, isStepCount } from 'ai';
import { z } from 'zod';
const billingAgent = new ToolLoopAgent({
model: "anthropic/claude-sonnet-4-6",
instructions: "You are a billing assistant. Never charge without explicit confirmation.",
stopWhen: isStepCount(15),
tools: {
getInvoice: tool({
description: "Retrieve invoice details by ID",
inputSchema: z.object({ invoiceId: z.string() }),
execute: async ({ invoiceId }, { context }) =>
fetchInvoice(invoiceId, context.accountId)
})
}
});
Not every agent starts from a blank model. HarnessAgent lets you run a preconfigured harness β Claude Code, GitHub Copilot Codex, or others β as an agent within the AI SDK's primitives. The harness provides its own tool loop and built-in capabilities (file system access, shell execution, MCP connections). Results stream into standard AI SDK result and UI primitives, so you get observability and streaming without rebuilding the harness internals.
The loop continues until a condition terminates it. The default in Vercel AI SDK's ToolLoopAgent is 20 steps β a conservative safety measure. Every production agent needs explicit, thought-out stop conditions rather than relying on defaults.
// Budget-based stop condition β the most important custom condition
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
const totalUsage = steps.reduce(
(acc, step) => ({
inputTokens: acc.inputTokens + (step.usage?.inputTokens ?? 0),
outputTokens: acc.outputTokens + (step.usage?.outputTokens ?? 0),
}),
{ inputTokens: 0, outputTokens: 0 }
);
// Estimate cost: $3/M input, $15/M output (claude-sonnet-4-6)
const costUSD =
(totalUsage.inputTokens * 3 + totalUsage.outputTokens * 15) / 1_000_000;
return costUSD > 0.50; // abort if cost exceeds $0.50
};
const agent = new ToolLoopAgent({
model: "anthropic/claude-sonnet-4-6",
tools,
stopWhen: [isStepCount(25), budgetExceeded], // whichever fires first
});
Called before each model invocation in the loop. Receives runtimeContext and the step history. Returns model call overrides for the next step only β temperature, tool subset, system prompt. Use it to escalate model capability mid-run (switch to opus for complex sub-tasks), restrict tool access after certain actions, or modify temperature based on observed uncertainty.
prepareStep: async ({ runtimeContext, steps }) => {
// Escalate to more capable model on step 3+
if (steps.length >= 3 && runtimeContext.taskComplexity === "high") {
return { model: "anthropic/claude-opus-4-8", temperature: 0.1 };
}
return {};
}
An agent with tools can make irreversible changes to the world. The guardrail layer is not optional β it is the difference between a production agent and a liability.
A tool without an execute function stops the loop and surfaces the pending call to your UI. The user approves or denies. On approval, the agent resumes with the result. Implement this for any tool with irreversible side effects β payment, deploy, delete.
Use @ai-sdk/policy-opa to author tool authorization rules as Open Policy Agent Rego policies. Rules evaluate tool name, arguments, and runtimeContext. Approvals become auditable, version-controlled policy files instead of bespoke if/else chains.
Agents burn tokens differently from single-turn requests. Context grows with every step, so later steps are more expensive than earlier ones. A 10-step agent with large tool results can spend 10Γ the cost of a simple request β and the compounding is nonlinear.
description field to decide when and how to call a tool. Vague descriptions cause wrong tool choices, wasted steps, and production incidents. Treat them as API contracts.tool_result blocks with is_error: true β never thrown as exceptions. Thrown exceptions break the message thread. The model needs to see the error to recover, retry with different arguments, or escalate.