Guides

Chatbot with memory

Wire MemHQ into a chat loop that remembers users across sessions — with OpenAI and Claude examples.

Chatbot with memory

The pattern: before each assistant turn, retrieve relevant memories; after each user turn, ingest the new conversation. Two MemHQ calls per round-trip — both return in under 150 ms, so they add negligible latency against your LLM call.

The basic loop

import { MemoryClient } from "@memhq/sdk";
import OpenAI from "openai";

const mem = new MemoryClient();
const openai = new OpenAI();

type Msg = { role: "user" | "assistant"; content: string };

async function reply(
  userId: string,
  history: Msg[],
  userMessage: string,
): Promise<string> {
  // 1. Retrieve relevant memory before generating the response.
  const { memories } = await mem.search({
    userId,
    query: userMessage,
    limit: 8,
  });

  const memoryBlock = memories
    .map((m) => `- ${m.content}`)
    .join("\n") || "(no memory yet)";

  // 2. Generate the assistant turn with memory injected into the system prompt.
  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [
      {
        role: "system",
        content: `You are a helpful assistant.\n\nWhat you remember about this user:\n${memoryBlock}`,
      },
      ...history,
      { role: "user", content: userMessage },
    ],
  });

  const assistant = completion.choices[0].message.content ?? "";

  // 3. Ingest the new turn so it's available next session.
  //    Fire-and-forget: add() returns in <100ms; extraction is async.
  await mem.add({
    userId,
    messages: [
      { role: "user", content: userMessage },
      { role: "assistant", content: assistant },
    ],
  });

  return assistant;
}
import { MemoryClient } from "@memhq/sdk";
import Anthropic from "@anthropic-ai/sdk";

const mem = new MemoryClient();
const claude = new Anthropic();

type Msg = { role: "user" | "assistant"; content: string };

async function reply(
  userId: string,
  history: Msg[],
  userMessage: string,
): Promise<string> {
  // 1. Retrieve relevant memory.
  const { memories } = await mem.search({
    userId,
    query: userMessage,
    limit: 8,
  });

  const memoryBlock = memories
    .map((m) => `- ${m.content}`)
    .join("\n") || "(no memory yet)";

  // 2. Generate with Claude, memory in the system prompt.
  const response = await claude.messages.create({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 1024,
    system: `You are a helpful assistant.\n\nWhat you remember about this user:\n${memoryBlock}`,
    messages: [
      ...history,
      { role: "user", content: userMessage },
    ],
  });

  const assistant =
    response.content[0].type === "text" ? response.content[0].text : "";

  // 3. Ingest the new turn.
  await mem.add({
    userId,
    messages: [
      { role: "user", content: userMessage },
      { role: "assistant", content: assistant },
    ],
  });

  return assistant;
}
from memhq import MemoryClient
from openai import OpenAI

mem = MemoryClient()
openai = OpenAI()

def reply(user_id: str, history: list[dict], user_message: str) -> str:
    # 1. Retrieve relevant memory.
    results = mem.search(user_id=user_id, query=user_message, limit=8)
    memory_block = "\n".join(f"- {m.content}" for m in results.memories) \
                   or "(no memory yet)"

    # 2. Generate the assistant turn.
    completion = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": f"You are a helpful assistant.\n\n"
                           f"What you remember about this user:\n{memory_block}",
            },
            *history,
            {"role": "user", "content": user_message},
        ],
    )
    assistant = completion.choices[0].message.content

    # 3. Ingest the new turn.
    mem.add(
        user_id=user_id,
        messages=[
            {"role": "user",      "content": user_message},
            {"role": "assistant", "content": assistant},
        ],
    )

    return assistant
from memhq import MemoryClient
import anthropic

mem = MemoryClient()
claude = anthropic.Anthropic()

def reply(user_id: str, history: list[dict], user_message: str) -> str:
    # 1. Retrieve relevant memory.
    results = mem.search(user_id=user_id, query=user_message, limit=8)
    memory_block = "\n".join(f"- {m.content}" for m in results.memories) \
                   or "(no memory yet)"

    # 2. Generate with Claude.
    response = claude.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=1024,
        system=f"You are a helpful assistant.\n\n"
               f"What you remember about this user:\n{memory_block}",
        messages=[*history, {"role": "user", "content": user_message}],
    )
    assistant = response.content[0].text

    # 3. Ingest the new turn.
    mem.add(
        user_id=user_id,
        messages=[
            {"role": "user",      "content": user_message},
            {"role": "assistant", "content": assistant},
        ],
    )

    return assistant

Session-start pattern: load context with ask

When a returning user opens a new session, you often want to greet them with context before they've said anything. Use ask to pull a cited summary and inject it into the system prompt at session start.

async function startSession(userId: string): Promise<string> {
  const { answer, citations } = await mem.ask({
    userId,
    question: "What do I know about this user — their goals, preferences, and recent activity?",
  });

  // Build a richer system prompt for the whole session
  const systemPrompt = [
    "You are a helpful assistant with memory of past conversations.",
    "",
    "Summary of what you know about this user:",
    answer,
    "",
    "Sources (cite these if the user asks how you know something):",
    ...citations.map((c) => `- ${c.content}`),
  ].join("\n");

  return systemPrompt;
}

// Usage at session start:
const system = await startSession("user_42");
// Then pass `system` as the system message for every turn in this session.
def start_session(user_id: str) -> str:
    result = mem.ask(
        user_id=user_id,
        question="What do I know about this user — their goals, preferences, and recent activity?",
    )

    system_prompt = "\n".join([
        "You are a helpful assistant with memory of past conversations.",
        "",
        "Summary of what you know about this user:",
        result.answer,
        "",
        "Sources (cite these if the user asks how you know something):",
        *[f"- {c.content}" for c in result.citations],
    ])

    return system_prompt

Production patterns

Run search and the previous turn's add in parallel

The search for the current turn and the add for the previous turn are independent. Overlap them to shave 80–150 ms off wall clock:

// At the end of turn N, kick off the ingest without awaiting.
// At the start of turn N+1, await both together.

let pendingAdd: Promise<unknown> | null = null;

async function reply(userId: string, history: Msg[], userMessage: string) {
  const [{ memories }] = await Promise.all([
    mem.search({ userId, query: userMessage, limit: 8 }),
    pendingAdd ?? Promise.resolve(),   // wait for the previous ingest
  ]);

  const assistant = await callLLM(memories, history, userMessage);

  // Start ingesting immediately, don't await
  pendingAdd = mem.add({
    userId,
    messages: [
      { role: "user", content: userMessage },
      { role: "assistant", content: assistant },
    ],
  });

  return assistant;
}

Streaming with Claude

If you're streaming the assistant turn, ingest after the stream completes — not before:

import Anthropic from "@anthropic-ai/sdk";

async function* replyStream(userId: string, history: Msg[], userMessage: string) {
  const { memories } = await mem.search({ userId, query: userMessage, limit: 8 });

  const memoryBlock = memories.map((m) => `- ${m.content}`).join("\n");

  let fullAssistant = "";

  const stream = claude.messages.stream({
    model: "claude-haiku-4-5-20251001",
    max_tokens: 1024,
    system: `You are a helpful assistant.\n\nMemory:\n${memoryBlock}`,
    messages: [...history, { role: "user", content: userMessage }],
  });

  for await (const chunk of stream) {
    if (
      chunk.type === "content_block_delta" &&
      chunk.delta.type === "text_delta"
    ) {
      fullAssistant += chunk.delta.text;
      yield chunk.delta.text;
    }
  }

  // Ingest after the full response is assembled
  await mem.add({
    userId,
    messages: [
      { role: "user", content: userMessage },
      { role: "assistant", content: fullAssistant },
    ],
  });
}

Shared workspace context

Pass both userId and groupId to scope memory to a project. MemHQ automatically merges user-private and group-shared memories at search time:

// Team member ingests meeting notes into the shared workspace
await mem.add({
  groupId: "workspace_acme",
  messages: meetingTranscript,
});

// Alice searches — gets her private notes AND the shared workspace memories
const { memories } = await mem.search({
  userId: "alice",
  groupId: "workspace_acme",
  query: "Q3 launch decisions",
  limit: 10,
});

Latency budget

CallTypical latencyNotes
search80–150 msHybrid retrieval + rerank
add< 100 msReturns immediately; extraction is async
ask300–600 msIncludes LLM synthesis server-side

The LLM call between search and the response dominates the round-trip. search and add are effectively free at typical chat latencies.

Where to go next