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.
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¶
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.