langchain-human-in-the-loop

star 3

Add human oversight to LangChain agents using HITL middleware - includes interrupts, approval workflows, edit/reject decisions, and checkpoints

christian-bromann By christian-bromann schedule Updated 2/11/2026

name: langchain-human-in-the-loop description: Add human oversight to LangChain agents using HITL middleware - includes interrupts, approval workflows, edit/reject decisions, and checkpoints language: js

langchain-human-in-the-loop (JavaScript/TypeScript)

Overview

Human-in-the-Loop (HITL) lets you add human oversight to agent tool calls. When agents propose sensitive actions (like database writes or sending emails), execution pauses for human approval, editing, or rejection.

Key Concepts:

  • humanInTheLoopMiddleware: Pauses execution for human decisions
  • Interrupts: Checkpoint where agent waits for human input
  • Decisions: approve, edit, or reject tool calls
  • Checkpointer: Required for persistence across interruptions

When to Use HITL

Scenario Use HITL? Why
Database writes ✅ Yes Prevent data corruption
Sending emails/messages ✅ Yes Review before sending
Financial transactions ✅ Yes Confirm before executing
Deleting data ✅ Yes Prevent accidental loss
Read-only operations ❌ No Low risk
Internal calculations ❌ No No external impact

Decision Tables

HITL Decision Types

Decision Effect When to Use
approve Execute tool as-is Tool call looks correct
edit Modify args then execute Need to change parameters
reject Don't execute, provide feedback Tool call is wrong

Code Examples

Basic HITL Setup

import { createAgent, humanInTheLoopMiddleware } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import { tool } from "langchain";
import { z } from "zod";

const sendEmail = tool(
  async ({ to, subject, body }) => {
    // Send email logic
    return `Email sent to ${to}`;
  },
  {
    name: "send_email",
    description: "Send an email",
    schema: z.object({
      to: z.string().email(),
      subject: z.string(),
      body: z.string(),
    }),
  }
);

const agent = createAgent({
  model: "gpt-4.1",
  tools: [sendEmail],
  checkpointer: new MemorySaver(),  // Required for HITL
  middleware: [
    humanInTheLoopMiddleware({
      interruptOn: {
        send_email: {
          allowedDecisions: ["approve", "edit", "reject"],
        },
      },
    }),
  ],
});

Running with Interrupts

import { Command } from "@langchain/langgraph";

const config = { configurable: { thread_id: "session-1" } };

// Step 1: Agent runs until it needs to call tool
const result1 = await agent.invoke({
  messages: [{ role: "user", content: "Send email to john@example.com saying hello" }]
}, config);

// Check for interrupt
if ("__interrupt__" in result1) {
  const interrupt = result1.__interrupt__[0];
  console.log("Waiting for approval:", interrupt.value);
  
  // Interrupt contains: {toolCall: {...}, allowedDecisions: [...]}
}

// Step 2: Human approves
const result2 = await agent.invoke(
  new Command({
    resume: {
      decisions: [{ type: "approve" }],
    },
  }),
  config
);

// Tool now executes and agent completes
console.log(result2.messages[result2.messages.length - 1].content);

Editing Tool Arguments

const config = { configurable: { thread_id: "session-2" } };

// Agent wants to send email
const result1 = await agent.invoke({
  messages: [{ role: "user", content: "Email alice about the meeting" }]
}, config);

// Human edits the arguments
const result2 = await agent.invoke(
  new Command({
    resume: {
      decisions: [{
        type: "edit",
        args: {
          to: "alice@company.com",  // Fixed email
          subject: "Project Meeting - Updated",  // Better subject
          body: "...",  // Edited body
        },
      }],
    },
  }),
  config
);

Rejecting with Feedback

const config = { configurable: { thread_id: "session-3" } };

// Agent wants to delete records
const result1 = await agent.invoke({
  messages: [{ role: "user", content: "Delete old customer data" }]
}, config);

// Human rejects
const result2 = await agent.invoke(
  new Command({
    resume: {
      decisions: [{
        type: "reject",
        feedback: "Cannot delete customer data without manager approval",
      }],
    },
  }),
  config
);

// Agent receives feedback and can try alternative approach

Multiple Tools with Different Policies

import { humanInTheLoopMiddleware } from "langchain";

const agent = createAgent({
  model: "gpt-4.1",
  tools: [sendEmail, readEmail, deleteEmail],
  checkpointer: new MemorySaver(),
  middleware: [
    humanInTheLoopMiddleware({
      interruptOn: {
        send_email: {
          allowedDecisions: ["approve", "edit", "reject"],
        },
        delete_email: {
          allowedDecisions: ["approve", "reject"],  // No edit
        },
        read_email: false,  // No HITL for reading
      },
    }),
  ],
});

Streaming with HITL

const config = { configurable: { thread_id: "session-4" } };

// Stream until interrupt
for await (const [mode, chunk] of await agent.stream(
  { messages: [{ role: "user", content: "Send report to team" }] },
  { ...config, streamMode: ["updates", "messages"] }
)) {
  if (mode === "messages") {
    const [token, metadata] = chunk;
    if (token.content) {
      process.stdout.write(token.content);
    }
  } else if (mode === "updates") {
    if ("__interrupt__" in chunk) {
      console.log("\nWaiting for approval...");
      break;  // Handle interrupt
    }
  }
}

// Resume after approval
for await (const [mode, chunk] of await agent.stream(
  new Command({ resume: { decisions: [{ type: "approve" }] } }),
  { ...config, streamMode: ["messages"] }
)) {
  // Continue streaming
}

Custom Interrupt Logic

import { createMiddleware } from "langchain";

const customHITL = createMiddleware({
  name: "CustomHITL",
  wrapToolCall: async (toolCall, handler, runtime) => {
    // Custom logic to decide if interrupt needed
    if (toolCall.name === "database_write") {
      const value = toolCall.args.value;
      
      if (value > 1000) {
        // Interrupt for large values
        const decision = await runtime.interrupt({
          toolCall,
          reason: "Large database write requires approval",
        });
        
        if (decision.type === "approve") {
          return await handler(toolCall);
        } else if (decision.type === "edit") {
          return await handler({ ...toolCall, args: decision.args });
        } else {
          throw new Error(decision.feedback || "Rejected");
        }
      }
    }
    
    // No interrupt needed
    return await handler(toolCall);
  },
});

Boundaries

What You CAN Configure

Which tools require approval: Per-tool policies ✅ Allowed decision types: approve, edit, reject ✅ Custom interrupt logic: Conditional interrupts ✅ Feedback messages: Explain rejections ✅ Modified arguments: Edit tool parameters

What You CANNOT Configure

Skip checkpointer: HITL requires persistence ❌ Interrupt after execution: Must interrupt before ❌ Force model to not call tool: HITL responds after model decides ❌ Modify model's decision-making: Only tool execution

Gotchas

1. Missing Checkpointer

// ❌ Problem: No checkpointer
const agent = createAgent({
  model: "gpt-4.1",
  tools: [sendEmail],
  middleware: [humanInTheLoopMiddleware({...})],  // Error!
});

// ✅ Solution: Always add checkpointer
import { MemorySaver } from "@langchain/langgraph";

const agent = createAgent({
  model: "gpt-4.1",
  tools: [sendEmail],
  checkpointer: new MemorySaver(),  // Required
  middleware: [humanInTheLoopMiddleware({...})],
});

2. No thread_id

// ❌ Problem: Missing thread_id
await agent.invoke(input);  // No config!

// ✅ Solution: Always provide thread_id
await agent.invoke(input, {
  configurable: { thread_id: "user-123" }
});

3. Wrong Resume Syntax

// ❌ Problem: Wrong resume format
await agent.invoke({
  resume: { decisions: [...] }  // Wrong!
});

// ✅ Solution: Use Command
import { Command } from "@langchain/langgraph";

await agent.invoke(
  new Command({
    resume: { decisions: [{ type: "approve" }] }
  }),
  config
);

4. Not Checking for Interrupts

// ❌ Problem: Not detecting interrupt
const result = await agent.invoke(input, config);
console.log(result.messages);  // May not have completed!

// ✅ Solution: Check for __interrupt__
if ("__interrupt__" in result) {
  // Handle human decision
} else {
  // Agent completed
}

Links to Documentation

Install via CLI
npx skills add https://github.com/christian-bromann/langchain-skills --skill langchain-human-in-the-loop
Repository Details
star Stars 3
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator
christian-bromann
christian-bromann Explore all skills →