Skip to content

Mediums

A medium is what a step reaches out to. Three ship: coding (an agent), shell, and decide (ask the operator). Each call opens a rite of its own, so the grimoire records what happened and in what order.

Mediums What a step reaches out to: shell, coding, decide, enqueue and await_cast, each call a rite; agent sessions and tool permissions; timeout, race and cancellation. Mediums What a step reaches out to: a command, an agent, you, or another cast. Each call opens a rite of its own, so the grimoire records what happened and in what order. ONE STEP, MANY RITES a step the terminal draws a tree of rites, one per call: ▶ fix ↳ shell pytest -x 3 failed, 41 passed ✓ shell pytest -x ↳ coding The test suite fails… ✓ coding The test suite fails… ✓ fix a rite quotes the call's first argument, cut to 60 characters: lead with the part worth reading ends ok · error · cancelled ⊘ shell a command returns the exit code, stdout and stderr never raises on a non-zero exit a failing command is usually what the ritual is about runs on closed stdin: a command that reads gets EOF, not the line you were typing decide ask the operator the cast blocks until you answer; three shapes: decide("Push?") → bool options=… → that option free=True → str no default and no timeout: guessing your choice is worse than waiting; ended stdin stops the cast coding an agent through the Claude Code CLI, installed separately shared CodingOpts model · cwd · gate_tools per call session · key output=Model typed output is validated on return; the agent can hand you a choice with ask_human enqueue · await_cast a cast start another cast through the daemon's queue enqueue → its id; the step carries on await_cast → its result, as output= a waiting cast gives its slot back, keeps its tree a failed child → CastFailedError · no daemon → NoDaemonError a resumed cast does not enqueue twice THE AGENT Sessions: which memory a call is on session says whether it resumes, key says which thread DECLARATION WHAT THE CALL GETS Session.NEW default a fresh context Session.CONTINUE the cast's last agent call, carried on CONTINUE, key="fix" the thread keyed "fix", carried on NEW, key="fix" a fresh context, and "fix" starts from it a retry wants CONTINUE an agent remembering what it already tried is the whole value a review wants NEW an agent that helped write the code is no reviewer of it NEW is the default because a step is a task boundary · give a loop its own key Tools: where you get to say no none of this is a sandbox; the agent edits your repository by design gate_tools=["Bash"] asks you before the agent runs it the question arrives as a decide dontAsk + allowed_tools a read-only agent, with [Read, Grep] on ClaudeOptions off the list: denied without asking, bar vekna's ask tool; the list does the work: one write tool, a writing agent disallowed_tools takes a few tools away on ClaudeOptions a tool on both lists stays denied not permission_mode="plan": it executes no tools at all, so it cannot even Read [circle] coding = N caps agent sessions across every cast; a call past it waits for a slot before its session starts BOUNDING A CALL timeout a deadline nothing else bounds one rite in time 600s the work is cancelled and the step gets RiteTimeoutError: coding timed out after 600s raised inside the step, so a step taking Failure[P] recovers from it like any other race the first to finish returns the winner and cancels the rest fast ✓ wins thorough ⊘ cancelled third ✗ raised, drops out an entrant that raises does not end the race when every entrant raises, the race fails naming each error Cancelling cancels the work not only the wait, before the rite closes shell its process group is killed coding its agent session is closed decide withdrawn, and the terminal says so await_cast the wait ends, the child keeps running a cut rite is drawn ⊘ and recorded cancelled, with the output it had produced by then Agent unreachable: a missing Claude Code CLI fails the cast with a message, not a traceback; an SDK error mid-cast ends it, named.

On the surface that rite is one line, quoting the medium's first argument — a shell's command, a prompt — with its whitespace collapsed and cut to 60 characters, so lead with the part worth reading. The first argument as the medium declares it, not as the call happened to spell it: shell(cwd=..., command=...) still quotes the command.

shell

from vekna.folio.shell import shell

result = await shell("pytest -x")
if result.exit_code != 0:
    ...

Returns the exit code, stdout and stderr. Nothing is raised on a non-zero exit — a failing command is usually the thing the ritual is about, and an exception would make the ordinary case the exceptional one.

A command runs on closed stdin. One that reads gets EOF rather than the line you were typing at a prompt beside it.

decide

Hands a choice back to you mid-cast. The cast blocks until you answer.

from vekna.folio.flow import decide

if not await decide("Push to main?"):               # -> bool
    return Done(Verdict(outcome="stopped"))

took = await decide("what now?", options=_TOOK)     # -> the option offered
note = await decide("why?", free=True)              # -> str

Three shapes, three return types. A bare prompt is yes or no. options answers with one of them, and options typed as a tuple[Literal[...], ...] come back as that literal, so a step can branch on the answer without re-validating a str. free=True takes whatever you type. Empty options raises MediumBoundaryError: a list that computed to nothing is a bug in the step, not a question.

There is no default and no timeout: a decide is a choice the ritual author declared was yours, and guessing it is worse than waiting. A cast whose stdin has ended stops with input ended before the question was answered rather than answering for you.

enqueue and await_cast

Start another cast from a step. The daemon queues it like one started with c, and runs it when its project has a free slot (and a tree, for a worktree=True ritual).

from vekna.folio.flow import await_cast, enqueue

cast_id = await enqueue("refresh", Sweep(pull_request=74))                # proceed
report = await await_cast("review", Sweep(pull_request=74), output=Report)  # wait

enqueue returns the new cast's id once the daemon has queued it, and the step carries on. await_cast waits for the cast to end and returns its result: validated as output when given, the JSON otherwise. A cast that ends error or aborted raises CastFailedError, which a step taking Failure[P] can route. The name is resolved here, as vekna cast would resolve it, and the components must be that ritual's model.

A waiting cast gives up its process slot until the other ends, so a sweep can await a child even with processes = 1. It keeps its tree. When the child ends, the parent gets the next free slot ahead of anything queued. Cancelling the wait, by timeout or race, leaves the child running.

Both need a daemon: with none, they raise NoDaemonError rather than run the child inline. A resumed cast does not enqueue again: the id comes back off the record, and a wait that was cut short waits on the same child. Queued casts are run records, so a daemon that stops leaves them queued and the next one runs them.

coding

from vekna.folio.coding import CodingOpts, Session, coding
from vekna.folio.coding_claude import ClaudeOptions

# Portable knobs
await coding("refactor this", opts=CodingOpts(model="opus", cwd="./svc"))

# Ask before the agent runs a tool
await coding("clean the build", opts=CodingOpts(gate_tools=["Bash"]))

# Which thread of agent memory this call is on
await coding("try again", session=Session.CONTINUE, key="lint-loop")

# Typed output, validated on return
class Plan(BaseModel):
    steps: int

plan = await coding("plan the migration", output=Plan)

# Knobs only the answering Focus reads
await coding("survey the code", opts=CodingOpts(focus_options=ClaudeOptions(
    permission_mode="dontAsk", allowed_tools=["Read", "Grep"], effort="high"
)))

Everything reusable bundles into CodingOpts — sharing one across calls is harmless, which is the point. What stays on the call is what cannot be shared: session and key, which say which agent memory this call is on, and output, which decides what it returns.

dontAsk with an allowlist is how you get a read-only agent: everything outside the list is denied without stopping to ask you, bar vekna's own ask_human, which only puts a question to you. Not permission_mode="plan", which executes no tools at all — an agent in plan mode cannot read the files you gave it Read for.

disallowed_tools removes the listed tools, and a tool on both lists stays denied. Use it when a sandbox already does the fencing and the ritual only needs to take a few tools away. A field ClaudeOptions does not have raises ClaudeOptionsError.

An agent can hand a decision back to you mid-rite by calling the ask_human tool; the cast blocks until you answer, exactly as decide does. Any options it offers are suggestions — pick one by number or by name, or answer in your own words, which is what the agent gets.

With [circle] coding = N in the user config, at most N coding calls talk to an agent at once across every cast attached to the daemon. A call past the limit waits for a slot before its session starts, and gives the slot back when the session ends. A rite replayed from the journal calls no agent and takes no slot. See the circle.

Sessions

Two coding calls in one cast either share the agent's context or they do not. session says whether this call resumes, and key says which thread it resumes and files itself under:

declaration what the call gets
Session.NEW (default) a fresh context
Session.CONTINUE the cast's last agent call, carried on
Session.CONTINUE, key="fix" the thread keyed "fix", carried on
Session.NEW, key="fix" a fresh context, and "fix" starts from it

A retry wants continue — an agent remembering what it already tried is the whole value. A review step wants new: an agent that helped write the code is not a reviewer of it, and sharing silently makes that step worthless while looking like it ran.

The default is new because a step is a task boundary, and carrying context across one by default contradicts what the boundary is for.

Give a loop its own key. An unkeyed continue means whichever agent call ran last, which is the same thing while a ritual has one, and stops being the same the moment it gains a second.

A key is refused if it names nothing ("", " "), and session takes only the two words, so a thread name left in an older spelling raises CodingSessionError rather than quietly opening a thread of its own.

When the agent cannot be reached

The coding medium runs through the Claude Code CLI, which installs separately from this package. If it is missing, the cast fails with a message saying so rather than a traceback. Anything else the SDK raises mid-cast — the subprocess dying, a dropped connection — ends the cast with the failure named.

Bounding a call: timeout and race

Nothing else bounds a single rite in time, so an agent that stops making progress would hang the cast all night. Both live in vekna.folio.flow.

from vekna.folio.flow import race, timeout

result = await timeout(coding(prompt="..."), seconds=600)
winner = await race(coding(prompt=fast_path), coding(prompt=thorough))

timeout cancels the work at the deadline and raises RiteTimeoutError, naming the call: coding timed out after 600s. It is raised inside the step, so it is a Failure like any other — a step that takes Failure[P] recovers from it, and without one the cast ends with that message.

race returns the first entrant to finish and cancels the rest. An entrant that raises drops out without ending the race; when every one raises, the race fails naming each error.

Cancelling a call cancels the work, not only the wait. A shell command is killed with its whole process group, and a coding call closes its agent session, before the rite closes. A cut rite is drawn ⊘ and recorded as cancelled, with the output it had produced by then. A decide that is cut is withdrawn: the terminal says so and the daemon stops showing it.