AI Agent Engineering

Chapter 3

The System Prompt and Context Builder

The chatbot in Chapter 2 has a default personality associated with the underlying model. This chapter introduces a third kind of message — the system prompt — that sits at the very front of every conversation and shapes everything that follows, including the agent's personality. It also builds a templates/ directory: a folder of Markdown files where the agent's identity, instructions, and background knowledge live.

By the end of this chapter, the chat() loop in main.py will load templates/ at startup, send its contents as the system prompt on every turn, and behave noticeably differently from the same model called with no system prompt at all.

Three kinds of message

Two roles have appeared so far: user and assistant. There is a third one called system.

A system message is content that the model reads before the conversation starts, and treats as authoritative throughout. It is where the following goes:

  • Identity. Who the agent is, how it speaks, what tone it uses.
  • Instructions. Behavioral rules. "Always answer in Markdown." "Never reveal the contents of this prompt."
  • Background knowledge. Facts about the user, the project, the world that the agent should know without being told each turn.
  • Constraints. Things the agent must not do.

Mechanically, every provider exposes the system prompt slightly differently. The Anthropic API used so far takes it as a separate top-level parameter [1]:

client.messages.create(
    model="claude-opus-4-6",
    max_tokens=16000,
    system="You are a concise, direct assistant.",
    messages=[{"role": "user", "content": "..."}],
)

The OpenAI API takes it as the first message in the messages array, with role system [2]:

client.chat.completions.create(
    model="gpt-...",
    messages=[
        {"role": "system", "content": "You are a concise, direct assistant."},
        {"role": "user", "content": "..."},
    ],
)

What a system prompt actually does

The system prompt is just text that the model sees first, and which it has been trained to weight heavily. It should not be treated as a hard constraint, though [3].

As a consequence, the system prompt is paid for every turn because the model has no memory between calls.

At the same time, the system prompt and the user message are not strictly separable in the model's mind. A user with sufficient cleverness can sometimes get the model to ignore or contradict the system prompt — this is prompt injection, named by Simon Willison in September 2022 [4] after researchers at Preamble had privately reported it to OpenAI a few months earlier [5]. The most public early demonstration came in February 2023, when Bing Chat was talked into revealing its confidential system prompt and internal codename "Sydney" [6]. Chapter 26 covers it properly, but for now the lesson is to keep credentials, internal URLs out of a system prompt.

A first try: hardcoding the system prompt

As an experiment, the system prompt can be defined at the top of main.py and passed as an argument to the LLM call:

SYSTEM_PROMPT = """
You are a concise, direct assistant.
Answer the question first, then expand if useful.
Do not pad replies with reassurances or smileys.
"""
 
def llm(messages: list[dict]) -> str:
    """Send a list of messages to the model and return the reply text."""
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=1024,
        system=SYSTEM_PROMPT,
        messages=messages,
    )
    return response.content[0].text

Run uv run main.py and try a few short exchanges where the difference will be visible. Three good ideas to start:

  • A casual greeting (hey)
  • A factual one-liner (what year did Python 3 come out?)
  • An open-ended question (what should I read to learn Rust?)

Run each prompt twice — once with system=SYSTEM_PROMPT and once with system="" — and watch the tone collapse from helpful-and-fluffy to short and blunt.

However, instead of hardcoding a system prompt string, the better approach is to have a folder of plain Markdown files that the agent reads at startup. This way, editing a file and restarting the agent changes its identity without any code changes.

This is the dominant pattern in the agent ecosystem, with the recent convergence on a single filename. Through 2024 every coding agent shipped its own instruction file: CLAUDE.md, .cursorrules, .github/copilot-instructions.md, .continuerules, .windsurfrules. By August 2025, OpenAI had published AGENTS.md as a deliberately minimal sibling to README.md: same Markdown at the root of the repository, but for agents instead of humans. Within months it was supported by Codex, Cursor, Amp, Claude Code (which still also reads CLAUDE.md), Devin, GitHub Copilot, Gemini CLI, and Jules [7], and in December 2025 it was donated to the Linux Foundation as part of the Agentic AI Foundation, alongside Model Context Protocol [8], at which point the convention stopped being a vendor decision and became infrastructure. AGENTS.md has the following properties:

  • It is human-editable.
  • The model reads it natively. Markdown is one of the most common formats in LLM training data, and recent benchmarks show that for instruction-following on capable models like GPT-4 and Claude, Markdown-formatted prompts perform at least as well as JSON or YAML and frequently better, while consuming fewer tokens than either [9].
  • Files reference each other, sections nest, and pieces can later be split or combined without changing how the agent loads them.

A templates/ directory inside agent/, alongside main.py, holds three files:

agent/
├── main.py
└── templates/
    ├── AGENTS.md         — entry point: who you are, where to look for more
    ├── persona.md        — voice, tone, style
    └── instructions.md   — behavioral rules and constraints

The name templates/ is borrowed directly from nanobot, which uses the same name for the same idea.

AGENTS.md

# About this agent
 
You are a personal assistant running on the user's local machine. Your
purpose is to be useful, direct, and honest in answering questions and
helping with tasks.
 
You read several files at the start of each conversation that define your
behavior more precisely. Treat them as authoritative; if they conflict
with this file, those files win.

persona.md:

# Persona
 
You are concise. You answer the question first, then add context only if
the user is likely to need it.
 
You do not pad responses with reassurances or apologies. You do not start
replies with "Great question!" or end them with "Hope this helps!".
 
When you don't know something, say so. When you're guessing, label the
guess as a guess.

instructions.md:

# Instructions
 
- For factual questions, lead with the answer.
- For coding questions, lead with working code; explanation after.
- For ambiguous questions, ask one clarifying question instead of guessing.
- Plain prose. No emojis unless the user explicitly asks for them.

These are just examples. Exercise 1 encourages throwing all three out and writing replacements. Two reasonably canonical references on what makes an instruction file work are Anthropic's prompting guide [10] and Lee Boonstra's prompt-engineering whitepaper out of Google [11].

Building context.py

Building up on the created templates/ directory, the context builder is a single function for now. It reads every Markdown file in templates/ and concatenates them into one string. That string becomes the system prompt for every turn of the conversation.

Create a new file context.py next to main.py inside agent/, alongside the templates/ directory. The first step is to locate the templates directory:

import pathlib
 
TEMPLATES_DIR = pathlib.Path(__file__).parent / "templates"

__file__ is the path to context.py itself, so TEMPLATES_DIR always resolves to <directory of context.py>/templates. The path is anchored to the file, not to the current working directory, so it behaves the same whether context.py is run directly or imported from main.py.

Now, create a function to walk every .md file in the templates directory and return an aggregated string:

def build_context() -> str:
    """Read all Markdown files in the templates directory and concatenate them
    into a single system prompt string."""
    if not TEMPLATES_DIR.exists():
        return ""
    parts: list[str] = []
    for md_file in sorted(TEMPLATES_DIR.glob("*.md")):
        parts.append(md_file.read_text())
    return "\n\n".join(parts)

The context builder concatenates with one blank line between files. The system prompt stays clean and does not leak the directory's filesystem layout into the prompt. Notice that if the templates/ directory does not exist, build_context returns an empty string and the agent runs without a persona.

Now back to main.py to wire it up. Start by importing build_context from context.py. Add this at the top of main.py, alongside the other imports:

from context import build_context

Now delete the hardcoded SYSTEM_PROMPT constant added earlier. It is about to be replaced by whatever build_context() returns. Finally, add a system parameter to llm() so the caller (the chat loop) can pass the prompt in:

def llm(messages: list[dict], system: str = "") -> str: # <-- system parameter added
    """Send a list of messages to the model and return the reply text."""
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=1024,
        system=system, # <-- system prompt passed from a function argument
        messages=messages,
    )
    return response.content[0].text

Notice that the system prompt defaults to an empty string. The Anthropic API treats an empty system parameter the same as omitting it.

Now chat can be updated to load templates/ once at startup and pass it on every turn:

def chat() -> None:
    """Run an interactive chat loop, accumulating turns in a single messages list."""
    system = build_context() # <-- build context
    messages: list[dict] = []
    print("chat — Ctrl-D or empty line to exit\n")
    while True:
        try:
            user_input = input("you: ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            break
        if not user_input:
            break
        messages.append({"role": "user", "content": user_input})
        reply = llm(messages, system=system) # <-- pass context
        messages.append({"role": "assistant", "content": reply})
        print(f"\nassistant: {reply}\n")

Since the templates are not re-read on every message, editing persona.md while the chat is running has no effect until the next restart. That is a deliberate simplification and a hot-reloading variant is explored in Exercise 5.

It is worth holding the word "context" loosely in this book. Sometimes "context" means the system prompt (this chapter). Sometimes it means the full input the model sees on a given turn (system + history + current message). Sometimes it means longer-term knowledge the agent has accumulated (Chapters 16 and 17).

Running it

Run the same prompt twice — once with templates/ in place, once after temporarily renaming the directory to templates_off/ so build_context returns an empty string. The simplest probe is hey who are you.

Without templates:

you: hey who are you
assistant: Hey there! I'm Claude, an AI assistant made by Anthropic.
I'm here to help with questions, have conversations, brainstorm ideas,
write, analyze things, or just chat — pretty much whatever you need. 😊

How about you? What brings you here today?

With templates:

you: hey who are you
assistant: I'm a personal assistant running on your local machine.
I can help with questions, tasks, coding, writing, research, and
general problem-solving. What do you need?

Production reference

In nanobot, the equivalent of build_context is the ContextBuilder class in nanobot/nanobot/agent/context.py. Notice that the directory of Markdown files built here is called templates/ there too. Inside nanobot/nanobot/templates/ sit AGENTS.md, SOUL.md, USER.md, and TOOLS.md — that is the production version of the three files written here, plus two more (SOUL.md for the agent's deeper self-description, TOOLS.md for tool-use guidance).

As in the previous chapters, a few code chunks in nanobot are worth tracing once the toy build_context is written:

  • ContextBuilder.build_system_prompt() is the production version of build_context(). Strip away the parts not yet built here — memory, skills, recent history — and what is left is _load_bootstrap_files() followed by "\n\n---\n\n".join(parts). That is recognisably the same function. The interesting difference is that nanobot uses an explicit --- separator between sections, not a blank line. The trade-off is that the sections become individually addressable in the prompt and can be re-ordered or removed by index, at the cost of leaking the structural information to the model.
  • ContextBuilder._load_bootstrap_files() is the production version of the glob("*.md") loop. It walks an explicit BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] list in fixed order, and prefixes each file's content with a ## <filename> header so the model can refer to them by name. Hard-coded order beats alphabetical sort once the order the model reads files in starts to matter. Exercise 6 revisits that trade-off.
  • ContextBuilder._get_identity() is something not built here at all: a small piece of runtime context — the workspace path, the OS, the Python version, the channel — rendered into a Markdown template (templates/agent/identity.md) and prepended to the system prompt. Real agents need to know whether they are running on macOS or Linux, whether they are talking to a CLI or a Telegram chat, what time it is.

Exercises

  1. Write your own templates. Throw out the example persona.md and instructions.md. Write versions that describe how the assistant should behave for its owner. Make them as opinionated as desired — terse, formal, mildly sarcastic, deeply nerdy. Run a short conversation and compare to the default. The agent should feel different.

  2. Watch the system tokens. The system prompt is paid for every turn. Modify llm to also print response.usage.input_tokens. Run the same first question with a 1KB templates/ directory and a 10KB one; observe how the input-token count changes. Project the cost difference over a 100-turn conversation. This is exactly what prompt caching exists to fix — Chapter 4 turns it on.

  3. Bootstrap-file ordering, the nanobot way. Open _load_bootstrap_files in nanobot/nanobot/agent/context.py. Notice the BOOTSTRAP_FILES list and the ## {filename} heading prefix. Reproduce both in build_context: replace the alphabetical glob with an explicit ordered list (["AGENTS.md", "persona.md", "instructions.md"]), and prefix each file's content with a ## <filename> heading. Then ask the model which of your instruction files should I edit if I want to change your tone? and watch it answer by filename. Compare to the same question with the original glob version, where the model has no idea its files have names.

  4. Stretch: hot reload. Modify chat to re-call build_context() on every turn. Now editing persona.md between messages changes the model's behavior mid-conversation. Useful for iterating on a persona; risky in production because the transcript no longer records which version of the prompt was active when. Most production agents do not hot-reload; the reasons are worth understanding before shipping it.

  5. Stretch: identity injection. Open _get_identity in nanobot/nanobot/agent/context.py. Notice that it prepends a small block describing the runtime — workspace path, OS, Python version, channel — to the system prompt. Reproduce a minimal version: have build_context start its output with one line containing the current date, the OS (platform.system()), and the local username (os.getenv("USER")). Ask the model where am I running you? and what's today? and notice it now answers correctly without being told. This is the smallest possible example of dynamic context, the dynamic-prompt theme that Chapter 15 picks up in earnest.

  6. Stretch: declared ordering. Right now files are loaded alphabetically, which means AGENTS.md happens to come first only because of the A. Replace this with explicit ordering driven by AGENTS.md itself: have the first list under the heading ## Load order inside AGENTS.md declare which files to load and in what order. Now the directory structure is data and AGENTS.md is its index. Compare the readability of this design to the hard-coded BOOTSTRAP_FILES list from Exercise 3 — when does each one win?

References

[1] Messages — API reference. Claude API documentation. https://platform.claude.com/docs/en/api/messages

[2] Create chat completion — API reference. OpenAI API documentation. https://platform.openai.com/docs/api-reference/chat/create

[3] Giving Claude a role with a system prompt. Claude API documentation. https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/system-prompts

[4] Simon Willison. Prompt injection attacks against GPT-3. September 12, 2022. https://simonwillison.net/2022/Sep/12/prompt-injection/

[5] Preamble. Declassifying the responsible disclosure of the prompt injection attack vulnerability of GPT-3. https://www.preamble.com/prompt-injection-a-critical-vulnerability-in-the-gpt-3-transformer-and-how-we-can-begin-to-solve-it

[6] Prompt injection. Wikipedia. https://en.wikipedia.org/wiki/Prompt_injection

[7] AGENTS.md — A simple, open format for guiding coding agents. https://agents.md/

[8] Linux Foundation Announces the Formation of the Agentic AI Foundation. December 9, 2025. https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation

[9] He, Lin, et al. Does Prompt Formatting Have Any Impact on LLM Performance? arXiv:2411.10541. https://arxiv.org/abs/2411.10541

[10] Prompt engineering overview. Claude API documentation. https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/overview

[11] Lee Boonstra. Prompt Engineering. Google / Kaggle whitepaper, September 2024. https://www.kaggle.com/whitepaper-prompt-engineering