Interruptible Agents: Mid-Task Corrections from Users
Patterns for building interruptible agents that support mid-task corrections — state preservation, approval gates, and human-in-the-loop UX best practices.
Published on • September 9, 2026
AI Assistant

Interruptible Agents: Mid-Task Corrections from Users
The agent goes off-track and you can’t stop it. It’s burning API credits, modifying files, or sending emails you never authorized. Without an interruption surface, autonomous loops produce a “let it finish or lose everything” experience that destroys user trust.
Why HITL Is a State Problem, Not a UI Problem
“Human-in-the-loop is not a UI feature. It is a state-persistence problem wearing a UI costume.” The hard problem isn’t the approval button — it’s that the agent must pause for seconds-to-days and resume the exact execution state: which tool, which arguments, which step in a multi-call plan.
Five Interruption Trigger Patterns
| Pattern | Description | Best For |
|---|---|---|
| Action allowlist/blocklist | Certain tool calls always gated | Simple, static policies |
| Risk score | Each action scored for risk | Flexible, needs calibration |
| Confidence threshold | Agent self-reports confidence | Cheap, crude |
| Critical path checkpoints | Fixed gates at milestones | Most predictable for users |
| User-driven pause | User can interject at any time | Maximum control |
LangGraph’s interrupt() Pattern
from langgraph.types import interrupt
def approve_node(state):
# Pause and surface value to client
approval = interrupt({
"action": "send_email",
"to": state["recipient"],
"subject": state["subject"],
"preview": state["body"][:200],
})
if approval == "approved":
return send_email(state)
return {"status": "cancelled"}
Resume with Command(resume=...). Key gotcha: resume re-executes the entire node from the top, so interrupt() must be at the top or side effects must be idempotent.
OpenAI Agents SDK Approval Flow
from agents import Agent, tool
@tool(needs_approval=True)
async def cancel_order(order_id: int) -> str:
return f"Cancelled order {order_id}"
# Dynamic approval rules
async def requires_review(_ctx, params, _call_id) -> bool:
return "refund" in params.get("subject", "").lower()
@tool(needs_approval=requires_review)
async def send_email(subject: str, body: str) -> str:
return f"Sent '{subject}'"
The flow: Model emits tool call → runner evaluates needs_approval → if required, execution pauses → RunResult.interruptions contains ToolApprovalItem → convert to RunState → approve/reject → resume.
Serialization for Long-Running Approvals
# Serialize state for durable storage
state_json = result.to_state().to_json()
# Store in database/queue
await db.store(f"approval:{run_id}", state_json)
# Later: recreate and resume
from agents import RunState
state = RunState.from_json(agent, stored_json)
await Runner.run(agent, state)
State Preservation: Checkpointing
Critical checkpoint fields:
| Field | Purpose |
|---|---|
runId | Prevents two workers resuming the same run |
stateVersion | Rejects or migrates old checkpoints |
status | Running, paused, approval, cancellation, failure |
nextStep | First unconfirmed transition |
pendingApproval | What was approved/rejected |
toolReceipts | Observed results and external IDs |
updatedAt | Detects abandoned ownership |
Two approaches:
- Deterministic replay (Temporal/Inngest) — State = inputs + log of side-effects; re-execute, skip side-effects with logged results
- Checkpoint snapshots (LangGraph) — Periodically serialize plan, working memory, partial outputs; restore on restart
Three Gate Patterns
Propose-then-confirm — Agent produces concrete action → human approves before execution. Best for high-stakes one-offs.
Dry-run/preview — Agent computes full effect without committing → human sees ground truth. Strictly better than propose-then-confirm when effect is computable.
Auto-approve allowlist — Actions proven safe through repetition run without prompt. Start strict, widen as evidence accumulates.
UI/UX: What to Show
The approval screen must answer three questions:
- What the agent is about to do (concrete action + arguments)
- Why (agent’s reasoning or previous steps)
- What could go wrong (failure modes)
Risk-based UI:
- Low-risk: Single approve button
- Medium-risk: Show objects + reversibility
- High-risk: Explicit confirmation with consequences
- Irreversible: Typed confirmation
Avoid over-gating: “If the agent asks approval on every search or file read, oversight becomes unbearable and the person approves without reading.”
Progressive Delegation
Start with conservative autonomy, expand as users build trust. Capture per-user approval patterns and persist them. New contexts start conservative; users who consistently approve a category get it moved to auto-approve.
Framework Comparison
| Framework | HITL Primitive | Where State Lives |
|---|---|---|
| OpenAI Agents SDK | needs_approval + RunResult.interruptions | RunState.to_json() |
| LangGraph | interrupt() + Command(resume=...) | Checkpointer (required) |
| PydanticAI | requires_approval + DeferredToolRequests | Deferred-tool results |
| Temporal | Signal + wait_condition | Durable Event History |
The Takeaway
Interruptible agents require solving state persistence first. Use interrupt() or needs_approval for the pause mechanism, checkpoint your state durably, and build approval UIs that answer what/why/what-could-go-wrong. Start with a blocklist approach and graduate to risk-score-based gating as you accumulate production data.
💡 Treat every agent action as a potential interruption point — users should always be able to say “stop” and get a clean state.