Haystack Tutorial (TypeScript): adding memory to agents for beginners

By Cyprian AaronsUpdated 2026-04-21
haystackadding-memory-to-agents-for-beginnerstypescript

This tutorial shows you how to give a Haystack agent short-term memory in TypeScript so it can remember earlier turns in a conversation. You need this when your agent must answer follow-up questions, keep context across messages, or avoid asking the user for the same details twice.

What You'll Need

  • Node.js 18+ installed
  • A TypeScript project with ts-node or tsx
  • Haystack TypeScript packages:
    • @haystack-ai/core
    • @haystack-ai/agents
  • An LLM API key, such as:
    • OpenAI
    • Anthropic
    • Azure OpenAI
  • A .env file for secrets
  • Basic Haystack familiarity: components, pipelines, and agents

Step-by-Step

  1. Install the packages and set up your environment. If you already have a TypeScript app, just add the Haystack dependencies and your model key.
npm install @haystack-ai/core @haystack-ai/agents dotenv
npm install -D typescript tsx @types/node
  1. Create a tiny memory store that keeps conversation turns in process. This is enough for beginners and makes the memory flow easy to see before moving to Redis or a database.
type ChatTurn = {
  role: "user" | "assistant";
  content: string;
};

class ConversationMemory {
  private turns: ChatTurn[] = [];

  addTurn(turn: ChatTurn) {
    this.turns.push(turn);
  }

  getTurns() {
    return [...this.turns];
  }
}
  1. Build an agent that receives the current user message plus prior turns from memory. The important part is that every new prompt includes the conversation history before calling the model.
import "dotenv/config";
import { OpenAIChatGenerator } from "@haystack-ai/core";

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

function buildPrompt(history: { role: string; content: string }[], input: string) {
  const transcript = history
    .map((turn) => `${turn.role.toUpperCase()}: ${turn.content}`)
    .join("\n");

  return [
    "You are a helpful support assistant.",
    "Use the conversation history when answering.",
    "",
    transcript,
    `USER: ${input}`,
    "ASSISTANT:",
  ]
    .filter(Boolean)
    .join("\n");
}
  1. Wrap generation in a simple chat loop that stores each user message and assistant response. This is the actual memory behavior: read history, generate response, write both sides back to memory.
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

const memory = new ConversationMemory();

async function askAgent(userInput: string) {
  const prompt = buildPrompt(memory.getTurns(), userInput);
  const result = await generator.run({ messages: [{ role: "user", content: prompt }] });

  const reply = result["replies"][0].content;
  memory.addTurn({ role: "user", content: userInput });
  memory.addTurn({ role: "assistant", content: reply });

  return reply;
}

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

  while (true) {
    const text = await rl.question("You> ");
    if (text.trim().toLowerCase() === "exit") break;

    const answer = await askAgent(text);
    console.log(`Agent> ${answer}`);
  }

  rl.close();
}

main();
  1. Add a small memory window so prompts do not grow forever. In production, you usually keep only the last few turns unless you are summarizing older context elsewhere.
class ConversationMemoryWindow {
  private turns: ChatTurn[] = [];

  constructor(private maxTurns = 8) {}

  addTurn(turn: ChatTurn) {
    this.turns.push(turn);
    if (this.turns.length > this.maxTurns) {
      this.turns = this.turns.slice(-this.maxTurns);
    }
  }

  getTurns() {
    return [...this.turns];
  }
}
  1. If you want persistence across restarts, replace the in-memory array with Redis, Postgres, or MongoDB. The interface stays the same; only addTurn() and getTurns() change.
interface MemoryStore {
  addTurn(sessionId: string, turn: ChatTurn): Promise<void>;
  getTurns(sessionId: string): Promise<ChatTurn[]>;
}

class InMemoryStore implements MemoryStore {
  private sessions = new Map<string, ChatTurn[]>();

  async addTurn(sessionId: string, turn: ChatTurn) {
    const current = this.sessions.get(sessionId) ?? [];
    current.push(turn);
    this.sessions.set(sessionId, current);
  }

  async getTurns(sessionId: string) {
    return [...(this.sessions.get(sessionId) ?? [])];
  }
}

Testing It

Run the script and ask two related questions back to back. For example, first say your name and city, then ask “What city did I mention?” — the agent should answer correctly because that information is now in memory.

Also test a follow-up that depends on earlier context, like “What should I pack for that trip?” after mentioning a destination. If it forgets immediately, check that you are appending both user and assistant turns after each response.

If responses start getting slow or expensive after several messages, your prompt is probably too long. That is where windowing or summarization becomes necessary.

Next Steps

  • Replace the in-memory store with Redis for multi-instance deployments
  • Add summarization so older turns compress into a compact running summary
  • Store per-user sessions so different users do not share conversation state

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