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:
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"]}'
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.
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.
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.
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.
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.
| benchmark | what it tests | questions | accuracy |
|---|---|---|---|
| LongMemEval-S | recall across ~50 sessions of chat history per question | 470 | 82.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.
| capability | n | accuracy |
|---|---|---|
| single-session assistant | 56 | 94.6% |
| single-session user | 70 | 94.3% |
| knowledge update | 78 | 87.2% |
| temporal reasoning | 133 | 79.7% |
| multi-session | 133 | 72.9% |
| single-session preference | 30 | 66.7% |
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 measures | result |
|---|---|
| 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.
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)
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.
A memory is a piece of text with a time, and optionally tags, metadata and a source. Two kinds exist:
| kind | what it is | answers |
|---|---|---|
episodic | something that happened, stored as it was said | what happened, and in what order |
derived | a standing fact, computed from episodes | what 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.
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.
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
Memories relate to each other in four ways. These are claims about the memories, not links between documents:
| relation | meaning |
|---|---|
supersedes | this replaced that |
contradicts | these cannot both be true |
derived_from | this was computed from that |
references | this 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.
Three options change what a write does. All are off by default, because each costs something and none is right for every application.
| option | what it does | when |
|---|---|---|
detect_conflicts | flags memories this one disagrees with | when contradictions matter more than write latency |
auto_supersede | marks older memories this one replaces | when the same fact is restated over time |
extract | also stores the standalone facts this text states | when you want a graph of claims, not just documents |
Writes are deduplicated by default, so replaying the same content is safe and will not create a second copy.
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
score is comparable within one response and not across them.
Do not store it or threshold on it between queries.
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.
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.
Two operations, for two different obligations:
| call | effect |
|---|---|
memories.delete | removes it from results, keeps the audit trail |
memories.erase | destroys 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
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.
| status | code | means |
|---|---|---|
| 401 | unauthorized | key missing, malformed or revoked |
| 403 | forbidden | key is valid but lacks the scope |
| 404 | not_found | no such resource — also what another organization's resources look like |
| 409 | conflict | collides with something stored |
| 422 | validation_error | rejected before anything was written |
| 429 | rate_limited | too 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.
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.
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.