When you talk about a capable system, it is natural to describe what it does in verbs.
It books flights. It sends emails. It updates spreadsheets. In Chapter 1, we saw how context makes it feel like you are talking to one continuous mind. In Chapter 2, we saw how memory makes it feel like that mind accumulates your history.
Now a new illusion appears: this mind does not just remember—it acts.
The puzzle is that nothing in what we have built so far can actually do anything: the model is a pure function that takes text as input and returns text as output, and memory lives in storage that only changes when your code writes to it. Between one response and the next, nothing moves unless some process you wrote moves it.
Yet the systems you build will be judged almost entirely by what they cause: what gets emailed, saved, paid, deployed. At some point, text must cross the boundary from a description of an action to an action that actually happens.
Where is that boundary? When a model outputs “send an email to alice@example.com,” who, precisely, is responsible for the SMTP handshake that follows? When a model writes perfect code that never runs, does it have any more agency than a static file on disk?
The stakes are both conceptual and practical:
- Conceptually, if you misplace where agency lives, you will misattribute responsibility. You will blame or trust the wrong component.
- Practically, if you treat agency as a mysterious property of the model, you will either build unsafe systems (by giving the model too much credit) or timid ones (by refusing to let anything real happen).
The agent you care about is real, but it is not inside the model. It is the composite: the model that decides, the memory that informs it, and—new in this chapter—the execution layer that turns text into effects.
The rest of this chapter follows that thread through four questions:
- How can a text generator cause effects in the world?
- What is “tool calling” really?
- Where does the “agent” begin and end?
- How do you control what actions are possible?
By the end, “the AI took action” will no longer feel like magic. It will feel like a pipeline you can sketch, test, and audit.
3.1 Text Generation and Real-World Effects
[DEMO: A chat box that accepts “Send ‘hello’ to Alice” and shows, side by side, (1) the model’s raw text output, and (2) a log of what the server actually did. Toggling a switch labeled “execute actions” turns real email sending on and off without changing the model’s output at all.]
When you ask a model “send an email to alice@example.com,” what do you actually get back?
You see a fluent reply: “I’ve sent the email.” You might see a nicely formatted email body. With function calling enabled, you might see structured JSON that looks like an API request.
Text alone never causes irreversible effects; only your code does. The point where an email leaves your infrastructure is the specific line that calls the email API; remove that line and the model no longer sends email.
The model has no agency. Agency is causality—the ability to cause effects in the world. The model produces text (data); your code interprets that text as instructions and executes them. Text by itself is inert data; interpreting and running it turns data into effects.
You can start with a simple approach: instruct the model to emit a recognizable command in its output and then have your code parse that command and decide whether to send an email.
// 1. Ask the model what to do
const response = await llm.complete([
{
role: 'system',
content:
'When sending email, reply with format: SEND_EMAIL <recipient> <message>.',
},
{
role: 'user',
content: 'Please email alice@example.com: "Hello Alice"',
},
]);
// 2. Naive, unsafe interpretation:
// Look for a magic phrase in the text and act on it.
if (response.text.startsWith('SEND_EMAIL ')) {
const [_, to, ...bodyParts] = response.text.split(' ');
const body = bodyParts.join(' ');
await sendEmail({ to, body }); // <-- This line causes the real-world effect
}
The model generates response.text; your code then decides whether to parse it and call sendEmail.
Your code parses the response and calls sendEmail using your credentials and infrastructure. The intent is still there in the output, but no email is sent because there’s no code left to call the email function.
You can treat the separation between model intent and code execution as a design boundary in your system.
Several implications follow from this separation:
First, an “agent demo” is always at least two things: the model producing an instruction and the system that executes it. The model’s reasoning chooses an action; the HTTP client, database calls, and email API execute those actions against external systems. Only the execution layer actually interacts with external services and data.
Second, you control the boundary. You decide which patterns of text count as “instructions,” how they are parsed, and what they are allowed to trigger. You can insist on rigid formats, or tolerate fuzzy ones. You can inject approvals, rate limits, and simulations in between. All of that lives in the execution layer.
Third, the most dangerous mistake in agent design is implicit execution. If arbitrary action-like text can trigger real effects, you have removed the boundary between model output and system execution.
The rest of the chapter is about making that boundary explicit, structured, and predictable.
[DEMO: Two panels. Left: a raw chat completion where the model returns “I will send an email…” in free text. Right: the same query with tool/function calling enabled, where the model returns JSON { "tool": "sendEmail", "arguments": { ... } }. A third panel shows the TypeScript router that receives the JSON and invokes a mock sendEmail function.]
Intent and effect are distinct. The next pattern to consider is “tool calling,” “function calling,” or whatever your SDK calls it.
Suddenly the model’s output changes shape. Instead of chatty prose, you see structured JSON naming tools and passing arguments. It feels like the model has learned a new trick: it now “uses” tools.
Mechanistically, nothing about the model has changed: it still only emits text. The difference is that you now treat some of that text as structured JSON that your code parses and routes to specific functions, instead of free-form prose.
Tool calling is structured output with routing. The model generates JSON matching a schema; your code parses it and dispatches to functions. Tools constrain the action space (safer, more predictable); code execution expands it.
To make this concrete, you can structure a loop where the model outputs JSON tool calls and your code routes and executes them:
// 1. Define the tools and their parameter schemas
const tools = {
sendEmail: {
description: 'Send a plain-text email to a single recipient.',
schema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
async execute(args: { to: string; subject: string; body: string }) {
return await emailClient.send(args); // real effect happens here
},
},
} as const;
// 2. Call the model, advertising the tools in context
const modelResponse = await llm.complete(
[
{
role: 'system',
content: 'You can call tools to perform actions. Think step by step.',
},
{
role: 'user',
content: 'Email alice@example.com with subject "Hi" and body "Hello Alice."',
},
],
{
tools: Object.entries(tools).map(([name, def]) => ({
name,
description: def.description,
schema: def.schema, // serialized schema
})),
},
);
// 3. The model outputs JSON *as text*
const toolCallText = modelResponse.toolCallJson; // e.g. '{"tool":"sendEmail","arguments":{...}}'
// 4. Your router interprets and executes it
async function handleToolCall(json: string) {
const { tool, arguments: rawArgs } = JSON.parse(json);
const def = (tools as any)[tool];
if (!def) {
throw new Error(`Unknown tool: ${tool}`);
}
const args = def.schema.parse(rawArgs); // validate & coerce
const result = await def.execute(args); // <-- this is the real-world action
return result;
}
In step 3, the model emits a string that is valid JSON conforming to the schema you provided; the rest is standard parsing and function dispatch in your code.
Tool calling feels different from naive parsing because you have standardized three things:
- Format. The model is asked to produce JSON with a known shape, not arbitrary prose you need to regex.
- Vocabulary. There is a closed set of tool names, each with a clear description.
- Router. There is exactly one place where model intent is translated into function calls.
This standardization is what lets frameworks offer “tool calling” as a first-class feature. Some APIs offer tool calling at the API level by transforming tool definitions into special token patterns the model was trained on, which makes tool calls more reliable than ad-hoc parsing but not infallible—the model can still hallucinate tool names, so your router must handle unknown tools. SDKs that add tool calling on top wrap your tools into prompt text; tool names, schemas, and descriptions are all serialized into the prompt, and the model still generates JSON as text that your code parses and routes.
Understanding tool calling as “structured output with routing” clarifies its tradeoffs.
Because the action space is constrained to named tools with schemas, you get:
- Predictable surface area: the model cannot invent a new tool name and have it magically exist.
- Validatable input: arguments must pass schema checks before any real action happens.
- Simple security stories: if there is no “deleteAllUsers” tool, the model cannot delete all users.
The cost is flexibility. Any behavior that cannot be expressed as a sequence of tool invocations is unavailable unless you add a new tool. Complex branching logic, ad-hoc data transformations, and novel compositions of tools all become verbose or require multiple round-trips.
This is why many systems pair tool calling with structured free-text reasoning (“think step by step, then call a tool”) or with code execution, which we will return to later. For now, the key is that “tool use” never breaks the fundamental constraint: the model still only writes strings; your router still decides what runs.
3.3 Agency as a System Property
[DEMO: A diagram view that shows three boxes—Model, Execution Layer, World—connected in a loop. Toggling checkboxes can disable (a) the model, (b) the execution layer, or (c) the feedback path. The UI highlights which combinations still qualify as an “agent” in the sense of causing effects.]
Once you have tools and routing, you need to decide where the agent boundary lies. The model may choose which tool to call, your code may override or veto calls, and humans may approve certain actions; together, these pieces form the agent system.
Consider a thermostat that measures temperature and turns a heater on or off: is it an agent? It certainly causes effects based on sensed state. What about a cron job that sends an email report once a day? There is no “intelligence,” but there is a clear causal loop.
The boundary matters because it determines how you think about responsibility and control.
Agency is a system property, not a model property. The agent is the entire loop: model deciding, code executing, results feeding back. The model is accountable for decisions; your code is accountable for execution; you are accountable for the design that combines them.
We can make this concrete with a minimal agent loop.
type ToolCall = {
tool: string;
arguments: Record<string, unknown>;
};
async function runSingleStepAgent(userInput: string, memory: MemoryStore) {
// 1. Build context: prior memory + current request
const context = await memory.retrieveRelevantFacts(userInput);
const modelResponse = await llm.complete(
[
{ role: 'system', content: 'You are an assistant that can use tools.' },
{ role: 'system', content: `Tools: ${JSON.stringify(toolDescriptors)}` },
{ role: 'system', content: `Context: ${JSON.stringify(context)}` },
{ role: 'user', content: userInput },
],
{ tools: toolDescriptors },
);
if (!modelResponse.toolCallJson) {
// Model chose to answer directly, no external effect
return { reply: modelResponse.text, effects: [] };
}
// 2. Model proposed an action
const call = JSON.parse(modelResponse.toolCallJson) as ToolCall;
// 3. Execution layer decides whether and how to run it
const { allowed, reason } = policy.check(call);
if (!allowed) {
return {
reply: `I cannot perform that action: ${reason}`,
effects: [],
};
}
const result = await executeTool(call); // <-- real-world effect(s)
// 4. Feed effect summary back into memory for future steps
await memory.store({
type: 'action',
call,
resultSummary: summarizeResult(result),
});
// 5. Ask model to explain what happened to the user
const explanation = await llm.complete([
{ role: 'system', content: 'Explain the result of this action to the user.' },
{ role: 'system', content: `Action: ${JSON.stringify(call)}` },
{ role: 'system', content: `Result: ${JSON.stringify(result)}` },
]);
return {
reply: explanation.text,
effects: [describeEffects(result)],
};
}
Where is the “agent” in this code?
- The model makes a decision: which tool to call with which arguments, and how to explain the result.
- The execution layer carries out the action, subject to policies.
- The memory captures the fact that the action occurred and what happened.
The agent is the composite of these pieces. Remove any one, and you get something qualitatively different:
- No model: a fixed script that always calls the same tool with the same arguments. Still causal, but not adaptive.
- No execution: a chat bot that promises to do things but never does. Conversational, but not agentic.
- No feedback: a system that acts but never learns from its actions. Powerful, but opaque and brittle.
Seeing agency as a system property changes how you debug failures. For example, when an email goes to the wrong person, you can separately examine the model’s decision, the execution layer’s checks, and your tool definitions; this leads to two habits described next.
First, you can draw a box around the agent boundary and treat it as a single component. From the outside, it is “something that, given inputs and permissions, may cause effects.” Internally, you are free to swap models, rewire tools, or change memory structure without changing the contract.
Second, you can make responsibility explicit. When something goes wrong—an email sent to the wrong person, a file deleted unexpectedly—you can ask three separate questions:
- Did the model choose a bad action, given its instructions and context?
- Did the execution layer authorize and run something it should have blocked?
- Did you define tools or policies that made the bad outcome possible?
This decomposition is how you debug and improve agent systems over time.
3.4 Controlling What Actions Are Possible
[DEMO: A “tool inspector” UI that shows a list of tools, each labeled as Read, Write, or External. Toggling a tool between categories updates a simulated policy engine: some actions now require confirmation, others are auto-approved or blocked. A panel shows example model tool calls and whether they would be allowed.]
The agent is the loop, and the execution layer is where causality lives. The key question becomes less “what can the model do?” and more “what do you allow to happen?”
If actions have real consequences—emails that cannot be unsent, payments that cannot be quietly reversed—who is responsible for preventing mistakes? If a model proposes something obviously harmful, should that be caught in the prompt, in the tool schema, or somewhere else?
You also face subtler questions. Not every action is equally risky. Reading a calendar is different from deleting it. Posting a draft to an internal channel is different from tweeting to the world. Not every agent needs to loop; a one-shot “send this report once per day” agent has different failure modes than an autonomous crawler.
To design sane systems, you need a vocabulary for classifying actions and matching scrutiny to stakes.
You should align the amount of review and control you apply to an action with how much damage it could cause. Read operations can usually run automatically. Write operations should be logged and may require soft confirmation. Irreversible external operations should require explicit human approval or be blocked entirely.
In code, this looks like attaching effect metadata to tools and enforcing policies centrally.
type EffectCategory = 'read' | 'write' | 'external' | 'irreversible';
interface ToolDefinition<A, R> {
name: string;
description: string;
effect: EffectCategory;
schema: z.ZodType<A>;
execute: (args: A) => Promise<R>;
}
const tools: ToolDefinition<any, any>[] = [
{
name: 'getUserProfile',
description: 'Fetch a user profile by ID.',
effect: 'read',
schema: z.object({ userId: z.string() }),
async execute({ userId }) {
return db.users.findById(userId);
},
},
{
name: 'updateUserEmail',
description: 'Update the email address for a user.',
effect: 'write',
schema: z.object({ userId: z.string(), newEmail: z.string().email() }),
async execute({ userId, newEmail }) {
return db.users.update(userId, { email: newEmail });
},
},
{
name: 'sendPayment',
description: 'Send a payment to a recipient.',
effect: 'irreversible',
schema: z.object({ toAccount: z.string(), amountCents: z.number().positive() }),
async execute({ toAccount, amountCents }) {
return paymentsClient.send({ toAccount, amountCents });
},
},
];
async function enforcePolicyAndExecute(
call: { tool: string; arguments: any },
ctx: { userId: string },
) {
const def = tools.find((t) => t.name === call.tool);
if (!def) throw new Error(`Unknown tool: ${call.tool}`);
const args = def.schema.parse(call.arguments); // validate first
switch (def.effect) {
case 'read':
// Reads are auto-approved
return def.execute(args);
case 'write':
// Writes may require soft confirmation or extra logging
await audit.logProposedAction(ctx.userId, def.name, args);
return def.execute(args);
case 'external':
case 'irreversible':
// High-stakes operations require explicit human approval
await approvals.request({
userId: ctx.userId,
tool: def.name,
args,
});
throw new Error('Action pending approval');
default:
throw new Error(`Unhandled effect category: ${(def as any).effect}`);
}
}
Several things are happening here that push back against the “model did it” narrative.
First, the catalog of possible actions is finite and explicit. If there is no sendPayment tool, payments are impossible no matter how eloquently the model asks. A model cannot hallucinate its way past your capability set.
Second, the classification of each action is visible and audited. “This tool is irreversible” is not a buried comment; it is a data field that policy code can inspect. Changing a tool’s effect from write to irreversible is a one-line, reviewable edit that tightens constraints without touching prompts.
Third, the policy is centralized. Instead of sprinkling “are you sure?” checks across code paths, you have a single enforcement function that sits between model intent and execution. This is where you implement approvals, rate limiting, role-based access, and environment differences (e.g., “in staging, external tools are simulated”).
Finally, you can define agents of different shapes against the same tool set:
- A single-action agent that runs once with
effect: 'write' tools but never loops.
- A background agent that runs on a schedule, but whose tools are all
read or reversible.
- An interactive agent that can propose
irreversible actions but must wait for UI approval.
None of these variations require changing the model. They are questions of orchestration and policy. You decide which tools to expose, how to classify them, and what gates to place in front of them.
In implementation terms, you require explicit approval and additional checks for high-risk operations, and allow low-risk read operations to execute automatically. This is the practical meaning of “you design the agent.” The model’s behavior is shaped by prompts and training, but the agent’s capabilities—what can ever happen—are shaped by the tool interface you build and the effect boundaries you enforce.
3.5 Code Execution as the Same Pattern
[DEMO: A split view where the model’s output is shown as JavaScript source code. A “run in sandbox” button executes it and shows effects (e.g., transformed data). Another toggle shows the same pattern expressed as a JSON tool call instead of raw code.]
We have been talking about named tools, but many systems take a more open approach: instead of constraining the model to a menu of functions, they let it write code directly.
Subjectively, this feels like the model has crossed some threshold. It is not just choosing from a list; it is authoring arbitrary programs. The same underlying issue still applies: your system decides whether and how to run the generated code. If code is never executed, it causes no effects; if it runs in a sandbox without network or filesystem access, it has less capability than a tool that can send email.
Mechanistically, nothing fundamental has changed.
Code execution is the same pattern as tool calling. The model outputs text that happens to be code; your runtime decides whether to execute it, under what constraints, and with which capabilities exposed.
Here is a stripped-down code-execution loop.
// 1. Model generates code as plain text
const codeResponse = await llm.complete([
{
role: 'system',
content:
'You are a JavaScript function that receives `input` and must return a JSON-serializable result.',
},
{ role: 'user', content: 'Given this array of numbers, return only the even ones: [1,2,3,4,5,6]' },
]);
const generatedCode = codeResponse.text; // e.g. "return input.filter(n => n % 2 === 0);"
// 2. Your sandbox decides what this code is allowed to do
const sandbox = new Sandbox({
// No network, no filesystem, only pure computation
allowedGlobals: ['input'],
});
// 3. You choose to run it (or not)
async function runGeneratedCode(code: string, input: unknown) {
const fn = sandbox.compileFunction('input', code);
return await fn(input); // <-- if this line never runs, no effects occur
}
const result = await runGeneratedCode(generatedCode, [1, 2, 3, 4, 5, 6]);
From the model’s perspective, generatedCode is just another string. It does not know whether you will run it, where, or with what permissions. Your sandbox decides:
- Whether compilation is allowed at all.
- Which globals and APIs are visible.
- How much CPU time and memory the code can use.
- Whether outbound network or disk access exists.
This is the exact same power the tool router had in the previous sections, but shifted from “which named function to call” to “which runtime capabilities to grant.”
The allure of code execution is obvious: within a constrained sandbox, you get extremely expressive behavior from a single model call. The model can:
- Orchestrate multiple API calls inside its own code.
- Implement conditional logic and loops without extra tool-round-trips.
- Perform complex data transformations purely in the sandbox.
The risks are also obvious:
- Bugs and mis-specifications become runtime errors deep in generated code.
- Security mistakes in the sandbox configuration can expose far more capability than intended.
- Observability becomes harder: instead of a clean log of tool invocations, you have arbitrary code doing arbitrary things.
A useful framing is to ask: what is the smallest, safest sandbox that still lets the model be useful?
Often the answer is a hybrid: use tool calling for side-effectful operations with audit requirements, and code execution for pure data manipulation inside a tightly constrained environment. Both are still elaborations of the same pattern: the model writes text; your infrastructure confers or withholds agency.
3.6 Observability for Causal Systems
When your systems only generate text, mistakes are cheap. A wrong answer can be corrected with another question. A misleading explanation can be clarified.
Once your systems cause effects, mistakes persist. An email sent to the wrong address cannot be retrieved from someone’s inbox. A file deleted from disk may not be recoverable. A payment initiated against the wrong account becomes a customer-support ticket.
At that point, you need more than a philosophical distinction between intent and effect. You need records: what the model proposed, what the execution layer actually did, under what authorization, and with what outcome.
The minimal version is a structured action log that sits exactly at the boundary between model output and execution.
interface ActionLogEntry {
id: string;
timestamp: string;
sessionId: string;
// What the user asked
userRequest: string;
// What the model proposed
proposedAction: {
tool: string;
arguments: Record<string, unknown>;
raw: string; // raw toolCallJson or code
};
// How policy evaluated it
authorization: 'automatic' | 'user_confirmed' | 'admin_approved' | 'blocked';
policyReason: string;
// What actually happened
execution: {
status: 'not_run' | 'success' | 'failure';
error?: string;
durationMs?: number;
};
// What external effects occurred (if any)
effects: Array<{
type: 'read' | 'write' | 'external';
resource: string;
reversible: boolean;
}>;
}
async function logAndExecute(
call: { tool: string; arguments: any },
ctx: { userId: string; sessionId: string; userRequest: string },
) {
const entry: ActionLogEntry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
sessionId: ctx.sessionId,
userRequest: ctx.userRequest,
proposedAction: {
tool: call.tool,
arguments: call.arguments,
raw: JSON.stringify(call),
},
authorization: 'automatic',
policyReason: '',
execution: { status: 'not_run' },
effects: [],
};
try {
const { allowed, reason, authorization } = policy.check(call, ctx);
entry.authorization = authorization;
entry.policyReason = reason;
if (!allowed) {
await actionLog.write(entry);
throw new Error(`Action blocked: ${reason}`);
}
const started = performance.now();
const result = await enforcePolicyAndExecute(call, ctx);
const durationMs = performance.now() - started;
entry.execution = { status: 'success', durationMs };
entry.effects = describeEffects(result);
await actionLog.write(entry);
return result;
} catch (err: any) {
entry.execution = { status: 'failure', error: String(err) };
await actionLog.write(entry);
throw err;
}
}
This kind of logging does not make the system safer by itself. It makes safety debuggable and auditable. When something unexpected happens, you can reconstruct:
- The original user request.
- The model’s proposed action.
- The policy decision that allowed or blocked it.
- The actual effects your tools produced.
You can also improve the agent intelligently. If you see frequent blocks for a particular tool, you might:
- Clarify the tool’s description so the model stops proposing it in inappropriate contexts.
- Tighten or loosen policy thresholds.
- Split a dangerous tool into safer sub-tools with narrower scopes.
All of these changes live in your system design, not in the model weights. That is the recurring theme: agency is architecture.
Bridge to Chapter 4
This chapter has stayed focused on a single claim: a model that only produces text has no agency by itself. Agency appears when you connect that text to code that can act, under rules you define, against systems you control.
We traced that connection through several layers:
- Intent vs. effect. The model proposes; your execution layer decides and acts.
- Tool calling. Structured outputs and a router give you a clean boundary between model and world.
- System boundaries. The “agent” is the loop: model, execution, memory, and feedback.
- Effect boundaries. You match scrutiny to stakes by classifying tools and enforcing policy.
- Code execution. Even when the model writes code, your sandbox grants or denies real power.
- Observability. Logs at the intent–effect boundary let you understand and improve causal behavior.
Causing effects is necessary but not sufficient. An agent that can send emails is only useful if it sends the right emails, at the right time, for the right reasons. An agent that can call APIs is only valuable if it composes those calls into coherent, multi-step solutions.
That brings us to the next element: reasoning.
In Chapter 4, we will look at how you structure the model’s thinking so that actions are preceded by plans, checked against expectations, and revised when reality does not match. You will see that “reasoning” is not a mysterious faculty, but another architectural pattern: breaking problems into steps, externalizing intermediate thoughts, and using multiple passes to verify and improve.