# Pyrula

> Durable agents and workflows for Python. Record a run, rerun it with a change, keep it as a test.

Source: https://docs.pyrula.io/

Every step, model call and tool call is journaled, so a run resumes after a crash, reruns with a
change, and replays as a test.

```bash
pip install "pyrula[agents]"
pyrula init
pyrula dev app.py
```

The scaffold talks to a scripted model, so it needs no key, broker or account. Until release,
install is a source build ([Install](https://docs.pyrula.io/getting-started/install.md)).

## Run, what-if, case, CI

| | On your machine | With Pyrula Cloud |
|---|---|---|
| **Run** | `pyrula dev` journals every step, model and tool call. A completed step is never re-run after a crash. | Every worker's runs in one timeline ([Connect](https://docs.pyrula.io/getting-started/cloud.md)). |
| **What-if** | `pyrula case run --prompt-file` or `--model`: model calls run live, recorded tool results answer the rest. | Fork a finished run and change its input, a step result, the model or the prompt. Writes denied, budget on ([What-if runs](https://docs.pyrula.io/workflows/guides/what-if.md)). |
| **Case** | `pyrula dev` keeps the newest finished run in `.pyrula/last/`. | Save a run or a what-if as a case, stored in your fixture store ([Saved cases](https://docs.pyrula.io/workflows/concepts/saved-cases.md)). |
| **CI** | `pytest --pyrula-cases cases`: no model call, no network. | `pyrula ci run` or the `replay-check` GitHub Action ([Replay checks in CI](https://docs.pyrula.io/workflows/guides/replay-ci.md)). |

[Record once, iterate free](https://docs.pyrula.io/workflows/guides/record-once-iterate-free.md) walks the left column.

## A safe agent

```python
from pyrula.agents import AgentContext, AgentLimits, ToolContext, agent, tool


@tool(read_only=True)
async def lookup_order(ctx: ToolContext, order_id: str) -> dict:
    return {"status": "delivered", "total_cents": 25_000}


@tool(requires_approval=lambda args: args["cents"] > 10_000, approval_timeout=3600)
async def refund(ctx: ToolContext, order_id: str, cents: int) -> str:
    return f"refunded {cents} cents on {order_id}"


@agent(limits=AgentLimits(max_cost_usd=2.50, max_total_tokens=200_000))
async def support(ctx: AgentContext, message: str) -> str:
    return await ctx.llm.run([{"role": "user", "content": message}], tools=[lookup_order, refund])
```

- A refund over $100 parks the run until a person answers from the dashboard or the resume
  endpoint; an hour with no answer denies it ([Approval](https://docs.pyrula.io/agents/authoring/tools.md#approval)).
  `pyrula dev` (embedded mode) refuses `approval_timeout`, so run a
  [worker](https://docs.pyrula.io/agents/serving/cli.md#what-embedded-mode-cannot-run) for this agent. Pydantic AI tools
  get no approval. [`examples/tool_approval.py`](https://github.com/pyrula-io/pyrula/blob/main/examples/tool_approval.py)
  runs an approval and a rejection.
- The caps are checked before each live model call, so a run can overshoot by one in-flight call
  per concurrent caller ([Spend caps](https://docs.pyrula.io/agents/authoring/agents.md#spend-caps)).
- `pyrula runs terminate <run_id>` ends a run with no worker
  ([Cancel and terminate](https://docs.pyrula.io/workflows/guides/cancel-and-terminate.md)).
- In a what-if or case run, `refund` is a write and is refused unless recorded or allowed.

## The engine

<div class="pyrula-home-grid">
  <a class="pyrula-home-card" href="/agents/">
    <span class="pyrula-home-kicker">Build</span>
    <strong>Agents</strong>
    <span>@agent and @tool on the durable engine. Anthropic, OpenAI and LiteLLM clients, MCP, an HTTP surface.</span>
  </a>
  <a class="pyrula-home-card" href="/workflows/">
    <span class="pyrula-home-kicker">Durable</span>
    <strong>Workflows</strong>
    <span>Steps, timers, signals and children, replayed from an append-only journal on SQLite or Valkey.</span>
  </a>
  <a class="pyrula-home-card" href="/kafka/">
    <span class="pyrula-home-kicker">Stream</span>
    <strong>Kafka</strong>
    <span>A pure-Rust client, no librdkafka. Agent output and its offset commit can share one transaction.</span>
  </a>
  <a class="pyrula-home-card" href="/primitives/">
    <span class="pyrula-home-kicker">Core</span>
    <strong>Primitives</strong>
    <span>Rust-backed Either, Option, Try, immutable IList/IMap, serde.</span>
  </a>
</div>

Coming from [Pydantic AI](https://docs.pyrula.io/getting-started/coming-from/pydantic-ai.md),
[LangGraph](https://docs.pyrula.io/getting-started/coming-from/langgraph.md), [Celery](https://docs.pyrula.io/getting-started/coming-from/celery.md),
[Temporal](https://docs.pyrula.io/getting-started/coming-from/temporal.md) or
[FastAPI](https://docs.pyrula.io/getting-started/coming-from/fastapi.md)? Each page maps that tool's concepts to Pyrula's.
For coding agents: [llms.txt](https://docs.pyrula.io/llms.txt).
