OpenAI Agents SDK Tool Approval Resume: RunState Across Processes and the v0.19.3 Streaming Fix
The lab used Python 3.10.2, openai-agents 0.18.3 and 0.19.3, a deterministic custom Model, and SQLite. It made no OpenAI or third-party model-provider API calls. The results verify cross-process RunState recovery, streamed resume, session persistence after an output guardrail, redelivery, and context serialization—not model tool-selection quality, network latency, token cost, or hosted queue performance.
Who Should Read This
- ● Python developers persisting and resuming OpenAI Agents SDK tool approvals across processes.
- ● Agent backend teams designing long-lived approvals, queue redelivery, and side-effect idempotency.
- ● Architects separating RunState, sessions, context serialization, and application-owned approval state.
OpenAI Agents SDK Tool Approval Resume: RunState Across Processes and the v0.19.3 Streaming Fix
Here is the conclusion first: OpenAI Agents SDK RunState can persist an Agent run waiting for human approval and resume it in another process. But openai-agents 0.18.3 had a dangerous streamed-resume boundary: after an approved tool produced its external side effect, an output guardrail tripwire could leave the Session with only the function_call and lose the matching function_call_output. Version 0.19.3 fixes that persistence order.
I reran the original cross-process lab on 0.19.3: the run still paused before side effects, an independent worker executed the approved tool once, rejection executed it zero times, and replaying the same approved state in two workers still produced two effects without an application idempotency ledger. I then compared 0.18.3 and 0.19.3 with the same deterministic tool, SQLite Session, and output guardrail. The older version lost the approved tool output after the tripwire; the newer version persisted and replayed the complete call/output pair.
The upgrade therefore fixes SDK Session persistence during streamed resume, not approval authorization or business exactly-once delivery. Applications still own queue redelivery, tool-schema compatibility, state-blob security, context-secret filtering, approval expiry, and idempotent external side effects.

1. Why This Is a Separate Problem from a LangGraph Approval Workflow
XBSTACK already has a LangGraph human-in-the-loop approval guide. That article is about interrupt, checkpointers, thread_id, and resuming graph nodes. The OpenAI Agents SDK search intent is different: developers work with function_tool(needs_approval=True), RunResult.interruptions, RunState.to_json(), and Runner.run(agent, state). There is no LangGraph topology or checkpointer that automatically becomes your business approval store.
Python HITL support also came from a concrete product need. The historical GitHub issue requesting native support described the same requirements developers still care about: pause before tool execution, expose a structured interruption, serialize the complete run, and resume after approve or reject. Those primitives now exist in openai-agents 0.18.3. The production gap begins after they work.
The official documentation explains that serialized state can include application context, approvals, usage, serialized tool input, nested agent-as-tool resumption data, trace metadata, and server-managed conversation settings. It also recommends recording Agent or SDK version information for approvals that may remain pending for a long time.
That produces the central conflict for this article:
RunState makes an Agent run resumable. The more durable that resume becomes, the less safely you can delegate authorization, idempotency, secrets, and version compatibility to SDK defaults.
The official in-process example is sufficient when a CLI immediately asks a person for a decision. A web admin panel, OA system, Slack workflow, email approval, or mobile client that resumes hours later needs a business record and failure model around the SDK state.
2. Test Environment, Tasks, and Evidence Boundary
The complete lab lives at:
experiments/openai-agents-runstate-approval-resume/
| Item | Value |
|---|---|
| Python | 3.10.2 |
| OpenAI Agents SDK | openai-agents==0.18.3 and 0.19.3 |
| RunState schema | 1.12 on 0.18.3; 1.13 on 0.19.3 |
| Model | deterministic custom Model |
| Provider API | not used |
| Persistence | JSON files + SQLite |
| Sensitive tool | deploy_release |
| Idempotency key | tenant-a:release:2026.07.24 |
I deliberately did not ask a real model to decide whether to deploy. That would mix model variance, networking, provider cost, and tool-choice quality into the runtime experiment. The deterministic model always emits one deploy_release call on the first response and a final message after receiving the tool result.
The lab verifies seven paths:
- the run pauses before any external effect;
- another process can load, approve, and resume it;
- rejection does not execute the tool;
- replaying the same approved state shows the redelivery behavior;
- an idempotency ledger prevents the duplicate effect;
- a context serializer prevents a demo secret from entering the state JSON;
- a streamed resume followed by an output guardrail tripwire either persists or loses the approved tool call/output pair, depending on the SDK version.
It does not test a real OpenAI Responses API request, nested Agent.as_tool() approvals, hosted MCP approvals, a real Redis/Temporal/Celery queue, simultaneous worker races, or exported traces. The streaming regression uses a deterministic custom Model and SQLiteSession, so it verifies SDK persistence semantics rather than model selection quality, production latency, or token cost.
August 5, 2026 Update: Why 0.18.3 Lost an Approved Tool Output and What 0.19.3 Fixes
The official OpenAI Agents SDK v0.19.3 release includes preserve approved tool output on streamed resume. PR #4126 explains the failure order: the approved tool had already completed its side effect, but the streamed runner evaluated Agent output guardrails before saving the resumed turn’s tool call/output items. If a tripwire aborted final delivery, the Session kept an orphaned tool call and lost the approved tool output.
The XBSTACK regression reduces that path to a provider-free fixture. A deterministic model emits approval_tool; the first streamed run pauses; the interruption is approved; Runner.run_streamed() resumes and the tool returns approved-result; an output guardrail then always trips. The external effect count is one in both versions. The Session contents differ:
| Version | Session after tripwire | Tool output persisted | Complete pair replayed next turn |
|---|---|---|---|
openai-agents==0.18.3 | user, function_call | No | No |
openai-agents==0.19.3 | user, function_call, function_call_output | Yes | Yes |
This is more dangerous than an ordinary exception: the external operation already happened, while the durable conversation history looks as though the tool never returned. A later model turn may request the operation again, and an audit system cannot reconstruct the complete causal chain from the Session.
Version 0.19.3 persists the approved call/output pair before output guardrails run. The same release also reconciles tool-name collisions during resume and preserves tool call/output order when deduplicating inputs. Those fixes improve SDK state consistency, but they do not prove that a stale approval is still authorized after tool definitions, permissions, or application versions change.
The evidence is stored in:
experiments/openai-agents-runstate-approval-resume/
├── stream_resume_regression.py
└── artifacts/
├── stream-resume-0.18.3.json
├── stream-resume-0.19.3.json
├── version-matrix.json
└── version-matrix.md
Projects using streamed Tool Approval with a Session should upgrade to at least openai-agents==0.19.3 and add a regression assertion that the approved call/output pair survives an output guardrail tripwire. They still need an application idempotency ledger because the SDK fix does not prevent the same approved RunState from being resumed more than once.
3. How Tool Approval Pauses and Resumes
The documented lifecycle has seven steps: the model emits a tool call; the Runner evaluates needs_approval; a missing decision creates a ToolApprovalItem; RunResult.interruptions surfaces it; the application converts the result to RunState and persists it; a human approves or rejects; then Runner.run(agent, state) resumes the original top-level run.

The tool boundary is small:
from agents import function_tool
@function_tool(needs_approval=True)
def deploy_release(release: str, idempotency_key: str) -> str:
return f"deployed:{release}"
The first run:
result = await Runner.run(
agent,
"Deploy release 2026.07.24",
context=AppContext(
tenant_id="tenant-a",
api_token=runtime_token,
),
)
assert len(result.interruptions) == 1
assert effect_count() == 0
state = result.to_state()
The observed paused result was:
{
"interruption_count": 1,
"tool_name": "deploy_release",
"arguments": "{\"release\":\"2026.07.24\",\"idempotency_key\":\"tenant-a:release:2026.07.24\"}",
"effect_count_before_approval": 0
}
This is an execution-layer boundary, not a prompt convention. The model already produced the call and arguments, but the function has not run.
Approval support extends beyond a plain function tool. The current docs cover Agent.as_tool(), ShellTool, ApplyPatchTool, local MCP server require_approval, and hosted MCP approval. An approval raised inside a nested agent surfaces on the outer run, so a production approval center should model the outer run and individual tool-call IDs rather than inventing an isolated UI state for every sub-agent.
4. RunState Owns SDK Execution State, Not the Whole Business Approval
RunState packages the information the Runner needs to continue: current Agent and turn, original input, model responses, generated items, approval state, usage, serialized tool input, guardrail results, trace metadata, and optional conversation_id or previous_response_id settings.
After filtering the custom context, the paused JSON in this lab was 6,279 bytes and contained top-level fields such as:
$schemaVersion
current_turn
current_agent
original_input
model_responses
context
tool_use_tracker
generated_items
current_step
last_model_response
last_processed_response
conversation_id
previous_response_id
trace
It should not be the only source of truth for:
- tenant ownership;
- operator permission;
- expiry or revocation;
- argument integrity after approval;
- queue redelivery or worker leasing;
- whether a deployment, payment, message, or database write already happened;
- state-blob encryption, access control, and deletion.

A production design should separate at least three records:
run_state_blob # encrypted SDK snapshot
approval_request # business approval lifecycle and authorization
idempotency_ledger # effect deduplication, leases, and stored-result reuse
A blob can be large and object-storage friendly. Approval records need frequent queries by operator, tenant, status, and expiry. Idempotency records need strong uniqueness and transaction semantics. Combining all three into one generic agent_runs record creates conflicting access, TTL, and consistency requirements.
The review screen should not render raw RunState tool input as its sole contract. When an interruption is created, generate an immutable review_snapshot containing only fields the reviewer must see, and calculate a canonical digest:
canonical_args = json.dumps(
parsed_arguments,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
arguments_digest = hashlib.sha256(canonical_args.encode()).hexdigest()
The human approves that snapshot and digest. Before execution, the worker extracts the tool name and arguments from RunState and recalculates the digest. A mismatch invalidates the approval and requires a new review. This blocks both parameter substitution and schema drift in which new code applies different defaults to old JSON.
Authorization must also be rechecked at execution time. Store the tenant, reviewer role, scope, target environment, and resource boundary that justified the decision. A user can lose production access while the request is pending, and a change window can close. Production execution should require authorization both when the decision is recorded and when the effect is attempted.
This is not an abstract security preference. A 2026 OpenAI Agents SDK community request for per-tool authorization explicitly separated guardrails from authorization: guardrails inspect content, while authorization decides whether an identity may execute a particular tool call under the current tenant, scope, rate limit, and session conditions. needs_approval=True proves that a human decision is required; it does not prove that the reviewer is authorized for the target resource, and it does not replace the worker’s execution-time permission check. Treat Tool Approval as one decision point inside an authorization system, not as the whole permission system.
For the broader permission, state, and production-governance path, continue with the XBSTACK AI Agent Hub.
5. Cross-Process Approval Requires Rebuilding the Agent Definition
The lab splits one approval across three processes:
- Process A runs the Agent, receives the interruption, writes the RunState, and exits.
- Process B acts as the Approval API, loads the state, records approval, and writes the decided state.
- Process C acts as the worker, rebuilds the Agent, loads the state, and resumes it.

Serialization:
def context_serializer(context: AppContext) -> Mapping[str, Any]:
return {"tenant_id": context.tenant_id}
payload = state.to_json(
context_serializer=context_serializer,
strict_context=True,
)
wrapper = {
"app_state_version": "runstate-approval-lab/v1",
"sdk_state": payload,
}
Loading in another process:
state = await RunState.from_json(
initial_agent,
wrapper["sdk_state"],
context_deserializer=context_deserializer,
strict_context=True,
)
The initial_agent parameter matters. JSON cannot recreate Python functions, asynchronous callbacks, tool implementations, database clients, secret-manager clients, or the executable Agent graph. The worker must build compatible definitions and allow the SDK to resolve serialized identities against them.
The approval process stores the decision:
interruption = state.get_interruptions()[0]
state.approve(interruption)
await persist(state)
The worker resumes:
state = await load_state(agent)
result = await Runner.run(agent, state)
Observed output:
{
"final_output": "Release workflow finished: deployed:2026.07.24",
"effect_count": 1
}
One implementation detail matters: in the 0.18.3 fixture, get_interruptions() still enumerated the original item after state.approve(interruption) and before the Runner resumed. Do not use interruption count as the business approval status. Maintain an application-owned state machine and update pending to approved or rejected with compare-and-set semantics.
Cross-process resume also needs an Agent registry. A worker should not always call the newest build_agent() implementation:
AGENT_FACTORIES = {
"runstate-approval-lab/v1": build_release_agent_v1,
"runstate-approval-lab/v2": build_release_agent_v2,
}
factory = AGENT_FACTORIES.get(record.app_state_version)
if factory is None:
raise StateVersionUnsupported(record.app_state_version)
agent = factory(runtime_dependencies)
Each factory pins Agent names, tool names, parameter schemas, critical prompt revisions, and handoff topology. Keep an old factory until its pending approvals expire, or revoke those approvals during migration. A JSON blob being readable does not prove that current code still has the same tool semantics.
Do not expose a generic endpoint that accepts arbitrary state JSON. Prefer POST /approvals/{approval_id}/resume; the service authenticates the caller, loads the tenant-scoped private record, and lets the worker fetch the blob. External clients should never be allowed to submit a modified executable snapshot or choose an object-storage URI.
6. Rejection Becomes a Tool Result Instead of Executing the Tool
The rejection path starts from the same paused state:
state.reject(
interruption,
rejection_message="Deployment was denied by the release manager.",
)
result = await Runner.run(agent, state)
The result was:
{
"final_output": "Release workflow finished: Deployment was denied by the release manager.",
"effect_count": 0
}
The sensitive tool did not run. The Agent received a model-visible rejection result and could explain, replan, or finish. The SDK also supports a run-level RunConfig.tool_error_formatter and a per-call rejection message override.
A business approval record needs more than a boolean:
| Status | Meaning | Next action |
|---|---|---|
pending | waiting for a human | do not enqueue a worker |
approved | arguments and authorization accepted | enqueue resume work |
rejected | explicitly denied | resume with rejection; do not execute tool |
expired/revoked | timed out or withdrawn | block the old state |
A run can contain multiple interruptions. The docs allow resolving only some and resuming; unresolved items can pause the run again. Model approvals at run_id + tool_call_id granularity rather than setting one approved=true flag for the entire run.
7. The Critical Failure: Replaying One Approved State Executes the Tool Twice
The replay experiment changes the production conclusion.
Worker A loaded the approved state and executed the tool. Instead of using Worker A’s latest state, the original approved snapshot was delivered to Worker B again. The Runner could not know whether this was a queue redelivery, a repeated button click, or a lost acknowledgment. The snapshot said that the call was approved and not completed, so the tool ran again.

Without an idempotency ledger:
{
"retry-a": "deployed:2026.07.24",
"retry-b": "deployed:2026.07.24",
"effect_count": 2
}
With a SQLite unique operation key:
{
"retry-a": "deployed:2026.07.24",
"retry-b": "reused:deployed:2026.07.24",
"effect_count": 1
}
The relevant transaction:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT result FROM idempotency_ledger WHERE idempotency_key = ?",
(idempotency_key,),
).fetchone()
if existing:
return f"reused:{existing[0]}"
result = perform_external_effect()
connection.execute(
"INSERT INTO idempotency_ledger(idempotency_key, result) VALUES (?, ?)",
(idempotency_key, result),
)
return result
Do not let the model freely invent the production idempotency key. Generate an immutable operation_key when the application creates the approval request, for example:
tenant_id + approval_id + logical_operation + target_resource
Payments, deployments, email providers, and ticket systems should receive that key when their APIs support idempotency. A local Agent database cannot close the crash window in which the remote effect succeeded but the local result was not committed.
This matches the boundary found in the AI SDK 7 interruption and tool-recovery case study: framework state recovery and business idempotency are different layers. The framework knows which call should continue; the business system knows whether the action already happened.
A harder crash window exists after a remote effect succeeds but before the worker stores the local idempotency result. A retry sees no local completion record and can repeat the effect. The response depends on the downstream system:
- pass the application operation key to a native downstream Idempotency-Key when available;
- query by a stable business identifier before creating a resource again;
- mark the operation
uncertainand require manual reconciliation when the remote system cannot deduplicate or be queried; - use a local transaction or transactional outbox when the effect is controlled by the same database.
The idempotency ledger should be a state machine rather than a boolean:
reserved → executing → succeeded
└→ failed_retryable
└→ failed_terminal
└→ uncertain
A worker acquires reserved with a lease. Another worker may take over only after the lease expires and the state permits it. succeeded reuses the stored result; uncertain stops automatic retry. The lab uses BEGIN IMMEDIATE plus a unique key; Postgres can use a unique insert, row lock, or advisory lock. The requirement is an atomic, durable claim to execution rights.
8. Context Is Persisted Data: A Token Can Travel with the State
The official Context guide is explicit: application context can be serialized with a RunState. It is not necessarily an in-memory-only dependency container.
The first experiment used a mapping context:
context = {
"tenant_id": "tenant-a",
"api_token": DEMO_SECRET,
}
Then:
payload = result.to_state().to_json(strict_context=True)
The result was mapping_context_secret_present = true. A mapping already satisfies the strict serialization rule; strict_context=True does not classify sensitive fields for you.
The second experiment used a custom object:
@dataclass
class AppContext:
tenant_id: str
api_token: str
Strict serialization without a serializer failed:
UserError: RunState serialization requires context to be a mapping when strict_context is True.
Provide context_serializer to serialize custom contexts.
The safe serializer persisted only the durable identifier:
def context_serializer(context: AppContext) -> Mapping[str, Any]:
return {"tenant_id": context.tenant_id}
The deserializer re-injected the credential at runtime:
def context_deserializer(payload: Mapping[str, Any]) -> AppContext:
return AppContext(
tenant_id=str(payload["tenant_id"]),
api_token=secret_manager.get("agent-runtime-token"),
)

The final result was safe_serializer_secret_present = false. The docs also warn that context_override can replace context during loading but cannot erase a secret already written into an existing state blob. If a credential was persisted, changing the loaded context does not remediate the historical exposure; rotate the credential and delete or re-encrypt the blob.
A useful split is:
PersistedContext
tenant_id
user_id
request_id
locale
feature_flags_snapshot
RuntimeDependencies
API clients
database connection
secret manager
access token
logger / trace exporter
Only the first group should be eligible for RunState serialization.
Even after filtering Context, RunState can contain original user input, tool arguments, model output, file paths, customer names, or proposed message bodies. “No API key” does not make it ordinary cache data. Treat the blob as sensitive business data:
- encrypt it with a KMS-managed data key;
- isolate object keys by tenant without embedding readable business data;
- store only URI, digest, versions, and key reference in the relational record;
- give Approval API and workers different least-privilege service identities;
- send only a redacted review snapshot to browsers;
- use different TTLs for pending, rejected, and completed states;
- delete blobs, debug exports, and cache copies together;
- audit reads, not just approval decisions.
Do not log complete state during debugging. Log approval ID, schema, byte size, digest, and error category. If a state has already copied a credential into logs, error tracking, backups, or a queue, deleting the primary blob is not sufficient remediation.
9. Long-Lived Approvals Need Both SDK Schema and Application Version Gates
The serialized SDK schema in this lab was $schemaVersion = 1.12. SDK compatibility is only one dimension. An approval created on Friday and accepted on Monday can cross changes such as:
deploy_release(release=...)becomingdeploy_release(artifact_id=...);- a new environment restriction in the prompt;
- a renamed tool;
- revoked production access for the operator;
- a canceled release target;
- a new SDK serialization schema.
Successful RunState.from_json() does not prove that the old approval is still valid.

The lab wraps the SDK state:
{
"app_state_version": "runstate-approval-lab/v1",
"sdk_state": {
"$schemaVersion": "1.12"
}
}
A production approval record should preserve at least:
CREATE TABLE approval_request (
approval_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
operator_scope TEXT NOT NULL,
run_id TEXT NOT NULL,
tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
arguments_digest TEXT NOT NULL,
app_state_version TEXT NOT NULL,
sdk_schema_version TEXT NOT NULL,
agent_revision TEXT NOT NULL,
state_blob_uri TEXT NOT NULL,
status TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
decided_by TEXT,
decided_at TIMESTAMP,
revoked_at TIMESTAMP
);
Before loading the blob, the worker must revalidate tenant, operator permission, argument digest, app version, expiry, and revocation. An incompatible version should create a fresh approval request rather than force an old state through current code.
10. RunState, Session, conversation_id, and previous_response_id Are Different State Layers
The SDK offers several continuation strategies:
| Mechanism | State location | Primary purpose | Next-run input |
|---|---|---|---|
result.to_input_list() | application memory/store | manual history management | prior items + new user input |
session | application store + SDK | conversation history across runs | same session or same backing store |
conversation_id | OpenAI Conversations API | named server-side conversation | same conversation_id |
previous_response_id | OpenAI Responses API | lightweight response chain | previous response ID |
RunState | application-owned blob | resume one interrupted run | restored RunState |
RunState preserves the run’s conversation_id, previous_response_id, and auto_previous_response_id configuration so a resumed approval can continue the same server-managed conversation. However, conversation_id and previous_response_id are mutually exclusive. A local session also cannot be combined with those server-managed mechanisms in the same run.
When a session is used, resume with the same session instance or another instance that points at the same store. RunState resumes the interrupted run; Session appends the resumed turn to the correct conversation history.
None of these identifiers is a business authorization credential:
conversation_idis nottenant_id;previous_response_idis not approval authorization;- a Session ID is not a tool idempotency key;
- a blob URI is not a public resume token.
For broader memory and identity boundaries, see AI Agent Memory Architecture. For high-risk tools, continue with AI Agent Security and MCP security governance.
11. Production Architecture: Approval API, Blob, Queue, Worker, and Idempotency Ledger
A production RunState approval system should separate responsibilities as follows.

Agent API
Run the Agent until completion or interruption. When interrupted, do not send the complete state JSON to the browser. Instead:
- calculate a digest of the tool arguments;
- store the encrypted state blob privately;
- create an
approval_request; - return a minimized, redacted review payload.
Approval API
Authenticate the reviewer, enforce tenant and scope authorization, and transition the record with compare-and-set:
UPDATE approval_request
SET status = 'approved', decided_by = ?, decided_at = CURRENT_TIMESTAMP
WHERE approval_id = ?
AND tenant_id = ?
AND status = 'pending'
AND expires_at > CURRENT_TIMESTAMP;
Only one updated row may enqueue the resume job. This blocks double clicks, duplicate webhooks, and expired approvals.
Queue
Deliver only approval_id, not the entire RunState blob. The worker reloads the record and private blob and revalidates authorization, versions, argument digest, and revocation. Design the queue for at-least-once delivery.
Worker
Choose an Agent factory from app_state_version, rebuild compatible definitions, inject runtime dependencies, call RunState.from_json(), verify the approved arguments, and resume with Runner.run(agent, state).
Idempotency Ledger
Every side-effecting tool acquires an immutable operation key before execution. Prefer downstream-native idempotency. Otherwise, use a unique database constraint, an execution state machine, and stored-result reuse. A worker receiving a message is not completion; acknowledge only after the external effect and recoverable result state have been recorded.
Audit and Trace
Record who approved which argument digest, which Agent revision resumed, which worker executed, what the downstream system returned, and whether the result was reused. Traces help explain SDK execution, but long-lived credentials should not be serialized merely to preserve export. include_tracing_api_key=True is an explicit option and should be treated as a security decision.
Minimum production metrics include:
| Metric | Meaning | Warning signal |
|---|---|---|
approval_pending_age | time waiting for review | rising P95 means the review channel is failing |
approval_expired_total | expired approvals | TTL or notification policy is wrong |
resume_attempts_total | resume attempts per approval | greater than one means redelivery or manual retry |
idempotency_reuse_total | reused completed effects | a spike indicates ACK or worker instability |
state_deserialize_failure | failed blob loads | SDK/app incompatibility or corruption |
argument_digest_mismatch | approved and executed inputs differ | mutation, schema drift, or attack |
effect_uncertain_total | remote outcome unknown | stop automatic retries and reconcile |
state_blob_bytes | serialized size | context, history, or trace is growing unexpectedly |
Correlate logs with approval_id, run_id, tool_call_id, and operation_key without logging complete arguments or state. A trace ID is a useful index, not a replacement for durable business audit records.
Regression tests must go beyond “approval succeeds.” Cover zero effects before approval, one approved effect, zero rejected effects, double-click approval, duplicate state resume, lost worker acknowledgment, context-secret scanning, argument-digest mismatch, expiry, permission revocation, old app versions, corrupted blobs, and partially unresolved interruptions. Assert database and external-effect state, not only final text.
This aligns with the broader AI Agent production-governance guide: the model proposes, the approval service authorizes, the worker executes, the business system enforces idempotency, and the audit layer makes the action replayable.
12. Final Decision: When the Official Example Is Enough
The in-process example is sufficient when all of these are true:
- the reviewer decides immediately in the same CLI or request lifecycle;
- the tool has no irreversible side effect;
- the process remains alive;
- no queue is involved;
- Context contains no durable credential;
- the state does not need to survive code versions.
Move to the durable architecture when any of these are true:
- approval can take minutes, hours, or days;
- frontend, Approval API, and worker are separate services;
- the tool deploys, pays, emails, writes databases, or changes cloud resources;
- queue messages, webhooks, or human actions can repeat;
- tenant authorization must be isolated;
- Agent, Tool, or prompt definitions change over time;
- state blobs need encryption, TTL, revocation, and audit.
Do not enable every high-risk tool at once. Start with read-only or reversible tools and verify blob persistence, notification, version loading, and audit. Then allow one internal tenant to use a low-frequency write tool while deliberately testing queue redelivery, worker restarts, and expiry. Only after idempotency reuse, permission revocation, and stale-state rejection are proven should deployment, payment, or outbound-message tools be enabled.
Rollback needs its own state plan. Reverting the application image is insufficient when the database contains both v1 and v2 pending states. Preserve version routing so old workers can drain old states while new v2 approvals stop being created. Mark incompatible records migration_required, regenerate the proposal, and require a new decision. Never apply an old approval to a new tool schema.
Track rollout gates such as deserialization success, duplicate resume rate, idempotency reuse, effects after rejection, effects after expiry, argument-digest mismatches, and manual reconciliation volume. Any execution after rejection or expiry—or execution despite a digest mismatch—is a safety violation, not an acceptable low error rate.
Before production, verify:
- sensitive tools use
needs_approvalor the corresponding approval mechanism; - the paused path has zero effects;
- state uses a strict, minimal context serializer;
- blobs are encrypted and tenant-scoped;
- approved arguments have an immutable digest;
- records support approved, rejected, expired, and revoked;
- workers rebuild Agents by application version;
- queues are treated as at-least-once;
- tools have stable idempotency keys and unique constraints;
- duplicate resume, rejection, expiry, and incompatible versions have automated tests.
The final result is: RunState moves OpenAI Agents SDK HITL from an in-process pause to a genuinely durable execution boundary. Production safety still depends on the application separating approval authorization, state versioning, context secrets, and side-effect idempotency. A JSON file completes the demo; a complete software system makes it safe to execute production actions.
If you call the lower-level Responses API and Conversation directly instead of resuming an Agents SDK RunState, there is a separate abort boundary: the client can observe a streamed function_call that never becomes a durable Conversation item. See OpenAI Responses API stream abort and the No tool call found error for reconciliation and recovery.
Reproducible Lab
experiments/openai-agents-runstate-approval-resume/
Run it with:
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python lab.py run-all
.venv/bin/python -m unittest discover -s tests -v
The main evidence is written to:
artifacts/results.json
Official Sources
- OpenAI Agents SDK: Human-in-the-loop
- OpenAI Agents SDK: RunState reference
- OpenAI Agents SDK: Running agents and state strategies
- OpenAI Agents SDK: Sessions
- OpenAI Agents SDK: Context
- PyPI: openai-agents 0.18.3
Continue from one agent pattern to the complete production system
The AI Agent hub organizes architecture, memory, tool use, evaluation, security, deployment and multi-agent coordination into a single learning path.
Next Reading
View Hub →
OpenAI Agents SDK Duplicate Tool Names: Why the Later Tool Wins
OpenAI Agents SDK duplicate tool names can trigger a provider 400 or last-wins local dispatch. Reproduce 0.19.2 and add a preflight uniqueness gate.
Implementing AI Agent Memory Systems: A 3-Layer Architecture and Practical Code to Solve Agent 'Amnesia'
Practical guide to implementing AI Agent memory systems. Compares the performance of vector databases versus graph databases for long-term memory storage.
AutoGen Hands-On Tutorial: Multi-AI Agent Conversational Collaboration, Tool Invocation, and Production Deployment Boundaries
AutoGen Hands-On Tutorial: Systematically deconstruct AutoGen's practical usage and production deployment boundaries in multi-agent conversational collaboration, covering AgentChat
LangChain Tutorial: Building an AI Agent with Tool Calling
A LangChain tutorial for building an AI agent with Pydantic tool schemas, AgentExecutor control flow, persistent memory, error handling, and production boundaries.
Xiaobai
Full-Stack AI Engineer
Xiaobai, a full-stack AI engineer building production Agent systems, product tools and independent software assets.
About Xiaobai & XBSTACK →Liked this article?
Join the newsletter
Every issue condenses production AI engineering changes, real failures, reproducible experiments, useful tools and new XBSTACK assets. No generic news digest and no filler.
DISCUSSION
Questions, verification and corrections
Sign in to comment. Every new comment is reviewed before publication; while pending, it is visible only to you and the administrator.