Skip to content
Pyrula

What's durable, and what isn't

Pyrula’s value is the journal: an append-only event log that lets a run resume after a crash and replay deterministically. An integration gives you durability only when the work flows through that log. Everything else is API compatibility, which is useful, but is not the same thing. This page draws the line so you know which you’re getting.

T1 durable-native. The work runs as a Pyrula run or a journaled step. It survives a crash, replays without re-executing, and can pause for a human. This is where the moat is.

T2 durable-backend. Pyrula stores another framework’s state durably. The framework owns its own execution model; Pyrula is the storage under it.

T3 compatibility. The API shape matches and events are transported, but nothing is journaled unless it happens inside a run. Removing Pyrula here costs you nothing, because you weren’t getting durability from it.

IntegrationTierWhy
from_langchain_tool, from_pydantic_ai_toolT1the wrapped call becomes a Pyrula @tool; inside a run it is journaled and memoized on replay
as_langgraph_nodeT1the node body is a Pyrula run: journaled, replayable, crash-safe, with durable human-in-the-loop
langgraph_agentT3 / T1 for LLMT3 by default; build the graph with durable_chat_model(...) and the model’s calls are journaled (T1 reasoning, T3 tools)
pydantic_ai_agent, openai_agents_agent, crewai_agentT3the foreign agent loop’s own model and tool calls are not journaled and re-run on replay
compat.openai.AsyncOpenAIT1 in a run / T3 standalonechat.completions.create (streaming or not) is journaled inside a run; a plain passthrough outside one
compat.anthropic.AsyncAnthropicT1 in a run / T3 standalonesame, for messages.create

A framework wrapper runs another framework’s whole agent loop inside one turn. Pyrula records the turn’s boundary and the events the framework emits, but not the framework’s internal steps. So replay resumes the turn and re-runs the inner loop from the top. That is fine for transport and lifecycle. It is not durability for the steps inside.

You don’t have to rewrite anything to get durability. Move the parts that matter across the line:

  • 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 an inbound graph

Section titled “durable_chat_model: T1 reasoning inside an inbound graph”

When you use langgraph_agent to run a LangGraph graph inside Pyrula, the wrapper is T3 by default: the graph re-executes live on replay. You can move the LLM reasoning calls to T1 without rewriting the graph. Wrap the model before building the graph:

from pyrula.agents.integrations import durable_chat_model
model = durable_chat_model(ChatOpenAI(model="gpt-4o"))
graph = build_my_graph(model=model)
agent_fn = langgraph_agent(graph, name="my-agent")

Inside a Pyrula run, every call the model makes is journaled and memoized. On replay, the response is returned from the journal without calling the provider again. That gives you deterministic replay, cost capture, and crash-safe reasoning at the model boundary.

Two v1 limits to know. Tools still execute live on replay (T3). A tool that returns different output on replay can change which graph paths run after it, which shifts the sequence of LLM calls and breaks the journal’s positional keying. Replay fidelity depends on your tools being effectively deterministic until durable_tool wrapping lands for frameworks. Separately, concurrent LangGraph branches both making LLM calls can race the journal counter and mis-key on replay; sequential graphs are not affected.

Outside a Pyrula run, the wrapper is a plain passthrough to the real model, so there is no lock-in.

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”

This is the outbound direction. Instead of running a foreign framework inside Pyrula, you drop a durable Pyrula workflow into a framework you already use.

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. Human-in-the-loop is durable end to end, through LangGraph’s own mechanism.

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, a non-streaming chat.completions.create is journaled: the result is recorded and, on replay, returned without calling the provider again. That is the whole point, one import buys you replay-safe model calls without threading a context through your code. Outside a run it is a plain passthrough to the real client (warned once), so there is no lock-in: swap the import back and nothing breaks.

Streaming (stream=True) is journaled too: chunks are recorded as they are yielded and a fully-consumed stream replays from the journal without calling the provider (a stream you abandon before it finishes is not journaled). Everything other than chat.completions.create forwards to the real client unchanged. Needs pip install pyrula-agents[openai].

Anthropic works exactly the same way: swap from anthropic import AsyncAnthropic for from pyrula.agents.compat.anthropic import AsyncAnthropic, and messages.create (streaming or not) is journaled inside a run. Needs pip install pyrula-agents[anthropic].

This is the outbound MCP direction. Instead of Pyrula consuming an MCP server, your Pyrula agent exposes tools as an MCP server, and those tools run as durable workflows.

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

Tier: T1/OUTBOUND. 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.
  • Best-effort: the fallback is the JSON-RPC request id, which is scoped to the current connection. A crash that drops the connection loses the id, and a reconnect can re-run the tool.

You can also 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).

When a tool needs human input. A tool that calls ctx.interrupt(...) pauses the run. What happens next depends on whether the connected MCP client supports elicitation.

If the client supports elicitation, the server calls ctx.elicit inline. Accepting resumes the durable run and returns the final result in the same tool call. Declining or cancelling leaves the run suspended. The elicit await is not bounded by the run timeout. The human can take as long as needed.

If the client does not support elicitation (or if the user declines), the run stays suspended and build_mcp_server returns an InterruptedResult carrying {run_id, name, payload}. The client resumes via the companion resume_tool that build_mcp_server registers automatically:

# Client receives an InterruptedResult, then resumes:
result = await client.call_tool("resume_tool", {
"name": interrupted.name,
"run_id": interrupted.run_id,
"value": "approved",
})

The resume_tool path works with any MCP client and needs no elicitation support. It is also the fallback when a client claims no elicitation capability. The run is left suspended with no data loss.

If losing the process must not lose the work, or the work must not run twice, put it in a step, a tool, or a node. If you only need the two systems to coexist, a framework wrapper is enough, and you should expect to keep your own durability story for what runs inside it.