Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 27

Human-in-the-Loop Approval

Recommended resources

Hello! It's great to continue our work on your automated content creation system.

In our last session, we built the creative engine of our workflow. We implemented parallel agents to generate text for LinkedIn and X, and visual content for Instagram, using a "fan-out, fan-in" pattern. All generated content is now collected by a placeholder joiner node.

Today, we'll implement a crucial quality control layer. The learning outcome for this lesson is to integrate a human-in-the-loop approval step before the final output node. We will replace our passive joiner with an interactive approval node. This will pause the workflow, allowing you to review, edit, or approve the AI-generated content before it's cleared for publishing, ensuring it perfectly aligns with your standards.

The Importance of Human Oversight

Before we dive into the code, let's consider why a human-in-the-loop (HITL) step is so critical in a content automation pipeline:

  • Quality & Brand Alignment: An LLM might occasionally produce content that is factually incorrect, off-brand, or simply lacks the nuance you're aiming for. An approval step is your final defense.
  • Editing & Enhancement: You might see an opportunity to add a timely reference, tweak a sentence for better impact, or adjust a hashtag. HITL allows for this final polish.
  • Safety & Control: It prevents the system from autonomously publishing something inappropriate or nonsensical, giving you complete control over the final output.

We will build a simple approval gate that looks like this:

Human-in-the-Loop Approval Workflow
This diagram shows the basic flow we'll implement. After the LLM generates the content, it's presented to a human who can then approve or reject it, determining the next step in the graph.

The Core Mechanism: Interrupts and Checkpointing

To build our approval node, we'll use a key feature in LangGraph called interrupts. An interrupt allows you to pause the graph's execution at a specific point inside a node and wait for external input before continuing.

For interrupts to work, LangGraph needs two things:

  1. A Checkpointer: A mechanism to save the graph's entire state when it's paused. This is like creating a bookmark. We'll use a simple in-memory checkpointer for now, but in production, this could be backed by a database like Postgres, which fits well with your experience.
  2. A Thread ID: A unique identifier for each run of the graph. This tells the checkpointer which "saved game" to load when you want to resume.

The official LangChain documentation provides the best overview of this mechanism.

Interrupts - Docs by LangChain

Let's start with the 'Interrupts' documentation from LangChain. It clearly explains the concept and its requirements.

Please read the introduction and the 'Pause using interrupt' sections. Focus on the definition of an interrupt, the role of the checkpointer and thread ID, and the sequence of events that happen when the interrupt() function is called inside a node.

To summarize the flow:

  1. Our approvalNode will call interrupt().
  2. The graph pauses, saves its state using the checkpointer, and returns a message to our main script.
  3. Our script prompts you for input (e.g., "approve" or feedback).
  4. You provide input.
  5. Our script resumes the graph by calling app.stream() or app.invoke() again, passing your input inside a Command object.
  6. Execution continues from where it left off inside the approvalNode.

Implementing the Approval Node

Now, let's apply this to our project. We'll replace the joinerNode from the last lesson with a more intelligent approvalNode and update our main script to handle the interaction.

1. Update the State and Graph Definition

First, let's update our ContentPipelineState to track the approval status. We also need to import the necessary functions and add a checkpointer when compiling the graph.

// Add these imports at the top of your file
import { MemorySaver } from "@langchain/langgraph/checkpoint/memory";
import { interrupt, type Command } from "@langchain/langgraph/types";
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

// Update the state interface
interface ContentPipelineState {
  topic: string;
  articles: Article[];
  analysis?: TrendAnalysis;
  linkedInPost?: LinkedInPost;
  xThread?: XThread;
  instagramCarousel?: InstagramCarousel;
  // New fields for the approval process
  isApproved?: boolean;
  feedback?: string;
}

// Update the channels in your StateGraph definition
const workflow = new StateGraph<ContentPipelineState>({
  channels: {
    // ... all previous channels
    isApproved: { value: null, default: () => undefined },
    feedback: { value: null, default: () => undefined },
  },
});

2. Create the approvalNode

This node replaces the old joinerNode. It displays the content and calls interrupt() to pause for your decision.

// Node for human approval
async function approvalNode(state: ContentPipelineState): Promise<Partial<ContentPipelineState>> {
  console.log("\n--- Content Generated, Awaiting Human Approval ---");
  // In a real app, you'd present this in a UI. For now, we'll log it.
  console.log("LinkedIn Post:", JSON.stringify(state.linkedInPost, null, 2));
  console.log("X Thread:", JSON.stringify(state.xThread, null, 2));
  
  // This is where the graph pauses and waits for external input.
  const decision = await interrupt({
    message: "Review the generated content. Type 'approve' to continue, or provide feedback to reject.",
  });

  // This code only runs AFTER the graph is resumed.
  // The 'decision' variable holds the value passed from Command(resume=...).
  console.log(`--- Resuming with decision: "${decision}" ---`);

  if (typeof decision === 'string' && decision.toLowerCase() === 'approve') {
    return { isApproved: true }; // Set approval flag in the state
  } else {
    return { isApproved: false, feedback: String(decision) }; // Flag as not approved and store feedback
  }
}

3. Update the Graph Structure

We now add the approvalNode and a rejectionNode to the graph. Crucially, we use addConditionalEdges to route the workflow based on the isApproved flag in our state. This is a powerful pattern for creating dynamic agentic systems.

// Add the new nodes to the graph
workflow.addNode("approval_agent", approvalNode);

async function rejectionNode(state: ContentPipelineState): Promise<null> {
    console.log("\n--- Content Rejected ---");
    console.log("Feedback received:", state.feedback);
    console.log("In a real system, this could trigger a refinement loop or alert a user.");
    return null;
}
workflow.addNode("handle_rejection", rejectionNode);


// --- Update Graph Edges ---
// Previous edges remain the same up to the fan-out
workflow.setEntryPoint("data_collection_agent");
workflow.addEdge("data_collection_agent", "trend_analysis_agent");
workflow.addEdge("trend_analysis_agent", "generate_text_content_agent");
workflow.addEdge("trend_analysis_agent", "generate_visual_content_agent");

// The two generation branches now feed into the approval agent
workflow.addEdge("generate_text_content_agent", "approval_agent");
workflow.addEdge("generate_visual_content_agent", "approval_agent");

// Add a conditional edge from the approval agent
workflow.addConditionalEdges(
  "approval_agent",
  (state) => (state.isApproved ? END : "handle_rejection"),
);

// The rejection node also goes to the end for this simple case
workflow.addEdge("handle_rejection", END);

4. Update the Execution Logic

Finally, we modify our main runGraph function to be interactive. It needs to listen for the __interrupt__ event, get your input, and resume the graph.

// CRITICAL: Compile the graph with a checkpointer
const checkpointer = new MemorySaver();
const app = workflow.compile({ checkpointer });

// Update the runGraph function to be interactive
async function runGraph() {
  const rl = readline.createInterface({ input, output });

  // Each run needs a unique thread_id for the checkpointer to work
  const config = { configurable: { thread_id: `thread-${Date.now()}` } };
  const initialState = {
    topic: "The rise of AI in modern frontend development",
  };

  let finalState;
  // We use .stream() to get all events, including interrupts
  const stream = app.stream(initialState, config);

  for await (const event of stream) {
    const [eventName] = Object.entries(event)[0];

    if (eventName === "__interrupt__") {
      console.log("\n--- Graph Paused: Awaiting Input ---");
      const userInput = await rl.question("Your input (e.g., 'approve' or feedback): ");
      
      // Resume the graph by streaming again with the user's input
      const resumeStream = app.stream(userInput as any, config); // Pass input directly
      for await (const resumeEvent of resumeStream) {
        const [resumeEventName, resumeEventValue] = Object.entries(resumeEvent)[0];
        if (resumeEventName === END) {
            finalState = resumeEventValue;
        }
      }
    } else if (eventName === END) {
        finalState = event[END];
    }
  }

  rl.close();
  console.log("\n--- Graph Finished ---");
  console.log("Final output:", finalState);
}

runGraph();

When you run this code, the workflow will execute as before, but after generating the content, it will print --- Graph Paused: Awaiting Input --- and wait. If you type approve, it will proceed to the end. If you type anything else, it will route to the rejectionNode and then end.


Advanced Pattern: Iterative Refinement

Our current setup is a binary approve/reject. A more powerful pattern is an iterative loop where you can provide feedback multiple times to refine the content. This is especially useful for creative tasks.

LLM Workflow with Human-in-the-Loop Control using LangGraph
This diagram shows a more advanced workflow where human review can lead back to the generation step for refinement, creating an iterative improvement cycle.

The following video provides an excellent walkthrough of building this exact pattern, where the graph loops back on itself until the user is satisfied.

LangGraph Crash Course #33 - Human In The Loop - Multi-turn Conversations

To see how to build a more advanced iterative refinement loop, watch this crash course video. It demonstrates a multi-turn conversation where the user can repeatedly provide feedback to an agent.

Watch the entire video (it's short!). Pay close attention to how the state tracks a list of human feedback and how the graph is designed to loop back to the model generation node if feedback is provided. The presenter's while loop in the main execution code is the key to managing this multi-turn interaction.

To implement this, you would modify your conditional edge to loop back to the content generation nodes if the content is not approved, passing along your feedback in the state. This allows the agents to use your feedback to create a new, improved version for your review.


Conclusion

Excellent work today! You have successfully integrated a critical human-in-the-loop approval step into your content automation pipeline. This adds an essential layer of control and quality assurance, transforming your system from a simple generator into a true collaborative assistant.

Key Takeaways:

  • Human-in-the-Loop: You've implemented a HITL process using LangGraph's interrupt feature.
  • Stateful Pausing: You learned that checkpointers and thread_id are essential for pausing and resuming stateful graph executions.
  • Interactive Execution: You modified the main application logic to handle interrupts, gather user input, and resume the workflow using a Command.
  • Conditional Routing: You used addConditionalEdges to dynamically route the graph based on the human's approval decision, a fundamental pattern for building complex agentic systems.

Preview of the next lesson:
Our graph now has a complex, multi-step flow. In the next lesson, "Manage data persistence and traceability across agent calls within the graph's state," we will explore how to make our checkpoints durable (e.g., saving to a database) and how to effectively log the journey of our data through the graph. This is crucial for debugging, auditing, and understanding how your agent is making decisions.