Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Quickstart

Scaffold a workflow, run it on a local SQLite store, open its event stream. No broker, key or account.

Terminal window
pip install "pyrula[workflows]"
pyrula init --workflow
pyrula dev app.py --open

Not on PyPI yet: build from source (Install) and prefix the other two with uv run.

wrote app.py
wrote .gitignore
next: pyrula dev app.py --open

app.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}
@step
def reserve_inventory() -> dict:
CALLS["reserve"] += 1
return {"sku": "widget", "qty": 2}
@step
def charge_card() -> dict:
CALLS["charge"] += 1
return {"amount": 4200}
@step
def 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.

pyrula dev app.py --open starts a local server, runs the workflow once and opens the dev UI:

=== Pyrula Dev Server ===
Target: app.py:checkout
Using SQLite store .pyrula/dev.db - runs survive a restart; --fresh starts empty
Cloud: off (no PYRULA_CLOUD_API_KEY) - running locally only
UI: 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.

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.

pyrula dev loads your code once: edit app.py, Ctrl-C, start again. Each start submits a fresh run.

Runs live in .pyrula/dev.db, so history survives a restart and a run stopped mid-sleep finishes when pyrula dev starts again.

Terminal window
pyrula dev --fresh # delete the file first
pyrula dev --store memory:// # keep runs in the process only
pyrula dev --store valkey://localhost:6379
pyrula dev --attach --store sqlite:///.pyrula/dev.db # browse runs, run nothing

For a real worker crash see Surviving worker loss.

View this page as Markdown