What it is

mapi stores what your agent learns about a person and gives it back when it is relevant. It is not a vector database with a nicer client: the difference is that it tracks currency and provenance, so an agent can tell what is still true and say why it believes it.

Three things follow from that, and they are the reason to use this over a plain index:

Quickstart

pip install mapi-sdk
from mapi_sdk import Mapi

client = Mapi(api_key="sm_...")          # or set MAPI_API_KEY
client.spaces.get_or_create("ada")

client.memories.add("Prefers window seats", space="ada", tags=["travel"])

for hit in client.search.execute("seating preference", space="ada"):
    print(hit.score, hit.content)

Async is the same surface, awaited:

from mapi_sdk import AsyncMapi

async with AsyncMapi() as client:
    await client.memories.add("Allergic to shellfish", space="ada")
    hits = await client.search.execute("allergies", space="ada")

Or without the SDK:

curl -X POST https://memory-api-7b178bde9ecc.herokuapp.com/v1/spaces/$SPACE/memories \
  -H "Authorization: Bearer $MAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content": "Prefers window seats", "tags": ["travel"]}'

How it works

Three stages, described at the level you need to use it well. The specifics of each — how candidates are ranked, what makes something count as a replacement, how facts are pulled out of text — are the product, and are not documented here.

On write

Text is normalised, split where it is too long to index as one unit, and embedded. Identical content is folded into what is already stored rather than duplicated. If you ask for it, the write also compares against what the space already holds, and records what this new memory replaces or disagrees with.

On read

A query runs against both meaning and wording, and the two rankings are combined — semantic search alone misses exact strings like order numbers and surnames, and keyword search alone misses paraphrase. Recency is weighed in, results that a returned memory has superseded are removed, and each result can report why it is there.

Over time

Memories accumulate typed relations, which is what makes the store answer questions an index cannot: what is current, what changed, what disagrees, and what a conclusion rests on. Deleting evidence marks the conclusions drawn from it stale rather than leaving them standing.

Numbers

Measured on a public benchmark, run end to end — retrieve, answer, and grade — not retrieval-only, because retrieval quality that never becomes a correct answer is not worth reporting.

benchmarkwhat it testsquestionsaccuracy
LongMemEval-Srecall across ~50 sessions of chat history per question47082.6%

Reported per capability, never as one blended score. A system can be excellent at recall and dangerous at knowing when a fact went stale, and one number hides exactly that.

LongMemEval-S — 470 questions

capabilitynaccuracy
single-session assistant5694.6%
single-session user7094.3%
knowledge update7887.2%
temporal reasoning13379.7%
multi-session13372.9%
single-session preference3066.7%

Finding the evidence

Accuracy above is the whole pipeline. These are the retrieval stage alone, scored against the evidence each benchmark labels — no model, no judge, so none of it carries grading variance:

what it measuresresult
Every piece of evidence delivered. The strict one: a question needing four sources counts only if all four arrive.96.8%
Some evidence delivered. At least one correct source in the window.99.4%
Correct evidence ranked first. How near the top the first right answer lands.0.939
Ranking quality. Credit for correct sources weighted by position.0.937

Complete evidence reaches the answer stage for 96.8% of questions, so most remaining errors are answering errors rather than recall errors — the honest reading, and the reason the weakest capability rows above are the ones being worked on.

Explainability

Every result can report why it is there. Ask for it with explain=True and each hit carries the reasons it survived to the top — which stage promoted it, and what moved its position. That is the difference between a ranking you can debug and a number you have to trust.

result = client.search.execute("allergies", space="ada", explain=True)
for hit in result:
    print(hit.score, hit.explain)
How these were graded. With LongMemEval's own published judge prompts, and by a model from a different family than the one answering — a model grading its own output scores itself generously, and the size of that effect is published alongside the results rather than quietly absorbed into them.

Spaces

A space is one person's memory. Retrieval never crosses a space boundary, so one user's memories cannot appear in another's results — that is a property of the storage layer, not a filter you have to remember to apply.

Most applications create one space per end user. Spaces are cheap; a space per user is the intended shape, not an abuse of it.

client.spaces.get_or_create("user-8134")     # slug you choose
client.spaces.list()

Every call takes a space slug or its id. Slugs are resolved once per process and cached, so using the readable name costs nothing per call.

Memories

A memory is a piece of text with a time, and optionally tags, metadata and a source. Two kinds exist:

kindwhat it isanswers
episodicsomething that happened, stored as it was saidwhat happened, and in what order
deriveda standing fact, computed from episodeswhat is true now

You write episodes. Derived memories appear when you ask for them, and always carry links back to the episodes they came from — which is what makes erasure propagate.

Event time

occurred_at is when it happened, not when you wrote it. Leaving it out on a backfill makes a year of history look like it all happened today, and every question about order is wrong afterwards.
from datetime import UTC, datetime

client.memories.add(
    "Ran the charity 5K in 27:12",
    space="ada",
    occurred_at=datetime(2023, 5, 20, tzinfo=UTC),
)

Event time is separate from the time the system learned something. Both are kept, which is why a memory can be read as of a past moment.

Currency

When a newer memory replaces an older one, the older is marked superseded and drops out of search results. It is not deleted: it stays readable, and it points at whatever replaced it.

This matters more than it sounds. An index that returns both a person's old address and their new one has technically retrieved correctly and practically misinformed the agent — and the agent has no way to tell which is which.

hits = client.search.execute("address", space="ada")
# returns the current address only

hits = client.search.execute("address", space="ada", include_superseded=True)
# returns the history too, each marked with its status

Relations

Memories relate to each other in four ways. These are claims about the memories, not links between documents:

relationmeaning
supersedesthis replaced that
contradictsthese cannot both be true
derived_fromthis was computed from that
referencesthis mentions that

Contradictions are surfaced, never resolved. Both memories stay active and both are returned, because either may be the true one — an agent told "these two disagree" can ask the user, where one handed a silent winner cannot.

Writing well

Three options change what a write does. All are off by default, because each costs something and none is right for every application.

optionwhat it doeswhen
detect_conflictsflags memories this one disagrees withwhen contradictions matter more than write latency
auto_supersedemarks older memories this one replaceswhen the same fact is restated over time
extractalso stores the standalone facts this text stateswhen you want a graph of claims, not just documents
extract is a choice, not an upgrade. It stores atomic claims alongside the original — never instead of it — which makes relationships between facts visible. It also spends a model call per write and suits questions about what is true better than questions about what happened. Turn it on when the graph is the point.

Writes are deduplicated by default, so replaying the same content is safe and will not create a second copy.

Searching

result = client.search.execute(
    "what does she eat",
    space="ada",
    limit=10,
    tags=["health"],        # narrow to tagged memories
    min_score=0.1,          # drop weak matches entirely
    explain=True,           # why each result is here
)

for hit in result:
    print(hit.score, hit.content)

result.conflicts    # pairs of returned ids that disagree
Use min_score when "nothing matched" is a real answer. Search returns the closest memories it has. Without a floor, a question about something never mentioned still returns the ten least-unrelated memories, and an agent cannot tell that from a real answer.

score is comparable within one response and not across them. Do not store it or threshold on it between queries.

Asking why

The call that separates a memory store from an index:

ctx = client.memories.context(memory_id, space="ada")

ctx.is_current      # False if something replaced it
ctx.current_head    # what replaced it
ctx.replaced        # what it replaced
ctx.derived_from    # the episodes it was computed from
ctx.derivatives     # what was computed from it
ctx.contradicts     # what disagrees with it

One request resolves the whole neighbourhood. This is what an agent should show a user who asks "why do you think that", and what you should log when an answer was wrong.

Keeping facts current

When you know a memory replaces another, say so:

client.memories.relate(
    new_id, space="ada", target_id=old_id, relation="supersedes",
)

The older memory becomes superseded immediately and leaves search results. Its content, its history and the link between them all remain readable.

If you would rather the system notice on its own, write with auto_supersede=True. It is deliberately conservative: a missed supersession leaves both memories visible and rankable, where a wrong one hides a true memory from every answer. The two mistakes are not equally cheap.

Erasure

Two operations, for two different obligations:

calleffect
memories.deleteremoves it from results, keeps the audit trail
memories.erasedestroys the content everywhere it can be reached, including history

erase returns an attestation: what was destroyed, how much, and which memories had claimed derivation from it. Those are marked stale, because a conclusion must not outlive the evidence it was drawn from — a summary of deleted data is still that data.

report = client.memories.erase(memory_id, space="ada")
report["stale"]      # what stopped being trustworthy

Errors

Every error is JSON with a stable code, a human detail, and a request_id. Quote the request id when reporting a problem; it is how a specific call gets found in the logs.

statuscodemeans
401unauthorizedkey missing, malformed or revoked
403forbiddenkey is valid but lacks the scope
404not_foundno such resource — also what another organization's resources look like
409conflictcollides with something stored
422validation_errorrejected before anything was written
429rate_limitedtoo many requests; honour Retry-After

The SDK maps these to typed exceptions and retries 429 and gateway errors with backoff. It does not retry 500: a request that made the server fail will usually fail again, and retrying hides it.

Keys and spaces

An API key authorises requests for one organization. It does not identify a person — two people on a team legitimately share a key, so a key can never tell you who acted. Sign-in identifies people; keys authorise programs.

Create keys in the dashboard. A key is shown once, at creation, and stored hashed — if it is lost it cannot be recovered, only replaced.

Reference

Every endpoint, parameter and response shape is in the API reference, generated from the service itself so it cannot drift from what the server actually accepts.

This page explains what things mean. The reference tells you exactly what to send.