Incantory
Sign in

Python SDK

The incantory Python package: sync and async clients that fetch, verify, pin and render prompts, plus eval reporting and webhook verification.

The incantory package needs Python 3.10 or later and has one runtime dependency, httpx. It has a sync client (Incantory) and an async client (AsyncIncantory) with the same surface.

bash
pip install incantory

Until the package is on PyPI, install it from a checkout of the repository with pip install ./sdk/python.

Quick start

python
from incantory import Incantory

with Incantory() as client:  # token from INCANTORY_TOKEN, URL from INCANTORY_URL
    prompt = client.prompts.get("acme/code-review@production")
    print(prompt.ref, prompt.content_hash)  # acme/code-review@v7 3f9c...
    out = prompt.render({"language": "Go", "code": "x := 1"})
    print(out.text if out.kind == "text" else out.messages)

Refs are owner/slug (latest), owner/slug@label or owner/slug@vN.

Async

python
import asyncio
from incantory import AsyncIncantory


async def main():
    async with AsyncIncantory(token="inc_...") as client:
        p = await client.prompts.get("acme/code-review@v7")
        async for item in client.prompts.iter(q="code review", kind="chat"):
            print(item["ref"])


asyncio.run(main())

Client options

python
Incantory(
    token=None,          # default: INCANTORY_TOKEN
    base_url="https://incantory.ai",
    cache_dir=None,      # default $XDG_CACHE_HOME/incantory or ~/.cache/incantory; False disables the disk cache
    lockfile=None,       # path to incantory.lock
    timeout=30.0,
    max_retries=3,
    transport=None,      # an httpx transport, for tests
    user_agent=None,
)

API

CallHTTP
prompts.get(ref, offline=False)see "Resolution and caching"
prompts.detail(ref)GET /api/v1/prompts/{o}/{s} with ?version= or ?label=
prompts.list(...), prompts.search(q, ...), prompts.iter(...)GET /api/v1/prompts
prompts.versions(ref, cursor, limit), prompts.version(ref, n)GET .../versions[/{n}]
prompts.diff(ref, a, b)GET .../diff/{a}..{b}
prompts.labels.list / set / delete / historyGET .../labels, PUT/DELETE .../labels/{name}, GET .../labels/{name}/history
prompts.fork(ref, version, slug, license_spdx)POST .../forks
prompts.create(slug, title, files, kind, ...)POST /api/v1/prompts
prompts.commit(ref, files, message, base_version, frontmatter)POST .../versions (409 stale_base when base_version is not the latest)
lock.pin(refs, path), lock.verify(path)resolves through prompts.get
evals.create_run(...), evals.report(results, run_id=... or create=...), evals.read_jsonl(path)POST /api/v1/evals/runs, POST /api/v1/evals/runs/{id}/results
device.start(device_name, scopes)device login (.poll(), .wait())
me()GET /api/me

A Prompt has ref, requested_ref, owner, slug, version, content_hash, kind, files, frontmatter, variables, target_models, yanked, deprecation, from_cache and stale, plus render(values, on_missing), entry, all_variables() and verify().

Rendering

python
from incantory import render

files = [{"path": "prompt.md", "content": "Hello {{name}}, tone {{tone|warm}}.", "isEntry": True}]
render(files, {"variables": [{"name": "name", "type": "string"}]}, {"name": "Ada"}).text
# 'Hello Ada, tone warm.'

render() is a line-by-line port of the TypeScript engine, pinned by shared test vectors. on_missing="error" (the default) raises RenderError listing every issue; "keep" and "empty" are for previews. No model is ever called.

Resolution and caching

  • @vN: a cached, hash-verified copy is returned without a request; otherwise GET .../versions/N.
  • @label: GET .../labels with If-None-Match (304 when unchanged), then the version.
  • latest: GET /api/v1/prompts/{o}/{s} with If-None-Match.
  • Every fetched version's contentHash is recomputed; a mismatch raises IncantoryError(code="integrity_error").
  • If resolving a label or latest fails with a network error or a 5xx, the last cached answer is used and prompt.stale is True. get(ref, offline=True) never touches the network.

The disk cache is shared with the TypeScript SDK.

Pinning with incantory.lock

python
client.lock.pin(["acme/code-review@production"])  # writes incantory.lock
for row in client.lock.verify():  # status ok | mismatch | missing, outdated, latestVersion
    print(row)
pinned = Incantory(lockfile="incantory.lock")  # get() of a locked ref serves the pinned version

The file is byte-identical to what the TypeScript SDK and the CLI write.

Typed variables

python
from incantory.codegen import typed_dict

src = typed_dict("acme/code-review", prompt.all_variables())  # Python source for a CodeReviewVars TypedDict

Reporting evals

python
results = client.evals.read_jsonl("results.jsonl")
client.evals.report(results, create={"ref": "acme/code-review@v7", "model": "claude-opus-5-5"})

See Evals and the CI gate.

Device login

python
flow = client.device.start(device_name="build box")
print(f"Enter {flow.user_code} at {flow.verification_url}")
if flow.wait() == "approved":
    client.token = flow.token  # minted locally; only its hash was sent

Verifying webhooks

verify_webhook_signature(secret, header, body, tolerance_sec=300) checks the Incantory-Signature header of a webhook delivery. body is the raw request body as bytes or str, exactly as received. It returns True or False and does not raise on malformed input; signatures older than tolerance_sec are rejected to stop replays.

python
from flask import Flask, request, abort
from incantory import verify_webhook_signature

app = Flask(__name__)


@app.post("/hooks/incantory")
def hook():
    if not verify_webhook_signature(SECRET, request.headers.get("Incantory-Signature", ""), request.get_data()):
        abort(401)
    event = request.get_json()
    # ...
    return "", 204

Errors and retries

IncantoryError has status (0 for network and SDK errors), code, message, request_id, details and retry_after. Retry behaviour matches the TypeScript SDK: see Rate limits and errors.