ActionBoxBlog

ML engineerBackendPlatformAutomation

LangGraph Human-in-the-Loop Approval with Actionbox

Add LangGraph human-in-the-loop approval with interrupt, a stable thread_id, Actionbox review, and fail-closed Command resume.

Network cables and connected server hardware

LangGraph's interrupt() can pause a graph before a consequential operation. Actionbox turns that surfaced interrupt into a durable request for a person, then the graph driver resumes the same checkpoint with Command(resume=...).

The safe LangGraph human-in-the-loop boundary keeps state ownership explicit:

Architecture Flow
1 Save checkpoint at interrupt 2 Surface interrupt payload 3 Create idempotent Action 4 Notify with bounded context 5 Approve or reject 6 Return typed boolean decision 7 Resume the same thread_id 8 Continue after interrupt LangGraph node Checkpointer Graph driver Actionbox Reviewer Store- API- LangGraph node Checkpointer Graph driver Actionbox Reviewer Store- API-

What LangGraph owns—and what Actionbox owns

ConcernOwner
Graph state and checkpointLangGraph checkpointer
Interrupt ID and thread_idLangGraph
Reviewer notification and inboxActionbox
Typed approval or rejectionActionbox
Side effect and its credentialsYour graph node or tool adapter

Actionbox does not replace the LangGraph checkpointer. Use a durable checkpointer in production and resume with the same stable thread_id used when the interrupt was created.

Install the SDKs

bash
python -m pip install actionbox-sdk langgraph
export ACTIONBOX_API_KEY="axb_live_..."

Complete LangGraph interrupt approval example

Keep interrupt() inside the graph node. Create the Actionbox Action in the driver after LangGraph surfaces the interrupt; otherwise the node can repeat that network side effect when it restarts on resume.

python
from __future__ import annotations

import asyncio
import hashlib
import json
import os
from typing import Any, TypedDict

from actionbox import Actionbox
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt


class ApprovalState(TypedDict, total=False):
    proposal: dict[str, Any]
    approved: bool
    outcome: str


def approval_node(state: ApprovalState) -> dict[str, bool]:
    proposal = state["proposal"]
    decision = interrupt({
        "kind": "actionbox_approval",
        "title": proposal["title"],
        "tool": proposal["tool"],
        "arguments": proposal["arguments"],
    })
    return {
        "approved": (
            isinstance(decision, dict)
            and decision.get("approved") is True
        )
    }


def execute_node(state: ApprovalState) -> dict[str, str]:
    if not state.get("approved"):
        return {"outcome": "rejected"}
    # Perform the exact proposed side effect here.
    return {"outcome": "executed"}


builder = StateGraph(ApprovalState)
builder.add_node("approval", approval_node)
builder.add_node("execute", execute_node)
builder.add_edge(START, "approval")
builder.add_edge("approval", "execute")
builder.add_edge("execute", END)
graph = builder.compile(checkpointer=InMemorySaver())


async def ask_actionbox(client, pending, thread_id: str) -> dict[str, bool]:
    value = pending.value
    rendered = json.dumps(value, sort_keys=True, ensure_ascii=False)
    identity = hashlib.sha256(
        f"{thread_id}\0{pending.id}".encode()
    ).hexdigest()

    action = await asyncio.to_thread(
        client.create,
        title=str(value.get("title") or "Approve LangGraph operation")[:200],
        description="LangGraph paused before this operation.",
        interaction={
            "type": "boolean",
            "label": "Allow this operation?",
            "true_label": "Approve",
            "false_label": "Reject",
        },
        context=[{
            "type": "code",
            "title": "Interrupt payload — remove secrets before sending",
            "language": "json",
            "content": rendered,
        }],
        metadata={
            "framework": "langgraph",
            "thread_id": thread_id,
            "interrupt_id": pending.id,
        },
        idempotency_key=f"langgraph:{identity}",
    )

    response = await asyncio.to_thread(action.wait, timeout=3600)
    if response is None and action.status == "open":
        await asyncio.to_thread(
            client.cancel,
            action.id,
            "LangGraph approval timed out; rejected fail-closed.",
        )

    return {
        "approved": (
            isinstance(response, dict)
            and response.get("type") == "boolean"
            and response.get("value") is True
        )
    }


async def main() -> None:
    config = {"configurable": {"thread_id": "release-2.18.0"}}
    thread_id = config["configurable"]["thread_id"]
    proposal = {
        "title": "Deploy version 2.18.0 to staging?",
        "tool": "deploy_service",
        "arguments": {"environment": "staging", "version": "2.18.0"},
    }

    with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as actionbox:
        result = await graph.ainvoke({"proposal": proposal}, config=config)

        while result.get("__interrupt__"):
            pending = list(result["__interrupt__"])
            decisions = await asyncio.gather(
                *(ask_actionbox(actionbox, item, thread_id) for item in pending)
            )
            resume = {
                item.id: decision
                for item, decision in zip(pending, decisions, strict=True)
            }
            result = await graph.ainvoke(
                Command(resume=resume),
                config=config,
            )

    print(result["outcome"])


if __name__ == "__main__":
    asyncio.run(main())

The repository contains the maintained runnable version at integrations/agent_frameworks/langgraph_interrupts.py. The example above uses InMemorySaver only to remain self-contained; replace it with a durable checkpointer before production use.

Why the Action belongs outside the interrupt node

LangGraph resumes by restarting the node from its beginning. Any code before interrupt() can run again. Creating an Actionbox Action there can therefore send duplicate requests unless the side effect is perfectly idempotent.

The cleaner pattern is:

  1. Let the node return a JSON-serializable interrupt payload.
  2. Let the driver observe __interrupt__.
  3. Create or recover the idempotent Action outside the graph node.
  4. Resume the same checkpoint with the typed decision.

Use a stable thread_id and interrupt ID

The thread_id reconnects a later invocation to the saved graph state. The interrupt ID identifies the exact paused operation within that thread. Hash both into the Actionbox idempotency key so a restarted driver recovers the same logical approval.

Do not use a random idempotency key on every retry. That turns one paused tool call into multiple human interruptions with potentially conflicting answers.

Resume multiple interrupts by ID

When LangGraph surfaces multiple interrupts, collect a decision for every item and pass an interrupt-ID map to Command(resume=resume). Do not assume the first interruption is the only one, and do not resume unrelated decisions by list position alone.

Fail closed and keep the payload complete

A missing, expired, malformed, or false typed response must resume as {"approved": false}. The execute node then returns without performing the side effect.

Redact secret-bearing arguments before creating the interrupt payload. Set a payload-size limit and reject an approval request that cannot show the complete reviewed operation; silent truncation breaks the connection between what the person saw and what the graph executes.

Production checklist

  • Replace InMemorySaver with a durable checkpointer.
  • Use the same stable thread_id for start and resume.
  • Create Actionbox Actions in the driver, not before interrupt().
  • Hash the thread and interrupt IDs into a stable idempotency key.
  • Handle every surfaced interrupt.
  • Treat timeout, expiry, rejection, and malformed responses as false.
  • Keep the side-effecting node after the approval node and make execution idempotent.
  • Report the real execution outcome after the tool finishes.

For the broader architecture, read human-in-the-loop AI agents. If you use the OpenAI runtime instead, follow the OpenAI Agents SDK human-in-the-loop tutorial. For checkpoint recovery concepts, see durable execution explained.

Create a free Source · Human approval API · Read the integration docs

Sources and further reading

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox