Pyrula writes each step of a run to a journal as it happens. When a worker dies, another one replays the journal and continues from the first unfinished step. Durable workflows and LLM agents for Python, on a Rust core.
| 0001 | run:init | nightly_orders | worker-2 |
| 0002 | step:pending | extract | worker-2 |
| 0003 | step:done | extract → 2.1M rows | 14m 02s |
| 0004 | step:pending | clean | worker-2 |
| 0005 | step:done | clean → 1.98M rows | 3m 40s |
| 0006 | step:pending | to_usd | worker-2 |
| 0007 | step:done | to_usd → 1.98M rows | 2m 11s |
| 0008 | step:pending | load | worker-2 |
| worker-2 is killed mid-load. worker-5 claims the run and replays 0001–0008; extract, clean and to_usd return their recorded results. | |||
| 0009 | step:pending | load | worker-5 |
| 0010 | step:done | load → 1.98M merged | 4m 05s |
| 0011 | run:complete | nightly_orders | 26m 12s |
A workflow and the journal it writes.
pyrula-workflowsReplay and recovery →Before a step runs, the worker writes step:pending. After it succeeds, step:done. Recovery resumes from the first step without a step:done. Sleeps, timers and signals are recorded too, so a waiting run picks up on another worker.
| seq | step | status | worker | time |
|---|---|---|---|---|
| 0003 | extract | done | worker-2 | 14m 02s |
| 0005 | clean | done | worker-2 | 3m 40s |
| 0007 | to_usd | done | worker-2 | 2m 11s |
| 0010 | load | done | worker-5 | 4m 05s |
| 0011 | run | complete | worker-5 | 26m 12s |
ctx.sleep suspends a run until a wake time that Pyrula records. ctx.wait_for_signal suspends it until a signal with that name arrives, and a signal sent before the run reaches the wait is buffered.
await ctx.sleep(timedelta(days=3))await ctx.wait_for_signal("payment_confirmed")invoice_followupPick a finished run and a point in it, change an input, a step's result, the model or the system prompt, and one of your workers runs the rest. Calls up to the fork are replayed from the recording, and anything that could touch the outside world is stopped unless you allow it. The result is a new run, linked to the one it came from.
Here the threshold changes after scoring, so all 128 model calls come from the recording.
pyrula-agentsAgents →Every turn is a workflow underneath. Finished model and tool calls are journaled, so a crashed turn resumes without repeating paid calls. Serve it over HTTP or drive it from Kafka. A client that drops reconnects from its last event id and misses nothing.
pyrula-kafkaKafka →@kafka_workflow starts a durable run for each record on an input topic and produces the result to an output topic. @kafka_agent does the same for an agent. The Kafka client underneath is pure Rust, with no librdkafka.
The default is at-least-once delivery. With delivery="exactly_once", the output record and the input offset are committed in one transaction. Delivery guarantees →
frompyrula.workflows.kafkaimportkafka_workflow# Invoice and Scored are pydantic models@kafka_workflow( input_topic="invoices", group="scorer", output_topic="scored-invoices", input_schema=Invoice, output_schema=Scored, delivery="exactly_once", )async defscore(ctx, invoice: Invoice) -> Scored: risk =awaitctx.step( "score", model_score, invoice)returnScored(id=invoice.id, risk=risk)
pyrula-cloud-clientConnect a worker →A hosted UI for watching and controlling runs. Runs from every connected worker appear in one timeline with their state: running, sleeping, waiting for a signal or complete. From a finished run, Cloud starts a what-if run.
Connecting is one environment variable, PYRULA_CLOUD_API_KEY. The code doesn't change.
Workflows and agents are plain Python functions. The core types, serde and the Kafka client are written in Rust and loaded through PyO3.
The API follows Scala's standard library. Either, Option and Try compose with map and flat_map, and the collections are immutable like Scala's List and Map. Every Pyrula package depends on pyrula, and you can use it on its own.
pyrulaRust-backed core: functional types and serde.Primitives →pyrula-kafkaPure-Rust, batch-first Kafka client.Kafka →pyrula-workflowsDurable execution engine on the Rust-backed core.Workflows →pyrula-agentsDurable agent runtime built on pyrula.workflows.Agents →pyrula-cloud-clientConnector for Pyrula Cloud.Cloud →EitherHolds a value or a typed error, without exceptions.ValidatedLike Either, but collects every error instead of stopping at the first.OptionPresent or absent, with no None ambiguity.TryCaptures a result or the exception that broke it.IList · IMap · ISetImmutable and structurally shared, so copies are cheap.