LangChain Tutorial (TypeScript): adding human-in-the-loop for intermediate developers
This tutorial shows how to pause a LangChain TypeScript workflow, send a proposed action to a human for review, and then continue only after approval or edits. You need this when the model can draft an answer or take an action, but your business rules require a person to confirm it first.
What You'll Need
- •Node.js 18+
- •A TypeScript project with
ts-nodeortsx - •
langchaininstalled - •An OpenAI API key
- •Basic familiarity with async/await in TypeScript
- •A terminal and a code editor
Install the packages:
npm install langchain @langchain/openai zod dotenv
npm install -D typescript tsx @types/node
Create a .env file:
OPENAI_API_KEY=your_key_here
Step-by-Step
- •Start with a small chain that produces structured output. For human-in-the-loop flows, you want the model to return something you can inspect and edit before execution.
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { z } from "zod";
import { StructuredOutputParser } from "langchain/output_parsers";
const schema = z.object({
subject: z.string(),
body: z.string(),
});
const parser = StructuredOutputParser.fromZodSchema(schema);
const prompt = ChatPromptTemplate.fromMessages([
["system", "You draft customer support emails. Return only valid JSON."],
["human", "Write a reply for this complaint: {complaint}\n{format_instructions}"],
]);
const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const chain = prompt.pipe(model).pipe(parser);
- •Add a human review gate before any downstream action. In production, this is usually where you stop the automation, store the draft, and wait for approval in your UI or admin tool.
async function requestHumanReview(draft: { subject: string; body: string }) {
console.log("\n=== HUMAN REVIEW REQUIRED ===");
console.log("Subject:", draft.subject);
console.log("Body:\n" + draft.body);
console.log("=============================\n");
const approved = true;
const editedDraft = {
...draft,
body: draft.body.replace("sorry", "apologize"),
};
return { approved, editedDraft };
}
- •Wire the chain to the review step and only continue if the human approves. This example keeps everything in one file so you can run it immediately, but the same pattern works when the review step is replaced by an HTTP endpoint or queue consumer.
async function main() {
const complaint =
"I was charged twice for my subscription and need this fixed today.";
const result = await chain.invoke({
complaint,
format_instructions: parser.getFormatInstructions(),
});
const review = await requestHumanReview(result);
if (!review.approved) {
console.log("Rejected by human reviewer.");
return;
}
const finalDraft = review.editedDraft;
console.log("Approved draft:", finalDraft);
}
main().catch(console.error);
- •If you want a real pause instead of a mock approval, collect input from stdin. This is the simplest local workflow for testing the human-in-the-loop boundary without building a UI first.
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
async function askHuman() {
const rl = readline.createInterface({ input, output });
const answer = await rl.question(
"Approve this draft? (yes/no/edit): "
);
rl.close();
return answer.trim().toLowerCase();
}
async function requestHumanReviewInteractive(draft: {
subject: string;
body: string;
}) {
console.log("\nSubject:", draft.subject);
console.log("Body:\n" + draft.body);
const decision = await askHuman();
if (decision === "no") return { approved: false, editedDraft: draft };
if (decision === "edit") {
return {
approved: true,
editedDraft: { ...draft, body: draft.body + "\n\nBest regards,\nSupport Team" },
};
}
return { approved: true, editedDraft: draft };
}
- •Replace the mock reviewer with your actual business action once approval is granted. In banking and insurance systems, that action might be sending an email, creating a case note, or submitting a claim update.
async function sendEmail(draft: { subject: string; body: string }) {
console.log("\n=== SENDING EMAIL ===");
console.log("Subject:", draft.subject);
console.log(draft.body);
}
async function mainWithRealGate() {
const complaint =
"I was charged twice for my subscription and need this fixed today.";
const result = await chain.invoke({
complaint,
format_instructions: parser.getFormatInstructions(),
});
const review = await requestHumanReviewInteractive(result);
if (!review.approved) {
console.log("Stopped before execution.");
return;
}
await sendEmail(review.editedDraft);
}
mainWithRealGate().catch(console.error);
Testing It
Run the file with tsx or your preferred TypeScript runner:
npx tsx index.ts
You should see the drafted email printed first, then a prompt asking for approval. Try all three paths: approve as-is, reject it, and edit it before sending.
If you want to harden this for production, verify that rejected drafts never reach the execution step and that every human edit is logged with who changed it and when. Also test timeout behavior so abandoned reviews do not leave workflows stuck forever.
Next Steps
- •Add persistence for pending reviews using Postgres or Redis so approvals survive restarts.
- •Move the review gate into an API route or queue worker so humans can approve from a dashboard.
- •Learn LangGraph next if you want more explicit state machines for multi-step agent workflows with checkpoints.
Keep learning
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
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