Rituals¶
A ritual is a named program made of steps. Casting one runs it to completion and prints its result.
The vocabulary¶
- Ritual — a named program. Its components become
--options. - Component — what a ritual needs before it can be cast, the way a spell
needs its material components. Typed values on the ritual's external
interface —
File,Directory,Text,Url,GitRef— declared as fields on one pydantic model, the ritual's only parameter. - Step — one deterministic hop. Takes a typed payload and returns the next
step's payload, or
Done(result). A ritual is a trampoline over steps, bounded bymax_steps. - Transition — what a step returns. A payload is the next step: each
payload class belongs to exactly one step, so returning
Attempt(...)names the step that takes anAttempt.Done[T]finishes with aT. The step's return annotation lists its exits, and mypy checks every return against it. - Medium — what a step reaches out to:
coding(an agent),shell,decide(ask the operator). Each call opens a rite of its own. - Focus — the backend behind a medium.
vekna.folio.coding_claudeis the Claude Agent SDK focus forcoding. - Grimoire — the event log of a cast: rites started, output deltas, rites finished, each with its status.
- Tome — a ritual library published as an installable package, so several projects cast the same rituals from a versioned dependency rather than from copied files. See below.
Steps and transitions¶
A step takes one payload and returns one transition. Nothing else is a step's business — no shared mutable state between them, no hidden control flow.
@step
async def review(state: Diff) -> Findings | Done[Verdict]:
if state.lines > 500:
return Done(Verdict(outcome="too big to review"))
return Findings(text=await coding(f"Review:\n{state.body}"))
The return annotation is the declaration: review goes on to whichever step
takes a Findings, or ends with a Verdict. There is no second place for the
graph to live, so nothing drifts, and a step whose body returns something its
annotation does not name fails mypy, not a cast.
A step may admit several payload shapes — Lint | Coverage — which is how two
different predecessors hand work to one successor. A ritual's components stay
one model, because they are one CLI interface. The one rule: a payload class
belongs to one step. Two steps declaring the same class is an error naming
both.
max_steps bounds the trampoline. A ritual that loops forever stops with
StepBudgetExceededError rather than running until you notice.
@step(max_visits=N) caps how often one cast enters one step, which ends a
retry loop sooner than the whole budget would. @step(timeout=600) bounds one
visit in seconds: an overrun cancels the step's work and fails it with
RiteTimeoutError, which recovers like any other failure.
@step returns a Step, importable from vekna.lexicon for annotations. It
carries only name, the function's name. Two steps may share a name, so key
per-step state, such as a retry budget, on it only while you keep yours unique.
@ritual("fix", worktree=True) asks for a checkout of its own: started from
the dashboard, the cast runs in a pooled worktree, not in the tree it was asked
from. The CLI reference has the pool.
When a step fails¶
A step that raises ends the cast — unless some step takes Failure[P], P
being the class of the payload that entered the failed step. Then the cast
goes there instead:
@step(max_visits=4)
async def fix(attempt: Attempt) -> Tests:
await coding(f"fix:\n{attempt.failures}")
return Tests(budget=attempt.budget)
@step
def triage(failure: Failure[Attempt]) -> Attempt | Done[Report]:
if failure.attempt >= 3:
return Done(Report(fixed=False, reason=failure.error.message))
return failure.payload.narrowed()
Failure carries the payload that entered, the error (type, message,
traceback), the rite that raised, and attempt — how many times this step
has failed in this cast. Recovery is an ordinary step: it declares its exits,
counts against max_steps, and a raise inside it goes to whichever step takes
Failure[Failure[Attempt]], or ends the cast. A step never catches its own
failure.
rituals show draws the edge as triage (on failure). A step whose payload is
a union fails into Failure[A] or Failure[B], whichever entered.
A failed cast still owes its report. Route every ending, the fatal ones
included, through one final step that writes the summary with emit_delta and
then returns Done or raises. Four items fixed before the agent died is a
result; a bare cast failed: line throws it away.
@step
def report(failure: Failure[Item]) -> Done[Summary]:
emit_delta(f"stopped at {failure.payload.name}: {failure.error.message}")
return Done(Summary(fixed=failure.payload.done_so_far))
vekna cast --unattended turns every decide into an
UnattendedPromptError raised inside the step, so a ritual meant for cron can
route it like any other failure. Or it can ask before asking: attended() is
false in such a cast.
Components become flags¶
The ritual's single parameter is a pydantic model, and each field becomes an option:
Validation is pydantic's. A field typed Annotated[int, Field(ge=0)] rejects
--bound -1 at the boundary rather than halfway through the third step, and
the error names the field.
vekna rituals list prints every ritual with the options it takes;
vekna rituals show <name> adds the step graph, drawn from each step's return
annotation. An exit no step takes — a class you annotated but never decorated a
step with — is refused when the library loads, before anything is cast.
Concurrency inside a step¶
A step may hold several medium calls at once — an asyncio.TaskGroup over two
shell calls runs both and waits for both — and each still gets its own rite,
so the grimoire records what actually happened concurrently. Each rite quotes
its own command, which is what tells the two lines apart.
@step
async def gates(state: Branch) -> Red | Done[Report]:
async with asyncio.TaskGroup() as group:
lint = group.create_task(shell("mise run lint:py"))
tests = group.create_task(shell("mise run test:py"))
...
Steps themselves stay sequential. The boundary between two steps is the thing that makes a ritual reproducible, and running two of them at once would give that up.
Where rituals come from¶
A rituals.py — or a rituals/ package — in the current directory or any
parent. A package is searched all the way down, so its __init__.py can stay
empty and you can split it by ritual, by kind, or not at all.
Two rules worth knowing before they bite:
- Every level needs its own
__init__.pyto be searched. A directory without one is not a package, and its rituals are invisible. - A directory holding both a
rituals.pyand arituals/is an error naming both, rather than a precedence rule quietly picking one. A half-finished move is the case this exists for.
A .vekna.toml (project) or ~/.config/vekna/config.toml (global) can name
more, resolved relative to the config file:
These are additive: naming the file that would have been found anyway is how you are explicit about it, and loading it twice is not an error. Two different sources claiming one ritual name still is, and so are two steps taking one payload class — both errors name the pair rather than letting whichever loaded first win, and every ritual collision is reported in one go. Two steps merely named alike are fine: a name routes nothing.
A modules entry that fails to import is skipped with a warning on stderr; the
rest still load. Asking for a ritual that is not there repeats why each skipped
module failed, and rituals list exits 1 while any is skipped. A broken file,
or a broken discovered rituals.py/rituals/, still stops the command.
Component defaults¶
A component whose answer belongs to the repository rather than the run — the
base branch, main here and master there — goes in the config, one table per
ritual:
[defaults.pr_check]
base_branch = "main"
max_prs = 3
[defaults."mycompany:review"] # a tome's ritual, quoted for the colon
depth = 2
Per key, a flag wins, then the project .vekna.toml, then the global config.
TOML values arrive typed. A queued cast takes the defaults of the repo it runs
in for every component its enqueuer left unset.
rituals show prints a configured component with its value and file:
A key the ritual does not take fails the cast, naming the file, the ritual and
the key. A value of the wrong type fails vekna rituals show too, naming the
same. A table for a ritual not installed here is ignored, so a global config
can hold defaults for tomes some projects lack. Secrets stay out: .vekna.toml
is committed, so pass a credential as a flag.
Tomes: rituals you install¶
A tome is a ritual library published as a package. modules names something
importable, not something on disk nearby — so a tome distributes like any other
Python dependency. Build a wheel, put it on your index, and every project that
installs it gets the same rituals:
A tome's rituals carry its name. The project's own stay bare; a tome's are
<tome>:<name>, so a tome and your project may both have a review:
The tome is its top-level package unless you name it — a table instead of a list, which is also how two tomes that would derive one name are told apart:
A tome's name must be a Python identifier. : belongs to the namespace, so
@ritual refuses a name that contains one.
A bare name finds the project's own ritual first, then the one tome that
offers it; offered by two tomes, it is an error naming both. rituals list
prints the project's rituals first, then each tome's, qualified.
The project directory then needs nothing but that .vekna.toml. The package is
swept exactly as a local rituals/ is — every submodule, all the way down — so
relative imports inside it resolve and rituals show draws the whole graph
rather than stopping at whatever __init__.py happened to re-export.
Two things to know:
- It installs into the same environment as vekna. The
veknacommand runs on its own interpreter, and a package your project can import is not automatically one that interpreter can. Installing vekna withpipxoruv toolmeans injecting the ritual package into that same environment rather than into the project's. - Every level still needs an
__init__.py. A namespace package has no directory for the sweep to walk, so its submodules are invisible — the same rule as a localrituals/, with the same silence if you skip it.
A tome carries its own test suite — vekna[trial] runs against the rituals
inside it, with no repository that casts them involved.
The word is for prose, not for the config key: modules takes a Python module
path and resolves the cwd as well as the environment, so mycompany.rites
loads whether it arrived from a wheel or sits in your own src/.