Quickstart
Scaffold a workflow, run it on a local SQLite store, open its event stream. No broker, key or account.
pip install "pyrula[workflows]"pyrula init --workflowpyrula dev app.py --openNot on PyPI yet: build from source (Install) and prefix the other
two with uv run.
Generated workflow
Section titled “Generated workflow”wrote app.pywrote .gitignore
next: pyrula dev app.py --openapp.py is a small checkout workflow:
from pyrula.workflows import WorkflowContext, step, workflow
# Counts real executions so the result payload can show what was not repeated.# This is a deliberately process-local demonstration counter, not durable# state: it lives in this worker's memory, not the journal. If a different# worker process resumes this run (e.g. after this one crashes or restarts),# the counts reset to zero in the new process even though the run itself# picks up right where it left off.CALLS = {"reserve": 0, "charge": 0, "receipt": 0}
@stepdef reserve_inventory() -> dict: CALLS["reserve"] += 1 return {"sku": "widget", "qty": 2}
@stepdef charge_card() -> dict: CALLS["charge"] += 1 return {"amount": 4200}
@stepdef send_receipt(order: str) -> dict: CALLS["receipt"] += 1 return {"order": order, "calls": dict(CALLS)}
@workflow(name="checkout")async def checkout(ctx: WorkflowContext, order: str = "A-1042") -> dict: await reserve_inventory() await charge_card()
# A durable sleep, not asyncio.sleep: the run suspends, the worker slot is # freed, and the run resumes later from the journal. await ctx.sleep(2)
return await send_receipt(order)@step records a function’s result. ctx.sleep suspends the run instead of blocking a thread.
Run the workflow
Section titled “Run the workflow”pyrula dev app.py --open starts a local server, runs the workflow once and opens the dev UI:
=== Pyrula Dev Server ===Target: app.py:checkoutUsing SQLite store .pyrula/dev.db - runs survive a restart; --fresh starts emptyCloud: off (no PYRULA_CLOUD_API_KEY) - running locally onlyUI: http://127.0.0.1:7978/ui
Press Ctrl+C to stop
reserve_inventory done charge_card done suspended - worker slot freed resumed from journal send_receipt done completed - {'order': 'A-1042', 'calls': {'reserve': 1, 'charge': 1, 'receipt': 1}}Saved the last run as .pyrula/last/checkout.case. It holds the run's inputs, every event and every recorded model and tool result, in full; .pyrula/ is local state, keep it out of version control.The run suspends at ctx.sleep(2) and resumes at send_receipt. Each step ran once: the card was not charged twice.
Inspect the event stream
Section titled “Inspect the event stream”http://127.0.0.1:7978/ui lists runs; opening one shows its result and event stream:
complete started 27s ago 0 ms (last attempt){'order': 'A-1042', 'calls': {'reserve': 1, 'charge': 1, 'receipt': 1}}
run:init step:pending reserve_inventory step:done reserve_inventory step:pending charge_card step:done charge_card run:sleeping suspended until 17:54:43 run:woken resumed at 17:54:43 step:pending send_receipt step:done send_receipt run:complete result: {'order': 'A-1042', 'calls': {...}}Every row expands to its payload. This is the journal the engine replays from. The duration
says last attempt because a resumed run’s final attempt mostly replays the journal.
Changing it
Section titled “Changing it”pyrula dev loads your code once: edit app.py, Ctrl-C, start again. Each start submits a fresh run.
Where the runs are kept
Section titled “Where the runs are kept”Runs live in .pyrula/dev.db, so history survives a restart and a run stopped mid-sleep
finishes when pyrula dev starts again.
pyrula dev --fresh # delete the file firstpyrula dev --store memory:// # keep runs in the process onlypyrula dev --store valkey://localhost:6379pyrula dev --attach --store sqlite:///.pyrula/dev.db # browse runs, run nothingFor a real worker crash see Surviving worker loss.
Further reading
Section titled “Further reading”- Workflows: steps, events, replay.
- Agents: an LLM loop on the same engine.
- Running a worker and Deployment modes for production.
- Core types: Either and Option.