mech.app
Dev Tools

Human-in-the-Loop Approval: How 12 Agent Frameworks Handle the Pause Button (and Why Most Fail)

Most agent frameworks treat human approval as input() and hope. We audited 12 frameworks for durability, idempotency, and typed I/O. Two pass.

Source: dev.to
Human-in-the-Loop Approval: How 12 Agent Frameworks Handle the Pause Button (and Why Most Fail)

Teams ship agent systems into production and discover on day three that “the agent needs to wait for a human sometimes” breaks every assumption in their stack. Not because they didn’t plan for human-in-the-loop (HITL). Because most agent frameworks reduce human approval to blocking the Python process on input() and hoping for the best.

A recent audit of twelve popular agent frameworks against a strict production rubric reveals the gap. Two frameworks pass. Ten fail in production. The failure modes cluster around state durability, idempotency, and channel abstraction.

The Production HITL Rubric

A production human approval primitive needs six properties. Most frameworks ship zero.

AxisWhat It MeasuresWhy It Matters
DurabilityDoes the agent survive a worker restart during a pending await? Is paused state in durable storage (Postgres), not in-process memory?Worker rotation kills in-memory state. Your approval request vanishes.
IdempotencyIf the agent retries after a crash, can the same approval resolve once without double-acting?Retry logic without idempotency keys charges the customer twice.
Typed I/OIs the request payload AND the human’s response a typed schema (Pydantic / Zod)? Or just str?Untyped responses break downstream tool calls. No validation means runtime explosions.
Channel AbstractionCan you swap channel (terminal → Slack → email → dashboard) without rewriting the agent?Hardcoded input() calls mean your approval flow lives in stdin forever.
Verifier HookIs there a built-in slot for an AI quality check on the human’s response before resuming?Approval response validation prevents type confusion between yes/no/maybe responses and catches malformed input.
Default UIDoes the framework ship an admin UI to view, claim, resolve in-flight tasks?Without a UI, your ops team refreshes a database table in pgAdmin.

Score 1 (absent/broken) to 5 (production-ready primitive in core). Max 30.

The Scorecard

The audit covered LangGraph, CrewAI, AutoGPT, LlamaIndex Workflows, Semantic Kernel, Haystack, Rivet, Flowise, n8n, Temporal (with AI SDK), Inngest, and Prefect.

Top Tier

FrameworkScoreNotes
LangGraph28/30Durable checkpoints, typed state, interrupt-before pattern. No default UI.
Temporal + AI SDK27/30Durable execution, signals, typed payloads, built-in idempotency. Requires Temporal infra.

Middle Tier

FrameworkScoreNotes
Inngest16/30Durable task queue with pause/resume, typed payloads. No verifier hooks or approval UI.
Prefect15/30Similar to Inngest. Strong task orchestration, weak approval primitives.
n8n14/30Visual workflow builder with wait nodes. State serialization is fragile.
Flowise13/30Similar to n8n. Idempotency is manual.

Bottom Tier

FrameworkScoreNotes
CrewAI8/30In-memory state, no idempotency, untyped I/O.
AutoGPT7/30Hardcoded input() calls, no durability.
LlamaIndex Workflows9/30In-memory state, no channel abstraction.
Semantic Kernel8/30No durable state, untyped I/O.
Haystack7/30In-memory state, no idempotency.
Rivet6/30Visual editor, but no production approval primitives.

The pattern: frameworks built on durable execution engines (Temporal, Inngest, Prefect) handle pauses correctly. Frameworks built on synchronous Python loops do not.

Implementation Patterns That Work

LangGraph Interrupt-Before

LangGraph’s interrupt_before mechanism checkpoints the entire execution graph before a specified node. The agent pauses, writes state to Postgres, and returns a thread ID. The human approval arrives via API, and the agent resumes from the checkpoint.

from langgraph.graph import StateGraph
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.errors import CheckpointNotFoundError

# Define graph with interrupt
graph = StateGraph(AgentState)
graph.add_node("approve_action", human_approval_node)
graph.add_edge("plan", "approve_action")

# Compile with durable checkpointer
checkpointer = PostgresSaver.from_conn_string("postgresql://...")
app = graph.compile(checkpointer=checkpointer, interrupt_before=["approve_action"])

# Run until interrupt
config = {"configurable": {"thread_id": "abc123"}}
try:
    result = app.invoke({"input": "transfer $5000"}, config)
    # Agent pauses. State is in Postgres.
except Exception as e:
    # Checkpoint write failed (disk full, Postgres down)
    logger.error(f"Checkpoint write failed: {e}")
    # Fail fast: alert ops, do not proceed
    raise

# Later: human approves via API
try:
    app.invoke(None, config)  # Resumes from checkpoint
except CheckpointNotFoundError:
    # Thread ID doesn't exist or checkpoint corrupted
    logger.error(f"Cannot resume thread {config['configurable']['thread_id']}")
    # Recovery path: notify human via fallback channel (email, Slack)
    # that approval request was lost. Human must re-initiate the action.
    # Do NOT restart from scratch automatically (risk of double-execution).
    send_alert_to_human(
        "Approval request lost due to checkpoint failure. "
        "Please re-submit the original request."
    )
    raise

The checkpoint includes the full state graph, pending tool calls, and LLM message history. If the worker dies, another worker loads the checkpoint and continues. The thread_id acts as the idempotency key. If the checkpoint write fails (disk full, Postgres down), the exception surfaces immediately and the agent fails fast. If the thread ID is invalid on resume, you get CheckpointNotFoundError. The production-safe recovery path is to notify the human that the approval request was lost and require manual re-submission. Automatic restart risks double-execution if the checkpoint was partially written.

Temporal Signal Pattern

Temporal’s signal mechanism allows external events to unblock a paused workflow. The workflow waits on a signal, the human sends the signal via API, and the workflow resumes.

import { defineSignal, setHandler, condition } from '@temporalio/workflow';
import { ApplicationFailure } from '@temporalio/common';

const approvalSignal = defineSignal<{ approved: boolean }>('humanApproval');

export async function agentWorkflow(input: string) {
  let approved = false;
  
  setHandler(approvalSignal, (payload) => {
    approved = payload.approved;
  });
  
  // Pause and wait for signal
  try {
    // condition() blocks until approved === true OR 7 days elapse
    // If timeout fires, condition() throws TimeoutError
    await condition(() => approved, '7d');
  } catch (err) {
    // Timeout expired, no approval received
    // Temporal guarantees cleanupPendingApproval() runs exactly once
    // even if the workflow is replayed or the worker restarts
    await cleanupPendingApproval(input);
    throw ApplicationFailure.nonRetryable(
      'Approval timeout after 7 days',
      'ApprovalTimeout'
    );
  }
  
  if (!approved) {
    // Human explicitly rejected (approved === false)
    await logRejection(input);
    throw ApplicationFailure.nonRetryable('Action rejected by human', 'Rejected');
  }
  
  // Continue execution
  return executeAction(input);
}

Temporal’s durable execution model persists the workflow state across worker restarts. The signal is delivered exactly once, even if the workflow retries. The 7-day timeout prevents infinite waits. When condition() times out, it throws a TimeoutError. The catch block runs cleanupPendingApproval(), which Temporal guarantees executes exactly once (even if the workflow is replayed or the worker crashes mid-cleanup). The workflow then throws a non-retryable error, marking the workflow as failed. If the human rejects, the workflow logs the rejection and fails explicitly.

Why Most Frameworks Fail

In-Memory State

CrewAI, AutoGPT, and LlamaIndex Workflows store agent state in Python process memory. When the worker restarts (Kubernetes pod eviction, OOM kill, deployment), the paused state vanishes. The approval request never resolves. The agent never resumes.

Kubernetes default grace period is 30 seconds. If your approval takes 5 minutes, in-memory state is lost on any pod rotation. If your approval takes 3 hours (human is in a meeting), the pod is evicted after typical idle timeouts (10-15 minutes). State loss becomes the expected case, not the edge case.

No Idempotency Keys

Frameworks without idempotency keys double-act on retry. The agent crashes mid-approval, retries, sends a second approval request, and the human approves both. The action executes twice. The customer is charged twice.

Untyped I/O

Frameworks that accept approval responses as str or dict have no validation layer. The human types “approve” instead of “approved”. The agent crashes on string comparison. Or worse, the agent interprets “no” as truthy and proceeds.

Hardcoded Channels

Frameworks that call input() or print() for approval lock you into terminal I/O. Moving to Slack requires rewriting the agent. Moving to email requires rewriting again. There’s no abstraction layer.

Failure Modes in Production

Worker rotation during approval:

  • Agent pauses for approval.
  • Kubernetes evicts the pod after 10 minutes (typical idle timeout).
  • New pod starts. No checkpoint. Approval request is orphaned.
  • Human approves. Nothing happens.

Retry without idempotency:

  • Agent sends approval request.
  • Worker crashes before recording the request ID.
  • Agent retries. Sends second approval request.
  • Human approves both. Action executes twice.

Timeout handling:

  • Agent pauses for approval.
  • Human is on vacation for 3 days.
  • Framework has no timeout. Agent waits forever.
  • Worker eventually OOMs or gets killed. No cleanup.

Channel lock-in:

  • Agent uses input() for approval.
  • You deploy to a server. No stdin.
  • Approval requests hang forever.

These failure modes surface after the initial deploy succeeds. The agent works in local testing (single process, no restarts, terminal I/O). Production introduces worker churn, network partitions, and async communication channels. The approval primitive breaks under these conditions.

Architecture for Durable Approval

A production-ready approval system needs four components:

  1. Durable state store: Postgres, Redis, or a workflow engine’s native storage. Must survive worker restarts.
  2. Idempotency layer: UUID or hash-based keys for approval requests. Duplicate approvals resolve to the same action.
  3. Channel adapter: Abstract interface for sending approval requests and receiving responses. Swap implementations without touching agent code.
  4. Timeout and cleanup: Explicit timeouts for approval waits. Cleanup logic for expired requests.
from typing import Protocol, Literal
from datetime import datetime, timedelta
from uuid import uuid4

class ApprovalRequest:
    id: str  # Idempotency key
    agent_id: str
    action: dict
    status: Literal["pending", "approved", "rejected", "expired"]
    created_at: datetime
    expires_at: datetime

class ApprovalChannel(Protocol):
    def send(self, request: ApprovalRequest) -> None: ...
    def poll(self, request_id: str) -> ApprovalResponse | None: ...

# In agent code
request = ApprovalRequest(
    id=str(uuid4()),
    action={"type": "transfer", "amount": 5000},
    status="pending",
    created_at=datetime.utcnow(),
    expires_at=datetime.utcnow() + timedelta(hours=24)
)

try:
    db.save(request)  # Durable storage
except Exception as e:
    logger.error(f"Failed to persist approval request: {e}")
    raise

try:
    channel.send(request)  # Slack, email, dashboard
except Exception as e:
    # Channel send failed, but request is in DB
    logger.warning(f"Failed to send approval notification: {e}")
    # Retry or alert ops

# Wait for response with timeout
response = await wait_for_approval(request.id, timeout=timedelta(hours=24))
if response.approved:
    execute_action(request.action)

The ApprovalChannel abstraction decouples the agent from the notification mechanism. The wait_for_approval function polls the database or listens for a signal. The timeout ensures cleanup. If db.save() fails, the agent crashes immediately (fail fast). If channel.send() fails, the request is still in the database and can be retried or manually resolved.

Technical Verdict

Use LangGraph if:

  • You deploy to environments with worker churn (Kubernetes, serverless, autoscaling clusters) where pod evictions and restarts are routine operations. The audit shows in-memory frameworks lose state on every restart, while LangGraph’s Postgres checkpoints survive.
  • You need typed state schemas and built-in idempotency without building your own checkpoint system.
  • You can build your own approval UI (LangGraph has no default dashboard). The interrupt-before API is clean, but you’ll need to expose it via REST or GraphQL for human operators.

Avoid LangGraph if:

  • You run single-process deployments with no worker rotation (local dev, single-server setups with manual restarts only). In-memory state works fine when the process never dies mid-approval.
  • You need a turnkey approval dashboard out of the box. LangGraph provides the primitives but no admin interface.

Use Temporal if:

  • You already run Temporal infrastructure or can justify the operational overhead (server cluster, Postgres/Cassandra, worker pools). Temporal is not a library you add to an existing service. It’s a separate system.
  • You need approval workflows that span days or weeks (vacation approvals, compliance reviews, multi-stage sign-offs). Temporal’s signal delivery and timeout handling are production-tested for long-running workflows.
  • You need signal-based coordination with external systems (webhooks, third-party APIs, event-driven architectures).

Avoid Temporal if:

  • You don’t have Temporal infrastructure and can’t justify the setup cost for a single approval use case. Running Temporal requires dedicated infrastructure (server cluster, database, worker pools). The operational burden is high.
  • Your approvals are synchronous (under 1 minute) and don’t need durability. Temporal is overkill for quick human confirmations in interactive sessions.

Use Inngest or Prefect if:

  • You already have a task queue (Celery, Bull, RabbitMQ) and want to extend it with approval primitives. Both frameworks integrate with existing queue infrastructure.
  • You need durable task orchestration but can build your own approval UI and verifier hooks. Inngest and Prefect provide the pause/resume primitives but not the full approval workflow.
  • Your approval SLA is measured in minutes to hours (not days). Both frameworks handle worker restarts correctly but lack the multi-day timeout handling and signal coordination of Temporal.

Avoid Inngest or Prefect if:

  • You need built-in approval UI or verifier hooks. Both frameworks require custom implementation for these components.
  • Your approval workflows span multiple days or require complex signal coordination. Use Temporal for long-running workflows with external event dependencies.

The core decision is durability. If your deployment model includes worker restarts during approval waits (Kubernetes, serverless

Tags

agentic-ai orchestration infrastructure

Primary Source

dev.to