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.
Durable-native versus compatibility
Section titled “Durable-native versus compatibility”| Tier | Execution | Replay behavior |
|---|---|---|
| T1 durable-native | A Pyrula run, step, or tool | Recorded work replays without re-executing and may suspend for human input. |
| T3 compatibility | A compatible API outside a run | Calls 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.
Moving work from T3 to T1
Section titled “Moving work from T3 to T1”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 agentfrom pyrula.agents.integrations import durable_chat_model
model = durable_chat_model(ChatOpenAI(model="gpt-4o"))
@agentasync def my_agent(ctx, message: str) -> str: result = await model.ainvoke([{"role": "user", "content": message}]) return result.contentInside 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].
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, 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 run | Surfaces |
|---|---|
| Journaled | chat.completions.create, including stream=True (a stream abandoned early isn’t journaled) and response_format= |
| Passed through | embeddings, 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 transportEach 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. - 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.
Choosing the boundary
Section titled “Choosing the boundary”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.