twin memory

Migrating from Mem0

Map Mem0 memories and user ids onto twin memory items and end users, and import an export with a short script.

Mem0 keeps memories per user_id. twin memory keeps items per end user, named by the X-Twin-Account header. A migration reads your Mem0 memories, turns each into an item, and adds them per user.

The console's Import page does this for you: choose a Mem0 export, check each user's end user id, and import. It maps memories as this page does. The script below does the same over plain HTTP.

How the concepts map

Mem0twin memory
A memory (memory text)An item of kind note. For raw chat history you still hold, add the turns as message items instead.
user_idThe X-Twin-Account header. One end user per Mem0 user.
agent_idAn about tag, such as agent:support-bot.
run_id / session_idsession. Forget by session removes them together.
Memory idItem id, such as mem0-<id>. The same id again is an edit, so a rerun is safe.
created_atat. Once an item is held, its time is fixed.
metadatametadata. Stored and returned by read; nothing filters on it.
categoriesabout tags, such as category:food. At most 20 per item.
expiration_dateexpires. From then, the item is forgotten.
Update a memoryAdd again with the same id, only to fix the same statement. When something changed, add a new item.
get_all / getitems, newest first and paged, and item by id.
Memory historyversions of the item id.
Delete a memoryforget with items.
Delete all of a user's memoriesforget with the sessions and items, or delete the end user in the console.
Searchsearch.

1. Export from Mem0

Mem0's list endpoint (POST /v3/memories/, client.get_all in its Python SDK) returns memories in pages, filtered by user_id. Page through it and save the memories to one JSON file, either as a list or as the {"results": [...]} envelope the endpoint returns.

The fields the script reads are id, memory, user_id, agent_id, session_id, run_id, created_at, metadata, categories and expiration_date. Exports differ between Mem0 versions and plans, so check these names against your file before you run it.

Checked against docs.mem0.ai/api-reference/memory/get-memories on 2026-10-21.

2. Map user ids to end user ids

An end user id holds letters, digits, ., _ and -, and up to 35 characters always fits, whatever your project is called. A shorter project slug leaves more room: the console's Import shows the limit for the project you are in, and End users gives the rule. Mem0 user ids are often emails, which do not fit. The script keeps an id that fits and replaces one that does not with a stable digest. Keep the mapping it prints, so your app sends the same header later.

3. Import

The script adds each user's memories in batches of 500 items, the most one add takes. It retries a 503 after a pause. Every item has an id, so running it twice changes nothing.

"""Import a Mem0 export into twin memory. Usage: python import_mem0.py export.json"""
import hashlib, json, os, re, sys, time
from collections import defaultdict

import requests

ADD = "https://api.twinmemory.dev/v1/memory/add"
FITS = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,34}")


def account_for(user_id: str) -> str:
    if FITS.fullmatch(user_id):
        return user_id
    return "u_" + hashlib.sha256(user_id.encode()).hexdigest()[:32]


def item_for(m: dict) -> dict:
    item = {"id": f"mem0-{m['id']}", "kind": "note", "text": m["memory"]}
    if m.get("created_at"):
        item["at"] = m["created_at"]
    if m.get("expiration_date"):
        item["expires"] = m["expiration_date"]
    if m.get("session_id") or m.get("run_id"):
        item["session"] = m.get("session_id") or m.get("run_id")
    about = [f"category:{c}" for c in m.get("categories") or []]
    if m.get("agent_id"):
        about.append(f"agent:{m['agent_id']}")
    if about:
        item["about"] = about[:20]
    item["metadata"] = {**(m.get("metadata") or {}), "mem0_id": m["id"]}
    return item


def post(account: str, batch: list[dict], key: str) -> None:
    for attempt in range(5):
        r = requests.post(
            ADD,
            headers={
                "Authorization": f"Bearer {os.environ['TWIN_MEMORY_API_KEY']}",
                "X-Twin-Account": account,
                "Idempotency-Key": key,
            },
            json={"items": batch},
            timeout=60,
        )
        if r.status_code != 503:
            r.raise_for_status()
            return
        time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
    r.raise_for_status()


def main(path: str) -> None:
    data = json.load(open(path))
    memories = data["results"] if isinstance(data, dict) else data
    by_user = defaultdict(list)
    for m in memories:
        by_user[m["user_id"]].append(item_for(m))
    for user_id, items in by_user.items():
        account = account_for(user_id)
        print(f"{user_id}\t{account}\t{len(items)} items")
        for start in range(0, len(items), 500):
            post(account, items[start:start + 500], f"mem0-import-{account}-{start}")


if __name__ == "__main__":
    main(sys.argv[1])

Every item here has an id, so a retry is the same items again. The Idempotency-Key header only names items sent without an id; it is there in case you change the script to drop them.

A request body is at most 1,000,000 bytes. If your memories are long, use smaller batches.

4. Check

Ask status about a few item ids. landed means searchable. Then run a search for a user you know well.

What changes

  • Search returns more than a list. A search answers with what is known now, dated facts and the passages behind them, each with an address. See Search.
  • Reading happens in the background. An add is searchable by its words within seconds. The facts read from it follow within minutes, and the known lines within about half an hour. Records and reading gives the timings, and status says how far each item has got.
  • No memory is overwritten for you. A later value does not replace an earlier one; both are kept with the day each was said. See Values and history.
  • Metadata does not filter. Narrow a search with scope, before and after instead.
  • An update is a new item, not an edit. Mem0's update replaces a memory. Here, send the same id again only to fix the same statement, such as a typo. When something changes, such as a new address, add a new item: memory keeps both, each with its date. A re-send replaces the text and keeps the item's about, metadata, expires and author unless you send them; null clears one. See Versions and edits.
  • get_all and get are items and item. items lists what you added for an end user, newest first, up to 200 a page, with each item's id, text, about, metadata and expires. item returns one by its id, whole. Both take a read key. They return items as you sent them; the facts memory read from them are in search.

On this page