Quickstart
Three commands. No broker, no API key, no account, and no second process.
pip install "pyrula-workflows[ui]"pyrula initpyrula dev --openThat 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.
What pyrula init writes
Section titled “What pyrula init writes”wrote app.py
next: pyrula dev --openapp.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.
What pyrula dev does
Section titled “What pyrula dev does”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:checkoutUsing in-memory store - history clears on exit; --valkey-url to persistUI: 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.
What the dev UI shows
Section titled “What the dev UI shows”--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.
Changing it
Section titled “Changing it”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.
Using a real store
Section titled “Using a real store”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:
- Running a worker for the process that executes runs in production.
- Deployment modes for how workers, stores, and your app fit together.
- Surviving worker loss for a real crash, not a simulated one.