- Published at
Build a Docs QA Assistant That Remembers Technical Review Rules with Mem0
Table of Contents
AI applications are only as useful as what they can remember. A coding assistant that forgets your project conventions, a review tool that re-learns the same style rules every session, or a support agent that can’t recall a user’s history — these are symptoms of stateless design. The fix is AI memory: a layer that persists context across requests, sessions, and even applications, and retrieves the right pieces at the right time.
Mem0 is a memory platform built for this kind of workflow. It provides APIs for adding, retrieving, and searching memories, with support for scoping memory to users, agents, apps, or runs. It also supports filters and metadata, which lets an application retrieve a narrower and more relevant set of memories.
Mem0 can be used at different levels of abstraction. You can work with it through the CLI, call the Platform API directly, use the Python or JavaScript SDKs, self-host the open-source version, or connect it to agent tools through integrations such as MCP.
In my previous tutorial, The No-Code Memory Bank: Automating Your Daily Standup with Mem0 CLI, I focused on the CLI-first workflow. That project used Mem0 from the terminal, with a few shell helpers and a small script, to log work updates during the day and turn them into a standup report.
In this tutorial, we’ll go a level deeper and work with the Platform API directly. We’ll build a technical docs QA assistant: a Node.js CLI that stores review rules as memories, retrieves the relevant rules for a Markdown draft, and returns structured QA issues.
We’ll call Mem0’s Platform API directly from Node.js using fetch. This lets us work with the raw request bodies and endpoint structure as we explore add, search, filters, metadata, and infer.
What We’re Building
The app has two main flows: first, it stores review rules in Mem0; then, when we review a draft, it retrieves the relevant rules and uses them to produce a structured QA report.
Technical review rules are a natural fit for memory. They are durable: the same rules can apply across drafts, review sessions, and projects. Instead of pasting a style guide into every prompt, we can store the rules once and retrieve the relevant ones at review time.
For example, the assistant should be able to flag issues like:
- “Response API” should be “Responses API”.
- A draft says Mem0 stores text exactly as written, but the rule says to mention inference behavior.
- A code example searches memories without an entity scope.
- A tutorial uses an environment variable but forgets to show it in
.env.
The CLI will:
- Seed review rules into Mem0.
- Read a Markdown draft from disk.
- Search Mem0 for relevant review rules.
- Send the draft and retrieved rules to OpenAI for review.
- Print structured QA issues to the terminal.

The review rules are stored once in Mem0. When we review a draft, the app searches Mem0 for relevant rules, passes those rules and the draft to OpenAI, and prints a structured QA report.
The assistant will return review feedback like this:
{
"summary": "Found 3 issues.",
"issues": [
{
"ruleId": "term-responses-api",
"severity": "medium",
"quote": "Response API",
"problem": "The draft uses the wrong product term.",
"suggestion": "Use \"Responses API\" instead of \"Response API\"."
}
]
}
The main idea is simple: the app checks an existing draft against remembered technical and editorial rules. The code for the completed project can be found on GitHub.
Prerequisites
You’ll need:
- Node.js 18 or later
- A Mem0 Platform API key
- An OpenAI API key
We’ll use Mem0’s Platform API directly in this tutorial. For the review step, we’ll use the OpenAI JavaScript SDK with the Responses API.
Create the Project
Create a new folder and initialize a Node.js project:
mkdir mem0-docs-qa
cd mem0-docs-qa
npm init -y
npm pkg set type=module
npm install openai dotenv
Create this folder structure:
mem0-docs-qa/
data/
review-rules.json
drafts/
example.md
src/
mem0.js
seed-rules.js
review-draft.js
.env
package.json
Update package.json with two scripts:
{
"type": "module",
"scripts": {
"seed": "node src/seed-rules.js",
"review": "node src/review-draft.js"
},
"dependencies": {
"dotenv": "latest",
"openai": "latest"
}
}
Create a .env file:
MEM0_API_KEY=your_mem0_api_key
OPENAI_API_KEY=your_openai_api_key
MEM0_USER_ID=docs-reviewer
MEM0_RULESET=docs-qa-assistant
OPENAI_MODEL=gpt-5-nano
You can replace OPENAI_MODEL with another Responses-compatible model that you have access to.
We’ll use MEM0_USER_ID to keep this tutorial’s memories under one user-specific memory space, and MEM0_RULESET as metadata so we can separate this rule set from other memories stored for the same user. Later, when we build the search request, we’ll use both values to retrieve only the review rules for this project.
Add the Review Rules
Create data/review-rules.json and add the following:
[
{
"id": "term-responses-api",
"type": "terminology",
"severity": "medium",
"product": "openai",
"rule": "Use \"Responses API\", not \"Response API\", when referring to OpenAI's newer text generation API.",
"bad": "Response API",
"good": "Responses API"
},
{
"id": "mem0-infer-behavior",
"type": "api-gotcha",
"severity": "high",
"product": "mem0",
"rule": "Do not say Mem0 stores text verbatim when infer is true. With infer enabled, Mem0 extracts useful memories from messages. If exact text storage is required, mention infer: false.",
"bad": "Mem0 stores each message exactly as written.",
"good": "Mem0 can extract memories from messages by default. Use infer: false when you want to store the provided text as-is."
},
{
"id": "mem0-search-entity-scope",
"type": "api-gotcha",
"severity": "high",
"product": "mem0",
"rule": "Mem0 V3 search requests must include at least one entity ID such as user_id, agent_id, app_id, or run_id inside the filters object. Do not place entity IDs as top-level search options.",
"bad": "client.search(\"Find relevant user memories\", { user_id: \"support-user-123\", top_k: 5 })",
"good": "client.search(\"Find relevant user memories\", { filters: { user_id: \"support-user-123\" }, top_k: 5 })"
},
{
"id": "metadata-for-workflow-labels",
"type": "architecture",
"severity": "medium",
"product": "mem0",
"rule": "Use metadata for application-specific workflow labels such as ruleset, rule_type, source, severity, and product. Do not rely only on natural language text when the app needs precise filtering.",
"bad": "Store the rule type only inside the memory text.",
"good": "Store rule_type and severity as metadata fields."
},
{
"id": "env-vars-required",
"type": "code-review",
"severity": "medium",
"product": "general",
"rule": "Flag code examples that depend on environment variables unless the tutorial shows the required .env keys and explains how they are loaded.",
"bad": "const client = new OpenAI();",
"good": "Show OPENAI_API_KEY in .env and load it with dotenv/config."
}
]
These rules are intentionally small. In a real workflow, you might have dozens of rules covering product terms, API changes, style preferences, banned phrases, code conventions, and documentation gotchas.
Create a Mem0 Helper
Create src/mem0.js and add:
import 'dotenv/config';
const MEM0_BASE_URL = 'https://api.mem0.ai';
function requiredEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const scopes = {
userId: requiredEnv('MEM0_USER_ID'),
ruleset: requiredEnv('MEM0_RULESET'),
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function mem0Fetch(path, options = {}) {
const response = await fetch(`${MEM0_BASE_URL}${path}`, {
method: options.method || 'GET',
headers: {
Authorization: `Token ${requiredEnv('MEM0_API_KEY')}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const raw = await response.text();
let data;
try {
data = raw ? JSON.parse(raw) : {};
} catch {
data = { raw };
}
if (!response.ok) {
throw new Error(
`Mem0 API error ${response.status}: ${JSON.stringify(data, null, 2)}`,
);
}
return data;
}
function formatRule(rule) {
return [
`Rule ID: ${rule.id}`,
`Rule type: ${rule.type}`,
`Product: ${rule.product}`,
`Severity: ${rule.severity}`,
`Rule: ${rule.rule}`,
rule.bad ? `Bad example: ${rule.bad}` : null,
rule.good ? `Good example: ${rule.good}` : null,
]
.filter(Boolean)
.join('\n');
}
export async function addRuleMemory(rule) {
return mem0Fetch('/v3/memories/add/', {
method: 'POST',
body: {
messages: [
{
role: 'user',
content: formatRule(rule),
},
],
user_id: scopes.userId,
metadata: {
source: 'docs-qa-seed',
ruleset: scopes.ruleset,
rule_id: rule.id,
rule_type: rule.type,
product: rule.product,
severity: rule.severity,
},
infer: false,
},
});
}
export async function waitForEvent(eventId, options = {}) {
const maxAttempts = options.maxAttempts || 20;
const intervalMs = options.intervalMs || 1000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const event = await mem0Fetch(`/v1/event/${eventId}/`);
if (event.status === 'SUCCEEDED') {
return event;
}
if (event.status === 'FAILED') {
throw new Error(`Mem0 event failed: ${JSON.stringify(event, null, 2)}`);
}
await sleep(intervalMs);
}
throw new Error(`Timed out waiting for Mem0 event ${eventId}`);
}
export async function searchRuleMemories(query, options = {}) {
const ruleTypes = options.ruleTypes || [];
const filters = {
AND: [
{ user_id: scopes.userId },
{ metadata: { ruleset: scopes.ruleset } },
],
};
if (ruleTypes.length > 0) {
filters.AND.push({
OR: ruleTypes.map((ruleType) => ({
metadata: { rule_type: ruleType },
})),
});
}
const response = await mem0Fetch('/v3/memories/search/', {
method: 'POST',
body: {
query,
filters,
top_k: options.topK || 12,
threshold: options.threshold ?? 0.0,
rerank: options.rerank ?? true,
},
});
return response.results || [];
}
There are a few important choices here.
First, we store the rules with infer: false:
{
infer: false,
}
By default, Mem0 can use LLM-based inference when adding memories. With inference enabled, Mem0 “infers” meaning from the data you send it. It can extract and consolidate useful information from the messages you send instead of treating memory as a simple key-value store.
For this project, we want different behavior. These review rules contain exact terminology, rule IDs, bad examples, and good examples. When infer is set to false, Mem0 stores each message verbatim without running the extraction LLM. That keeps the rule text intact for later retrieval.
Because we use infer: false, the formatted multi-line rule becomes the memory text. Later, when we search for relevant rules, we’ll use natural-language queries instead of only searching by rule ID so semantic search can match the rule content, examples, and explanation.
Second, we attach metadata such as ruleset, rule_type, product, and severity:
metadata: {
source: "docs-qa-seed",
ruleset: scopes.ruleset,
rule_id: rule.id,
rule_type: rule.type,
product: rule.product,
severity: rule.severity
}
Metadata gives us structured fields that we can use later during search. In this app, ruleset separates this tutorial’s review rules from other memories stored for the same user, while rule_type, product, and severity make it easier to organize and filter the rules as the rule set grows.
Third, every search includes user_id inside filters:
const filters = {
AND: [{ user_id: scopes.userId }, { metadata: { ruleset: scopes.ruleset } }],
};
Mem0 search requests need an entity scope such as user_id, agent_id, app_id, or run_id, and those values belong inside the filters object. In this tutorial, user_id defines the memory space we are searching, and metadata.ruleset narrows the result to this project’s review rules.
Finally, we use threshold: 0.0:
{
threshold: options.threshold ?? 0.0,
}
This tutorial has a tiny seeded rule set, so we want to inspect the top-ranked rules without applying a similarity cutoff. At the time of writing, Mem0’s V3 search default threshold is 0.1, and passing 0.0 disables filtering. In a larger production memory store, you would probably keep the default threshold or raise it so unrelated rules are filtered out before review.
Seed the Rules Into Mem0
Create src/seed-rules.js and add the following code:
import { readFile } from 'node:fs/promises';
import { addRuleMemory, waitForEvent } from './mem0.js';
async function loadRules() {
const file = new URL('../data/review-rules.json', import.meta.url);
const json = await readFile(file, 'utf8');
return JSON.parse(json);
}
async function main() {
const rules = await loadRules();
console.log(`Seeding ${rules.length} review rules into Mem0...\n`);
for (const rule of rules) {
const result = await addRuleMemory(rule);
console.log(`Queued ${rule.id}: ${result.status}`);
if (result.event_id && result.status === 'PENDING') {
await waitForEvent(result.event_id);
}
if (result.status === 'FAILED') {
throw new Error(`Failed to store ${rule.id}`);
}
console.log(`Stored ${rule.id}\n`);
}
console.log('Done.');
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Run the seed script:
npm run seed
You should see output similar to:
Seeding 5 review rules into Mem0...
Queued term-responses-api: SUCCEEDED
Stored term-responses-api
Queued mem0-infer-behavior: SUCCEEDED
Stored mem0-infer-behavior
Queued mem0-search-entity-scope: SUCCEEDED
Stored mem0-search-entity-scope
Queued metadata-for-workflow-labels: SUCCEEDED
Stored metadata-for-workflow-labels
Queued env-vars-required: SUCCEEDED
Stored env-vars-required
Done.
Depending on how quickly Mem0 processes each add request, you may see PENDING or SUCCEEDED after each rule is queued. The polling step matters because Mem0’s V3 add endpoint can queue memory processing and return an event_id. Polling that event helps us avoid searching before the write pipeline has completed.
Running the seed script more than once will create duplicate rule memories. For a real app, add an idempotency step, delete the previous memories for this user/ruleset before re-seeding, or store a unique seed batch identifier in metadata so cleanup is easier.
Create a Draft with Intentional Issues
Create drafts/example.md with a short technical draft that contains a few intentional issues:
# Add Memory to a Node.js Support Agent with Mem0
In this guide, we'll build a support agent that remembers useful details about a user's previous conversations.
The app stores user preferences in Mem0. Mem0 stores each memory exactly as written, so we do not need to worry about inference behavior.
We'll use the OpenAI Response API to generate support replies.
Here is the search code:
```js
const results = await client.search('Find relevant user memories', {
user_id: 'support-user-123',
top_k: 5,
});
```
The app also uses OpenAI and Mem0 API keys:
```js
const openai = new OpenAI();
```
This draft includes several problems:
- It says Mem0 stores each memory exactly as written without explaining
infer. - It uses “Response API” instead of “Responses API”.
- It shows the entity scope outside
filters. - It uses environment variables without showing the
.envsetup.
Now we’ll build the reviewer that catches these issues.
Build the Review Script
Create src/review-draft.js:
import 'dotenv/config';
import { readFile } from 'node:fs/promises';
import OpenAI from 'openai';
import { searchRuleMemories } from './mem0.js';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
function requireArg(value, message) {
if (!value) {
throw new Error(message);
}
return value;
}
function uniqueMemories(results) {
const seen = new Set();
const unique = [];
for (const result of results) {
const key = result.id || result.memory;
if (!seen.has(key)) {
seen.add(key);
unique.push(result);
}
}
return unique;
}
function formatMemories(memories) {
return memories
.map((memory, index) => {
const metadata = memory.metadata || {};
return [
`Rule ${index + 1}`,
`Memory ID: ${memory.id || 'unknown'}`,
`Rule ID: ${metadata.rule_id || 'unknown'}`,
`Rule type: ${metadata.rule_type || 'unknown'}`,
`Severity: ${metadata.severity || 'unknown'}`,
`Memory: ${memory.memory}`,
].join('\n');
})
.join('\n\n---\n\n');
}
async function retrieveRelevantRules(draft) {
const preview = draft.slice(0, 4000);
const queries = [
`Technical terminology, product names, and API names to check in this draft:\n${preview}`,
`Mem0 API gotchas, search filters, metadata, inference behavior, and entity scoping rules relevant to this draft.`,
`Code review rules for environment variables, setup instructions, and code examples relevant to this draft.`,
];
const batches = await Promise.all(
queries.map((query) =>
searchRuleMemories(query, {
topK: 8,
threshold: 0.0,
rerank: true,
}),
),
);
return uniqueMemories(batches.flat());
}
async function reviewDraft(draft, memories) {
const rules = formatMemories(memories);
const response = await openai.responses.create({
model: process.env.OPENAI_MODEL,
instructions: `
You are a technical documentation QA reviewer.
Inspect the draft against the retrieved review rules.
Do not rewrite the draft. Only report issues that violate the retrieved rules.
Return valid JSON only, with this shape:
{
"summary": "One sentence summary.",
"issues": [
{
"ruleId": "rule id from memory metadata, if available",
"severity": "low | medium | high",
"quote": "exact quote from the draft, if available",
"problem": "what is wrong",
"suggestion": "specific correction"
}
]
}
Rules:
- Only flag issues supported by the retrieved rules.
- If the draft does not violate a rule, do not mention that rule.
- Do not invent product facts that are not in the retrieved rules.
- Keep suggestions concise and actionable.
`.trim(),
input: `
Retrieved review rules from Mem0:
${rules}
Draft to review:
${draft}
`.trim(),
});
return response.output_text;
}
async function main() {
const draftPath = requireArg(
process.argv[2],
'Usage: npm run review -- drafts/example.md',
);
const draft = await readFile(draftPath, 'utf8');
const memories = await retrieveRelevantRules(draft);
if (memories.length === 0) {
console.log('No relevant review rules found in Mem0.');
return;
}
console.log(`Retrieved ${memories.length} relevant rules from Mem0.\n`);
const report = await reviewDraft(draft, memories);
console.log(report);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Run the review:
npm run review -- drafts/example.md
You should get output similar to:
Retrieved 5 relevant rules from Mem0.
{
"summary": "The draft violates multiple guidelines by placing entity IDs at the top level in search, asserting verbatim memory storage when inference is enabled, referencing the wrong OpenAI API name, and omitting required environment-variable usage for API keys.",
"issues": [
{
"ruleId": "mem0-search-entity-scope",
"severity": "high",
"quote": "const results = await client.search('Find relevant user memories', {\n user_id: 'support-user-123',\n top_k: 5,\n});",
"problem": "Top-level entity_id is used in search options; entity IDs must be placed inside a filters object.",
"suggestion": "Wrap entity IDs inside a filters object, e.g., { filters: { user_id: 'support-user-123' }, top_k: 5 }"
},
{
"ruleId": "mem0-infer-behavior",
"severity": "high",
"quote": "Mem0 stores each memory exactly as written, so we do not need to worry about inference behavior.",
"problem": "Claims Mem0 stores text verbatim; contradicts mem0-infer-behavior rule which describes inference-enabled memory extraction.",
"suggestion": "Update to reflect that Mem0 can extract memories from messages by default; use infer: false to store provided text as-is."
},
{
"ruleId": "term-responses-api",
"severity": "medium",
"quote": "We'll use the OpenAI Response API to generate support replies.",
"problem": "References the API as 'Response API' instead of the correct 'Responses API'.",
"suggestion": "Use 'Responses API' in references."
},
{
"ruleId": "env-vars-required",
"severity": "medium",
"quote": "The app also uses OpenAI and Mem0 API keys:\n\n```js\nconst openai = new OpenAI();\n```",
"problem": "Code depends on API keys but the draft does not show required .env keys or how they are loaded.",
"suggestion": "Show required .env keys (e.g., OPENAI_API_KEY) and demonstrate loading them (e.g., dotenv) in code."
}
]
}
Your output may not match this exactly, but it should flag the same kinds of issues. Notice that the app retrieved five review rules but returned four issues. That is expected: the metadata rule was available to the reviewer, but the sample draft did not violate it.
That is the core pattern: retrieve the relevant rules first, then use them to review the draft.
Why We Used Metadata
The simplest possible version of this app could store each rule as plain text and run one semantic search. That works for a demo, but metadata makes the system easier to grow.
In our seed script, each rule includes metadata:
metadata: {
source: "docs-qa-seed",
ruleset: scopes.ruleset,
rule_id: rule.id,
rule_type: rule.type,
product: rule.product,
severity: rule.severity
}
That lets us filter rules later. For example, to search only terminology rules, we can pass ruleTypes:
const terminologyRules = await searchRuleMemories(
'Product and API terminology rules for this draft',
{
ruleTypes: ['terminology'],
topK: 5,
},
);
The filter helper turns that into an OR filter over metadata values:
filters.AND.push({
OR: ruleTypes.map((ruleType) => ({
metadata: { rule_type: ruleType },
})),
});
This matters because application labels such as ruleset, rule_type, source, and severity are not just prose. They are retrieval controls. If we only write “this is a terminology rule” inside the memory text, the app has to rely on semantic matching. If we store rule_type in metadata, the app can make more precise retrieval decisions.
The metadata keys need to be consistent. If a metadata filter does not return the results you expect, check that the keys in your search filter match the keys you stored with the memory. For that reason, predictable names like ruleset, rule_type, and severity are useful. It also helps to log the final filters object when debugging missing results.
Why Search Needs a Scope
Memory becomes risky when different users, apps, or projects share one memory store without clear boundaries.
In Mem0, fields such as user_id, agent_id, app_id, and run_id define the entity scope for a memory search. In this tutorial, we use user_id as the scope and ruleset metadata as an application-level label.
For this app, we store each rule under a user_id:
{
user_id: scopes.userId,
}
And every search includes that same scope inside filters:
const filters = {
AND: [{ user_id: scopes.userId }, { metadata: { ruleset: scopes.ruleset } }],
};
Mem0 search requests require at least one entity ID, such as user_id, agent_id, app_id, or run_id, inside the filters object. Top-level entity IDs are rejected, so the scope belongs in the filter expression itself.
We also filter by metadata.ruleset so this tutorial only retrieves review rules from the docs-qa-assistant rule set. That gives us two layers of isolation:
user_idcontrols the Mem0 entity scope.rulesetcontrols the application-level rule set.
For a larger app, you could choose a different scoping strategy. For example, you might use app_id as the main entity scope for app-level memories, or run_id for session-level memories. The main principle is to avoid unscoped searches and to be deliberate about which memory space you are querying.
Where This Could Go Next
This CLI is intentionally small, but the pattern can grow in several useful directions.
You could add a command for storing new review rules from the terminal:
npm run add-rule -- "Use OpenMemory MCP, not Open Memory MCP"
You could add GitHub Actions support so the assistant reviews changed Markdown files in a pull request.
You could separate rule sets by project:
metadata: {
source: "docs-qa-seed",
ruleset: "mem0-blog",
rule_type: "api-gotcha"
}
You could also add a human approval step. For example, when the assistant flags an issue, the reviewer could mark it as useful or not useful. Useful findings could become new review rules.
The important point is that the memory store should hold durable context, not transient prompt clutter. Product terminology, API gotchas, editorial rules, and recurring review comments are exactly the kind of context that becomes more valuable when it persists across sessions.
Conclusion
In this tutorial, we built a Node.js docs QA assistant with Mem0 and OpenAI.
The assistant:
- Stores technical review rules in Mem0.
- Uses metadata to organize rules by ruleset, product, type, and severity.
- Searches with an entity-scoped filter so retrieval stays isolated.
- Uses
infer: falsewhen exact rule text matters. - Explains why
threshold: 0.0is useful for this tiny demo but should be reconsidered for larger memory stores. - Returns a structured QA report for a Markdown draft.
The result is a persistent technical reviewer that can reuse the rules you do not want to repeat in every prompt.