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

These commands scaffold a workflow, run it with an in-memory store, and open its event stream in the local UI. They require no broker, API key, account, or second process.

Terminal window
pip install "pyrula-workflows[ui]"
pyrula init --workflow
pyrula dev --open

The generated workflow uses an in-memory store. A command-line option can replace it with a durable store without changing the workflow code.

The packages are not on PyPI yet, so today the first line is a source build (Install) and the other two run as uv run pyrula init --workflow and uv run pyrula dev --open from the clone. Everything below is the same.

wrote app.py

app.py is a small checkout workflow. Every side effect goes through ctx, which is what makes the run replayable:

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 applies the same model to time: it suspends the run instead of blocking a thread, and a resume picks up from the journal rather than starting over.

pyrula dev --open starts a local server, runs the workflow once, and opens the dev UI so you can see the run’s event stream. The terminal output looks like this:

=== Pyrula Dev Server ===
Target: app.py:checkout
Using in-memory store - history clears on exit; --valkey-url to persist
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}}

The run suspends at ctx.sleep(2) and resumes from the journal at send_receipt. The calls counter in the result shows each step ran once: the card was not charged twice.

--open puts you on http://127.0.0.1:7978/ui, a list of runs by workflow name with their status and age. Opening a run shows its result and then the event stream that produced it, one row per event:

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 full payload. This is the same stream the engine replays from on a resume, so what you are reading is the durable record, not a log written alongside it.

The duration reads last attempt because a resumed run’s final attempt only replays the journal. It is not how long the run took end to end.

pyrula dev loads your code once at startup, so edit app.py, stop the server with Ctrl-C, and start it again. Each start submits a fresh run, so the UI accumulates one row per attempt and you can compare them.

The store here is in memory: history clears on exit. Pass --valkey-url to point pyrula dev at a real store and the run’s history survives past the process. Killing a worker mid-run and having another one pick it up, without repeating finished steps, is a step further than the dev server shows: see Surviving worker loss.

  • Workflows for the durability model: steps, events, replay.
  • Agents to put an LLM loop on top of the same engine.

For production:

Core types: Either and Option.