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.
pip install "pyrula-workflows[ui]"pyrula init --workflowpyrula dev --openThe 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.
Generated workflow
Section titled “Generated workflow”wrote app.pyapp.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}
@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 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.
Run the workflow
Section titled “Run the workflow”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}}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.
Inspect the event stream
Section titled “Inspect the event stream”--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.
Further reading
Section titled “Further reading”- Workflows for the durability model: steps, events, replay.
- Agents to put an LLM loop on top of the same engine.
For production:
- 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.