Skip to content

Rituals

A ritual is a named program made of steps. Casting one runs it to completion and prints its result.

Anatomy of a ritual How a ritual runs: components become flags, an entrypoint returns the first payload, steps hop by typed payloads until Done, failures route to recovery steps, and rituals are found nearby, in config, or in installed tomes. Anatomy of a ritual A named program made of steps. You write the loop, the branches and the stopping condition; the agent gets one bounded piece of work at a time. Determinism lives at the step boundaries. FROM THE COMMAND LINE TO THE FIRST STEP The command vekna cast fix --bound 5 options before the ritual's name are vekna's; everything after it belongs to the ritual Components one pydantic model, the ritual's only parameter; each field is a --option validated at the boundary --bound -1 is refused before step one, and the error names the field Defaults fill the gaps per key, the first that answers wins 1 a flag on the command 2 [defaults.<ritual>] in .vekna.toml 3 the same table in the user config The entrypoint @ritual("fix") takes the components and returns the first step's payload; from here the trampoline runs the cast THE TRAMPOLINE A payload is the next step a step takes one typed payload and returns one transition: the next step's payload, or Done @ritual first payload run_tests takes Tests fix takes Attempt Done(result) printed as JSON, the cast ends triage takes Failure[Attempt] Tests Attempt Tests green raises Attempt gave up One payload class belongs to one step, so returning Attempt(…) names fix. No name routes anything. The annotation is the graph there is no second place for it to live, so nothing drifts def fix(a: Attempt) -> Tests | Done[Verdict] • exits are declared once, in the return type • mypy checks every return against them • an exit no step takes is refused at load • two steps on one class: an error naming both vekna rituals show fix draws it: steps: (start) → run_tests run_tests → fix, (done) fix → run_tests, triage (on failure) triage → fix, (done) a step may admit a union, Lint | Coverage, to join two paths THE MECHANISMS When a step fails a raise goes to the step taking Failure[P], P being the payload that entered Failure carries payload what entered · error type, message, traceback · rite what raised · attempt count • recovery is an ordinary step with exits • its own raise → Failure[Failure[P]], or the end • a step never catches its own failure • no such step: the cast ends, the error named --unattended turns each decide into a routable failure Bounds a ritual that loops forever stops rather than runs on max_steps the whole trampoline StepBudgetExceededError max_visits=N entries into one step ends a retry loop sooner timeout=600 one visit, in seconds RiteTimeoutError, recovered as a Failure A failed cast still owes its report route every ending through one step that writes the summary Inside one step concurrency lives in a step, never between steps step lint tests two shell calls in one TaskGroup: both run, each its own rite gates fix report steps run one at a time: the boundary between two is what makes a cast reproducible WHERE RITUALS COME FROM Found nearby rituals.py or a rituals/ package in this directory or any parent; a package is swept all the way down every level needs an __init__.py; both a file and a package is an error Named in config [rituals] files = […] modules = […] in .vekna.toml or the user config, additive to what was found Installed as a tome a ritual library shipped as a package, its rituals carrying its name: vekna cast mycompany:review installed in vekna's own environment One library a bare name finds the project's own ritual first, then a tome's two sources claiming one name: an error naming both, every collision reported at once A ritual's vocabulary: component · step · transition · medium · focus · grimoire (the cast's event log) · tome

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 by max_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 an Attempt. Done[T] finishes with a T. 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_claude is the Claude Agent SDK focus for coding.
  • 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.

if not attended():
    emit_delta(f"{len(threads)} threads wait for you")
    return Done(summary)

Components become flags

The ritual's single parameter is a pydantic model, and each field becomes an option:

class Review(BaseModel):
    base: str = "main"
    only: Path | None = None
vekna cast review --base develop --only src/

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__.py to be searched. A directory without one is not a package, and its rituals are invisible.
  • A directory holding both a rituals.py and a rituals/ 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:

[rituals]
files = ["ops/release.py"]
modules = ["mycompany.rites"]

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:

  --base-branch <str>  = main (/home/me/work/app/.vekna.toml)

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:

# .vekna.toml, in each project that wants them
[rituals]
modules = ["mycompany.rites"]
pip install mycompany-rites
vekna cast housekeeping --depth 2

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:

vekna cast review            # the project's own
vekna cast mycompany:review  # the tome's

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:

[rituals]
modules = { rites = "mycompany.rites" }

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 vekna command runs on its own interpreter, and a package your project can import is not automatically one that interpreter can. Installing vekna with pipx or uv tool means 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 local rituals/, 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/.