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.
pip install incantoryUntil the package is on PyPI, install it from a checkout of the repository with pip install ./sdk/python.
Quick start
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
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
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
| Call | HTTP |
|---|---|
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 / history | GET .../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
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; otherwiseGET .../versions/N.@label:GET .../labelswithIf-None-Match(304 when unchanged), then the version.- latest:
GET /api/v1/prompts/{o}/{s}withIf-None-Match. - Every fetched version's
contentHashis recomputed; a mismatch raisesIncantoryError(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.staleisTrue.get(ref, offline=True)never touches the network.
The disk cache is shared with the TypeScript SDK.
Pinning with incantory.lock
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 versionThe file is byte-identical to what the TypeScript SDK and the CLI write.
Typed variables
from incantory.codegen import typed_dict
src = typed_dict("acme/code-review", prompt.all_variables()) # Python source for a CodeReviewVars TypedDictReporting evals
results = client.evals.read_jsonl("results.jsonl")
client.evals.report(results, create={"ref": "acme/code-review@v7", "model": "claude-opus-5-5"})Device login
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 sentVerifying 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.
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 "", 204Errors 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.