Skip to main content

promptree

promptree turns a directory of Jinja templates into a type-safe Python prompt tree, with runtime validation and generated .pyi stubs for IDE autocomplete.

It gives you:

  • Dot-notation access to prompt folders and files
  • Runtime validation through Jinja StrictUndefined
  • Generated .pyi stubs for IDE autocomplete
  • Full Jinja support, including include, extends, and macros
  • Raw strings as the only integration format, so there is no adapter layer

Install

pip install promptree

Optional extras:

pip install promptree[watch]

Quick Start

Directory layout:

prompts/
├── system.md
└── user/
    ├── greeting.md
    └── farewell.txt

Example templates:

{# prompts/system.md #}
System prompt for {{ name }}.
{# prompts/user/greeting.md #}
Hello {{ name }}!
{# prompts/user/farewell.txt #}
Goodbye {{ name }}.

Use them from Python:

from promptree import Promptree

prompts = Promptree("./prompts")

print(prompts.system(name="Claude"))
print(prompts.user.greeting(name="Tim"))
print(prompts.user.farewell(name="Tim"))

This direct Promptree(...) usage is runtime-dynamic. Editors can execute the code correctly, but they cannot infer from the filesystem whether prompts.system is a directory node or a template file. For precise VS Code autocomplete and call signatures, generate the prompt package and import its tree object:

promptree generate ./prompts
from prompts import tree

print(tree.system(name="Claude"))
print(tree.user.greeting(name="Tim"))

The CLI can generate an importable package and matching type stubs inside the prompt directory:

promptree generate ./prompts
promptree check ./prompts
promptree --version

For local VS Code navigation with absolute file links:

promptree generate ./prompts --stub-link-mode file-uri

You can also run the CLI as a module:

python -m promptree generate ./prompts

Pre-commit And CI

promptree check regenerates the stubs in memory and exits non-zero if the files on disk are stale. That makes it useful for both local pre-commit enforcement and CI.

If you keep a hand-written __init__.py in a prompt directory, promptree generate will leave it alone and promptree check will ignore that file.

The repository includes a hook definition in .pre-commit-hooks.yaml, and a consumer can wire it up like this:

- repo: https://github.com/your-org/promptree
  rev: v0.1.0
  hooks:
    - id: promptree-check
      files: ^prompts/

For CI, run the same command directly:

promptree check ./prompts

Why No Adapter Layer

Rendered prompt text is the common format across libraries like pydantic_ai, LangChain, and the OpenAI Python client. promptree focuses on generating and validating that text well, rather than wrapping every downstream API.

If you want IDE type safety, prefer importing the generated prompt package over constructing Promptree(...) inline in application code.

pydantic_ai

Full runnable example:

from dataclasses import dataclass

from pydantic_ai import Agent, RunContext
from prompts import tree as prompts


@dataclass
class MyDeps:
    user_name: str
    language: str


agent = Agent("openai:gpt-4o", deps_type=MyDeps)

# Static: render once at agent creation
agent_static = Agent(
    "openai:gpt-4o",
    instructions=prompts.system(name="Claude"),
)


# Dynamic: re-evaluated every run with access to ctx.deps
@agent.instructions
def dynamic_instructions(ctx: RunContext[MyDeps]) -> str:
    return prompts.system(
        name=ctx.deps.user_name,
        language=ctx.deps.language,
    )


result = agent.run_sync(
    prompts.user.greeting(name="Tim"),
    deps=MyDeps(user_name="Tim", language="en"),
)
print(result.output)

When a run needs its own deployment context on top of the deployed one, bind inside the same decorator:

@agent.instructions
def personalized_instructions(ctx: RunContext[MyDeps]) -> str:
    scoped = prompts.bind(ctx.deps.owner_context, allowed_fields=["owner_hints"])
    return scoped.system(name=ctx.deps.user_name)

This needs a context-enabled tree, so generate the package with --context-env or build the tree with Promptree(..., context=...). See Layered context.

LangChain

from langchain_core.messages import HumanMessage, SystemMessage

from promptree import Promptree

prompts = Promptree("./prompts")

messages = [
    SystemMessage(content=prompts.system(name="Claude")),
    HumanMessage(content=prompts.user.greeting(name="Tim")),
]

Raw OpenAI Client

from openai import OpenAI

from promptree import Promptree

client = OpenAI()
prompts = Promptree("./prompts")

client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": prompts.system(name="Claude")},
        {"role": "user", "content": prompts.user.greeting(name="Tim")},
    ],
)

Generated Package

When you run promptree generate ./prompts, promptree writes two files into the prompt directory:

  • __init__.py with a tree object backed by Promptree
  • __init__.pyi with nested classes and typed call signatures for autocomplete

Generated template stubs also embed:

  • a Markdown source link
  • the first 10 non-empty lines of the underlying template text

This does not force VS Code to jump directly into the .md or .jinja file on Go to Definition, but it does make the generated symbol carry a useful source reference and prompt excerpt in the stub itself.

Stub source links support two modes:

  • relative (default): emits links like [Source](./src/docmist/prompts/...), suitable for portable, committable generated files
  • file-uri: emits links like [Source](file:///...), useful when VS Code only treats absolute local file links as clickable

Configure this with --stub-link-mode relative or --stub-link-mode file-uri.

You can tune the excerpt length with --stub-source-lines N on both generate and check.

That means you can import the generated package and get both runtime access and IDE support from the same directory.

Deployment Context

Templates can declare deployment-specific text through direct context.<field> access:

{% if context.region_hints %}
{{ context.region_hints }}
{% endif %}

Context is scoped to each callable prompt and follows the prompt directory structure:

{
  "weather": {
    "region_hints": "Use local warning information."
  },
  "documents": {
    "summary": {
      "storage_hints": "Internal documents are stored under /internal."
    }
  }
}

Configure exactly one source:

prompts = Promptree("./prompts", context={...})
prompts = Promptree("./prompts", context_file="./context.json")
prompts = Promptree("./prompts", context_env="PROMPTREE_CONTEXT")

Missing prompt entries and fields default to empty strings, so an empty object is a valid entry for a prompt that declares fields. Prompts without declared fields must not appear in the file at all. Unknown prompts, unknown fields, non-string values, and invalid nesting raise PromptContextError. Context values are rendered as literal text and are never evaluated as Jinja source.

Create and validate context files with:

promptree context init ./prompts --output context.json
promptree context check ./prompts context.json

context init writes a skeleton holding every prompt that declares at least one field, with all values empty, and the result always passes context check. That makes the generated file a machine-readable description of the context interface: its diff between two releases tells your deployments what changed. Adding a field stays backwards compatible because missing fields render as empty strings.

When templates are analyzed

Without a context source, Promptree(...) stays lazy and parses nothing until a prompt is called. As soon as context, context_file, or context_env is passed, the constructor analyzes the whole tree up front — even when the environment variable is unset. Template violations should not surface only in deployments that happen to mount a context file.

Layered context

Deployment context has three lifecycles: the templates define which fields exist, a deployment sets its instance values once at startup, and a single job may want to add values of its own — per user, per tenant, per request. bind adds that last layer:

prompts = Promptree("./prompts", context_file="./instance.json")

scoped = prompts.bind(
    {"documents": {"summary": {"owner_hints": "Prefers bullet points."}}},
    allowed_fields=["owner_hints"],
)
print(scoped.documents.summary())

bind validates the overlay against the analysis the constructor already computed, so it costs a dictionary walk: no template parsing and no file access. The overlay follows the same rules as a context file, and unknown prompts, unknown fields, non-string values, and invalid nesting raise PromptContextError.

The layer itself behaves like this:

  • Field-wise: a field the overlay omits keeps the value below it. Only the fields it sets change.
  • Empty strings never override. Tree-wide, "" means "not set", so a cleared input field falls back to the instance value instead of suppressing it.
  • Chainable: prompts.bind(team).bind(owner) layers field by field, and the last bind wins.
  • allowed_fields checks presence, not value. Every field the overlay mentions must be listed, even when its value is "", so a misconfigured writer fails immediately. Omit the argument to allow every field, or pass an empty collection to allow none.

bind returns a new tree and never mutates the base one. Both share the Jinja environment, its template cache, and the analysis, so one bind per request is the intended pattern and is safe while other requests render from the same base tree.

Binding requires a context-enabled tree, because the analysis happens in the constructor. A tree built without a context source stays lazy and raises PromptContextError on bind. Pass an empty dict to enable context without setting any instance values:

prompts = Promptree("./prompts", context={})

Because bind is a real method, it wins over prompt attribute lookup: a root-level bind.md or bind/ would be silently unreachable. promptree rejects that name during analysis instead. Only the root is reserved, so foo/bind.md stays available as prompts.foo.bind.

Dependencies

Static includes and inheritance automatically contribute their fields to the calling prompt. Includes without context and imports without an explicit with context are rejected when their target needs deployment context. Underscore directories such as _partials/ stay absent from the public prompt tree but can provide shared static dependencies, and their fields count towards every prompt that includes them.

Dot-prefixed files and directories are skipped, so a stray .backup/old.md cannot break generation. A template that includes such a file explicitly still pulls it into the analysis.

A prompt whose dependency chain contains a dynamic template reference cannot be resolved statically:

{% include template_name %}

That prompt is excluded from the context mechanism and declares no fields. Using context fields in the same chain is an error naming the file and line of the dynamic reference. Only that one prompt is affected; the rest of the tree keeps using context normally.

Errors

PromptContextError reports violated context rules: invalid data, misuse of the reserved context name, and dependencies that cannot forward context. TemplateAnalysisError reports templates that fail to parse at all. Both derive from PromptreeError, and the CLI prints them without a traceback.

Generated packages

Generated packages can opt into environment loading:

promptree generate ./prompts --context-env PROMPTREE_CONTEXT
promptree check ./prompts --context-env PROMPTREE_CONTEXT

Each generated stub documents the fields its prompt uses:

class _WeatherNode:
    def __call__(self, *, name: Any) -> str:
        """[Source](./weather.md)

        Context fields: region_hints, style_hints

        Hello {{ name }}
        {% if context.region_hints %}{{ context.region_hints }}{% endif %}
        """
        ...

context never appears in TemplateCallable.variables or in generated call signatures — it is supplied by the tree, not by the caller.

Metadata

Release files for promptree 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for promptree 0.4.0
File Size Uploaded
promptree-0.4.0.tar.gz 47.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for promptree 0.4.0
File Interpreter ABI Platform
promptree-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 71.0 kB

Release files / promptree-0.4.0.tar.gz

Download URL promptree-0.4.0.tar.gz
Size 47.8 kB
Tags Source
SHA-256 checksum
How to use checksums
f91c9a74a660d689dc756eeb7408c6ded366743934f8f18b1f1c48790cf7ba99
BLAKE2b-256 checksum
How to use checksums
a8e62ffab8b37821409099f4d9e2b552532d1151f36dd30d2765f9aa5cb11917
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.

Transparency log

Release files / promptree-0.4.0-py3-none-any.whl

Download URL promptree-0.4.0-py3-none-any.whl
Size 23.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ada92dafa0563cd435279469e708bc7a04113bcf03bacb0284374e1f3e371847
BLAKE2b-256 checksum
How to use checksums
40a85da8cc068bac34bde1af9aedac8a00cdbbfa931aae2b5c3edc7716587252
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page