Module 5, Lecture 5.3 | Section 3: Prompt and Context Engineering
This is the lecture where everything comes together. Modules 2 through 4 built the conceptual foundation: LLMs as next-token predictors, context windows as a shared resource, tool calling as a protocol, system prompts as engineering artifacts. Lecture 5.1 covered prompting principles. Lecture 5.2 covered system prompt architecture. This lecture builds the actual system prompt and runs the first real agent.
By the end, there is working code — a Python program that takes user input, calls the Anthropic API, executes real filesystem tools, and produces real output. code-agent-v0.py and code-agent-v1.py are the full implementations.
A coding agent that can read, explore, and modify files needs exactly three tools to get started:
list_files(path) — Returns a directory listing with file names and types. The agent uses this to discover what exists before reading anything. This is the first step in the Discover → Read → Modify pattern.
read_file(filename) — Returns the complete contents of a file. Simple and direct — and deliberately naive. As discussed in Lecture 4.3, returning an entire file's contents can be expensive in tokens: a 500-line file costs roughly 2,000 tokens per read. This design is kept intentionally simple for now. Lab 4 replaces it with a search_file + read_lines pattern that costs approximately 130 tokens for the same task.
edit_file(path, old_str, new_str) — Creates or modifies files using string replacement. If old_str is empty, the file is created with new_str as contents. Otherwise, the first occurrence of old_str is replaced with new_str. The agent must read a file before editing it — it needs the exact text to provide as old_str. This is the same approach Claude Code uses for its own edit tool.
These three tools form a minimal but complete interface. The agent cannot run code, cannot search the web, cannot talk to any API. That scope restriction is intentional: the goal is to build the simplest thing that works before adding complexity.
One important note about constraints: the tools themselves are the actual enforcement mechanism. If there is no delete tool, the agent cannot delete files — regardless of what any instruction says. The constraints in the system prompt tell the model what the rules are; the absence of a tool makes it physically impossible to violate them. This distinction matters as agents grow more capable.
The system prompt follows the four-section architecture from Lecture 5.2: identity, tools, behavioral guidelines, constraints. Each section has a specific job.
You are a coding assistant. Your job is to help users read,
understand, and modify code files in their project directory.
Two sentences. No filler. The scope is explicit — code files, project directory — which primes the model to generate source code and stay within that domain.
## Available Tools
**list_files(path)**: List the files and directories at a given path.
Use this to understand the project structure before looking for
specific files. Returns file names and types.
**read_file(filename)**: Read the complete contents of a file. Use
this to understand existing code before making changes. Always read
a file before editing it.
**edit_file(path, old_str, new_str)**: Create or edit a file.
- To create: set old_str to empty string, new_str to file contents.
- To edit: set old_str to exact text to replace, new_str to replacement.
You must read the file first to know what text to use for old_str.
The last sentence of edit_file's description — "You must read the file first" — is behavioral guidance placed exactly where the model needs it: in context when it is deciding whether to call this tool. This is more effective than a separate rule buried elsewhere in the prompt.
## How to Work
- Read before editing. Never modify a file you haven't read in this session.
- Be concise. Respond with what the user needs — not explanations of your
process unless the user asks.
- When in doubt, ask. If a request is ambiguous, ask one clarifying question
before acting.
- Report what you did. After completing a task, briefly confirm what was done.
Example: "Added the multiply function to math_utils.py on line 12."
Each guideline addresses a specific failure mode: blind edits, verbose process narration, acting on ambiguous requests, and silent success or failure.
## Constraints
- Only access files within the current project directory. Do not
attempt to read or modify files outside the project.
- Never delete files. If the user asks you to delete something,
explain that you cannot and suggest alternatives.
- Do not execute code. You can read and write files, but not run them.
Constraints are non-negotiable. Guidelines express preferences and can flex under task pressure. Constraints cannot. The placement at the end of the system prompt follows the attention pattern from Lecture 5.2 — the model re-attends to the end of its context before generating a response, so constraints placed here are active in memory when decisions are made.
One well-chosen example demonstrates the complete read → inspect → edit sequence:
The example:
User: Add a docstring to the add function in math_utils.py.
Assistant:
[calls read_file("math_utils.py")]
[locates the add function at line 4]
[calls edit_file("math_utils.py",
"def add(a, b):",
"def add(a, b):\n \"\"\"Return the sum of a and b.\"\"\""
)]
Done — added a one-line docstring to the add function on line 4.
This example teaches three things simultaneously: read first, make a minimal surgical edit rather than rewriting the whole file, and confirm briefly what was done. One example like this is worth ten abstract rules.
Beyond the system prompt, the tools must be registered with the Anthropic API as structured JSON schemas. This is what the API uses to guarantee that tool calls come back as structured objects rather than text to parse:
TOOLS = [
{
"name": "list_files",
"description": "List the files and directories at a given path. ...",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list. Use '.' for the current directory."
}
},
"required": ["path"]
}
},
# read_file and edit_file follow the same pattern
]
Each tool entry has a name, a description (the prompt to the model), and an input_schema that defines the expected arguments as a JSON Schema object. The required field ensures the model must provide those arguments — they cannot be omitted.
When the model decides to call a tool, the API returns a tool_use content block with the tool name and typed, validated arguments. No regex. No string parsing. The full tool schema is appended to every API call by the SDK, which is why every word in a tool description carries a recurring token cost.
code-agent-v0.py is not an agent. It is a diagnostic. Its purpose is to make the API response structure completely visible before adding the complexity of a loop.
The program defines the full system prompt, the tool schemas, and three stub functions that do nothing except print what they received. It then takes a single user prompt, calls the API once, and displays the result:
for block in response.content:
if block.type == "tool_use":
print(f"\n[TOOL CALL] → {block.name}")
print(f" Arguments: {block.input}")
if block.name == "list_files":
list_files(**block.input)
elif block.name == "read_file":
read_file(**block.input)
elif block.name == "edit_file":
edit_file(**block.input)
elif block.type == "text":
print(f"\n[REPLY TO USER]\n{block.text}")
The critical observation: when you ask "Create hello.py with a hello world function," response.stop_reason is "tool_use" and response.content contains a tool_use block. The model is not typing a reply — it is issuing a structured instruction to call edit_file. When you ask "What does edit_file do?", stop_reason is "end_turn" and response.content contains a text block. Two completely different response structures from the same API endpoint.
What v0 does not do: it calls the stub but never sends the result back to the model. The conversation stops after one API call. This omission is intentional. It lets you see "the model asked for this tool call" cleanly, before the loop makes that harder to observe. The stub prints, proving dispatch worked — but the agent never gets an answer back. That is what v1 fixes.
Running v0 also exposes an immediate failure: when asked to write a JavaScript hello world and put it in hello.js, the model's first action is to call edit_file directly — without first calling list_files or read_file. It violated the read-first rule on its first attempt. This is a real observation from running the code, and it illustrates exactly why the system prompt needs testing before being wired into a running agent.
code-agent-v1.py is the first working agent in the course. Same system prompt, same tool schemas — but real tool implementations and a real loop.
Before looking at any code, understand what a working agent actually does at the level of control flow. A model does not "run" continuously. It processes one context and produces one response. That's it. The model itself has no loop, no memory across calls, no ongoing process. The agent loop is the code around the model that simulates continuity — that gives the impression of an ongoing, capable assistant.
The loop does two things:
These are not the same concern. They operate at different timescales and with different exit conditions. That is why there are two loops.
A single loop — ask the user, call the API, print the reply, repeat — cannot handle tool use correctly. Consider what happens when the model calls a tool:
read_file with this argument."All of this happens within a single user request. From the user's perspective, they asked one question and are waiting for one answer. The back-and-forth between model and tools is internal — completely invisible to the user. A single outer loop would have no way to express this: it would need to exit and re-enter the loop body multiple times for the same user message.
The two-loop architecture makes the distinction explicit in code. The outer loop belongs to the user. The inner loop belongs to one model turn.
while True:
user_input = input("You: ").strip()
if not user_input:
print("Goodbye.")
break
messages.append({"role": "user", "content": user_input})
# --- inner loop runs here ---
# back to outer loop: prompt for next user message
The outer loop is simple. It asks for input, appends it to the message history, delegates all agent work to the inner loop, then comes back to ask for the next input. One iteration of the outer loop is one complete exchange from the user's perspective. The user types something; eventually, the agent responds; the prompt reappears.
The user is entirely unaware of what happens in the inner loop. They do not know how many tools the model called, how many API requests were made, or how many times the messages array grew. From their perspective, it is exactly like typing to a chat interface.
while True:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages
)
# Append the full assistant response to history
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = dispatch_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
messages.append({"role": "user", "content": tool_results})
# loop again — the model gets another turn
else: # stop_reason == "end_turn"
for block in response.content:
if block.type == "text":
print(f"\nAssistant: {block.text}\n")
break # return to outer loop
The inner loop is where the agent actually thinks. It is driven entirely by one value: stop_reason. Understand stop_reason and you understand the loop.
stop_reason == "tool_use": the model is not done.
The model has produced a response, but that response is not a reply to the user — it is a request to the agent code. The model decided it needs information from the outside world before it can answer. It has specified exactly which tool to call and exactly what arguments to pass. The agent's job is to execute that tool, package the result, and hand it back to the model.
Then the loop iterates. The model gets another API call. It sees its own tool request and the result that came back. From that updated context, it decides again: call another tool, or produce a final answer?
stop_reason == "end_turn": the model is done.
The model has produced its final text response. There are no more tool requests. The inner loop exits, the reply is surfaced to the user, and control returns to the outer loop.
This stop_reason check is the beating heart of the agent. Every agent loop built on the Anthropic API uses this exact structure, regardless of how many tools it has, how complex its system prompt is, or how long the conversation runs.
Walk through every step for the prompt "Create hello.py with a hello world function":
Step 1: Call the API. The entire messages array is sent — system prompt, all previous conversation, all tool calls, all tool results, and the current user message. Every API call carries the full accumulated context. The model sees everything that has happened in this session.
Step 2: The model decides. Given the system prompt, the tool schemas, and the user's request, the model determines that edit_file should be called with old_str="" (create) and appropriate file contents. It returns a response with stop_reason = "tool_use" and a tool_use content block.
Step 3: Append the assistant's response to messages. This step is non-obvious and critical. response.content is a list of content blocks — it may contain tool_use blocks, a text block, or both. The entire list is appended to messages as the assistant turn. Not just the tool calls. Not just any text. The complete content array. This matters because the model must see its own tool request in the message history when it evaluates the results on the next iteration.
Step 4: Check stop_reason. It's "tool_use". The inner loop does not exit.
Step 5: Execute every requested tool. A single API response can contain multiple tool_use blocks — the model can request more than one tool per turn when the requests are independent. The code iterates over all content blocks, executes each tool via dispatch_tool, and collects the results. Each result is packaged as a tool_result object that references the original request by tool_use_id. This tie-back is how the model knows which result corresponds to which request when it evaluates them.
Step 6: Append tool results as a user turn. The tool_result objects are appended to messages with role: "user". Not "assistant". This surprises most students. Tool results come from the agent code — from the real world outside the model. They re-enter the conversation from the user side, exactly the way any external information does. The model did not produce them; the code did. Marking them as user-role is the protocol the API requires to represent "this is information arriving from outside."
Step 7: Loop again. The inner loop calls the API a second time with the now-longer messages array. The model sees its own tool request at [1] and the result at [2]. It produces a final text reply with stop_reason = "end_turn".
Step 8: Surface the reply and break. The text content blocks are printed. The inner loop exits. The outer loop prompts for the next user input.
stop_reason as a State MachineThe inner loop can be understood as a state machine with two states:
┌──────────────────────────────────────────┐
│ │
▼ │
┌─────────────────────┐ stop_reason ┌──────────────┐
│ Call API with │ == "tool_use" │ Execute │
│ full messages │ ──────────────────────────► │ all tools, │
│ array │ │ append │
└─────────────────────┘ │ tool_result │
│ │ as user msg │
│ stop_reason └──────────────┘
│ == "end_turn"
│
▼
Surface text reply,
exit inner loop,
return to outer loop
The agent is always in one of two states: calling the API, or executing tools. stop_reason determines which transition to take. An agent can stay in the tool_use → execute tools → call API cycle for as many iterations as the model requires — reading files, listing directories, making edits, checking results — before finally arriving at end_turn and surfacing a reply.
The messages array is the shared state between both loops. It is the model's entire working memory. Every API call sends the complete array; every tool call and result extends it. Understanding how it grows makes the loop's behavior concrete.
Single-tool exchange — "Create hello.py with a hello world function":
[0] role: user "Create hello.py with a hello world function"
[1] role: assistant [tool_use: edit_file(path="hello.py", old_str="", new_str="...")]
[2] role: user [tool_result id=tu_001: "Created hello.py"]
[3] role: assistant [text: "Done — created hello.py with a hello world program."]
The inner loop ran twice. On the first iteration, the model requested a tool (entry [1]); on the second, it produced the final reply (entry [3]). The user sees only [0] and [3]. Entries [1] and [2] are the conversation between the agent code and the model.
Multi-tool exchange — "Add a main block to hello.py that calls the function":
The model must first read the file to know what text to replace. This is the read-before-edit rule playing out in real tool calls.
[0] role: user "Add a main block to hello.py"
[1] role: assistant [tool_use: read_file(filename="hello.py")]
[2] role: user [tool_result id=tu_002: "def hello():\n print('Hello, world!')\n"]
[3] role: assistant [tool_use: edit_file(path="hello.py",
old_str="def hello():",
new_str="def hello():\n print('Hello, world!')\n\nif __name__ == '__main__':\n hello()")]
[4] role: user [tool_result id=tu_003: "Edited hello.py"]
[5] role: assistant [text: "Done — added a main block to hello.py."]
The inner loop ran three times: read → edit → final reply. Each iteration is a full API call. The model on iteration 3 can see the contents of hello.py (from [2]) and the confirmation that the edit succeeded (from [4]). It uses both to verify its work before producing the reply at [5]. The user sees only [0] and [5].
What "three times" means in practice: each inner loop iteration is a separate call to client.messages.create. Each call sends the full messages array, which by iteration 3 is 200+ tokens larger than it was at the start. This is where token costs accumulate for agentic tasks — not in the individual responses, but in the repeated sending of a growing context on every inner-loop iteration.
A single API response can contain more than one tool_use block. When the model determines that multiple tools can be called independently — for example, reading two unrelated files — it may batch those requests into a single response. The agent handles this naturally: the for block in response.content loop collects every tool_use block, and all results are packaged into a single tool_result user message.
[1] role: assistant [tool_use: read_file("utils.py"),
tool_use: read_file("tests.py")] ← two tool calls, one response
[2] role: user [tool_result id=tu_004: "...",
tool_result id=tu_005: "..."] ← two results, one message
Both results re-enter as a single user turn. The model sees both on the next iteration and can proceed with complete information. The inner loop still iterates the same number of times — it's just that some iterations carry more work per round.
Step back from the code and notice what the messages array actually is. It is not a log. It is the model's context — the complete record of everything that has happened in this session. Every fact the model knows about the files, every action it has taken, every result it has seen — all of it exists in this array.
When the inner loop calls the API on its third iteration, the model is not "remembering" what happened on iterations one and two. The model has no persistent memory. Instead, the messages array contains the full record of iterations one and two, and the model reads all of it fresh on every call. The loop creates the illusion of an agent that remembers its own actions; the messages array makes that illusion real.
This has a practical implication: if a tool result returns a large amount of text — say, a 300-line file — that text is in the messages array for every subsequent API call in this session. The context grows, and every call pays for all of it. This is not a theoretical concern for the current implementation; it is the primary reason Lab 4 replaces read_file with a targeted search_file + read_lines pair.
def list_files(path):
entries = os.listdir(path)
lines = []
for entry in sorted(entries):
full = os.path.join(path, entry)
kind = "dir" if os.path.isdir(full) else "file"
lines.append(f"{entry} [{kind}]")
return "\n".join(lines)
list_files is os.listdir() with sorting and type annotation. It returns a string. That string becomes a tool_result content block in the next API call, and the model reads it to understand what is in the directory.
def read_file(filename):
with open(filename, "r", encoding="utf-8") as f:
return f.read()
read_file opens and reads the file. The entire contents become the tool result. This is the naive design — every token of file content is injected into the context.
def edit_file(path, old_str, new_str):
if old_str == "":
with open(path, "w", encoding="utf-8") as f:
f.write(new_str)
return f"Created {path}"
else:
with open(path, "r", encoding="utf-8") as f:
contents = f.read()
if old_str not in contents:
return f"Error: text not found in {path}"
updated = contents.replace(old_str, new_str, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(updated)
return f"Edited {path}"
edit_file has two behaviors. When old_str is empty, it creates the file. When old_str is provided, it reads the file, finds the first occurrence, replaces it, and writes back. If old_str is not found, it returns an error string. That error string goes into the tool result, the model reads it on the next inner loop iteration, and a well-prompted model will report the failure to the user rather than pretending it succeeded.
Every tool returns a string. That string is the tool's answer to the model — how the world outside the model communicates back through the conversation.
Before wiring any system prompt into a running agent, test it with targeted cases. code-agent-v0.py is purpose-built for this: one prompt in, one API call, full visibility of what the model chose to do.
Three cases cover the most important surface area:
| Test | What it checks |
|---|---|
| "Create hello.py with a hello world function." | File creation — does the model use edit_file with empty old_str? |
| "Add a multiply function to math_utils.py." | Read-first enforcement — does the model call read_file before edit_file? |
| "Delete main.py." | Constraint compliance — does the model refuse and explain? |
When running these tests, the first result is often a failure. The model may edit without reading. It may ask for clarification when none is needed. It may ignore a constraint. This is normal — the diagnostic is not "did it pass?" but "what exactly did it do, and which part of the prompt failed to constrain it?"
Failure modes and their fixes:
| Failure | Cause | Fix |
|---|---|---|
| Edits without reading | Read-first instruction missing or buried | Add to edit_file description AND to guidelines |
| Verbose process narration | No conciseness constraint | Add explicit word limit |
| Wrong argument format | Tool description unclear | Rewrite with an explicit argument example |
| Constraint ignored | Constraint placed too late | Move earlier; repeat at the end |
| Asks when it shouldn't | Clarification rule too broad | Add: "only ask if truly ambiguous" |
When the tools change, the system prompt must change with them. Lab 4 replaces read_file with a search_file + read_lines pair. That change requires:
read_file description from the Available Tools sectionA system prompt written for one set of tools will produce incorrect behavior if the tools change without the prompt being updated. The tool descriptions are part of the prompt — they guide model behavior, not just document the API.
What if the model edits without reading? First, check that the instruction appears in the edit_file tool description, not only in the guidelines. Second, make the language more explicit: "You MUST call read_file before calling edit_file on any existing file. Never edit a file you have not read in the current session." Specificity is more reliably followed than generality.
Should the system prompt grow as the agent gets more tools? It will grow, but resist letting it grow unchecked. Each new tool description adds tokens to every API call. When the prompt starts to feel unwieldy, that is a signal to consider dynamic loading — the Skills architecture covered in later modules, which loads tool documentation only when the relevant capability is needed.
Why use string-replacement edit rather than full-file write? Full-file replacement requires the model to reproduce the entire file correctly, including every line it did not change. String replacement is surgical — the model specifies only what changes. This reduces hallucination risk, reduces token cost, and makes the edit verifiable: if the string is not found, the operation fails rather than silently corrupting the file.
Can the system prompt be tested in Claude.ai? The behavioral aspects — does the model follow guidelines, does it respect constraints — can be tested through claude.ai's system prompt field. But tool-calling behavior requires the API: the tool schemas and tool_use content blocks are API features that are not available through the chat interface.