Human-in-the-Loop

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page’s examples target Python 3.12 with LangChain 1.4.2 / langchain-core 1.6.5 / LangGraph 1.2.12 and langchain-ollama 1.1.0, Java 21 with LangChain4j 1.20.2 (langchain4j-agentic 1.20.2-beta30), and Spring Boot 4.1 with Spring AI 2.0.1, using DocsAssistant's Ollama llama3.1:8b chat model, as checked on 2026-09-28 (see the AI Agents baseline).

An agent that only reads can be wrong; an agent that writes can be wrong in the world. Human-in-the-loop (HITL) design decides where a person must look before the agent proceeds, what they can do when they look, and how the agent waits for them without losing its place. This page pays off the forward reference from Tool Design: DocsAssistant’s one write action, opening a GitHub issue when a page is wrong, never runs without a human’s approval.

Approval gates for write actions

An approval gate pauses the agent immediately before a consequential action, shows a reviewer exactly what will happen — the tool, the arguments, the agent’s reasons — and lets them decide:

  • approve — run the action as proposed;

  • edit — change the arguments (fix the issue title, drop a label) and run the edited action;

  • reject — do not run it, optionally telling the agent why so it can adjust;

  • respond — do not run the tool, and answer on its behalf (the reviewer supplies the result).

Three properties make a gate trustworthy. It is enforced by the runtime, not requested in the prompt, so no model output can skip it. It shows the actual call that will execute, not the model’s description of it. And it is durable: the agent’s state is checkpointed at the pause, so the reviewer can answer minutes or days later, from a different process, and the run resumes exactly where it stopped.

sequenceDiagram participant U as User participant A as DocsAssistant participant CP as Checkpointer participant H as Reviewer participant G as GitHub U->>A: "The memory page is wrong" A->>A: gather evidence, propose github_open_issue(...) A->>CP: interrupt: save state + pending call A-->>H: review request (tool, args, reasons) H->>A: resume with approve / edit / reject CP->>A: restore state alt approve or edit A->>G: create issue (scoped token) G-->>A: issue URL else reject A->>A: tell the user why not end A-->>U: result

Interrupt and resume in LangChain and LangGraph

HumanInTheLoopMiddleware

For a create_agent agent, the gate is one middleware: interrupt_on maps each tool name to either False (never interrupt) or the decisions a reviewer may take. The agent needs a checkpointer, because an interrupt is a saved state that a later call resumes.

Gating DocsAssistant’s write tool
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

agent = create_agent(
    ChatOllama(model="llama3.1:8b", temperature=0),
    tools=[docs_search, github_open_issue],
    middleware=[HumanInTheLoopMiddleware(interrupt_on={
        "github_open_issue": {"allowed_decisions": ["approve", "edit", "reject"]},
        "docs_search": False,
    })],
    checkpointer=InMemorySaver(),
)

config = {"configurable": {"thread_id": "report-42"}}
result = agent.invoke(
    {"messages": [("user", "The memory page says MessageWindowChatMemory keeps 10 messages.")]},
    config,
)
request = result["__interrupt__"][0].value   # {"action_requests": [...], "review_configs": [...]}

# ...later, when the reviewer has decided (same thread_id):
agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config)

When the model asks for github_open_issue, the run stops before the tool executes and returns an interrupt entry. Its value lists each pending call and what the reviewer may do with it — this is the actual payload, as produced by the example:

The review request the middleware produces
{
  "action_requests": [{
    "name": "github_open_issue",
    "args": {
      "page_path": "modules/ROOT/pages/ai/agents/memory.adoc",
      "problem": "States the wrong default window size for MessageWindowChatMemory.",
      "evidence": "https://docs.spring.io/spring-ai/reference/api/chat-memory.html"
    },
    "description": "Tool execution requires approval\n\nTool: github_open_issue\nArgs: {...}"
  }],
  "review_configs": [{
    "action_name": "github_open_issue",
    "allowed_decisions": ["approve", "edit", "reject"]
  }]
}

The resume payload carries one decision per pending call, in order: {"type": "approve"}, {"type": "edit", "edited_action": {"name": …​, "args": {…​}}}, {"type": "reject", "message": "…​"} or {"type": "respond", "message": "…​"}. In production, the InMemorySaver becomes a durable checkpointer (see Memory) so the pause survives restarts. See LangChain — Human-in-the-loop.

LangGraph interrupt

In a hand-built graph, interrupt(value) pauses inside any node: value is handed to the caller, and the node resumes — re-running from its start — with whatever the caller passes as Command(resume=…​). This version also routes by the model’s confidence, which the middleware cannot do on its own:

An approval node with confidence-based escalation
from typing import Literal, TypedDict

from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from pydantic import BaseModel

llm = ChatOllama(model="llama3.1:8b", temperature=0)
ESCALATE_BELOW = 0.5         # too unsure to even propose an issue: a human investigates instead


class Assessment(BaseModel):
    page_is_wrong: bool
    confidence: float        # 0.0-1.0, the model's own estimate
    problem: str
    evidence: str


class ReportState(TypedDict):
    page_path: str
    user_report: str
    assessment: dict         # plain data, so the checkpointer can serialise it
    outcome: str


def assess(state: ReportState) -> dict:
    a = llm.with_structured_output(Assessment).invoke(
        f"User says {state['page_path']} is wrong: {state['user_report']}. Check the official "
        "sources; say whether the page is wrong, how confident you are (0-1), and the evidence.")
    return {"assessment": a.model_dump()}


def route(state: ReportState) -> Literal["approval", "escalate", "__end__"]:
    a = state["assessment"]
    if not a["page_is_wrong"]:
        return END
    return "approval" if a["confidence"] >= ESCALATE_BELOW else "escalate"


def approval(state: ReportState) -> Command[Literal["file_issue", "__end__"]]:
    a = state["assessment"]
    decision = interrupt({                          # pauses here; the value goes to the caller
        "question": "Open this GitHub issue?",
        "page_path": state["page_path"],
        **a,
    })
    if decision["type"] == "approve":
        return Command(goto="file_issue")
    if decision["type"] == "edit":
        return Command(goto="file_issue", update={"assessment": {**a, "problem": decision["problem"]}})
    return Command(goto=END, update={"outcome": f"rejected: {decision.get('reason', '')}"})


def file_issue(state: ReportState) -> dict:
    a = state["assessment"]
    result = github_open_issue.invoke(
        {"page_path": state["page_path"], "problem": a["problem"], "evidence": a["evidence"]})
    return {"outcome": str(result)}


def escalate(state: ReportState) -> dict:
    return {"outcome": "escalated to the docs maintainers' triage queue"}


builder = StateGraph(ReportState)
builder.add_node("assess", assess)
builder.add_node("approval", approval)
builder.add_node("file_issue", file_issue)
builder.add_node("escalate", escalate)
builder.add_edge(START, "assess")
builder.add_conditional_edges("assess", route)
builder.add_edge("file_issue", END)
builder.add_edge("escalate", END)
report_flow = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "report-42"}}
paused = report_flow.invoke(
    {"page_path": "modules/ROOT/pages/ai/agents/memory.adoc",
     "user_report": "It says MessageWindowChatMemory keeps 10 messages by default."},
    config,
)
review_request = paused["__interrupt__"][0].value      # show this to the reviewer

finished = report_flow.invoke(Command(resume={"type": "approve"}), config)

Because the node re-runs from the beginning on resume, code before interrupt() must be safe to repeat — here it only reads state; never call the write tool before the interrupt. The assessment is stored as a plain dict (model_dump()), which the checkpointer can serialise without registering custom types. See LangGraph — Persistence and LangChain — Human-in-the-loop.

LangChain4j and Spring AI

Neither Java framework has a durable, checkpointed interrupt for tool calls, so approval uses one of two patterns.

Ask the human in the flow. LangChain4j’s agentic module has a built-in HumanInTheLoop agent: a non-AI agent whose responseProvider asks a person and writes the answer into the AgenticScope. Marked async(true), it lets agents that do not need the answer keep running while it waits.

LangChain4j: a human approval step inside a workflow
import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.agentic.UntypedAgent;
import dev.langchain4j.agentic.workflow.HumanInTheLoop;

HumanInTheLoop approval = AgenticServices.humanInTheLoopBuilder()
        .description("Asks a maintainer to approve the drafted issue")
        .outputKey("approval")
        .async(true)
        .responseProvider(scope -> approvalInbox.askAndWait(          // your UI / chat / queue
                (String) scope.readState("issueDraft")))
        .build();

UntypedAgent reportFlow = AgenticServices.sequenceBuilder()
        .subAgents(issueDrafter, approval, issueFiler)   // issueFiler checks "approval" first
        .outputKey("result")
        .build();

See LangChain4j — Agents and Agentic AI (human-in-the-loop). This holds a thread (or a future) for as long as the human takes, so it suits minutes, not days.

Persist a pending action. For approvals that may take days, the tool the model calls does not perform the write at all: it records a pending action and returns its ID; a separate, authenticated endpoint — not reachable by the model — executes it when a human approves. This works identically in LangChain4j and Spring AI.

Spring AI: the model proposes, a human approves out of band
public class IssueProposalTools {

    private final PendingActionRepository pending;

    public IssueProposalTools(PendingActionRepository pending) {
        this.pending = pending;
    }

    @Tool(name = "github_propose_issue", description = """
            Propose a GitHub issue for a wrong documentation page. It is NOT filed until a
            maintainer approves it; tell the user it is awaiting review.""")
    public String propose(String pagePath, String problem, String evidence, ToolContext ctx) {
        PendingAction action = pending.save(PendingAction.issue(
                pagePath, problem, evidence, (String) ctx.getContext().get("reporter")));
        return "Proposed as pending action " + action.id() + "; awaiting maintainer approval.";
    }
}

@RestController
class ApprovalController {                        // secured: maintainers only

    private final PendingActionRepository pending;
    private final GitHubTools github;             // the least-privilege write tool

    ApprovalController(PendingActionRepository pending, GitHubTools github) {
        this.pending = pending;
        this.github = github;
    }

    @PostMapping("/approvals/{id}/approve")
    String approve(@PathVariable UUID id) {
        PendingAction a = pending.claim(id);      // PENDING -> APPROVED, exactly once
        return github.openIssue(a.pagePath(), a.problem(), a.evidence(),
                new ToolContext(Map.of("reporter", a.reporter())));
    }
}

The pending record is also the audit trail (who proposed, who approved, when), and claim makes approval idempotent. Spring AI 2.0 also lets you take over the tool loop per call — disable the auto-registered tool-calling advisor with AdvisorParams.toolCallingAdvisorAutoRegister(false) and execute each round yourself with ToolCallingManager.executeToolCalls(…​) — which is the documented way to gate tool execution on an in-process approval step. For asking the user a clarifying question mid-task, the community spring-ai-agent-utils library provides AskUserQuestionTool. See Spring AI — Tool calling (User-Controlled Tool Execution) and spring-ai-agent-utils.

Escalation thresholds and uncertainty routing

Approval gates are about consequence; escalation is about uncertainty. The idea is to route each decision by how sure the agent is: act when confident, ask when unsure, hand over when lost. Useful uncertainty signals include a self-reported confidence (cheap, but models are often over-confident — calibrate it against labelled cases), the agreement between several samples (self-consistency: no majority means escalate), retrieval scores below a floor, a critic’s rejection, or simply a policy category ("anything touching billing goes to a human").

Design the thresholds explicitly and per action, and measure them:

  • set them from data — plot the agent’s accuracy against its confidence on an evaluation set and choose the point where acting autonomously meets the error rate you can accept (see Agent Evaluation);

  • make escalation cheap and graceful for the user ("I’ve passed this to a maintainer; you’ll get a link") so the agent is not tempted, by its prompt, to avoid it;

  • log every escalation with its reason — the escalation rate is one of the most useful health metrics an agent has, and it is the input for improving the agent later (see Learning & Improvement).

The autonomy slider

The same agent can run at different levels of autonomy, and the level is best set per action rather than per agent:

Level What the agent does DocsAssistant example

Manual

Nothing on its own; a human performs the action, perhaps from the agent’s draft

A maintainer files the issue themselves from DocsAssistant’s summary

Suggest

Proposes the action; a human approves, edits or rejects it

DocsAssistant drafts the issue; a maintainer clicks approve

Act

Performs the action and reports afterwards

DocsAssistant searches the docs without asking

Autonomy is earned: start write actions at suggest, measure how often reviewers approve unchanged, and move an action towards act only when that rate, and the cost of the rare mistake, justify it. Moving back must be just as easy — a configuration change, not a redeploy. With the middleware above, the slider is literally a mapping:

The autonomy slider as configuration
from langchain.agents.middleware import HumanInTheLoopMiddleware

# The autonomy slider, per tool: "manual" (a human does it), "suggest" (the agent proposes,
# a human approves or edits), "act" (the agent acts on its own).
AUTONOMY = {
    "docs_search": "act",
    "docs_get_section": "act",
    "github_open_issue": "suggest",
}

DECISIONS = {
    "manual": {"allowed_decisions": ["respond", "reject"]},   # a human supplies the result
    "suggest": {"allowed_decisions": ["approve", "edit", "reject"]},
    "act": False,                                             # no interrupt
}


def hitl_for(autonomy: dict[str, str]) -> HumanInTheLoopMiddleware:
    return HumanInTheLoopMiddleware(interrupt_on={t: DECISIONS[level] for t, level in autonomy.items()})

"respond" lets the human supply the tool result instead of running the tool, which is exactly manual. See LangChain — Human-in-the-loop.

In other frameworks

Framework Human-in-the-loop support

OpenAI Agents SDK

needs_approval=True (or a callable) on a function tool pauses the run with a pending approval; the saved RunState is approved or rejected and the run resumed.

Claude Agent SDK

The can_use_tool callback decides each tool call (allow, deny, or allow with modified input); permission_mode sets the default level, and hooks such as PreToolUse can require confirmation.

Microsoft Agent Framework

approval_mode="always_require" on a tool, and request/response steps in workflows that pause for human input with checkpointing.

CrewAI

human_input=True on a Task asks a person to review the result, and Flows support human feedback steps.