Skip to content

Prompt Engineering

Build reliable, testable, versioned system prompts in 9 incremental steps. Each step adds one concept -- from simple typed blocks to composable reasoning strategies, runtime guards, and prompt pipelines.

This is the recommended starting point for prompt engineering

This guide walks you through the complete prompt system step by step. For deep reference on individual features, see the PromptBlocks, ConversationFlow, Strategies, Guards, and Context pages.

What You'll Build

A prompt system for a data analyst agent with typed blocks, priority-based token budgeting, composable reasoning strategies, runtime guards, dynamic context injection, conversation flows that evolve across turns, version control, and automated testing. The same patterns used in production agent systems where prompt quality determines agent quality.

Concepts

Most frameworks treat prompts as strings. Promptise treats them as software components -- composable, versioned, testable, debuggable. Prompts have types, priorities, lifecycle hooks, and debugging tools. They compose from independent parts. They adapt at runtime based on context. They drop gracefully when the context window gets tight.

The prompt system has three layers:

  1. PromptBlocks -- typed building blocks (identity, rules, format, examples, context) with priorities that determine what survives when the context window is tight
  2. Strategies and perspectives -- control how the agent reasons (chain-of-thought, self-critique, decompose) and from what angle (analyst, critic, advisor)
  3. Guards -- enforce policy before and after generation (content filtering, length limits, schema validation with retry)

Step 1: Typed Blocks

Start with the @prompt decorator and typed blocks instead of raw strings:

from promptise.prompts import prompt
from promptise.prompts.blocks import blocks, Identity, Rules, OutputFormat

@prompt(model="openai:gpt-5-mini")
@blocks(
    Identity("Expert data analyst", traits=["statistical thinking", "clear communication"]),
    Rules(["Cite specific data points", "Include confidence intervals", "Never fabricate data"]),
    OutputFormat(format="markdown", instructions="Use ## headers for each section"),
)
async def analyze(dataset: str, question: str) -> str:
    """Given this dataset:
{dataset}

Answer this question: {question}"""

result = await analyze(
    dataset="Monthly users: Jan=10k, Feb=12k, Mar=15k",
    question="What is the growth trajectory?",
)

Eight block types, each with a specific role:

Block Priority Purpose
Identity 10 (highest) Who the agent is -- always included
Rules 9 Hard constraints the agent must follow
OutputFormat 8 How to structure the response
ContextSlot Configurable Dynamic runtime content (user info, memory, tool results)
Section Configurable Custom content blocks
Examples 4 Few-shot examples
Conditional Varies Blocks that appear/disappear based on predicates
Composite Varies Groups of blocks with shared config

Step 2: Priority-Based Token Budgeting

When the total prompt exceeds your token budget, the PromptAssembler drops blocks starting from the lowest priority. This is deterministic -- given the same blocks and budget, you get the same result every time.

from promptise.prompts.blocks import blocks, Identity, Rules, Examples, Section, ContextSlot

@prompt(model="openai:gpt-5-mini", max_tokens=4000)
@blocks(
    Identity("Senior data analyst"),                          # Priority 10 -- last to drop
    Rules(["Cite sources", "Use provided data only"]),        # Priority 9
    ContextSlot("user_data", priority=7),                     # Priority 7
    Section("methodology", "Use regression analysis...", priority=5),  # Priority 5
    Examples([                                                 # Priority 4 -- first to drop
        {"input": "Revenue: $10M", "output": "Growth: stable"},
        {"input": "Revenue: $15M", "output": "Growth: 50% increase"},
    ]),
)
async def analyze(dataset: str) -> str:
    """Analyze: {dataset}"""

Drop order when budget is tight:

  1. Examples go first (priority 4) -- the agent can often function without them
  2. Custom sections go next (priority 5)
  3. Context slots go based on their configured priority
  4. Output format instructions survive longer (priority 8)
  5. Rules survive almost everything (priority 9)
  6. Identity is the last thing standing (priority 10)

Step 3: Reasoning Strategies

Control how the agent thinks with composable strategies:

from promptise.prompts.strategies import (
    chain_of_thought, self_critique, structured_reasoning,
    plan_and_execute, decompose,
)

# Chain of thought -- think step by step
@prompt(model="openai:gpt-5-mini")
async def analyze(text: str) -> str:
    """Analyze: {text}"""

result = await analyze.with_strategy(chain_of_thought)("Revenue data...")

# Self-critique -- generate, critique, improve
result = await analyze.with_strategy(self_critique)("Revenue data...")

# Compose strategies with +
result = await analyze.with_strategy(chain_of_thought + self_critique)("Revenue data...")
# Agent thinks step-by-step, then critiques its own reasoning

# Plan and execute -- create a plan, then follow it
result = await analyze.with_strategy(plan_and_execute)("Complex multi-step task...")

# Decompose -- break into sub-questions, answer each, synthesize
result = await analyze.with_strategy(decompose)("Multi-domain question...")

Five strategies compose freely:

Strategy How it reasons
chain_of_thought Step-by-step reasoning, then final answer
structured_reasoning Formal premises, analysis, and conclusions
self_critique Generate, critique, improve
plan_and_execute Create plan, execute steps in order
decompose Break into sub-questions, synthesize answers

Step 4: Perspectives

Perspectives frame how the agent thinks, orthogonal to what reasoning process it uses:

from promptise.prompts.strategies import analyst, critic, advisor, creative, CustomPerspective

# Built-in perspectives
result = await analyze.with_perspective(analyst)("Revenue data...")
result = await analyze.with_perspective(critic)("Proposed strategy...")
result = await analyze.with_perspective(advisor)("Business decision...")
result = await analyze.with_perspective(creative)("Marketing campaign...")

# Custom perspective
security_reviewer = CustomPerspective(
    "You are a senior security engineer reviewing this system for vulnerabilities."
)
result = await analyze.with_perspective(security_reviewer)("Architecture doc...")

# Combine strategy + perspective
configured = (
    analyze
    .with_strategy(chain_of_thought + self_critique)
    .with_perspective(analyst)
)
result = await configured("Complex financial data...")

An analyst perspective with chain-of-thought produces different output than a creative perspective with the same strategy.


Step 5: Guards

Guards enforce policy before the LLM call (input guards) and after the response (output guards):

from promptise.prompts import prompt
from promptise.prompts.guards import guard, content_filter, length, schema_strict

# Content filtering -- block/require specific words
@prompt(model="openai:gpt-5-mini")
@guard(content_filter(blocked=["password", "secret", "ssn"]))
async def process(data: str) -> str:
    """Process: {data}"""

# Length enforcement -- min and max
@prompt(model="openai:gpt-5-mini")
@guard(length(min_length=100, max_length=2000))
async def summarize(text: str) -> str:
    """Summarize: {text}"""

# JSON schema validation with automatic retry
expected_schema = {
    "type": "object",
    "required": ["findings", "confidence"],
    "properties": {
        "findings": {"type": "array", "items": {"type": "string"}},
        "confidence": {"type": "number", "minimum": 0, "maximum": 1},
    },
}

@prompt(model="openai:gpt-5-mini")
@guard(schema_strict(expected_schema, max_retries=3))
async def analyze_structured(data: str) -> str:
    """Analyze and return JSON: {data}"""

# If the output doesn't match the schema, the guard rejects it and retries
# with the validation error as feedback to the LLM

Custom guards -- any callable:

from promptise.prompts.guards import input_validator, output_validator

def check_pii(text: str) -> bool:
    """Return False to reject."""
    return "SSN" not in text.upper()

@prompt(model="openai:gpt-5-mini")
@guard(
    input_validator(check_pii, error_message="Input contains PII"),
    output_validator(check_pii, error_message="Output contains PII"),
)
async def process(data: str) -> str:
    """Process: {data}"""

Step 6: Context Providers

11 built-in context providers inject information into the prompt automatically before every LLM call:

from promptise.prompts.context import (
    ToolContextProvider,
    MemoryContextProvider,
    UserContextProvider,
    EnvironmentContextProvider,
    ConversationContextProvider,
    ErrorContextProvider,
    StaticContextProvider,
    CallableContextProvider,
    ConditionalContextProvider,
)

# Static context -- always included
static = StaticContextProvider(
    "Company policy: all data analysis must include confidence intervals."
)

# Dynamic context from a function
async def get_user_prefs(ctx):
    return f"User timezone: {ctx.user.timezone}, language: {ctx.user.language}"

dynamic = CallableContextProvider(get_user_prefs, priority=6)

# Conditional context -- only when predicate is true
premium_ctx = ConditionalContextProvider(
    content="You have access to premium data sources.",
    predicate=lambda ctx: ctx.user.plan == "premium",
    priority=5,
)
Provider What it injects
ToolContextProvider Descriptions of available tools
MemoryContextProvider Relevant memories from vector search
TaskContextProvider Current task description and status
UserContextProvider User identity, preferences, permissions
EnvironmentContextProvider Environment variables and system info
ConversationContextProvider Recent conversation history
TeamContextProvider Peer agents and their capabilities
ErrorContextProvider Recent errors for self-correction
OutputContextProvider Expected output format and constraints
StaticContextProvider Fixed content, always included
CallableContextProvider Custom async function returning context
ConditionalContextProvider Content included only when a predicate is true

Step 7: ConversationFlow

Static prompts work for simple agents. Complex conversational agents need prompts that change as the conversation progresses:

from promptise.prompts import ConversationFlow, Phase

flow = ConversationFlow(
    phases={
        "greeting": Phase(
            blocks=["identity", "greeting_instructions", "capability_summary"],
            transitions={
                "investigation": lambda ctx: ctx.turn > 1,
            },
            on_enter=lambda ctx: print("Entering greeting phase"),
        ),
        "investigation": Phase(
            blocks=["identity", "investigation_rules", "tool_context", "memory_context"],
            transitions={
                "resolution": lambda ctx: ctx.state.get("has_enough_info"),
            },
        ),
        "resolution": Phase(
            blocks=["identity", "resolution_rules", "output_format", "examples"],
        ),
        "handoff": Phase(
            blocks=["identity", "handoff_instructions", "summary_format"],
        ),
    },
    initial_phase="greeting",
)

The system prompt on turn 1 uses greeting blocks. After the user responds, it transitions to investigation with different active blocks. Once the agent has enough information, the resolution phase activates with output format and examples. Each phase has on_enter and on_exit hooks for custom logic.


Step 8: PromptBuilder and Registry

Fluent runtime construction when decorators aren't convenient:

from promptise.prompts import PromptBuilder
from promptise.prompts.strategies import chain_of_thought, self_critique, analyst
from promptise.prompts.guards import schema_strict

prompt = (
    PromptBuilder("Analyze the following data: {data}")
    .identity("senior data analyst")
    .rules(["cite all sources", "use only provided data"])
    .strategy(chain_of_thought + self_critique)
    .perspective(analyst)
    .output_format("JSON with 'findings' and 'confidence' keys")
    .guard(schema_strict(schema))
    .build()
)

result = await prompt(data="Revenue: $10M, $12M, $15M")

Version control for prompts:

from promptise.prompts import prompt, version

@prompt(model="openai:gpt-5-mini")
@version("1.0.0")
async def analyze(data: str) -> str:
    """Analyze the provided data and return findings."""
    ...

@prompt(model="openai:gpt-5-mini")
@version("2.0.0")
async def analyze(data: str) -> str:
    """Analyze the provided data with improved methodology."""
    ...

# Both versions coexist -- route to latest by default, or pin to specific version
# A/B test: deploy v2 to 10% of traffic, compare results, promote or roll back

Shared defaults with PromptSuite:

from promptise.prompts import PromptSuite
from promptise.prompts.strategies import chain_of_thought, analyst
from promptise.prompts.guards import length

suite = PromptSuite(
    strategy=chain_of_thought,
    perspective=analyst,
    guards=[length(min_length=100)],
    constraints=["cite sources"],
)

@suite.prompt
async def analyze(data: str) -> str:
    """Analyze: {data}"""

@suite.prompt
async def summarize(data: str) -> str:
    """Summarize: {data}"""

# Both prompts inherit strategy, perspective, guards, and constraints

Step 9: Testing and Debugging

Unit test your prompts with pytest:

from promptise.prompts.testing import mock_llm, mock_context, assert_schema

async def test_analysis_prompt():
    with mock_llm(response='{"findings": ["growth stable"], "confidence": 0.85}'):
        result = await analyze(data="Revenue: $10M, $12M, $15M")

    assert_schema(result, expected_schema)
    assert "findings" in result

async def test_guard_blocks_pii():
    with pytest.raises(GuardError):
        await process(data="SSN: 123-45-6789")

async def test_context_injection():
    with mock_context(user={"plan": "premium", "timezone": "UTC"}):
        # Verify premium context is included
        result = await analyze(data="test")
        assert "premium data sources" in result.prompt_trace

Debug with PromptInspector:

from promptise.prompts import PromptInspector

inspector = PromptInspector(analyze)
trace = await inspector.trace(data="Revenue: $10M, $12M, $15M")

print(f"Blocks included: {trace.included_blocks}")
print(f"Blocks dropped: {trace.dropped_blocks}")
print(f"Total tokens: {trace.total_tokens}")
print(f"Guard results: {trace.guard_results}")
print(f"Render time: {trace.render_time_ms}ms")

When your agent produces unexpected output, the inspector shows exactly what prompt it received, what context was injected, what blocks were dropped, and what guards ran.


Prompt Chaining

Compose prompts into multi-step pipelines:

from promptise.prompts.chaining import chain, parallel, branch, retry, fallback

# Sequential -- output of A feeds into B
pipeline = chain(research_prompt, analysis_prompt, formatting_prompt)
result = await pipeline("Raw data...")

# Parallel -- run simultaneously, collect all results
results = await parallel(analyst_prompt, critic_prompt, advisor_prompt)("Business plan")

# Conditional routing
router = branch(
    (lambda x: "financial" in x, financial_prompt),
    (lambda x: "technical" in x, technical_prompt),
    default=general_prompt,
)
result = await router("Financial report on Q3...")

# Retry with backoff on guard rejection or LLM error
resilient = retry(strict_prompt, max_retries=3, backoff=2.0)
result = await resilient("Input data...")

# Fallback -- try alternatives on failure
safe = fallback(detailed_prompt, simple_prompt, minimal_prompt)
result = await safe("Input data...")

YAML Templates

Define prompts as portable files:

# customer_analysis.prompt
name: customer_analysis
version: "1.0.0"
template: "Analyze this customer's behavior: {customer_data}"

blocks:
  - type: identity
    content: "You are a senior customer analytics specialist."
  - type: rules
    content:
      - "Base all conclusions on provided data only"
      - "Flag any data quality issues"
  - type: examples
    content: |
      Input: { "purchases": 12, "returns": 3 }
      Output: { "retention_risk": "medium", "reason": "25% return rate" }

strategy: chain_of_thought + self_critique
perspective: analyst

guards:
  - type: schema
    schema:
      type: object
      required: ["retention_risk", "reason"]

Load from a file, URL, or directory:

from promptise.prompts.loader import load_prompt, load_directory

prompt = load_prompt("customer_analysis.prompt")
result = await prompt(customer_data='{"purchases": 5, "returns": 0}')

# Load all .prompt files from a directory
prompts = load_directory("prompts/")

Complete Example

import asyncio
from promptise.prompts import prompt, PromptSuite, ConversationFlow, Phase
from promptise.prompts.blocks import blocks, Identity, Rules, OutputFormat, Examples
from promptise.prompts.strategies import chain_of_thought, self_critique, analyst
from promptise.prompts.guards import guard, content_filter, schema_strict

# Schema for structured output
analysis_schema = {
    "type": "object",
    "required": ["findings", "confidence", "recommendations"],
    "properties": {
        "findings": {"type": "array", "items": {"type": "string"}},
        "confidence": {"type": "number", "minimum": 0, "maximum": 1},
        "recommendations": {"type": "array", "items": {"type": "string"}},
    },
}

# Create a suite with shared defaults
suite = PromptSuite(
    strategy=chain_of_thought + self_critique,
    perspective=analyst,
    constraints=["cite data sources", "include confidence intervals"],
)

@suite.prompt
@prompt(model="openai:gpt-5-mini")
@blocks(
    Identity("Senior data analyst", traits=["statistical rigor", "clear communication"]),
    Rules(["Use only provided data", "Never fabricate numbers", "Flag data quality issues"]),
    OutputFormat(format="json", instructions="Return valid JSON matching the schema"),
    Examples([
        {"input": "Revenue: $10M, $12M, $15M", "output": '{"findings": ["50% growth over 3 months"], "confidence": 0.9, "recommendations": ["Continue current strategy"]}'},
    ]),
)
@guard(
    content_filter(blocked=["password", "ssn"]),
    schema_strict(analysis_schema, max_retries=2),
)
async def analyze_data(dataset: str, question: str) -> str:
    """Dataset:
{dataset}

Question: {question}

Respond with valid JSON."""

async def main():
    result = await analyze_data(
        dataset="Q1: $2.1M, Q2: $2.8M, Q3: $3.2M, Q4: $3.9M",
        question="What is the annual growth trend and what should we expect for next year?",
    )
    print(result)

asyncio.run(main())

What's Next

Go deeper on each feature:

Feature used in this guide Deep reference
Block types and priorities PromptBlocks
Conversation phases ConversationFlow
Fluent construction Prompt Builder
Context injection Context & Variables
Reasoning strategies Strategies
Input/output guards Guards & Validation
Versioning and suites Suite & Registry
YAML templates Loader & Templates
Debugging and tracing Inspector & Observability
Testing utilities Testing Utilities
Multi-step pipelines Prompt Chaining

Other guides: