5.6 System Prompt Customization
What you'll learn
- The 15 blocks the agent's instructions are composed from, and which of the two messages each one rides
- Which 3 you actually own (AGENTAO.md, skills, custom Tool descriptions)
- Which ones are runtime-injected and not safe to override
The agent's instructions are rebuilt every chat() turn, not a static string, and they arrive in two messages: a stable system message, and a volatile user tail assembled per request and never persisted. This section walks through the stitched blocks — where each comes from, which you can customize, and which you shouldn't touch.
System prompt structure
Two messages carry the instructions, not one. The system message is the stable half; the volatile half rides a trailing user message rebuilt on every request and never written into the transcript.
┌──────────────────────────────────────────────┐
│ System message — stable for a whole session │
│ │
│ 1. Project instructions (AGENTAO.md) │ ← you write
│ 2. Identity / capability description │ ← fixed
│ 3. Reliability principles │ ← fixed
│ 4. Task classification │ ← fixed
│ 5. Execution protocol │ ← fixed
│ 6. Completion standard │ ← fixed
│ 7. Untrusted-input boundary │ ← fixed
│ 8. Operational guidelines │ ← fixed
│ 9. Available sub-agents │ ← fixed list
│ 10. Available skills catalog │ ← changes with the enabled set
│ 11. <memory-stable> stable memories │ ← slow-changing
│ === the cacheable prefix ends here === │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ …conversation history… │
│ this turn's user message (persisted): │
│ <system-reminder> │ ← per turn
│ Current Date/Time: 2026-04-16 15:30:00 │
│ (Thursday) │
│ </system-reminder> │
│ <the text you passed to chat()> │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Volatile tail — one user message, request-only│
│ <system-reminder> │
│ 12. Active skills full text │ ← changes on activation
│ 13. Current todos │ ← dynamic, per request
│ 14. <memory-context> dynamic recall │ ← per turn
│ 15. Plan-mode prompt (conditional) │ ← conditional
│ </system-reminder> │
└──────────────────────────────────────────────┘The tail is request-only. It is assembled for one outgoing request and is never appended to agent.messages, so it does not reach the session file, the replay record or compaction. Do not copy the date/time reminder's pattern for new volatile content: that one is persisted, and a persisted tail would pile one todos snapshot into the transcript per turn.
Until 0.5.0 there was a sixteenth block: a "Reasoning Requirement" section between the operational guidelines and the sub-agent list, rendered only when the thinking_callback= constructor kwarg was set. Nothing else switched it on, so a host on transport= — agentao's own CLI included — never had it, and it was retired with the kwarg. Its text is in docs/migration/0.4.x-to-0.5.0.md §7 if you want it in your AGENTAO.md.
Blocks 12–15 left the system message in 0.4.26 (stage 0a of docs/design/llm-api-adapters.md §2.3). They were the reason the "stable" prefix was not actually stable: <memory-context> is query-specific, so it changed every turn, and since messages[0] is the head of the provider's cached prefix, that invalidated the cache covering the whole history every turn. One side effect worth knowing: because the tail is rebuilt per request rather than per turn, a todo_write the model makes in one tool iteration is visible to the next one.
The skills catalog (block 10) went with them in 0.4.26 and came back in 0.4.27. It lists every enabled skill, including the active ones, so activating a skill leaves the system message byte-identical; what activation changes is block 12, in the tail, which is also how the model learns which catalog entries are already active. The catalog changes only when the enabled set does — enable, disable, install, reload — and those already rewrite the activate_skill tool's skill_name enum, so the prefix was being rebuilt on those events anyway. It sits ahead of <memory-stable> because it changes less often: a save_memory rebuilds from block 11 on, and the catalog stays cached — on a token-prefix cache. With the explicit breakpoints below the system message is one cached block, and a save_memory re-writes all of it.
The date/time reminder
Each turn, the runner puts the current date and time in front of the user's text (ChatLoopRunner.run):
<system-reminder>
Current Date/Time: 2026-04-16 15:30:00 (Thursday)
</system-reminder>
<the text you passed to chat()>The format is %Y-%m-%d %H:%M:%S (%A). The value is datetime.now(): the local time of the host process, with no timezone. If your users are in another timezone, say so in AGENTAO.md. On a turn with images, the reminder goes into the text part.
The reminder is in neither of the two instruction messages, on purpose:
- Not in the system message. The time changes every second. In the prefix, it would break the prompt cache on every turn.
- Not in the volatile tail. The tail is never stored. The date/time is stored with the user message, so a resumed session or a replay shows when each turn happened.
tests/test_date_in_prompt.py pins both sides: the reminder is in the user message, and it is in neither the system prompt nor the volatile tail.
Three injection points you own
1. AGENTAO.md — project instructions
Place it at the root of working_directory. Loaded automatically on construction. If there is no AGENTAO.md, an AGENTS.md in the same place is read instead (never both). A repository you did not write may carry an AGENTS.md; pass project_instructions="" to read neither file.
# Project
## Stack
- Python 3.12 + FastAPI + Pydantic v2
- Frontend: Next.js 14 App Router + shadcn/ui
## Code conventions
- Ruff + black, line length 100
- async functions never use threading; for CPU-bound use `asyncio.to_thread`
- New endpoints must include an OpenAPI docstring
## Hard constraints
- Never cross-query tenants (every endpoint must have a tenant_id guard)
- DB schema changes must go through Alembic migrations
- Do not use `datetime.now()` directly; use `app.utils.time.now()` (UTC + tenant TZ aware)Best practices:
- Keep it to hard constraints and project facts (not operational how-to)
- 500–1500 words — longer pushes out other blocks
- Commit to git so the team shares
- Use
##sections so the LLM absorbs structure
How the file is found and placed
- One file, root only. Agentao checks
<working_directory>/AGENTAO.md, then<working_directory>/AGENTS.md(the agents.md convention). The first file that exists is the only one read. The two are never merged. Subdirectories and parent directories are not searched. In the CLI, the working directory is the directory where you startagentao. - Read once, at construction.
/clearand/newdo not re-read it. To pick up an edit, start a new process or build a newAgentao. - Read as UTF-8. A leading YAML frontmatter block is stripped (useful for a file carried over from another tool). An empty file adds nothing.
- No silent swap. If
AGENTAO.mdexists but cannot be read (permissions, bad encoding), Agentao logs a warning and starts with no project instructions. It does not fall back toAGENTS.md. - Host override. Any string passed as
project_instructions=is used verbatim, and no file is read.""turns the feature off. - Sub-agents do not see it. A sub-agent's project block is the
system_instructionsof its own definition.
The text opens the stable system prompt, in this frame:
=== Project Instructions ===
<content of AGENTAO.md>
=== Agent Instructions ===
<Agentao's built-in sections: identity, reliability, …>Because it is the first block of the cached prefix, an edit invalidates the whole prompt cache. Lookup: agentao/prompts/helpers.py (load_project_instructions). Framing: agentao/prompts/builder.py. Tests: tests/test_agentao_md.py.
When the instructions do not take effect
agentao.log records the load:
Loaded project instructions from /path/to/project/AGENTAO.md
Could not load AGENTAO.md: <error>The first line gets the suffix (ignored leading YAML frontmatter) when a frontmatter block was stripped. Work through this list:
- No log line at all. No file was found. Check that the file is in the root of the working directory, not in a subdirectory. Check the exact name
AGENTAO.md. Case matters on a case-sensitive file system. Could not load …. Fix the file permissions. If the error is a decode error, save the file as UTF-8. PowerShell 5.1 writes UTF-16LE from>andOut-File.- Loaded, but the wrong file. With both files present,
AGENTS.mdis ignored. - Loaded, but no effect. Print
agent._build_system_prompt()(see Inspect the system prompt) and look for=== Project Instructions ===. If it is missing, check whether your host passesproject_instructions=. If the file changed, restart.
2. Skills — on-demand long docs
Need more than 1500 words of guidance? Split into a Skill (5.2). Full body is only injected when activated.
3. Memory <memory-stable> — user-level persistent
Good for "cross-project, user-stable" facts:
# The LLM auto-saves during conversation
save_memory("user-profile", "Senior Python dev, prefers tabs, UTC+8 Shanghai")Injected in the stable block on every subsequent session.
What you can't (and shouldn't) customize
| Block | Why |
|---|---|
| Agent capability description | Governs how the agent uses tools, thinks |
| Reliability / operational rules | Core Agentao quality |
| Sub-agent / skills catalog | Reflects registration state, not static text |
If you want to drastically reshape agent behavior (remove a capability, impose a totally different mission), there's no public API. Recommended pattern: layer via AGENTAO.md + skills — don't try to replace.
Inspect the system prompt
# Right after construction
agent = Agentao(working_directory=Path.cwd())
print(agent._build_system_prompt()) # the stable system message
print(agent._build_volatile_tail()) # the request-only tail ("" when empty)⚠️ Both are private; not guaranteed stable. Debug only. _build_system_prompt() returns the stable half only — if you are looking for todos, an active skill's instructions or dynamic recall, they are in the tail.
In production, log the character length as a metric:
sp = agent._build_system_prompt()
logger.info("system_prompt_chars", extra={"len": len(sp)})A bloated prompt will:
- Eat useful context
- Increase per-turn cost
- Degrade cache hit rate (if too much dynamic content after the cache prefix)
Prompt Cache tactics
The system message is byte-identical across the turns of a session, so vendor prompt caches can reuse it — and, more importantly, reuse the history behind it.
What goes in the stable prefix
The whole system message (blocks 1–11), and then the conversation history itself. The prefix a provider can reuse ends at the last message that has not changed since the previous request.
What breaks the cache
- Mutating
AGENTAO.mdbetween turns — it is block 1, so this invalidates everything. - A
save_memorythat lands in<memory-stable>(block 11). - Enabling, disabling, installing or reloading a skill (block 10, and the
activate_skillenum in the tools block with it). Activating one does not. - A model or endpoint switch (agentao drops its own token anchor there too).
- Not todos, active-skill text, recall or the plan prompt: since 0.4.26 those live in the request-only tail, after the history, so changing them costs one uncached tail and nothing else.
The trade: the tail is re-sent in full, uncached, on every request — every tool iteration, not every turn. With nothing active it is empty or a few dozen tokens. An active skill's full text is the large item: measured in this repo with 14 skills on disk, the system message is about 5.2k tokens and the tail is empty, and activating one skill (doc-coauthoring) makes the tail about 4.1k tokens on every request for as long as it stays active. Deactivate skills you are done with, and measure your own deployment — these are local token estimates, not billed figures.
Explicit breakpoints (opt-in)
For an endpoint that honours Anthropic-style cache_control over the ordinary Chat Completions wire, set LLM_PROMPT_CACHE=anthropic (optionally LLM_PROMPT_CACHE_TTL=1h). agentao then marks at most three breakpoints per agent-turn request — the system message, the last tool definition, and the end of stable history — and leaves the fourth slot for the endpoint's own automatic caching.
Off by default, and deliberately not inferred from your base URL or model name: agentao verified that the OpenAI SDK forwards the key unchanged, not that your gateway honours it. Verify your endpoint, then turn it on. See docs/reference/configuration.md §2.
Monitor cache hit rate
On OpenAI: the response includes usage.prompt_tokens_details.cached_tokens. Ideal case: from turn 2 onward, the system message and everything but the newest messages and the tail is cached.
Different AGENTAO.md per business line
Multi-tenant / multi-product: each working_directory can have a different AGENTAO.md:
/data/tenants/acme-corp/
├── AGENTAO.md ← acme's conventions
└── .agentao/
/data/tenants/globex/
├── AGENTAO.md ← globex's conventions
└── .agentao/This is the cleanest way to do per-tenant customization — no code branching, just directory layout.
Dynamic AGENTAO.md
Some facts are per-session (subscription tier, language, region). Write AGENTAO.md into the session's working directory before constructing the agent:
def prepare_workdir(tenant, user) -> Path:
workdir = Path(f"/tmp/session-{user.id}")
workdir.mkdir(exist_ok=True)
(workdir / "AGENTAO.md").write_text(f"""
# User Context
- Tenant: {tenant.name} ({tenant.plan})
- User: {user.name}, role: {user.role}, locale: {user.locale}
- Today: {datetime.now().isoformat()}
- Current feature: {user.current_feature}
## Allowed actions
{format_allowed_actions(tenant.plan)}
""")
return workdir
agent = Agentao(working_directory=prepare_workdir(tenant, user))Now the agent's system prompt is tailored to this session.
"But I really want to replace the whole prompt"
No public API. You can subclass Agentao and override the private method:
from agentao import Agentao
class MyAgentao(Agentao):
def _build_system_prompt(self) -> str:
parent = super()._build_system_prompt()
return "# Your company's top-level charter\n\n...\n\n" + parent
agent = MyAgentao(working_directory=Path.cwd())⚠️ Relies on a private method name; retest on every version upgrade. Prefer AGENTAO.md + skills.
⚠️ Common pitfalls
Don't ship without these
- ❌ Oversized AGENTAO.md — long preambles dilute the model's attention to the actual user message
- ❌ Shared AGENTAO.md across sessions — one tenant's rules apply to another
- ❌ Sensitive info in AGENTAO.md — gets shipped to every LLM call (and possibly logged)
Each pitfall below has the full fix.
❌ Oversized AGENTAO.md
2000+ words eats too much context. Move operational how-to into skills; keep AGENTAO.md for hard constraints + key facts.
❌ Shared AGENTAO.md across sessions
If all agents point to the same working_directory, they share AGENTAO.md — but you may want per-tenant customization. Per-session working_directory is the clean answer.
❌ Sensitive info in AGENTAO.md
AGENTAO.md is a project file — it may land in git, in memory, or in logs. Never include API keys, real credentials, or customer PII.
End of Part 5. You now have the full toolkit to teach the agent your business: tools, skills, MCP, permissions, memory, system prompt. Next: safely running all of this in production.
TL;DR
- The instructions are rebuilt every turn — never assume they're a static string you can cache.
- They arrive in two messages: a stable system message (blocks 1–11) and a request-only volatile tail (blocks 12–15, one
usermessage that never enters the transcript). - You own 3 blocks:
AGENTAO.md(project hard rules), skill bodies (activated knowledge), custom Tool descriptions (when/how to call). - The rest — date, working directory, available tools/skills catalog, memory recall, todos, etc. — are runtime-injected and should not be overridden.
- Keep
AGENTAO.mdshort and absolute ("never run X", "always use Y format") — long preambles dilute attention.