The short version: store your agent's memory as provider-neutral JSON keyed by user, expose GET /memory/export and POST /memory/import, and export the data the user gave you — not the embeddings you can recompute. That single endpoint satisfies GDPR Article 20's machine-readable-export requirement and insures you against the failure mode China just demonstrated in production: on July 15, 2026, a regulator switched off Doubao's and Qwen's companion agents, and the memory millions of users had built up became, at best, read-only on a countdown. If your product's value lives in memory the platform owns, that memory is a liability, not a moat.
Here is how to make it an asset instead. The code is small on purpose.
1. Model memory as a portable record, not a table#
The mistake is treating "export" as a database problem. A pg_dump is keyed to your schema and useless to anyone without your source code — which is exactly what Article 20 forbids. Design a public shape first, then map your tables into it.
// memory-schema.ts — the shape you promise to keep stable
export interface MemoryExport {
schema_version: "1.0";
exported_at: string; // ISO 8601
user: { id: string; email?: string };
memories: MemoryEntry[]; // long-term facts the user gave you
threads: SavedThread[]; // conversations worth keeping
config: AgentConfig; // persona, preferences, instructions
}
export interface MemoryEntry {
id: string;
kind: "fact" | "preference" | "instruction";
text: string; // human-readable, not an embedding
source: "user" | "agent";
created_at: string;
}
Notice what is absent: no vectors, no internal trace IDs, no operational metadata. Embeddings are inferred data you can regenerate on import in seconds, and shipping a 1,536-float array per memory turns a readable file into noise. Export the sentence, not its coordinates.
2. Write the export as a pure mapping#
Keep the export function boring: read the user's rows, map them into the public shape, and return it. No side effects, so you can unit-test it against the schema.
// export.ts
import { db } from "./db";
import type { MemoryExport } from "./memory-schema";
export async function buildExport(userId: string): Promise<MemoryExport> {
const [rows, threads, cfg] = await Promise.all([
db.memories.where({ user_id: userId }),
db.threads.where({ user_id: userId, saved: true }),
db.agentConfig.get(userId),
]);
return {
schema_version: "1.0",
exported_at: new Date().toISOString(),
user: { id: userId },
memories: rows.map((r) => ({
id: r.id,
kind: r.kind,
text: r.text,
source: r.source,
created_at: r.created_at.toISOString(),
})),
threads: threads.map((t) => ({ id: t.id, title: t.title, messages: t.messages })),
config: { persona: cfg.persona, preferences: cfg.preferences, instructions: cfg.instructions },
};
}
3. Ship the endpoint — and treat it like a password reset#
A memory export is a complete dossier on one person. The endpoint that hands it over deserves the same suspicion you'd give an account-recovery flow: a real authenticated session, a signature so the user (and the next app) can trust the file's origin, per-user rate limiting, and an audit row on every call.
// server.ts
app.get("/memory/export", requireFreshAuth, rateLimit("2/day/user"), async (req, res) => {
const data = await buildExport(req.user.id);
const body = JSON.stringify(data);
const sig = sign(body, process.env.EXPORT_SIGNING_KEY!); // HMAC over the exact bytes
await db.auditLog.insert({ user_id: req.user.id, action: "memory.export", at: new Date() });
res
.setHeader("Content-Type", "application/json")
.setHeader("Content-Disposition", `attachment; filename="agent-memory-${req.user.id}.json"`)
.setHeader("X-Memory-Signature", sig)
.send(body);
});
requireFreshAuth matters: don't let a stale cookie or a long-lived agent token trigger a full export. Re-authenticate. The signature lets an importing app verify the file came from you unmodified — the difference between a portable format and a forgeable one.
4. Make import the inverse, and regenerate what you dropped#
Import is where you earn back the embeddings you refused to export. Read the file, validate the schema_version, upsert the memories, and recompute vectors on the way in.
// import.ts
export async function importMemory(userId: string, file: MemoryExport) {
if (file.schema_version !== "1.0") throw new Error("unsupported schema_version");
for (const m of file.memories) {
const embedding = await embed(m.text); // recomputed, never shipped
await db.memories.upsert({ user_id: userId, ...m, embedding });
}
await db.agentConfig.set(userId, file.config);
}
Now a user leaving a shut-down platform — or your product — can carry their agent whole into the next one. That is the property the China rules made concrete and GDPR Article 20 has quietly required all along: the memory belongs to the user, in a form another app can read.
The one line that decides it#
If a regulator, a platform, or a competitor's outage can erase your users' memory tomorrow and they'd have nothing to walk away with, you don't have a memory feature — you have counterparty risk. Ship the export endpoint this week; it's a hundred lines and it's the cheapest insurance in your stack.
For the architecture decision underneath this — whether to lean on a platform's built-in memory at all — see Platform Memory vs Your Own Store. For the regulatory event that forced the question, read China Regulated the AI Persona, Not the Model and this week's Wire on portability. If you'd rather buy the memory layer than build it, we compared the options in Statewave vs Mem0 vs Zep.



