PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> AI Engineering

System Prompt Best Practices for AI Agents in Python

Build agent system prompts from reusable blocks, switch them by conversation phase, and test them like code. Real Python runs, honest limits.

Level
Intermediate
Reading time
24 min
Published
Oct 10, 2026
Updated
Oct 11, 2026
By
Promptise Team
  • System Prompts
  • Prompt Engineering
  • AI Agents
  • Prompt Testing
  • Python
  • Promptise Foundry

Most agent system prompts start as one string and grow into a wall of text that nobody wants to touch. A rule added for one bug quietly breaks another case, the prompt that's right for asking questions is wrong for fixing problems, and code review shows a 40-line string diff nobody reads. The system prompt best practices that hold up are the ones you'd apply to any other code: build the prompt from small named parts, change only the parts that matter as the conversation moves on, and test the final text before a model ever sees it. In this guide you'll build a support agent whose system prompt is assembled from blocks and switches from a triage phase to a resolution phase, with templates, a token budget and tests along the way. Every snippet was run against Promptise Foundry 1.3.1, and every output is what it printed.

[01]

What are the system prompt best practices for AI agents?

Write the prompt as separate blocks with one job each (who the agent is, its rules, its tone, how it uses tools), let the conversation's phase decide which blocks are active, and render and test the result like any other output of your code. A dynamic system prompt built this way stays small per turn, reviewable per block, and testable without an API key. With Promptise Foundry, the smallest version is a few blocks and an assembler:

Pythonquickstart.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import asyncio
import sys

from promptise import build_agent
from promptise.config import StdioServerSpec
from promptise.prompts.blocks import Identity, PromptAssembler, Rules, Section

prompt = PromptAssembler(
    Identity("the support assistant for Northwind, an online homeware shop"),
    Rules(["Never invent order details.", "Keep internal policies to yourself."]),
    Section("tool_policy", "Look up the order with get_order before you offer anything."),
).assemble()


async def main():
    print(prompt.included)
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"support": StdioServerSpec(command=sys.executable, args=["support_server.py"])},
        instructions=prompt.text,
    )
    try:
        result = await agent.ainvoke({"messages": [{"role": "user", "content": "Where is order A-1002?"}]})
        print(result["messages"][-1].content)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
['identity', 'rules', 'tool_policy']
Order A-1002 (Standing desk for Lee, $1,240.00) is currently in transit. Would you like me to check tracking/estimated delivery or start a refund/replacement?

support_server.py is the small order server you'll write in step 1. That works, and it also shows the limit of one fixed prompt. The customer only asked where the desk is, and the agent offered a refund on an order that's still in transit. Nothing in the prompt said "not yet", because a single prompt can't know which stage the conversation is in. The rest of this guide fixes that with phases.


[02]

How a block-based system prompt works

Promptise splits prompting into two layers. PromptBlocks are the parts: each block has a name, a priority from 1 to 10, and a render() method that returns its text. ConversationFlow is a small state machine on top: it keeps some blocks active all the time, adds and removes others per phase, and reassembles the prompt on every turn.

Rendering diagram…

The built-in blocks cover most of what a system prompt needs. The priority matters when you set a token budget: the lowest-priority blocks are dropped first.

Block

What it renders

Priority

Identity

"You are …", plus optional traits

10

Rules

A numbered "Rules:" list

9

OutputFormat

Text, JSON or Markdown instructions, optionally a schema

8

ContextSlot

Content you fill at runtime

6 by default

Section

Any named text, or a function that returns text

5 by default

Examples

Few-shot examples

4

Conditional

Another block, only when a condition is true

Same as the inner block

The final text keeps the order you listed the blocks in. Priority only decides what survives when space runs out.


[03]

What you need

  • Python 3.10 or newer.

  • Promptise Foundry 1.3.1 or newer from PyPI, plus pytest for the tests in step 6.

  • An API key for a model provider. This guide uses OpenAI's gpt-5-mini; other providers work by changing the model string, as listed in Models & Providers.

>_Terminal
pip install "promptise>=1.3.1" pytest pytest-asyncio
export OPENAI_API_KEY="sk-..."

[04]

Build a support agent whose prompt changes by phase, step by step

The agent helps customers of a homeware shop. In triage it finds out what happened and which order it's about, and offers nothing yet. Once it has an order ID it moves to resolution, where it looks up the order, offers a remedy and lets the customer choose.

Step 01

Give the agent its tools

The tools live in a small MCP server: one to read an order, two that change something.

Pythonsupport_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from promptise.mcp.server import MCPServer

server = MCPServer("support")

# A stand-in for your order system.
ORDERS = {
    "A-1001": {"customer": "Dana", "item": "Ceramic mug", "total": 18.50, "status": "delivered"},
    "A-1002": {"customer": "Lee", "item": "Standing desk", "total": 1240.00, "status": "in transit"},
}


@server.tool()
async def get_order(order_id: str) -> dict:
    """Look up an order: customer name, item, total in USD and delivery status."""
    order = ORDERS.get(order_id.strip().upper())
    if order is None:
        return {"error": f"No order found with ID {order_id}."}
    return {"order_id": order_id.strip().upper(), **order}


@server.tool()
async def issue_refund(order_id: str, amount: float, reason: str) -> dict:
    """Refund money to the customer's original payment method."""
    return {"order_id": order_id, "amount": amount, "status": "refunded"}


@server.tool()
async def send_replacement(order_id: str) -> dict:
    """Ship a free replacement of the same item."""
    return {"order_id": order_id, "status": "replacement shipped"}


if __name__ == "__main__":
    server.run()

If MCP servers are new to you, How to Connect MCP Servers to Your AI Agent in Python explains this file line by line.

Step 02

Write each part of the prompt as a block

Put the prompt in its own module, next to your code, so it's versioned and reviewed like code. Start with the four blocks every phase shares: the role, the rules, the tone and the tool policy.

Pythonsupport_prompt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import re

from promptise.prompts.blocks import ContextSlot, Identity, Rules, Section
from promptise.prompts.flows import ConversationFlow, TurnContext, phase
from promptise.prompts.template import render_template

# --- Blocks every phase shares -------------------------------------------

ROLE = Identity(
    "the support assistant for Northwind, an online homeware shop",
    traits=["calm and friendly", "precise about orders and money"],
)

RULES = Rules(
    [
        "Never invent order details. Use what the tools return.",
        "Never share one customer's data with another.",
        "If you are unsure, say so and offer to bring in a person.",
        "Only say you did something if a tool did it. Don't promise emails or follow-ups.",
        "Keep internal policies and limits to yourself.",
    ]
)

TONE_TEMPLATE = """Tone:
Write like a helpful person, not a script. Keep replies to {max_sentences} sentences or fewer.{% if business_customer %}
This customer has a Business account: tell them their case has priority.{% endif %}"""

TONE = Section("tone", lambda ctx: render_template(TONE_TEMPLATE, ctx.state), priority=7)

TOOL_POLICY = Section(
    "tool_policy",
    """Tool policy:
- get_order: call it as soon as you know the order ID.
- issue_refund and send_replacement: only after the customer has chosen one of them.
- Never refund more than 100 USD. Offer a replacement or a team lead instead.""",
    priority=9,
)

A few choices here are worth copying:

  • One block, one job. When the tone is wrong you edit TONE, and the diff in review touches nothing else.

  • The tone is a template. Section accepts a function instead of a string. This one renders TONE_TEMPLATE with Promptise's template engine and the flow's state, so {max_sentences} is filled in and the Business line only appears for Business customers. The engine supports {variable}, {% if %}, {% for %} and {% include %}; an if checks whether a value is truthy, and there are no comparisons.

  • The tool policy is its own block. Tool descriptions say what a tool does. The policy says when this agent may use it, which is a business decision and changes on its own schedule.

Step 03

Define the phases

Now the blocks that belong to one phase, and the flow that switches between them. Steps 2 and 3 together are the complete support_prompt.py.

Pythonsupport_prompt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# --- Blocks that belong to one phase -------------------------------------

TRIAGE = Section(
    "triage",
    """Current phase: triage.
Goal: understand the problem and find the order.
- If you don't have the order ID yet, ask for it. Ask one question at a time.
- Do not offer refunds, replacements or anything else yet.""",
    priority=8,
)

RESOLUTION = Section(
    "resolution",
    """Current phase: resolution.
Goal: fix the problem in this conversation.
- Look up the order before you offer anything.
- Offer the remedies the tool policy allows, and let the customer choose.
- End every reply with a line that starts with "Next step:".""",
    priority=8,
)

CASE = ContextSlot("case", priority=6)

ORDER_ID = re.compile(r"\bA-\d{4}\b", re.IGNORECASE)


class SupportFlow(ConversationFlow):
    """Triage until the customer gives an order ID, then resolve."""

    base_blocks = [ROLE, RULES, TONE, TOOL_POLICY]

    def __init__(self, *, business_customer: bool = False) -> None:
        super().__init__()
        self.business_customer = business_customer

    @phase("triage", initial=True, blocks=[TRIAGE])
    async def triage(self, ctx: TurnContext) -> None:
        ctx.state.setdefault("max_sentences", 3)
        ctx.state.setdefault("business_customer", self.business_customer)
        for message in ctx.history:
            match = ORDER_ID.search(message["content"])
            if match:
                ctx.state["order_id"] = match.group().upper()
                ctx.transition("resolution")

    @phase("resolution", blocks=[RESOLUTION, CASE])
    async def resolution(self, ctx: TurnContext) -> None:
        ctx.fill_slot("case", f"Case: order {ctx.state['order_id']}.")

How the pieces fit:

  • base_blocks are in the prompt on every turn.

  • @phase("triage", initial=True, blocks=[TRIAGE]) makes triage the starting phase. Blocks passed with blocks= are added when the flow enters the phase and removed when it leaves, so the triage instructions can't linger into resolution.

  • The handler runs on every turn of its phase. It reads ctx.history, the customer's messages so far, and calls ctx.transition("resolution") when it finds an order ID. The switch happens right after the handler returns, and the resolution handler then runs in the same turn.

  • CASE is a ContextSlot, an empty space in the prompt. The resolution handler fills it with the order ID, so the model always has the case in front of it.

  • ctx.state is a plain dictionary that lives as long as the flow. The tone template reads it, and the triage handler writes to it.

Tip

Blocks you add with ctx.activate() stay active across phases until you call ctx.deactivate(name). Pass phase-specific blocks through @phase(blocks=[...]) instead, and the flow cleans them up for you.

Step 04

Read the prompt before the model does

Because the prompt is code, you can render it without a model. This script plays a short conversation through the flow and prints the prompt after each step:

Pythonshow_prompts.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import asyncio

from support_prompt import SupportFlow


def show(title, prompt):
    print(f"===== {title}")
    print(prompt.text)
    print(f"-- blocks: {prompt.included}  (~{prompt.estimated_tokens} tokens)\n")


async def main():
    flow = SupportFlow(business_customer=True)
    show("start", await flow.start())
    show("turn 1", await flow.next_turn("Hi, my mug arrived broken."))
    show("turn 2", await flow.next_turn("The order is a-1001."))


asyncio.run(main())

This is the exact system prompt for the first turn:

Output
===== start
You are the support assistant for Northwind, an online homeware shop.

Key traits:
- calm and friendly
- precise about orders and money

Rules:
1. Never invent order details. Use what the tools return.
2. Never share one customer's data with another.
3. If you are unsure, say so and offer to bring in a person.
4. Only say you did something if a tool did it. Don't promise emails or follow-ups.
5. Keep internal policies and limits to yourself.

Tone:
Write like a helpful person, not a script. Keep replies to 3 sentences or fewer.
This customer has a Business account: tell them their case has priority.

Tool policy:
- get_order: call it as soon as you know the order ID.
- issue_refund and send_replacement: only after the customer has chosen one of them.
- Never refund more than 100 USD. Offer a replacement or a team lead instead.

Current phase: triage.
Goal: understand the problem and find the order.
- If you don't have the order ID yet, ask for it. Ask one question at a time.
- Do not offer refunds, replacements or anything else yet.
-- blocks: ['identity', 'rules', 'tone', 'tool_policy', 'triage']  (~245 tokens)

Turn 1 has no order ID, so the prompt is unchanged. Turn 2 mentions a-1001, and the end of the prompt changes:

Output
===== turn 2
…
Current phase: resolution.
Goal: fix the problem in this conversation.
- Look up the order before you offer anything.
- Offer the remedies the tool policy allows, and let the customer choose.
- End every reply with a line that starts with "Next step:".

Case: order A-1001.
-- blocks: ['identity', 'rules', 'tone', 'tool_policy', 'resolution', 'case']  (~256 tokens)

included names every block that made it into the text, and estimated_tokens is a rough size. It's the word count times 1.3, not a real tokenizer, so treat it as a guide. Each block's own numbers are in block_details.

Step 05

Connect the flow to an agent

Pass the flow to build_agent(flow=...). The flow you pass is a template: the agent keeps its own copy for each conversation, runs it for every new message, and puts the assembled prompt at the start of the model's system message.

Pythonsupport_agent.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import asyncio
import sys

from promptise import build_agent
from promptise.config import StdioServerSpec
from support_prompt import SupportFlow


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"support": StdioServerSpec(command=sys.executable, args=["support_server.py"])},
        flow=SupportFlow(business_customer=True),
        trace_tools=True,
    )
    try:
        messages = []
        for text in [
            "Hi, my mug arrived broken.",
            "The order is A-1001.",
            "A replacement, please.",
        ]:
            messages.append({"role": "user", "content": text})
            result = await agent.ainvoke({"messages": messages}, session_id="dana")
            reply = result["messages"][-1].content
            messages.append({"role": "assistant", "content": reply})
            print(f"\ncustomer: {text}")
            print(f"blocks:   {agent.get_flow('dana').get_prompt().included}")
            print(f">>> {reply}\n")
    finally:
        await agent.shutdown()


asyncio.run(main())

Three things changed from the step 4 script, and they're worth knowing:

  • session_id="dana" names the conversation. The agent keeps one flow per session_id (or per CallerContext when you pass a caller and no session), so this customer's phase and case stay hers. agent.get_flow("dana") returns that conversation's flow; the SupportFlow instance you passed in is never advanced itself.

  • No `await flow.start()`. Since Promptise 1.3.0 the agent feeds every user message to the flow, the first one included, so a customer who opens with "My mug from order A-1001 arrived broken" goes straight to resolution.

  • No `instructions`. Since 1.3.0 the model gets one system message: the flow's prompt, then your instructions if you pass any, then the list of tools. With a flow and no instructions, Promptise's generic default prompt is not added.

Here's the run:

Output
customer: Hi, my mug arrived broken.
blocks:   ['identity', 'rules', 'tone', 'tool_policy', 'triage']
>>> Sorry to hear that — I’ll prioritize this since you’re on a Business account. Could you please send the order ID for the mug?

→ Invoking tool: get_order with {'order_id': 'A-1001'}
✔ Tool result from get_order: {"order_id": "A-1001", "customer": "Dana", "item": "Ceramic mug", "total": 18.5, "status": "delivered"}

customer: The order is A-1001.
blocks:   ['identity', 'rules', 'tone', 'tool_policy', 'resolution', 'case']
>>> I'm sorry your Ceramic mug from order A-1001 (total $18.50) arrived broken — as a Business account your case has priority. You can choose a full refund of $18.50 to your original payment method, a free replacement of the same mug, or I can escalate to a team lead; tell me which you prefer and I'll proceed.  
Next step: Please tell me whether you'd like a refund, a replacement, or escalation to a team lead.

→ Invoking tool: get_order with {'order_id': 'A-1001'}
✔ Tool result from get_order: {"order_id": "A-1001", "customer": "Dana", "item": "Ceramic mug", "total": 18.5, "status": "delivered"}
→ Invoking tool: send_replacement with {'order_id': 'A-1001'}
✔ Tool result from send_replacement: {"order_id": "A-1001", "status": "replacement shipped"}

customer: A replacement, please.
blocks:   ['identity', 'rules', 'tone', 'tool_policy', 'resolution', 'case']
>>> Replacement shipped for order A-1001. Your case had priority as a Business account.  
Next step: I shipped the replacement — reply if you need anything else.

Read it from the top and you can see the prompt steering. In triage, the agent asked for the order and offered nothing. The moment the order ID arrived, the phase switched, the agent looked the order up, offered what the tool policy allows, a refund within the limit, a replacement or a team lead, and closed with a "Next step:" line, which only the resolution block asks for. It shipped the replacement only after the customer chose, and looked the order up once more before it did.

Two things in this run are worth learning from, too:

  • Base blocks speak on every turn. The Business line appears in all three replies because TONE is a base block and says "tell them". If you want it said once, move that sentence into the triage block.

  • A prompt guides; it doesn't enforce. The first version of the rules had three items. In that run the agent told the customer "(refunds are capped at $100)" and promised "I’ll email new tracking to the address on file", neither of which it should have. Rules 4 and 5 were added for that, as one small edit to one block, and in the run above the cap didn't come up and no reply promised anything a tool hadn't done. Rules lower the odds; they don't make a behaviour impossible.

Step 06

Test the prompt without calling a model

The flow is plain Python, so you can test what it produces in milliseconds, with no API key. Test the decisions (which phase, which blocks) and pin the exact text with a reviewed snapshot:

Pythontest_support_prompt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from pathlib import Path

import pytest

from support_prompt import SupportFlow

SNAPSHOTS = Path(__file__).parent / "prompt_snapshots"


async def prompt_after(*messages, business_customer=False):
    flow = SupportFlow(business_customer=business_customer)
    prompt = await flow.start()
    for text in messages:
        prompt = await flow.next_turn(text)
    return prompt


async def test_triage_does_not_offer_remedies():
    prompt = await prompt_after("Hi, my mug arrived broken.")
    assert "triage" in prompt.included and "resolution" not in prompt.included
    assert "Do not offer refunds" in prompt.text


async def test_an_order_id_moves_the_flow_to_resolution():
    prompt = await prompt_after("Hi, my mug arrived broken.", "The order is a-1001.")
    assert prompt.included[-2:] == ["resolution", "case"]
    assert "Case: order A-1001." in prompt.text
    assert "Current phase: triage" not in prompt.text


async def test_only_business_customers_get_the_priority_line():
    assert "Business account" in (await prompt_after(business_customer=True)).text
    assert "Business account" not in (await prompt_after(business_customer=False)).text


@pytest.mark.parametrize("phase, messages", [("triage", []), ("resolution", ["Order A-1001."])])
async def test_prompt_matches_the_reviewed_snapshot(phase, messages):
    text = (await prompt_after(*messages)).text
    snapshot = SNAPSHOTS / f"{phase}.txt"
    if not snapshot.exists():  # first run: write it, review it, commit it
        snapshot.parent.mkdir(exist_ok=True)
        snapshot.write_text(text)
    assert text == snapshot.read_text(), f"The {phase} prompt changed. Review the diff, then update {snapshot}."
>_Terminal
pytest -o asyncio_mode=auto -q test_support_prompt.py
Output
.....                                                                    [100%]
5 passed in 3.05s

The first run writes prompt_snapshots/triage.txt and resolution.txt. Commit them. From then on, every change to the prompt shows up twice: in the block you edited, and as a readable text diff of the prompt the model will actually get. Here's what happens when someone raises the refund limit in the tool policy from 100 to 500 USD:

Output
E       AssertionError: The triage prompt changed. Review the diff, then update …/prompt_snapshots/triage.txt.
E       assert 'You are the ...ing else yet.' == 'You are the ...ing else yet.'
E         
E         Skipping 703 identical leading characters in diff, use -v to show
E         Skipping 252 identical trailing characters in diff, use -v to show
E         - more than 100 USD. O
E         ?           ^
E         + more than 500 USD. O
E         ?           ^
…
2 failed, 3 passed in 2.61s

With these tests in CI, a policy change can't slip through as a side effect. Someone has to look at it, delete the snapshot and commit the new one. These tests check the prompt, not the model's answers. For testing what the model does with it, Promptise has prompt testing utilities with mocked and real model calls.


[05]

Keep the prompt inside a token budget

Long prompts cost money on every call and leave less room for the conversation. PromptAssembler.assemble() takes a token_budget and drops blocks, lowest priority first, until the estimate fits. Here the triage prompt gets two few-shot examples, which are nice to have but not essential:

Pythonbudget.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from promptise.prompts.blocks import BlockContext, Examples, PromptAssembler

from support_prompt import ROLE, RULES, TONE, TOOL_POLICY, TRIAGE

EXAMPLES = Examples(
    [
        {"customer": "My lamp flickers.", "assistant": "Sorry about that. What's the order ID?"},
        {"customer": "Wrong colour arrived.", "assistant": "Let's fix that. What's the order ID?"},
    ]
)

assembler = PromptAssembler(ROLE, RULES, TONE, TOOL_POLICY, TRIAGE, EXAMPLES)
ctx = BlockContext(state={"max_sentences": 3, "business_customer": False})

for budget in (None, 230):
    prompt = assembler.assemble(ctx, token_budget=budget)
    print(f"budget={budget}: ~{prompt.estimated_tokens} tokens, dropped {prompt.excluded}")

for block in prompt.block_details:
    print(f"  {block.name:<12} priority {block.priority:>2}  ~{block.estimated_tokens:>3} tokens  included={block.included}")
Output
budget=None: ~273 tokens, dropped []
budget=230: ~230 tokens, dropped ['examples']
  identity     priority 10  ~ 29 tokens  included=True
  rules        priority  9  ~ 74 tokens  included=True
  tone         priority  7  ~ 20 tokens  included=True
  tool_policy  priority  9  ~ 54 tokens  included=True
  triage       priority  8  ~ 50 tokens  included=True
  examples     priority  4  ~ 42 tokens  included=False

The examples went first, and the role and rules stayed. Set priorities on your own Section blocks with this in mind: a policy the agent must follow deserves 9, background reading can sit at 3. Since 1.3.0, ConversationFlow takes a token_budget too, as a constructor argument or a class attribute (token_budget = 230 on SupportFlow), and drops blocks the same way on every turn. Check what a tight budget drops: at 200 tokens, the triage prompt above loses tone and then the triage block itself.


[06]

Serving many conversations at once

Because the agent keeps one flow per conversation, a single agent can serve every customer. Here are two customers talking to the same agent, one after the other, each in their own session:

Pythonper_session.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import asyncio
import sys

from promptise import build_agent
from promptise.config import StdioServerSpec
from promptise.conversations import InMemoryConversationStore
from support_prompt import SupportFlow


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"support": StdioServerSpec(command=sys.executable, args=["support_server.py"])},
        flow=SupportFlow(),  # a template: each conversation gets its own copy
        conversation_store=InMemoryConversationStore(),
    )
    try:
        for session_id, text in [
            ("dana", "My mug from order A-1001 arrived broken."),
            ("lee", "Hi, I have a question about my desk."),
        ]:
            answer = await agent.chat(text, session_id=session_id)
            print(f"{session_id}: {text}\n  blocks: {agent.get_flow(session_id).get_prompt().included}\n  >>> {answer}\n")
    finally:
        await agent.shutdown()


asyncio.run(main())

agent.chat() loads and saves each session's history in the conversation store and passes its session_id to the flow, and agent.get_flow(session_id) shows where each conversation stands. Dana's first message already names her order, so her conversation starts in resolution:

Output
dana: My mug from order A-1001 arrived broken.
  blocks: ['identity', 'rules', 'tone', 'tool_policy', 'resolution', 'case']
  >>> I see order A-1001 (Ceramic mug, $18.50) was delivered — I'm sorry it arrived broken. I can either send a free replacement of the same mug or issue a full $18.50 refund to your original payment method; which would you prefer?

Next step: Tell me whether you'd like a replacement or a refund and I'll proceed.

lee: Hi, I have a question about my desk.
  blocks: ['identity', 'rules', 'tone', 'tool_policy', 'triage']
  >>> Hi — I can help. Can you please provide your order ID for the desk so I can look it up?

Each customer has their own phase and their own case. In Promptise Foundry 1.2.1 that took extra code: every conversation on an agent shared the one flow instance, so Lee's first message got Dana's phase and case, and the agent read Dana's order out to Lee. The agent, its MCP connections and the conversation store are shared; only the small flow object is per conversation. Conversations covers the persistent stores for production.

The agent keeps flows in memory for up to 10,000 conversations and drops the least recently used first. A dropped flow, or one on a freshly started agent, is rebuilt from the conversation's user messages, so with a persistent conversation store a conversation keeps its phase across restarts and workers. State that doesn't come from the messages, like the constructor's business_customer, comes from the template you passed to build_agent.


[07]

Templates, prompt files and guards for one-shot prompts

Blocks and flows are for the agent's system prompt. Many apps also have one-shot prompts around the agent: summarise a case, classify a ticket, draft a reply for review. For those, Promptise lets you keep the prompt in a .prompt YAML file, with typed arguments, constraints and guards, and load it as a callable. Here's a hand-off summary for the team lead who takes over a case:

YAMLprompts/handoff_summary.prompt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
name: handoff_summary
version: "1.0.0"
description: Summarize a support case for the team lead who takes it over.
author: support-team
tags: [support, handoff]

template: |
  Summarize this support case for the team lead who takes it over.
  Order: {order_id}

  {conversation}

arguments:
  order_id:
    description: The order the case is about
    required: true
  conversation:
    description: The conversation so far, one message per line
    required: true

constraints:
  - At most three bullet points
  - Say what the customer wants and what has already been done

guards:
  - type: length
    max_length: 600
  - type: content_filter
    blocked: ["password"]
Pythonhandoff.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import asyncio

from promptise.prompts.loader import load_prompt

handoff = load_prompt("prompts/handoff_summary.prompt")

CASE = {
    "order_id": "A-1002",
    "conversation": (
        "Customer: My standing desk is a week late and I need it for a new hire on Monday.\n"
        "Assistant: The order shows the desk is in transit. A team lead will take over your case.\n"
        "Customer: Then I want to cancel and get my 1,240 USD back."
    ),
}


async def main():
    print(handoff.render(**CASE))
    print("\n--- model output ---")
    print(await handoff(**CASE))


asyncio.run(main())
Output
Summarize this support case for the team lead who takes it over.
Order: A-1002

Customer: My standing desk is a week late and I need it for a new hire on Monday.
Assistant: The order shows the desk is in transit. A team lead will take over your case.
Customer: Then I want to cancel and get my 1,240 USD back.
You MUST follow these requirements:
1. At most three bullet points
2. Say what the customer wants and what has already been done

--- model output ---
- Order A-1002: customer says the standing desk is a week late and is needed for a new hire on Monday; they want to cancel the order and receive a full refund of $1,240.
- Current status: system shows the desk is "in transit"; I informed the customer and escalated their case, telling them a team lead will take over.

render() shows the exact text without calling a model, the same review habit as step 4. Calling the prompt runs the guards: the content filter checks the text going in and coming out, and the length guard rejects an answer over 600 characters with a GuardError instead of passing it on. Since 1.3.0, guards also run when a prompt is an agent's instructions: input guards check each user message and output guards check each reply.

The rest of the prompting layer, and when to reach for it:

You want to

Use

Docs

Build a system prompt from parts

PromptAssembler and blocks

Blocks

Change the prompt by conversation phase

ConversationFlow and @phase

Flows

Build a one-shot prompt in code at runtime

PromptBuilder

Builder

Keep prompts in files, use variables and includes

load_prompt, render_template

Loader & Templates

Inject user, environment or custom data

UserContext, @context providers

Context & Variables

Add step-by-step reasoning or a point of view

chain_of_thought, analyst and others

Strategies

Run prompts in sequence, in parallel or with retries

chain, parallel, branch, retry, fallback

Chaining

Validate what goes in and comes out

content_filter, length, schema_strict

Guards

Group and version prompts

PromptSuite, PromptRegistry

Suite & Registry


[08]

Honest limits

These are true of Promptise Foundry 1.3.1, and worth knowing before you ship:

  • A phase changes the prompt, not the tools. In triage the model can still call issue_refund; the prompt only asks it not to. For anything that must not happen, enforce it where the action runs: in the tool or MCP server, or with human approval on the agent.

  • Flows live in the agent's memory. One per conversation, up to 10,000. An evicted flow, or one on a restarted agent, is rebuilt by replaying the conversation's user messages, so anything your own code puts into a flow's state from elsewhere doesn't survive that. Derive phase decisions from the messages.

  • Token budgets are estimates. The estimate is words times 1.3, not a real tokenizer. A flow assembles every active block unless you give it a token_budget, and a tight budget can drop a phase block, so keep phase prompts small and give phase blocks a high priority.

  • The template engine is minimal on purpose. {% if %} tests truthiness only, and a missing {variable} raises KeyError. Compute anything more complex in Python and pass the result in.

  • Pass a `session_id` or a caller. A call with neither gets a throwaway flow built from the messages it carries, and agent.get_flow() has nothing to return for it.


[09]

Frequently asked questions

What is a dynamic system prompt?

A system prompt that's assembled again for each turn from the current state, instead of written once. In this guide the role and rules stay fixed while the phase instructions and the case details change as the conversation moves from triage to resolution. That keeps each turn's prompt focused on the job at hand.

How do you write a system prompt for an AI agent?

Say who the agent is, the rules it must follow, how it should sound, and when it may use each tool, as separate sections. Then add instructions for the current stage of the conversation and the facts of the case. Keep each part short, render the full prompt and read it, and change one part at a time.

Should the system prompt change during a conversation?

Yes, when the agent's job changes. Asking questions and resolving a problem need different instructions, and putting both in one prompt invites the agent to jump ahead, as the quick example did with a refund for an order still in transit. Change the prompt between turns, keep the core rules in every version, and make the switch something your code decides and your tests check.

How do you test a system prompt?

Test it in two layers. First, without a model: assert which blocks are active in each phase and compare the rendered text against a reviewed snapshot, so every change shows up in review. Second, with a model: run real conversations and check the replies, because a prompt can be correct and still not be followed.

Where should prompt templates live in a Python project?

In version control, next to the code that uses them. System prompts built from blocks live in a Python module, as in support_prompt.py. One-shot prompts can live in .prompt YAML files and load with load_prompt, or a whole folder with load_directory. Either way, pin the rendered text in snapshots so reviews show what changed.


[10]

Where to go next

  • Prompt engineering overview: the two layers and how they fit together.

  • Layer 1: PromptBlocks: every block type and the assembler.

  • Layer 2: ConversationFlow: phases, hooks and the turn context.

  • Testing Utilities: PromptTestCase, mocked model calls and prompt assertions.

  • Building Agents: the rest of build_agent.

  • How to Connect MCP Servers to Your AI Agent in Python: the agent and tool basics this guide builds on.

  • OpenAPI to MCP: Turn Any REST API into an MCP Server: give your agent tools from an existing API.

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths