Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

What's durable, and what isn't

An integration is durable only when its work is recorded in Pyrula’s journal. API compatibility alone does not provide replay or crash recovery.

TierExecutionReplay behavior
T1 durable-nativeA Pyrula run, step, or toolRecorded work replays without re-executing and may suspend for human input.
T3 compatibilityA compatible API outside a runCalls execute normally and are not journaled. The application supplies its own durability.

Every shipped integration can reach T1 by placing consequential work in a run, step, or tool. Compatibility surfaces remain T3 when used without ambient run state.

The guarantee matrix lists the tier of each capability and the SDK versions used in its tests.

Move work that requires replay or crash recovery into the journal:

  • Wrap side effects in ctx.step. Anything that charges a card, writes to a system of record, or must not run twice belongs in a step, so its result is journaled and replay skips it.
  • Expose consequential tools as Pyrula @tools. A tool call routed through Pyrula is journaled and memoized, so a crash mid-agent doesn’t re-fire it.
  • Put the durable part behind as_langgraph_node. Keep your graph, but make the node that needs crash-safety or human-in-the-loop a Pyrula run.

durable_chat_model: T1 reasoning inside a run

Section titled “durable_chat_model: T1 reasoning inside a run”

Wrap a LangChain chat model so its async calls (ainvoke/astream) are journaled whenever they run inside a Pyrula run:

from langchain_openai import ChatOpenAI
from pyrula.agents import agent
from pyrula.agents.integrations import durable_chat_model
model = durable_chat_model(ChatOpenAI(model="gpt-4o"))
@agent
async def my_agent(ctx, message: str) -> str:
result = await model.ainvoke([{"role": "user", "content": message}])
return result.content

Inside a Pyrula run, every ainvoke/astream call the model makes is journaled and memoized. On replay, the response is returned from the journal without calling the provider again. The synchronous path (invoke, and _generate underneath it) is not journaled, so calling it inside a run raises UnjournaledSurface instead of running the model live on every replay with no warning - use ainvoke/ astream there instead.

Outside a Pyrula run, both the async and synchronous paths pass through to the wrapped model without journaling.

Install with pip install pyrula-agents[langchain].

as_langgraph_node: durability inside someone else’s graph

Section titled “as_langgraph_node: durability inside someone else’s graph”

as_langgraph_node runs a durable Pyrula workflow as a node in an existing LangGraph graph.

from pyrula.agents.integrations import as_langgraph_node
node = as_langgraph_node("provision_infra", store=store)
graph.add_node("provision", node)

The node runs a Pyrula workflow as one graph step. Because the run id is derived from the graph’s thread_id, resuming the graph re-attaches the same run instead of starting a new one, so a crash mid-step doesn’t double-execute. And when the workflow calls ctx.interrupt, the node bridges to LangGraph’s own interrupt() / Command(resume=...): the graph pauses and checkpoints, and resuming it feeds the human’s answer back into the Pyrula run, so both the interrupt and its answer are journaled.

It needs a checkpointed graph (for the thread_id and resume) and a Pyrula worker consuming the store, since the node submits and awaits the run rather than executing it inline. Install with pip install pyrula-agents[langgraph].

pyrula.agents.compat.openai.AsyncOpenAI is an openai.AsyncOpenAI-shaped client. Swap the import and your existing code keeps working:

# from openai import AsyncOpenAI
from pyrula.agents.compat.openai import AsyncOpenAI
client = AsyncOpenAI()
resp = await client.chat.completions.create(model="gpt-4o", messages=[...])

Inside a Pyrula run, chat.completions.create is journaled and replays without calling the provider. Outside a run it passes straight through (with a one-time warning), so swapping the import back costs nothing. Needs pip install pyrula-agents[openai].

Inside a runSurfaces
Journaledchat.completions.create, including stream=True (a stream abandoned early isn’t journaled) and response_format=
Passed throughembeddings, files, models, beta.assistants and other non-generation surfaces
Refused (they’d call the provider live on every replay)responses, completions, audio, images, videos, realtime, beta.chat, beta.realtime, beta.threads, chat.completions.parse, chat.completions.stream, and with_raw_response / with_streaming_response at the top level, under chat, chat.completions and beta

Use create(response_format=...) or create(stream=True) instead of the refused shortcuts.

Anthropic works the same way: import AsyncAnthropic from pyrula.agents.compat.anthropic. messages.create (streaming or not) is journaled; beta.messages and beta.sessions are refused inside a run; beta.agents and other config surfaces pass through. Needs pip install pyrula-agents[anthropic].

build_mcp_server: durable MCP tool servers

Section titled “build_mcp_server: durable MCP tool servers”

build_mcp_server exposes Pyrula tools through MCP and executes each tool call as a durable workflow.

from pyrula.agents.mcp import build_mcp_server
server = build_mcp_server([charge_card, send_email], store=store)
server.run() # stdio or any FastMCP transport

Each tool call becomes a durable run in the store. Crash the server mid-tool and the run picks up where it left off when a worker restarts it.

Topology. The MCP server submits the run and waits for the result. A separate worker process consuming the same store executes it. Tools must be context-generic (no ctx.llm, no subagents). They receive a workflow BaseContext, not an agent context. Install with pip install pyrula-agents[mcp].

Crash-safety boundary. Idempotency-key strength varies by how the client identifies its requests:

  • Strong: the client sends a stable token in the MCP _meta field (_meta: {idempotency_key: "..."}). This token survives reconnects and retries, so re-submitting after a crash re-attaches the existing run rather than starting a new one.
  • None: without a token every call is its own run. A retry after a timeout or a reconnect runs the tool again. The JSON-RPC request id is deliberately not used as a fallback: ids restart with every session, so two clients each sending id 1 would attach to one run and each read the other’s result.

Supply an idempotency_from_request callback to extract a stable token from any field in the incoming request (auth token, custom header, or anything FastMCP surfaces in its Context).

Atomic tool body. The tool call is one durable step. A tool body cannot call ctx.step, ctx.interrupt, timers, signals, or other durable orchestration methods. Keep human input and multi-step orchestration in the calling workflow or agent.

Put work in a step, tool, or node when it must survive process loss or must not execute again during replay. Calls outside a run require an application-managed durability strategy.