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 assistantfrom 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 assistantSession-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_promptProduction 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
| Call | Typical latency | Notes |
|---|---|---|
search | 80–150 ms | Hybrid retrieval + rerank |
add | < 100 ms | Returns immediately; extraction is async |
ask | 300–600 ms | Includes 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
- Memory API reference — full request/response shapes
- How MemHQ Works — ingest pipeline, retrieval, and synthesis explained
- Per-user knowledge graphs — namespace and isolate memory across users