agent_signal

Approved once. Runs once. Even after crashes.

Tested approval templates for OpenAI Agents SDK, LangGraph, Google ADK and Vercel AI SDK. Pause, approve, resume, no repeats.

ACT 2 · WITH AN AGENT SIGNAL TEMPLATEopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # FIXED
→ tool_call charge_fixed(amount=1999) · needs_approval
|| paused: ['charge_fixed'], charges=0
state.approve(charge_fixed) · resume
charge ch_1 · $19.99 · idempotency key
✗ model API 503 (simulated) after tool ran · retrying from scratch
same key → payment API returns ch_1, no new charge
final charges=[('ch_1', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
✓ CHARGED ONCE · final charges=[('ch_1', 1999)]
Approved once. Runs once. Even after crashes.
Get the templateFix my agent, $79
ACT 1 · WITHOUT AN APPROVAL FLOWopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # NAIVE / B) retry after crash
→ tool_call charge_naive(amount=1999)
charge ch_1 · $19.99
✗ model API 503 (simulated) after tool ran · retrying from scratch
→ tool_call charge_naive(amount=1999)
charge ch_2 · $19.99
final charges=[('ch_1', 1999), ('ch_2', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
ch_2 · customer A-1042$19.99 DUPLICATE
> charge_customer($19.99)
ACT 1 · WITHOUT AN APPROVAL FLOWopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # NAIVE / B) retry after crash
→ tool_call charge_naive(amount=1999)
charge ch_1 · $19.99
✗ model API 503 (simulated) after tool ran · retrying from scratch
→ tool_call charge_naive(amount=1999)
charge ch_2 · $19.99
final charges=[('ch_1', 1999), ('ch_2', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
ch_2 · customer A-1042$19.99 DUPLICATE
!! charged twice: $39.98 out
ACT 2 · WITH AN AGENT SIGNAL TEMPLATEopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # FIXED
→ tool_call charge_fixed(amount=1999) · needs_approval
|| paused: ['charge_fixed'], charges=0
state.approve(charge_fixed) · resume
charge ch_1 · $19.99 · idempotency key
✗ model API 503 (simulated) after tool ran · retrying from scratch
same key → payment API returns ch_1, no new charge
final charges=[('ch_1', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
|| paused: needs approval
ACT 2 · WITH AN AGENT SIGNAL TEMPLATEopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # FIXED
→ tool_call charge_fixed(amount=1999) · needs_approval
|| paused: ['charge_fixed'], charges=0
state.approve(charge_fixed) · resume
charge ch_1 · $19.99 · idempotency key
✗ model API 503 (simulated) after tool ran · retrying from scratch
same key → payment API returns ch_1, no new charge
final charges=[('ch_1', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
APPROVAL NEEDED
charge_fixed · $19.99 · A-1042
✓ ApprovedReject
✓ approved by a human
ACT 2 · WITH AN AGENT SIGNAL TEMPLATEopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # FIXED
→ tool_call charge_fixed(amount=1999) · needs_approval
|| paused: ['charge_fixed'], charges=0
state.approve(charge_fixed) · resume
charge ch_1 · $19.99 · idempotency key
✗ model API 503 (simulated) after tool ran · retrying from scratch
same key → payment API returns ch_1, no new charge
final charges=[('ch_1', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
✓ charged once: $19.99
ACT 2 · WITH AN AGENT SIGNAL TEMPLATEopenai-agents 0.24.0 · idempotent-approval
$ python idempotent_approval.py # FIXED
→ tool_call charge_fixed(amount=1999) · needs_approval
|| paused: ['charge_fixed'], charges=0
state.approve(charge_fixed) · resume
charge ch_1 · $19.99 · idempotency key
✗ model API 503 (simulated) after tool ran · retrying from scratch
same key → payment API returns ch_1, no new charge
final charges=[('ch_1', 1999)]
PAYMENTS LEDGER
ch_1 · customer A-1042$19.99
✓ CHARGED ONCE · final charges=[('ch_1', 1999)]
Approved once. Runs once. Even after crashes.
Get the templateFix my agent, $79
Replayed from openai-agents-idempotent-approval/run_output.txt · model and payment API mocked

01 / Method

  1. 01

    Tested on every new release

    Every template runs on a clean box and reruns when its framework version changes. The run output and tested version sit beside the code.

  2. 02

    We say what is mocked

    Usually just the LLM, so you can run it without an API key. Everything else is the real SDK.

  3. 03

    The traps, written down

    Deprecated options, wrong call ids, side effects that run twice: noted per version.

02 / The approval loop

Pause. Persist. Ask a human. Resume.

The same four-beat loop sits under every approval template, whatever the framework calls it: interruptions in the OpenAI Agents SDK, interrupt() in LangGraph, tool confirmation in Google ADK.

Agent.run()tool_callPAUSE · persisthuman: y / nresumedone

03 / Inside a template

Code, run log, and the traps, in one place.

The OpenAI Agents SDK starter pauses risky tools for approval, checkpoints the run to a string, and resumes after a human approves or rejects. It also shows the new run-wide tool_error_formatter scrubbing secrets from crashed tools.

02_approval_resume.pysrc
"""Human-in-the-loop: needs_approval -> interruption -> serialize RunState -> restore -> approve/reject -> resume."""
import asyncio
from agents import Agent, Runner, RunConfig, RunState, function_tool
from fake_model import ScriptedModel

@function_tool(needs_approval=True)
def delete_file(path: str) -> str:
    """Delete a file."""
    return f"deleted {path}"

@function_tool(needs_approval=True)
def send_email(to: str) -> str:
    """Send an email."""
    return f"emailed {to}"

def make_agent():
    return Agent(name="Ops", instructions="ops",
                 model=ScriptedModel([("delete_file", {"path": "/tmp/x"}), ("send_email", {"to": "a@b.c"})]),
                 tools=[delete_file, send_email])

async def main():
    cfg = RunConfig(tracing_disabled=True)
    agent = make_agent()
    r = await Runner.run(agent, "clean up and notify", run_config=cfg)
    print("interruptions:", [(i.tool_name, i.arguments) for i in r.interruptions])
    saved = r.to_state().to_string()          # durable checkpoint (e.g. DB)
run_output.txtsrc
### 02_approval_resume.py
interruptions: [('delete_file', '{"path": "/tmp/x"}'), ('send_email', '{"to": "a@b.c"}')]
serialized state: 12612 chars
interruptions after resume: []
final: Model saw tool outputs: call_0=deleted /tmp/x | call_1=Human declined sending email.
Open the template →

04 / The library

Pinned framework versions. "Coming soon" means not tested yet, and we label it that way.

OpenAI Agents SDK · v0.24.0 · Python✓ Tested

Approval + resume & tool error formatting

Pause tool calls for human approval, checkpoint the run to a string, approve/reject, and resume. Plus the new run-wide tool_error_formatter that scrubs secrets from crashed tools.

#human-in-the-loop#durability#error handlingTested on openai-agents 0.24.0Free
LangGraph · 1.2.15 · Python✓ Tested

Human-in-the-loop approval graph

A propose → approval → execute StateGraph that pauses with interrupt(), saves the pause in a checkpointer under a thread_id, and resumes with Command(resume=...) down the approve or reject path.

#human-in-the-loop#checkpointingTested on langgraph 1.2.15Free
LangGraph · 1.2.15 · Python✓ Tested

Idempotent approval: no double charges on resume

When a run resumes after interrupt(), LangGraph re-runs the paused node from the top. This template shows the double-charge bug and the fix: approval-only node, separate execute node, and an idempotency key derived from thread_id.

#human-in-the-loop#idempotency#checkpointingTested on langgraph 1.2.15Pro
OpenAI Agents SDK · v0.24.0 · Python✓ Tested

Idempotent approved side effects

The Agents SDK runs an approved tool once per resume, but side effects still repeat on a duplicate resume of the same RunState or a whole-run retry after a crash. Shows both failures (2 charges) and the fix (1 charge).

#human-in-the-loop#idempotency#durabilityTested on openai-agents 0.24.0Pro
LangGraph · 1.2.15 · Python✓ Tested

Multiple interrupts in one node

One node calls interrupt() three times and is answered one resume at a time (matched by position), plus two parallel nodes that interrupt in the same step and are resumed together with a dict keyed by Interrupt.id.

#human-in-the-loop#interruptsTested on langgraph 1.2.15Free
LangGraph · 1.2.15 · Python✓ Tested

Minimal web UI to approve paused runs

A stdlib-only web page (plain HTML forms, no JavaScript) that lists paused LangGraph runs and approves or rejects them with Command(resume=...), with 409/400/404 handling.

#human-in-the-loop#approval UITested on langgraph 1.2.15Free

What broke in agent frameworks this week.

Every Monday: releases that matter, what to skip, and one starter you can run today. Free.

Get the Monday email ↓