Developers on the OpenAI Agents SDK: Why Handoffs Break in Production and How to Make Them Durable

A customer opens the support chat at 4:52 p.m. and writes that a blender arrived cracked. Your triage agent reads the message, classifies it as a refund request, and hands off to the refunds agent. The refunds agent asks for the order number, confirms the item, checks the return window, and calls issue_refund. The payment provider starts processing.
At that exact moment, Kubernetes evicts the pod to make room on a node. The process dies mid-call.
The customer’s next message lands on a fresh pod. That pod has no idea a refund agent was in control, no idea the order was verified, and no idea a refund call was in flight. Triage greets the customer politely and asks what the problem is. The customer explains everything again, gets handed to refunds again, and the refund tool runs a second time. Now support has a duplicate refund to reverse and an annoyed customer who has already posted a screenshot.
Why handoffs are fragile
In the OpenAI Agents SDK, you define agents with instructions, tools, guardrails, and handoffs, and a runner drives the loop: call the model, run tools, follow handoffs, repeat until there is a final output.
The catch is where that loop lives. By default, the whole run exists in the memory of a single process. Several pieces of state matter, and all of them are volatile:
- The conversation so far, including tool results that never reached a database.
- Which agent currently has control. After a handoff, the specialist is the active agent, but that fact lives in the runner’s local variables.
- Which tool calls already ran, and which one was in progress when things stopped.
- Guardrail outcomes that shaped the path the run took.
When the process ends, all of that ends with it. Rolling deploys, autoscaler scale-downs, spot reclaims, and OOM kills all produce the same result as a crash. On a busy service this happens routinely, not an edge case.
Long-running tools make it worse. A refund waiting on a payment provider or a tool waiting on human approval can outlast an HTTP request timeout. The gateway gives up, the client retries, and a second copy of the run starts while the first may still be going.
Why the usual workarounds fall short
Retry the whole run. This is the most common fix and the most dangerous one. Restarting from the first user message replays every LLM call and every tool call. LLM output is not deterministic, so the second run can route differently, ask different questions, or reach a different answer. Tools with side effects run twice. For a refund, that is a real money problem.
Store the chat transcript and resume from it. Saving messages helps the customer avoid repeating themselves, but a transcript is not an execution record. It usually does not say whether issue_refund finished, failed, or was mid-flight. It also tends to lose the active agent, so the resumed run starts back at triage.
Increase timeouts. This moves the failure around without removing it, and makes deploys slower.
Put the run behind a queue. A queue redelivers the start of the work, but not how far the last attempt got, so redelivery still means starting over.
None of these records progress where failures actually happen: between individual model calls and tool calls.
The approach: treat the agent loop as a durable workflow
The agent loop is already a workflow, with steps, branching (handoffs), and side effects (tools). Durable execution engines run that shape on a few principles:
- Every side effect is a recorded step. Each LLM call and each tool call runs as an activity. When an activity completes, its input and output are written to a durable store before the loop continues.
- State is persisted between steps. The conversation, the current agent, and intermediate results are, in a well-designed setup, part of the workflow’s recorded state, not just local variables.
- Recovery replays history instead of redoing work. After a crash, a new worker loads the history, skips past completed activities using their recorded results, and resumes at the first step that did not finish.
- Orchestration logic is deterministic. Decisions are made from recorded results, so replay always takes the same path. Nondeterministic work, like calling a model, lives inside activities.
- Retries are per step. A flaky tool call is retried on its own, not the whole conversation.
Applied to handoffs, two practical rules follow.
First, record which agent is active as part of the persisted state. A handoff is a state transition. If the identity of the current specialist is stored alongside the conversation, recovery resumes with the refunds agent in control, not triage. Whatever framework or integration you use, check that this is actually true in your setup rather than assuming it.
Second, make specialist tools idempotent. A completed activity is not re-executed, but one interrupted mid-call will usually run again, so the tool must tolerate a repeat. Pass an idempotency key derived from stable values, such as session ID plus order ID, and have the downstream system reject duplicates.
Implementing it with Dapr Workflow
Dapr, the CNCF graduated runtime, includes a workflow engine that fits these principles. Workflows are written as code in Python and several other languages. History is event-sourced into a state store you choose, such as PostgreSQL or Redis. After a crash, the workflow replays, and completed activities are not re-executed. Orchestrator code has to be deterministic, with I/O kept in activities. Retry policies with exponential backoff can be attached per activity.
You do not have to rewrite your agent as a hand-built workflow to get this. Diagrid publishes a Python package named diagrid with integrations for several agent frameworks, including the OpenAI Agents SDK. In that integration, the workflow orchestrates the agent loop, the LLM call and each tool call run as durable activities, and conversation state is passed between activities.
Install it with pip install “diagrid[openai_agents]”, and the wiring looks like this:
from agents import Agent, function_tool
from diagrid.agent.openai_agents import DaprWorkflowAgentRunner
agent = Agent(name=”support_agent”, instructions=”…”, model=”gpt-4o-mini”, tools=[get_order])
runner = DaprWorkflowAgentRunner(agent=agent, name=”support-agent”, max_iterations=10)
runner.start()
async for event in runner.run_async(user_message=”…”, session_id=”s-001″): …
Your agent definition stays a normal Agent; only the runner changes. Pass a stable session_id per conversation, and verify how the runner treats a repeated ID, and max_iterations caps runaway tool calling.
For local development, run dapr init, add a state store component with actorStateStore: “true”, and start the app:
dapr run –app-id support-agent –resources-path ./resources — python3 agent.py
A word of caution on handoffs specifically. The integration’s documented scope is the agent loop: model calls, tool calls, and conversation state. Do not assume it gives you special handoff semantics. Build a test with a triage agent and a specialist, kill the process mid-tool-call, and confirm what happens on recovery. If the active agent is not preserved the way you need, you can make it explicit: keep the current specialist in the state you control, or structure each specialist as its own durable run keyed by session. Either way, verify the behavior in your own environment before you rely on it.
If you would rather not operate the workflow engine and its state store yourself, managed options exist. Catalyst, for example, is a durable execution platform for AI agents built on Dapr. It recovers agents from the point of failure without re-running completed steps, adds OpenTelemetry tracing for each activity, and supports human-in-the-loop approvals. It can run hosted, in your own cloud, or self-hosted. Compare it against running open-source Dapr on your own clusters and test both with your real workloads.
See also: Tech-driven security and insurance: the new frontier
A rollout checklist
Use this list to move an existing OpenAI Agents SDK app from in-memory runs to durable ones:
- ☐ Mark every tool that has side effects (refunds, emails, ticket updates).
- ☐ Add idempotency keys to every side-effecting tool, built from stable IDs like session and order, never from timestamps or random values.
- ☐ Choose a stable session_id per customer conversation and pass it consistently on every message.
- ☐ Decide where the active agent is recorded, and confirm it survives a restart.
- ☐ Move all I/O out of orchestration logic and into tools or activities so replay stays deterministic.
- ☐ Set retry policies per tool with backoff and a sensible attempt limit, instead of retrying whole runs.
- ☐ Set max_iterations to bound runaway loops.
- ☐ Return a session or run ID to the client quickly and stream progress, rather than holding a long HTTP request open for slow tools.
- ☐ Write a chaos test: start a handoff, kill the pod during a specialist’s tool call, and assert that the tool’s side effect happened exactly once and the customer was not re-asked for details.
- ☐ Run the chaos test during a real rolling deploy, not just a manual kill.
Conclusion
Handoffs feel like a modeling feature, but in production they are a state problem. A handoff creates information that must outlive the process: who is in charge, what was confirmed, and which side effects already happened.
Treating the agent loop as a durable workflow addresses that directly. Record each model call and tool call, persist conversation state between steps, keep tools idempotent, and make the active agent part of the saved state. Do that, and a pod restart in the middle of a refund becomes a short pause instead of a customer repeating their story to a bot that has forgotten them.




