Skip to content

API reference

The grimoire's index — generated from the docstrings themselves.

Engine

The Ritual is drawn, sealed, and performed as a Rite.

Graph builder and executable plan. A Ritual registers Sigils (nodes), static edges — several per source, enabling fan-out — and conditional edges (routers); compile() validates the graph and returns a Rite. Execution follows the BSP superstep model implemented in sanctum.ritual.scheduler: all frontier Sigils run concurrently, their deltas merge deterministically (through each Conduit's reducer when the Ritual has an AetherSchema, by overwrite otherwise), and edges decide the next frontier. Cycles are allowed and are what enables agentic behavior (think -> act -> observe -> ...), bounded by recursion_limit. With a Codex, every superstep leaves a Seal (resumption, interrupt(), time-travel); astream yields Omens live. Fan-in is "any" by default — add_sigil(..., join="all") turns a Sigil into a barrier over its static predecessors. Subgraphs (Circles) arrive in a later phase.

Ritual

The circle where Sigils are bound before the invocation.

Mutable graph builder. Register nodes with add_sigil, connect them with add_edge/add_conditional_edge (or set_entry_point for a START edge), then call compile() to validate the graph and obtain an executable Rite. Builder methods return self to allow chaining.

When an AetherSchema is given, every delta key must name a declared Conduit and merges through that Conduit's reducer; without a schema, deltas merge by plain overwrite. Within a superstep, deltas are applied in Sigil insertion order — the order Sigils were bound to the Ritual — so concurrent writes to the same Conduit are deterministic.

A source with several static edges fans out: all targets activate in the next superstep. Fan-in uses "any" semantics by default: a Sigil runs as soon as any predecessor activates it, and multiple activations within one superstep coalesce into a single execution. Binding a Sigil with join="all" makes it a barrier instead: it waits — across supersteps when branches have uneven lengths — until every static predecessor has activated it, then runs once.

add_sigil(name, fn, policy=None, *, join='any')

Bind a Sigil to the Ritual.

Registers a node in the graph. fn receives the full Aether (state dict) and must return a partial delta to be merged into the state; it may be sync or async. policy grants the Sigil its resilience — timeout, retries with backoff, on_error fallback (see SigilPolicy); it overrides compile(default_policy=...).

join sets the fan-in semantics: "any" (default) runs the Sigil as soon as any predecessor activates it; "all" makes it a barrier that waits for every static predecessor — activations accumulate across supersteps (and survive Seals), and the Sigil runs once when the last predecessor arrives. A join="all" Sigil needs at least one static incoming edge and cannot be the target of a conditional edge or an on_error fallback (checked at compile time).

Raises:

Type Description
RitualValidationError

If name is START/END, already bound, fn is not callable, or join is not "any"/"all".

add_edge(source, target)

Trace a fixed path from one Sigil to the next.

Registers a static edge: after source completes, target activates in the next superstep. source may be START and target may be END; cycles (edges back to earlier Sigils) are allowed. A source may carry several static edges — all targets activate together (fan-out) — but static edges and a conditional edge are mutually exclusive on the same source.

Raises:

Type Description
RitualValidationError

If the edge starts at END, ends at START, duplicates an existing edge, or source already has a conditional edge.

add_conditional_edge(source, router, path_map=None)

Trace a branching path decided at invocation time.

Registers a conditional edge: after source completes, router receives the full Aether and returns the name of the next Sigil (or END). When path_map is given, the router's return value is used as a key into it and the mapped name is followed instead. Routing a Sigil back to an earlier one is how cycles — and agentic loops — are built.

Raises:

Type Description
RitualValidationError

If source is START or END, router is not callable, or source already has an outgoing edge (static or conditional).

set_entry_point(name)

Mark a Sigil where the invocation begins.

Equivalent to add_edge(START, name). May be called several times: all entry points activate in the first superstep (fan-out from START).

compile(recursion_limit=DEFAULT_RECURSION_LIMIT, codex=None, default_policy=None, wards=None)

Seal the Ritual into a Rite.

Validates the graph and returns the executable plan. Checks, in order: an entry point exists, every static edge and every path_map target references a bound Sigil (or END), every policy's on_error names a bound Sigil (other than its owner), every Sigil is reachable from START (on_error fallbacks count as reachable), and every Sigil has an outgoing edge. Cycles are valid — the graph is a cyclic state graph, not a DAG. A conditional edge without path_map may route anywhere, so it counts as reaching every Sigil; its actual return values are checked at invocation time.

Parameters:

Name Type Description Default
recursion_limit int

Maximum supersteps per Invocation before the Rite raises RecursionLimitError (default 25).

DEFAULT_RECURSION_LIMIT
codex Codex | None

Seal store. When given, the Rite writes a Seal at the end of every superstep and supports resumption, interrupt(), and time-travel.

None
default_policy SigilPolicy | None

Resilience applied to Sigils without their own policy (see SigilPolicy).

None
wards Sequence[Ward] | None

Middleware pipeline, applied in registration order — each Ward's output delta is the next one's input (see sanctum.wards).

None

Raises:

Type Description
RitualValidationError

On the first violation found, with a message naming the offending Sigils.

Rite

The sealed plan of the invocation, ready to be performed.

Immutable executable graph produced by Ritual.compile(). Delegates execution to the BSP Scheduler: every frontier Sigil runs concurrently within a superstep, deltas merge deterministically in Sigil insertion order (through Conduit reducers when the Rite has an AetherSchema, by overwrite otherwise), and edges decide the next frontier until END or recursion_limit. With a Codex attached, every superstep leaves a Seal, enabling resumption after interrupt() and time-travel from any historic Seal.

ainvoke(input=None, *, invocation_id=None, seal_id=None, updates=None) async

Perform the invocation, fresh or resumed from a Seal.

Fresh run — input given, no seal_id: runs the superstep loop with a shallow copy of input as the initial Aether. Each active Sigil receives a copy of the full Aether (mutating it has no effect), deltas merge deterministically, and edges compute the next frontier. Returns the final Aether when the frontier empties or holds only END.

Resumption — input omitted (requires a Codex and invocation_id): restores the Invocation's latest Seal — or the Seal named by seal_id for time-travel — and continues from its frontier and superstep count. updates optionally injects new data into the restored Aether before continuing (human-in-the-loop after interrupt()); with a schema it merges through the Conduit reducers. Resumption appends new Seals to the same history.

Parameters:

Name Type Description Default
input Aether | None

Initial Aether values for a fresh run; omit to resume.

None
invocation_id str | None

Identifier of this execution session; generated (uuid4 hex) when omitted on a fresh run.

None
seal_id str | None

Historic Seal to time-travel from (implies resumption).

None
updates Mapping[str, Any] | None

Delta injected into the restored Aether on resumption.

None

Raises:

Type Description
AetherValidationError

If the input, a delta, or updates writes outside the declared Conduits (schema Rites only).

Interrupt

If a Sigil pauses the Invocation via interrupt().

RecursionLimitError

If the Invocation exceeds the Rite's recursion_limit supersteps without reaching END.

SealError

If resumption lacks a Codex or invocation_id, no Seals exist, or seal_id is unknown.

SigilExecutionError

If a Sigil raises during a superstep.

TypeError

If a Sigil returns something other than a mapping.

ValueError

If a router returns a value that is neither a bound Sigil nor END, or is missing from its path_map.

astream(input=None, *, invocation_id=None, seal_id=None, updates=None, mode='updates') async

Perform the invocation, streaming Omens as it unfolds.

Async generator over the same fresh/resume semantics as ainvoke (input, invocation_id, seal_id, updates). The scheduler runs as a background task; Omens flow through a queue and are yielded as they happen, filtered by mode:

  • "updates" (default): SigilCompleted per finished Sigil, with its delta.
  • "values": SuperstepCompleted with the full Aether after each superstep.
  • "omens": granular lifecycle — RiteBegan, SuperstepBegan, SigilBegan, SigilCompleted, SealWritten, RiteManifested.
  • "tokens": TokenEmitted payloads pushed by Sigils through their injected writer, delivered while the Sigil still runs.

Modes combine: mode={"updates", "tokens"} yields the union. Exceptions from the Invocation (Interrupt, SigilExecutionError, RecursionLimitError, and everything else ainvoke raises) propagate to the consumer after the already-emitted Omens are drained. Closing the generator early cancels the Invocation.

Raises:

Type Description
ValueError

If mode names an unknown stream mode.

invoke(input=None, *, invocation_id=None, seal_id=None, updates=None)

Perform the invocation synchronously.

Convenience wrapper around ainvoke for non-async callers — same fresh/resume semantics. Must not be called from within a running event loop.

Wards of endurance: what to do when a Sigil falters.

Per-Sigil resilience policies. A local model can stall, a Spell can flake — the engine must not hang or die for it. A SigilPolicy bounds each attempt with a timeout, retries transient failures with exponential backoff and jitter, and can divert a definitive failure to a fallback Sigil instead of failing the Invocation.

Precedence on failure (documented contract):

  1. timeout bounds every attempt — an attempt past it raises SigilTimeoutError (a SigilExecutionError subclass);
  2. retries re-run the Sigil when the failure matches retry_on (timeouts included by default), emitting a SigilRetried Omen per attempt and sleeping backoff(attempt) seconds between them;
  3. on_error — once retries are exhausted, jump to the named fallback Sigil: the failed superstep is aborted (no Seal written, sibling deltas discarded), the failure is appended to the reserved "__errors__" Conduit, and the fallback runs as the next superstep (which writes its Seal normally);
  4. otherwise the failure propagates as SigilExecutionError.

Timeouts bound async Sigils; a synchronous Sigil body cannot be interrupted mid-run — offload blocking work with asyncio.to_thread.

BackoffFn = Callable[[int], float] module-attribute

Delay in seconds before retry number attempt (1-based).

SigilPolicy dataclass

The endurance granted to one Sigil.

timeout bounds each attempt in seconds (None = unbounded); retries is how many extra attempts follow a matching failure; backoff maps the retry number to a delay in seconds (default: exponential with jitter, base 0.1s, cap 5s); retry_on is the tuple of exception types worth retrying (default: any Exception — includes SigilTimeoutError); on_error names the fallback Sigil to jump to after the last attempt fails (see the module docstring for the full precedence and Seal interaction).

Attach per Sigil with add_sigil(name, fn, policy=...) or globally with compile(default_policy=...); the per-Sigil policy wins.

exponential_backoff(base=0.1, cap=5.0, jitter=True)

Build the classic exponential-backoff-with-jitter delay function.

Retry attempt waits min(cap, base * 2**(attempt-1)) seconds, scaled by a uniform factor in [0.5, 1.0] when jitter is on (jitter decorrelates retry storms when several Sigils fail together).

Halting the ritual so the outside world may speak.

Human-in-the-loop control flow. A Sigil calls interrupt() to pause the Invocation: the current superstep is aborted (its deltas discarded), the scheduler writes a Seal when a Codex is attached, and the Interrupt signal propagates to the caller. The caller resumes later with rite.ainvoke(invocation_id=...), optionally injecting new data into the Aether via updates; the interrupted superstep's frontier re-executes in full.

Interrupt

Bases: Exception

The ritual paused, awaiting words from beyond the circle.

Control-flow signal, not a failure: raised by interrupt() inside a Sigil and propagated to the caller after a Seal is written. Carries the caller-facing reason and the name of the Sigil that paused (sigil, filled in by the scheduler).

interrupt(reason='')

Pause the Invocation from inside a Sigil, leaving a Seal behind.

Raises Interrupt, which aborts the current superstep (its deltas are discarded), persists a Seal when the Rite has a Codex, and surfaces to the caller of ainvoke/invoke. On resumption the whole interrupted frontier — including the Sigil that paused — runs again, so interrupting Sigils should check the Aether to decide whether the awaited data has arrived.

The ways a ritual may be refused or go wrong while performed.

Exceptions raised by the Ritual layer.

RitualValidationError

Bases: Exception

The Ritual is malformed and cannot be sealed into a Rite.

Raised while building or compiling a Ritual whose graph is invalid: missing entry point, edges referencing unknown Sigils, Sigils unreachable from START, or Sigils without an outgoing edge.

RecursionLimitError

Bases: Exception

The ritual spun past its allotted supersteps and was cut short.

Raised by a Rite when an Invocation exceeds the Rite's recursion_limit supersteps without reaching END — typically a cycle whose stop condition never triggers. The message includes the invocation_id and the last Sigil executed.

SigilExecutionError

Bases: Exception

A Sigil failed mid-ritual and the superstep was abandoned.

Raised when a Sigil raises during a superstep: the sibling Sigils of that superstep are cancelled and none of its deltas are applied. Carries the offending Sigil's name (sigil), a snapshot of the Aether at the moment of failure (aether), and the original exception as __cause__.

SigilJoinError

Bases: Exception

The rite concluded while a join still awaited absent celebrants.

Raised when an Invocation's frontier empties while one or more join="all" Sigils are still waiting for static predecessors that will never run — typically because a router steered a feeding branch away from the join. pending maps each waiting Sigil to the sorted list of predecessors it is still missing.

SigilTimeoutError

Bases: SigilExecutionError

A Sigil pondered past its allotted time and was cut off.

Raised when an attempt exceeds its SigilPolicy's timeout. Subclass of SigilExecutionError, so generic failure handling (retries with the default retry_on, on_error fallbacks, caller except clauses) covers timeouts too. timeout carries the configured bound in seconds.

State

The Conduits are drawn: the shape the shared energy must take.

State schema. A Conduit declares how one key of the Aether merges deltas (its reducer); an AetherSchema is the complete set of Conduits a Ritual operates on. When a Ritual has a schema, every delta key must name a declared Conduit and is merged through that Conduit's reducer instead of plain overwrite.

Aether = dict[str, Any] module-attribute

The shared state dict, keyed by Conduit name.

Conduit dataclass

One channel of the Aether and the way it merges new energy.

Declares the reducer (current, update) -> new applied when a Sigil's delta writes to this key. Defaults to overwrite. The reducer is skipped when the key is not yet present in the Aether: the delta value is set directly, so custom reducers never receive a missing current value.

AetherSchema

The declared shape of the shared energy.

Maps each Aether key to its Conduit. Values in the constructor mapping may be Conduit instances, Annotated[T, reducer] forms, or plain types (which default to overwrite):

AetherSchema({
    "messages": Conduit(reducer=append),
    "score": Annotated[int, add],
    "verdict": str,
})

Delta application is deterministic: within a superstep, deltas are applied in Sigil insertion order (the order Sigils were bound to the Ritual), and each key merges through its Conduit's reducer.

conduits property

A copy of the mapping from Aether key to its Conduit.

from_class(source) classmethod

Read the Conduits from a class's annotations.

Sugar for declaring the schema as an annotated class::

class ChantAether:
    messages: Annotated[list, append]
    score: int  # plain annotation -> overwrite

Annotated[T, reducer] fields use the first callable metadata item as the Conduit's reducer.

validate_input(input)

Check that the initial Aether only uses declared Conduits.

Raises:

Type Description
AetherValidationError

If input contains keys not declared in the schema.

apply_delta(aether, delta, *, sigil)

Merge one Sigil's delta into the Aether through the Conduits.

Each delta key must name a declared Conduit; its value merges via the Conduit's reducer, or is set directly when the key is not yet present. Returns a new dict; aether is not mutated.

Parameters:

Name Type Description Default
aether Mapping[str, Any]

Current state.

required
delta Mapping[str, Any]

Partial update returned by the Sigil.

required
sigil str

Name of the Sigil that produced the delta, for error attribution.

required

Raises:

Type Description
AetherValidationError

If the delta writes to a key not declared in the schema; the message names the Sigil.

apply_deltas(aether, deltas)

Merge one superstep's deltas into the Aether, deterministically.

Deltas are applied strictly in the order given as (sigil_name, delta) pairs; the engine passes them in Sigil insertion order (the order Sigils were bound to the Ritual). This makes concurrent writes to the same Conduit within a superstep deterministic: the Conduit's reducer folds each delta in, in that fixed order. Returns a new dict; aether is not mutated.

How new energy folds into each Conduit of the Aether.

Built-in reducers. A reducer is any callable (current, update) -> new that merges a Sigil's delta value into a Conduit's current value: overwrite (the default) replaces, append concatenates lists, add sums numbers, and merge_dict shallow-merges dicts. Any custom callable with the same signature is a valid reducer. Reducers are only called when the Conduit already holds a value; a delta to a missing key sets it directly.

Reducer = Callable[[Any, Any], Any] module-attribute

A Conduit's merge function: (current, update) -> new value.

overwrite(current, update)

Replace the current value with the update (the default reducer).

append(current, update)

Concatenate the update list after the current list.

The delta value must be a list of items to append (wrap single items). Returns a new list; neither input is mutated.

add(current, update)

Sum the update into the current value (numeric accumulation).

merge_dict(current, update)

Shallow-merge the update dict over the current dict.

Keys present in both take the update's value. Returns a new dict; neither input is mutated.

Persistence

Seals — the wax impressions each superstep leaves behind.

Checkpoint model and storage contract. A Seal is the snapshot written at the end of a superstep: the full Aether, the frontier of the next superstep, the superstep number, a timestamp, and free-form metadata. A Codex stores Seals per Invocation (keyed by invocation_id) and yields them back to enable resumption, human-in-the-loop pauses, and time-travel.

Seal dataclass

The wax impression of one superstep.

Immutable checkpoint. aether is the full state after the superstep's deltas were applied; frontier the Sigils active in the next superstep — resumption continues from it; superstep the 1-based count within the Invocation; timestamp epoch seconds; metadata free-form context (e.g. interrupt details). seal_id uniquely identifies the Seal for time-travel.

Codex

Bases: ABC

The ledger where Seals are inscribed and consulted.

Abstract async storage contract for Seals, keyed by invocation_id. Histories are append-only: resuming an Invocation appends new Seals after the old ones, and get returns the most recently written Seal (not the highest superstep number). Implementations: MemoryCodex (ephemeral, for tests), SqliteCodex (local file), PostgresCodex (optional extra, sanctum.codex.postgres).

put(invocation_id, seal) abstractmethod async

Append a Seal to the Invocation's history.

get(invocation_id) abstractmethod async

Return the Invocation's most recently written Seal, or None.

list(invocation_id) abstractmethod async

Return the Invocation's full Seal history, oldest first.

A Codex written in breath: it fades when the session ends.

In-memory Seal store, the reference implementation for tests and ephemeral invocations. Not shared across processes and not persistent.

MemoryCodex

Bases: Codex

Ephemeral in-memory ledger of Seals.

Stores histories in a plain dict keyed by invocation_id. Intended for tests and short-lived local runs; contents vanish with the object.

put(invocation_id, seal) async

Append a Seal to the Invocation's history.

get(invocation_id) async

Return the Invocation's most recently written Seal, or None.

list(invocation_id) async

Return the Invocation's full Seal history, oldest first.

A Codex bound in a single local volume: one SQLite file.

Durable Seal store on the Python stdlib sqlite3 module — no external dependencies, local-first. The Aether, frontier, and metadata are stored as JSON columns.

Limitation: every value in the Aether and metadata must be JSON-serializable (str, int, float, bool, None, and lists/dicts thereof). Non-serializable state raises SealError at put time; convert rich objects to plain data in your Sigils, or use MemoryCodex.

The interface is async to honor the Codex contract, but operations execute synchronously inline: local SQLite calls are short-lived and this keeps the core dependency-free.

SqliteCodex

Bases: Codex

Durable single-file ledger of Seals.

Persists Seals to a SQLite database at path, creating the file and schema on first use. Recreating the object over the same path sees the same history. Requires JSON-serializable Aether and metadata (see the module docstring).

put(invocation_id, seal) async

Append a Seal to the Invocation's history.

Raises:

Type Description
SealError

If the Seal's Aether, frontier, or metadata cannot be serialized to JSON.

get(invocation_id) async

Return the Invocation's most recently written Seal, or None.

list(invocation_id) async

Return the Invocation's full Seal history, oldest first.

Streaming & tracing

The signs a ritual gives off as it unfolds.

Streaming event model. Every Omen is a frozen, keyword-only dataclass stamped with an epoch timestamp at creation. The scheduler emits Omens at each lifecycle point; Rite.astream filters them by mode:

  • "updates": SigilCompleted — one Omen per finished Sigil, with its delta (emitted at real completion time, so parallel Sigils appear in completion order).
  • "values": SuperstepCompleted — the full Aether after each superstep.
  • "omens": the granular lifecycle — RiteBegan, SuperstepBegan, SigilBegan, SigilCompleted, SealWritten, RiteManifested.
  • "tokens": TokenEmitted — intermediate payloads a Sigil pushes through its injected writer (e.g. Oracle tokens), streamed before the Sigil finishes.

Modes combine: pass a set/list of names to receive the union.

STREAM_MODES = {'updates': (SigilCompleted,), 'values': (SuperstepCompleted,), 'tokens': (TokenEmitted,), 'omens': (RiteBegan, SuperstepBegan, SigilBegan, SigilRetried, SigilCompleted, SealWritten, RiteManifested, SpellCallRepaired, SpellCallRejected, DeltaRejected, CircleEchoed)} module-attribute

The Omen classes each astream mode yields.

Omen dataclass

A sign given off by the ritual, stamped with its moment.

Base class of every streaming event; timestamp is epoch seconds at creation.

RiteBegan dataclass

Bases: Omen

The invocation has started.

SuperstepBegan dataclass

Bases: Omen

A superstep is about to run its frontier.

frontier lists the active Sigils in insertion order.

SigilBegan dataclass

Bases: Omen

A Sigil has started executing within a superstep.

SigilRetried dataclass

Bases: Omen

A Sigil faltered and is being attempted again.

Emitted before each retry granted by the Sigil's policy. attempt is the retry number (1-based); cause is the repr of the failure that triggered it.

SigilCompleted dataclass

Bases: Omen

A Sigil finished and returned its partial delta.

Emitted when the Sigil returns — before the superstep's deltas are merged into the Aether.

SuperstepCompleted dataclass

Bases: Omen

A superstep's deltas were merged; aether is the full state.

SealWritten dataclass

Bases: Omen

A Seal was inscribed in the Codex at the end of a superstep.

TokenEmitted dataclass

Bases: Omen

A Sigil pushed an intermediate payload through its writer.

Streams while the Sigil is still running — this is how Oracle tokens reach the consumer without waiting for the superstep to finish.

RiteManifested dataclass

Bases: Omen

The invocation concluded; aether is the final state.

DeltaRejected dataclass

Bases: Omen

A Ward vetoed a Sigil's delta before it reached the Aether.

Emitted when Ward.after_sigil raises WardRejection: the delta is discarded, the superstep aborts, and the Sigil's on_error policy applies if present. ward is the vetoing Ward's class name.

SpellCallRepaired dataclass

Bases: Omen

A garbled spell call was mended locally before casting.

Emitted by the summon loop's repair layer when a malformed call (broken JSON, fenced blocks, prose around the payload) was recovered without consulting the Oracle again. detail describes the mend.

SpellCallRejected dataclass

Bases: Omen

A spell call could not be executed; the Oracle was asked to correct.

Emitted by the summon loop's repair layer when a call is unparseable, names an unknown Spell, or carries invalid arguments. reason is the correction message injected into the transcript for the Oracle.

CircleEchoed dataclass

Bases: Omen

An Omen that sounded inside a Circle, echoed to the outer stream.

Emitted by a circle(...) Sigil for every Omen its inner Rite produces: circle is the mounted Circle's name, omen the inner event untouched (including inner TokenEmitted payloads). Consumers that only care about the outer graph can ignore these; observability layers can unwrap them to trace the full nested execution.

resolve_modes(mode)

Translate astream's mode into the Omen classes to yield.

Accepts a single mode name or an iterable of names; combined modes yield the union of their Omen classes.

Raises:

Type Description
ValueError

If a mode name is unknown or no mode is given.

Reading the ritual's signs after the fact — local-first tracing.

Observability without external services. TraceRecorder is a Ward: register it with compile(wards=[...]) (or summon(wards=[...])) and it captures the graph manifest plus every Omen, assembling a complete trace of one Invocation — graph, superstep timeline with per-Sigil durations, deltas, spell calls with arguments and results, retries, repairs, rejections, and Seals — written to a .sanctum-trace.json file when the Rite manifests (call flush() to write earlier, e.g. after a failure). render_trace() turns a trace file into a self-contained HTML viewer: a single file, no server, no external requests. Tracing is opt-in by design: without a recorder the engine pays nothing.

TraceRecorder

Bases: Ward

The chronicler of the ritual: records everything, changes nothing.

Observes the Omen stream (on_omen) and the graph manifest (on_compile); never touches deltas, so results are identical with or without it. Use one recorder per Invocation. The trace is written to path when RiteManifested arrives; after an aborted Invocation, call flush() to persist what was captured.

on_compile(manifest)

Remember the graph this recorder is watching.

on_omen(omen) async

Record the Omen; write the trace when the Rite manifests.

flush()

Write the trace assembled so far to path and return the path.

build()

Assemble the trace document from the recorded Omens.

render_trace(trace_path, html_path=None)

Render a trace file into a self-contained HTML viewer.

One output file, no server, no external requests: all CSS is inline and the graph is a static SVG laid out in Python (simple hierarchical layers from START to END; back-edges drawn dashed). Returns the path of the written HTML (defaults to the trace path with an .html suffix).

Oracles

The voice consulted during the invocation, and the shape of its answers.

Abstract LLM interface. An Oracle receives a message transcript (dicts with role/content) plus the JSON schemas of the Spells it may request, and answers with an OracleResponse: free text, zero or more SpellCalls, and usage counters. arcana identifies the concrete model. All implementations are local-first — the core never assumes proprietary APIs.

Message = dict[str, Any] module-attribute

One entry of the transcript: at least role and content.

SpellCall dataclass

The Oracle's request to cast one Spell.

spell names the Spell, arguments matches its JSON schema, and call_id correlates the request with the result message injected back into the transcript.

OracleResponse dataclass

One full answer from the Oracle.

text is the assistant's message; spell_calls the Spells it wants cast before continuing (empty means the answer is final); usage holds free-form counters (e.g. prompt/completion tokens).

Oracle

Bases: ABC

The abstract voice every Entity consults.

Implementations must set arcana (the concrete model identifier) and provide both a complete-answer path (generate) and a token stream (stream_generate). Implementations: ScriptedOracle (deterministic, for tests), OllamaOracle (sanctum.oracle.ollama), and TransformersOracle (sanctum.oracle.transformers).

arcana instance-attribute

Identifier of the concrete model behind this Oracle.

generate(messages, spells=None) abstractmethod async

Answer the transcript in one piece.

messages is the conversation so far; spells the JSON schemas (name, description, parameters) of the Spells the Oracle may request via spell_calls.

stream_generate(messages, spells=None) abstractmethod

Answer the transcript as an async stream of text chunks.

Yields incremental text; pair it with a Sigil's writer to push tokens through astream.

An Oracle that reads from a prepared script.

Deterministic Oracle for tests: answers are handed over in order from a fixed script, never touching a real model (non-negotiable principle #4). Records every call for assertions.

ScriptedOracle

Bases: Oracle

The voice that only ever says what was written for it.

script is a sequence of OracleResponse (or plain strings, shorthand for a text-only response) returned one per generate call, in order. Exhausting the script raises RuntimeError — a scripted test asked one question too many. Every call is recorded in calls as (messages, spells) for assertions.

generate(messages, spells=None) async

Return the script's next response, recording the call.

stream_generate(messages, spells=None) async

Yield the script's next response word by word.

stream_response(messages, spells=None) async

Yield the next response's text word by word, then the response.

Mirrors the tool-aware streaming capability of the production adapters so streaming code paths stay testable without a model.

Weathering the imperfect speech of small local models.

Robust spell-calling. Local 7-14B models fumble tool calls in known ways: malformed JSON, prose mixed with the payload, single quotes, unbalanced braces, or no native tool support at all. This module treats those as first-class inputs, not errors: extract_json recovers objects from messy text (used by the repair layer in sanctum.grimoire.repair), and PromptedSpellCalling wraps any Oracle to emulate tool calling through the prompt — schemas are injected into the system prompt with a delimited invocation format, and the answer's text is parsed back into SpellCalls.

PromptedSpellCalling

Bases: Oracle

A voice taught to cast Spells by instruction, not by wiring.

Oracle wrapper emulating tool calling for models or endpoints without native support. With mode="always" (default) every generate that carries Spell schemas is rewritten: schemas go into the system prompt (inject_spell_prompt) and the answer's text is parsed back into SpellCalls (parse_spell_blocks). With mode="auto" native tool calling is tried first, and the wrapper falls back — and stays — on prompted calling the first time the endpoint rejects tools (an OracleResponseError mentioning tools).

stream_generate passes chunks through with the prompt injected; delimited blocks appear verbatim in the stream (parsing needs the full text), so prefer generate inside spell-calling loops.

generate(messages, spells=None) async

Answer the transcript, emulating tool calls when needed.

stream_generate(messages, spells=None) async

Stream the inner Oracle with the Spell prompt injected.

extract_json(text)

Tolerantly extract one JSON object from model output.

Tries, in order: the first fenced /json block, the outermost brace region, and the raw text. Each candidate is parsed as-is, then with simple mends — appending missing closing braces, and swapping single quotes for double quotes when the candidate contains no double quotes. Returns the first dict found, or None when nothing parses.

render_spell_prompt(spells)

Write the system-prompt section teaching the delimited call format.

Lists every Spell with its JSON schema and shows the exact block the model must produce to cast one.

inject_spell_prompt(messages, spells)

Fold the Spell section into the transcript's system prompt.

Appends to the existing leading system message, or prepends a new one. Returns a new list; messages is not mutated.

parse_spell_blocks(text)

Split a prompted answer into prose and SpellCalls.

Every <spell_call>...</spell_call> block is parsed with extract_json; blocks that still refuse to parse become SpellCalls flagged "__malformed_json__", so the repair layer treats prompted failures exactly like native ones. The prose outside the blocks is returned as the response text.

Small local models frequently ignore the delimiter format and emit the call as bare JSON instead (observed with 0.5-3B models on llama-server): when no blocks are present but the whole answer parses to an object carrying a "spell" name, it is routed through the repair layer as a malformed call — validated, executed, and surfaced as a SpellCallRepaired Omen.

The ways the Oracle's voice can fail to arrive.

Exceptions raised by the Oracle adapters. Every message is written to be actionable: it names the endpoint involved and states the most likely fix (start the server, pull the model, raise the timeout).

OracleError

Bases: Exception

The Oracle could not be consulted.

Base class of all Oracle adapter failures.

OracleConnectionError

Bases: OracleError

No one answered at the Oracle's address.

The model server could not be reached (connection refused, DNS failure). The message names the address and how to start a server there.

OracleTimeoutError

Bases: OracleError

The Oracle stayed silent past the allotted time.

The request exceeded the configured timeout. Common with local models on first request, while weights load into memory.

OracleResponseError

Bases: OracleError

The Oracle's voice arrived, but as a refusal.

The server answered with an error status (unknown model, malformed request, server fault). The message includes the HTTP status, a body snippet, and a hint when the cause is recognizable.

Grimoire

The Grimoire's pages: Spells, the @spell inscription, and the Tome.

Tool model and registry. A Spell wraps a callable with a JSON schema (name, description, parameters) an Oracle can reason about. Spells are declared with the @spell decorator — schema inferred from type hints and the docstring — or loaded from an AgentGrimoire-style directory tree via Tome.load_from_directory. A Tome is the ordered registry of Spells handed to an Entity.

Spell dataclass

One inscribed tool: a callable and the schema that describes it.

parameters is a JSON Schema object describing the keyword arguments of fn, which may be sync or async. Instances stay callable — invoking the Spell directly calls fn unchanged.

schema()

The JSON schema handed to Oracles: name, description, parameters.

execute(arguments=None) async

Cast the Spell with keyword arguments matching its schema.

Awaits async callables transparently and returns the raw result.

Raises:

Type Description
SpellExecutionError

If the underlying callable raises; the original exception is chained as __cause__.

Tome

The bound collection of Spells an Entity may cast.

Ordered registry keyed by Spell name (registration order is preserved and is the order Oracles see). Build it from Spell instances, register incrementally, or load a whole directory tree with load_from_directory.

register(spell)

Inscribe a Spell in the Tome; returns self for chaining.

Raises:

Type Description
ValueError

If a Spell with the same name is already inscribed.

get(name)

Return the Spell inscribed under name.

Raises:

Type Description
SpellExecutionError

If no such Spell exists — so the ReAct loop can surface an unknown-Spell request to the Oracle as an error message instead of crashing.

schemas()

The JSON schemas of every Spell, in registration order.

load_from_directory(path) classmethod

Load Spells from an AgentGrimoire-style directory tree.

Proposed manifest convention for AgentGrimoire (github.com/zquintero246/AgentGrimoire): one folder per domain, one subfolder per Spell, each holding a spell.json manifest next to its implementation::

<root>/
  text/                    # domain folders: text/, files/, ...
    word_count/
      spell.json
      spell.py
  system/
    shout/
      spell.json
      spell.py

spell.json fields:

  • name (required): the Spell's name as exposed to Oracles.
  • entrypoint (required): "<file>.py:<function>" relative to the Spell's folder; the target may be a plain function or an @spell-decorated Spell.
  • description (optional): falls back to the entrypoint's docstring first line.
  • parameters (optional): JSON Schema of the arguments; falls back to the schema inferred from the entrypoint's type hints.

Raises:

Type Description
ValueError

If a manifest is missing required fields or its entrypoint cannot be resolved.

spell(fn=None, *, name=None, description=None)

Inscribe a Python function as a Spell.

Decorator, usable bare or with arguments::

@spell
def word_count(text: str) -> int:
    """Count the words in a text."""
    return len(text.split())

The Spell's name defaults to the function name, the description to the docstring's first line, and parameters to a JSON Schema inferred from the type hints (defaults become optional properties). The decorated object is a Spell but remains directly callable.

Mending the Oracle's imperfect spell calls before they are cast.

Repair layer, applied to EVERY spell call — native tool calling or prompted — before execution. Three tiers: local repair (tolerant JSON extraction via sanctum.oracle.robust.extract_json, no model round trip), conversational repair (a correction message returned to the Oracle through the transcript, written so a small model knows exactly what to fix), and surrender (SpellCallParseError, raised by the summon loop once max_repair_rounds is exhausted). Repairs and rejections surface as SpellCallRepaired / SpellCallRejected Omens.

RepairOutcome dataclass

What the repair layer decided about one spell call.

Exactly one of call/correction is set: call is the executable (possibly mended) SpellCall; correction is the message to return to the Oracle. repaired describes the local mend applied, if any; raw preserves the original unparseable text for debugging.

repair_spell_call(call, tome)

Validate and, when possible, mend one spell call.

Checks in order: (1) arguments flagged "__malformed_json__" are recovered with extract_json — a recovered full call object ({"spell": ..., "arguments": {...}}) replaces both name and arguments; unrecoverable text becomes a correction with the original preserved. (2) Unknown Spell names get a correction listing the available Spells. (3) Arguments are checked against the Spell's JSON schema — missing required or unexpected keys get a correction naming them. Corrections are phrased for a small model: they state exactly what to change.

summon — calling an Entity into the circle, ready to act.

Agent factory. Builds the canonical ReAct loop as a Rite using nothing but the public primitives — Ritual, a Conduit with the append reducer, and conditional edges — demonstrating they suffice for agentic behavior:

oracle -> (spell_calls?) -> spells -> oracle -> ... -> END

The Aether holds messages (append; dicts with role/content), plus repair_rounds and rejected_calls for the repair layer. The oracle Sigil sends the system role plus the transcript to the Oracle and appends its answer (carrying spell_calls when Spells were requested). The spells Sigil passes EVERY call through the repair layer (sanctum.grimoire.repair) before casting: malformed JSON is recovered locally (SpellCallRepaired Omen), while unknown Spells and invalid arguments become correction messages back to the Oracle (SpellCallRejected Omen). Consecutive correction-only rounds are bounded by max_repair_rounds; past it the loop surrenders with SpellCallParseError. Spell execution failures stay conversational: they are injected as error messages and the loop survives. The loop ends when an answer carries no spell_calls.

summon(oracle, tome=None, role=DEFAULT_ROLE, *, spell_calling='native', max_repair_rounds=2, recursion_limit=DEFAULT_RECURSION_LIMIT, codex=None, wards=None)

Summon an Entity: an Oracle bound to a Tome, sealed as a Rite.

Returns a compiled Rite implementing the ReAct loop described in the module docstring. Invoke it with rite.ainvoke({"messages": [{"role": "user", "content": ...}]}); the final Aether's messages holds the full transcript.

Parameters:

Name Type Description Default
oracle Oracle

The voice consulted each turn.

required
tome Tome | None

The Spells available to the Entity; None summons a spell-less conversationalist.

None
role str

System prompt prepended (not stored in the Aether) on every consultation.

DEFAULT_ROLE
spell_calling str

How Spell schemas reach the model — "native" (the Oracle's tool support), "prompted" (wrap in PromptedSpellCalling: schemas in the system prompt, calls parsed from text), or "auto" (native first, falling back to prompted when the endpoint rejects tools).

'native'
max_repair_rounds int

Consecutive correction-only rounds tolerated before the loop surrenders with SpellCallParseError (default 2). A round with at least one executable call resets the counter.

2
recursion_limit int

Superstep bound forwarded to compile().

DEFAULT_RECURSION_LIMIT
codex Codex | None

Optional Seal store forwarded to compile().

None
wards Sequence[Ward] | None

Middleware pipeline forwarded to compile(). The Oracle's usage counters are attached to each assistant message, so a UsageWard tallies them out of the box.

None

Raises:

Type Description
ValueError

If spell_calling names an unknown strategy.

Wards

The protective circles drawn around the ritual.

Middleware interface. A Ward observes and intercepts the engine's work through three optional async hooks, all no-ops by default:

  • before_sigil(name, aether): called before a Sigil executes.
  • after_sigil(name, aether, delta) -> delta: called with the Sigil's delta before it merges into the Aether — return it (possibly transformed), or raise WardRejection to veto it. Transformed deltas are what Seals, Omens, and downstream Wards see.
  • on_omen(omen): called for every Omen the engine emits, before it reaches the stream — build tracing/metrics here without touching the engine.

Wards are registered with compile(wards=[...]) and applied as a pipeline in registration order: the delta returned by one Ward is the input of the next.

Ward

One circle of protection: observe, transform, or veto.

Subclass and override any hook; the defaults observe nothing and pass deltas through unchanged. Hooks run inside the superstep, so keep them fast — offload heavy work.

on_compile(manifest)

Called once when the Ward is bound to a compiled graph.

manifest describes the graph: {"sigils": [names], "edges": {source: [targets]}, "conditional_edges": {source: [targets] or ["*"] when dynamic}}. Treat it as read-only. Lets observability Wards (e.g. TraceRecorder) know the structure they are watching.

before_sigil(name, aether) async

Called before Sigil name executes (aether is a copy).

after_sigil(name, aether, delta) async

Inspect or transform Sigil name's delta; return the delta.

The returned mapping replaces the Sigil's delta for everything downstream (next Wards, reducers, Seals, SigilCompleted Omens).

Raises:

Type Description
WardRejection

To veto the delta (see the class docstring).

on_omen(omen) async

Called for every Omen before it reaches the stream.

A scribe that records every change the ritual makes.

Local audit trail: one JSON Lines entry per applied delta, no external services. Each line is {"timestamp": epoch, "sigil": name, "delta": {...}}; non-JSON-serializable values are stringified rather than breaking the ritual. Place an AuditWard after a RedactWard in the pipeline so the trail only ever contains redacted content.

AuditWard

Bases: Ward

The ledger of deltas, written line by line to a local file.

Appends one JSON object per Sigil delta to path (created on first write). Writes are synchronous appends of small lines — adequate for local-first auditing; use one file per Invocation for clean trails.

after_sigil(name, aether, delta) async

Record the delta with a timestamp; pass it through unchanged.

A tally of every consultation the Oracle grants.

Token and call accounting without external services. Oracle-consulting Sigils attach the Oracle's usage counters to the messages they append (summon does this automatically when the OracleResponse carries usage); this Ward accumulates them per Sigil and for the whole Invocation. Create one UsageWard per Invocation — or call reset() — to keep totals meaningful.

UsageWard

Bases: Ward

The tally-keeper of Oracle calls and tokens.

Scans each delta's messages for entries carrying a usage mapping and accumulates the counters. summary() reports the totals per Sigil and for the Invocation.

after_sigil(name, aether, delta) async

Accumulate usage counters found in the delta; pass it through.

summary()

Report accumulated usage: total and per Sigil.

Returns {"total": {counters..., "calls": n}, "by_sigil": {sigil: {counters...}}}.

reset()

Forget every counter (e.g. between Invocations).

A veil drawn over what must not be written down.

Redaction before persistence and observability. This Ward masks regex matches (API keys, emails, ...) in every string of the delta before it merges into the Aether — so Seals, the SigilCompleted Omens, downstream Wards (place an AuditWard after it), and the final result only ever see the masked text. It cannot redact what a Sigil already sent to an external system; it guards Sanctum's own outputs.

RedactWard

Bases: Ward

The veil over secrets: masks patterns in every delta string.

patterns is an iterable of regex strings or compiled patterns; every match anywhere in the delta (recursing through dicts, lists, and tuples) is replaced by mask.

after_sigil(name, aether, delta) async

Return the delta with every pattern match masked.