Skip to content
Pyrula

Quickstart

Three commands. No broker, no API key, no account, and no second process.

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

That gets you a durable workflow running against an in-memory store, with a browser open on its event stream. Swapping in a real store later is one flag; nothing about the code changes.

wrote app.py
next: pyrula dev --open

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, 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}
@workflow(name="checkout")
async def checkout(ctx: WorkflowContext, order: str = "A-1042") -> dict:
def reserve_inventory() -> dict:
CALLS["reserve"] += 1
return {"sku": "widget", "qty": 2}
await ctx.step("reserve_inventory", reserve_inventory)
def charge_card() -> dict:
CALLS["charge"] += 1
return {"amount": 4200}
await ctx.step("charge_card", 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)
def send_receipt() -> dict:
CALLS["receipt"] += 1
return {"order": order, "calls": dict(CALLS)}
return await ctx.step("send_receipt", send_receipt)

ctx.step runs a callable once and records the result. ctx.sleep is the same idea applied 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}}

reserve_inventory and charge_card each run and record their result. Then ctx.sleep(2) suspends the run: the worker slot frees up rather than sitting there idle for two seconds. When the sleep is over, the run resumes from the journal and picks up at send_receipt.

The line to look at is calls in the completed payload: {'reserve': 1, 'charge': 1, 'receipt': 1}. charge_card ran once, even though the run suspended and came back. The card was not charged twice. That counter is the receipt for it, not a claim you have to take on faith.

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

Running it for real rather than in pyrula dev:

Looking for the core types instead? See Either and Option.