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.
Three tiers
Section titled “Three tiers”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.
| Integration | Tier | Why |
|---|---|---|
from_langchain_tool, from_pydantic_ai_tool | T1 | the wrapped call becomes a Pyrula @tool; inside a run it is journaled and memoized on replay |
as_langgraph_node | T1 | the node body is a Pyrula run: journaled, replayable, crash-safe, with durable human-in-the-loop |
langgraph_agent | T3 / T1 for LLM | T3 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_agent | T3 | the foreign agent loop’s own model and tool calls are not journaled and re-run on replay |
compat.openai.AsyncOpenAI | T1 in a run / T3 standalone | chat.completions.create (streaming or not) is journaled inside a run; a plain passthrough outside one |
compat.anthropic.AsyncAnthropic | T1 in a run / T3 standalone | same, for messages.create |
Why the agent wrappers are only T3
Section titled “Why the agent wrappers are only T3”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.
Moving work from T3 to T1
Section titled “Moving work from T3 to T1”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].
The OpenAI drop-in
Section titled “The OpenAI drop-in”pyrula.agents.compat.openai.AsyncOpenAI is an openai.AsyncOpenAI-shaped client. Swap
the import and your existing code keeps working:
# from openai import AsyncOpenAIfrom 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].
Durable MCP tools
Section titled “Durable MCP tools”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 transportTier: 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
_metafield (_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.
Rule of thumb
Section titled “Rule of thumb”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.