LangGraph Tutorial (TypeScript): adding human-in-the-loop for advanced developers

By Cyprian AaronsUpdated 2026-04-22
langgraphadding-human-in-the-loop-for-advanced-developerstypescript

This tutorial shows you how to add a human approval gate into a LangGraph workflow in TypeScript, so your agent can pause before risky actions and continue only after review. You need this when the graph is allowed to draft an answer, but a person must approve edits, tool calls, or final output before the system moves forward.

What You'll Need

  • Node.js 18+
  • TypeScript 5+
  • langgraph
  • @langchain/openai
  • @langchain/core
  • An OpenAI API key set as OPENAI_API_KEY
  • A terminal that can run ts-node, tsx, or compiled Node output
  • Basic familiarity with LangGraph state graphs and conditional edges

Step-by-Step

  1. Start by defining a small state shape that includes both the model output and a human approval flag. The important part is that the graph can pause after generating a draft, then resume with a decision from outside the model.
import { StateGraph, START, END, Annotation } from "@langgraph/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

const State = Annotation.Root({
  input: Annotation<string>(),
  draft: Annotation<string | null>({
    default: () => null,
  }),
  approved: Annotation<boolean | null>({
    default: () => null,
  }),
});

type GraphState = typeof State.State;

const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});
  1. Add one node to create the draft and one node to wait for human input. In production, the “human” step is usually where you persist state, notify an operator, or stop execution until someone posts a decision back into the system.
async function draftNode(state: GraphState) {
  const response = await llm.invoke([
    new HumanMessage(`Draft a concise customer-facing reply for: ${state.input}`),
  ]);

  return {
    draft: response.content.toString(),
  };
}

async function humanReviewNode(state: GraphState) {
  console.log("\n=== HUMAN REVIEW REQUIRED ===");
  console.log("Draft:", state.draft);
  console.log("Approve this draft before sending it?");

  return {
    approved: null,
  };
}
  1. Use conditional routing to branch based on approval. This is the core pattern: once the graph reaches the review node, your application decides whether to continue or terminate.
function routeAfterReview(state: GraphState) {
  if (state.approved === true) return "send";
  if (state.approved === false) return "reject";
  return "wait";
}

async function sendNode(state: GraphState) {
  return {
    draft: `${state.draft}\n\n[Sent to customer]`,
  };
}

async function rejectNode(state: GraphState) {
  return {
    draft: `${state.draft}\n\n[Rejected by reviewer]`,
  };
}
  1. Wire the graph together with explicit edges for approve, reject, and wait states. Notice that the graph itself does not magically collect human input; your app has to supply that value on a later invocation.
const graph = new StateGraph(State)
  .addNode("draft", draftNode)
  .addNode("review", humanReviewNode)
  .addNode("send", sendNode)
  .addNode("reject", rejectNode)
  .addEdge(START, "draft")
  .addEdge("draft", "review")
  .addConditionalEdges("review", routeAfterReview, {
    send: "send",
    reject: "reject",
    wait: END,
  })
  .addEdge("send", END)
  .addEdge("reject", END)
  .compile();
  1. Run it in two phases: first generate the draft and stop at review, then resume with an approval decision. In a real service, phase one would persist state and phase two would be triggered by your admin UI or internal tooling.
async function main() {
  const initial = await graph.invoke({
    input: "A customer says they were charged twice for last month's invoice.",
    approved: null,
    draft: null,
  });

  console.log("\nFirst pass result:");
  console.log(initial);

  const approvedRun = await graph.invoke({
    input: "A customer says they were charged twice for last month's invoice.",
    approved: true,
    draft: initial.draft,
  });

  console.log("\nApproved result:");
  console.log(approvedRun);
}

main().catch(console.error);
  1. If you want real pause-and-resume semantics across requests, add a checkpointer and thread ID. That lets you store intermediate state and continue later without rebuilding everything in memory.
import { MemorySaver } from "@langgraph/langgraph";

const checkpointer = new MemorySaver();

const resumableGraph = new StateGraph(State)
  .addNode("draft", draftNode)
  

Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides