Haystack Tutorial (TypeScript): adding human-in-the-loop for beginners

By Cyprian AaronsUpdated 2026-04-21
haystackadding-human-in-the-loop-for-beginnerstypescript

This tutorial shows how to pause a Haystack TypeScript pipeline, send a model decision to a human for review, and continue only after approval or correction. You need this when the model is making high-impact decisions like claim routing, KYC review, fraud flags, or customer-facing replies where a second set of eyes is mandatory.

What You'll Need

  • Node.js 18+ and npm
  • A TypeScript project with ts-node or tsx
  • Haystack TypeScript package installed
  • An OpenAI API key
  • Basic familiarity with Haystack components, pipelines, and generators

Install the packages:

npm install haystack tsx dotenv
npm install -D typescript @types/node

Set your environment variable:

export OPENAI_API_KEY="your-key-here"

Step-by-Step

  1. Start with a simple pipeline that produces a draft answer. The human-in-the-loop part comes later; first you need a model output that can be reviewed before it is returned to the user.
import "dotenv/config";
import { Pipeline } from "haystack";
import { OpenAIChatGenerator } from "haystack/integrations/openai";

const generator = new OpenAIChatGenerator({
  apiKey: process.env.OPENAI_API_KEY!,
  model: "gpt-4o-mini",
});

const pipeline = new Pipeline();
pipeline.addComponent("generator", generator);

pipeline.connect("generator.replies", "generator.replies");

const result = await pipeline.run({
  generator: {
    messages: [{ role: "user", content: "Draft a short claim status update for a customer." }],
  },
});

console.log(result.generator.replies[0].content);
  1. Add a review function that simulates the human step. In production this would be an internal UI, Slack approval, or ticketing workflow; for beginners, use stdin so you can test the flow locally.
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

const rl = readline.createInterface({ input, output });

async function askHumanForApproval(draft: string) {
  console.log("\n--- Draft for review ---");
  console.log(draft);

  const decision = await rl.question("\nApprove? (y/n): ");
  if (decision.toLowerCase() !== "y") {
    const corrected = await rl.question("Enter corrected version: ");
    return { approved: false, text: corrected };
  }

  return { approved: true, text: draft };
}
  1. Wrap generation and approval into one flow. This is the core pattern: generate first, stop for review, then either publish the draft or replace it with the human-corrected version.
import "dotenv/config";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { OpenAIChatGenerator } from "haystack/integrations/openai";

const generator = new OpenAIChatGenerator({
  apiKey: process.env.OPENAI_API_KEY!,
  model: "gpt-4o-mini",
});

const rl = readline.createInterface({ input, output });

async function askHumanForApproval(draft: string) {
  console.log("\n--- Draft for review ---");
  console.log(draft);

  const decision = await rl.question("\nApprove? (y/n): ");
  if (decision.toLowerCase() !== "y") {
    const corrected = await rl.question("Enter corrected version: ");
    return { approved: false, text: corrected };
  }

  return { approved: true, text: draft };
}

const reply = await generator.run({
  messages: [{ role: "user", content: "Write a polite response explaining a claim is still under review." }],
});

const draft = reply.replies[0].content;
const reviewed = await askHumanForApproval(draft);

console.log("\n--- Final output ---");
console.log(reviewed.text);

rl.close();
  1. Add guardrails so the human only reviews risky cases. In insurance and banking workflows you usually do not want every low-risk response to wait for approval; route only specific outputs based on confidence, keywords, or business rules.
function needsReview(text: string) {
  const riskyTerms = ["denied", "fraud", "terminated", "escalated", "legal"];
  return riskyTerms.some((term) => text.toLowerCase().includes(term));
}

const draftText =
  "Your claim has been escalated to our fraud team for manual review.";

if (needsReview(draftText)) {
  const reviewed = await askHumanForApproval(draftText);
  console.log(reviewed.text);
} else {
  console.log(draftText);
}
  1. Keep an audit trail of what happened. For regulated environments you want to store the original model output, the reviewer action, the final text, and timestamps so you can explain every decision later.
type ReviewRecord = {
  requestId: string;
  draftText: string;
  finalText: string;
  approvedByHuman: boolean;
  reviewedAt: string;
};

const record: ReviewRecord = {
  requestId: crypto.randomUUID(),
  draftText,
  finalText: reviewed.text,
  approvedByHuman: reviewed.approved,
  reviewedAt: new Date().toISOString(),
};

console.log(JSON.stringify(record, null, 2));

Testing It

Run the script with tsx and enter both approval paths once. First approve a draft by typing y, then rerun and reject it so you can verify the correction path works too.

Check that the final printed output matches either the original model text or your edited version. Also confirm that your audit record contains the original draft, final text, reviewer decision, and timestamp.

If you are wiring this into an app later, test with one low-risk prompt and one high-risk prompt. The low-risk prompt should skip review if your rule says so; the high-risk prompt should always stop for human approval.

Next Steps

  • Replace stdin with a real reviewer UI or Slack approval button
  • Persist review records in Postgres or your document store
  • Add policy checks before approval so humans only see compliant drafts

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