Give all your AI assistants combined, long-term memory while keeping full control of your data.
Assistant Memory is a lightweight memory service built around the Model Context Protocol (MCP). It speaks MCP over HTTP today (stdio support is on the way) and also exposes classic REST endpoints. Store conversations and documents and let any MCP-enabled assistant recall them when needed.
Memory is a shared store for clients such as Petals, Claude Code, Codex, and other HTTP or MCP tools. It owns source storage, file conversion, derived claims, provenance, and recall. A client owns access to its external accounts, the choice of material to submit, and any actions taken from remembered information.
A stored source and the facts extracted from it are separate. Memory retains source content even when extraction finds no task. Summaries, claims, and possible commitments are derived views; processing completion does not mean every fact has become a graph claim. Read the source text when those views are insufficient.
MemoryClient uses strict partition access by default. Pass an explicit
partitionKey for context-specific evidence. For ordinary operations that
must read a user's active partitions, derive a separate client:
const workspaceClient = client.withWorkspaceAccess();
const result = await workspaceClient.querySearch({
userId,
query: "workshop notes",
});The workspace client sends x-memory-access-scope: workspace. An explicit
partitionKey still limits that request to one partition. Workspace reads
exclude inactive partitions and other users; legacy NULL-partition rows remain
visible only before migration completes. New root content uses Memory's
memory:personal partition after migration, child sources inherit their
parent partition, and existing-object mutations resolve the actual owned
partition before applying strict write checks.
Use strict access for preparation, partition-specific evidence, cleanup, and maintenance. The AI graph cleanup engine now keeps every read, model context, and mutation in one active owned partition. Partitioned and workspace cleanup remain disabled at the request and worker boundaries. Enable or run cleanup in production only after the partition-isolation checks pass in the deployed environment and the owner gives explicit approval. The admin user-self-identity backfill remains strict-only pending a partition-scoped implementation.
Documents and files can carry optional sourceContext: facts supplied by the host about origin, authorship, relationships, chronology, and completeness. Person messages can use sourceKind: "message" with messageId, threadId, authoredAt, and participants identified by email or a stable providerId. Transcript ingestion accepts an optional sourceKind for the parent source's origin. No client needs to turn its content into email or identify tasks before storing it.
If a processing receipt was saved but its queue job was not, resubmit the same ingestion request with the source-preserving defaults to restore the job. Legacy updateExisting: true replacement requests intentionally tombstone and recreate the source; use the processing retry endpoint when retained conversion settings are available.
Status reads share a narrow queue-interruption projection across HTTP, the SDK, and MCP. When the exact retained ingestion job has ended in a terminal failed state while its durable receipt still says queued or processing, the response reports status: "failed" with errorCode: "PROCESSING_INTERRUPTED". This is a read-only projection: it does not change the receipt or retry the job. It keeps the receipt's source version and the same user, partition, strict, and workspace access checks. A Redis outage or status-inspection error remains an error; it is not reported as a failed operation. Use the existing retry path only after checking that the retained operation is appropriate to retry. Retry rechecks the current source and source version and can return a conflict when the source lifecycle changed.
Display-only document/file title updates refresh the linked Document label and search embedding without repeating extraction. Source identity retirement blocks parent creation as well as content writes for every supported source type. Purged processing receipts retain status and internal references but erase the external identity and content hash.
Concurrent document/file requests with the same identity reuse the first stored fallback timestamp when none is supplied. Readable email attachments can refine an existing request's label and statement while preserving its current-message citation, status, and manual confirmation or dismissal. Incoming lifecycle updates require a known matching requester; authenticated outgoing owner messages can update the owner's work.
Matched email revisions can remove an earlier inferred deadline with explicit removal wording, such as “There is no deadline now” or “Er is geen deadline meer.” Omitted or ambiguous dates preserve the existing deadline, and email revisions do not clear dates set by the user. Presentation excerpts keep their original source citation when later messages change task status.
Correcting an email source also removes later inferred status and deadline claims that depend on its request evidence. Manual decisions and independent evidence remain. Request history in each extraction prompt is limited to 32,000 serialized characters, with active and dismissed state before older history and an explicit omitted-record count. Full history remains available to validate matches. Dutch dates introduced only by “voor” or “tegen” do not establish deadlines; explicit wording such as “uiterlijk,” “vóór,” or “deadline voor” is required.
Attachment evidence in each parent extraction prompt is limited to 32,000 serialized characters, with prefixes of at most 4,000 characters from each of the first 100 sources. Complete converted text remains available through source reads. Attachment refinements refresh canonical labels and search embeddings. Identical file replays refresh the linked Document label from a new title or, when no title is stored, its filename without re-extracting.
See the ingestion contract for generic and email examples, processing receipts, source reads, and HTTP/SDK/MCP support. Consumer migration notes list the changes needed for existing integrations.
With RUN_MIGRATIONS=true, the first database request applies pending migrations.
The server can print “listening” before this happens. Other database requests wait
until migrations finish.
Container logs include database.migrations.started, a progress event every ten
seconds, and database.migrations.completed after commit or
database.migrations.failed on error. The progress phase distinguishes
connecting, waiting_for_lock, and applying; elapsedMs and
statementsStarted help identify a long-running operation. Migration 0029 also
reports backfill stages and row counts. These stage counts describe work inside
the transaction; only the completion event confirms it committed.
Avoid restarting a container while its migration is running: the unfinished transaction rolls back and its work starts again. Logs exclude connection strings, SQL, and memory content.
Initial POST /query/graph reads honor maxNodes (100 by default) and select
nodes in stable ID order. Use graph search or neighborhood expansion to inspect
other nodes. Claims and source references are limited to the selected nodes.
SOURCE_BLOB_UPLOAD_TIMEOUT_MS limits the storage PUT to 60,000 ms by default.
The client cancels a stalled request before releasing database locks. Cancellation
does not prove that storage rejected the upload: a lost response can leave saved
bytes. An unknown-outcome receipt stays open until cleanup observes and deletes
the object. If the object never arrives, the receipt stays pending rather than
reporting deletion complete. Bucket preparation and URL signing run outside the
locked PUT and its deadline.
Anything can become memory. Tools that ingest into Assistant Memory:
- screenpipe-distiller — distills your daily computer activity (via Screenpipe) into durable memory.
- Google AI Studio importer — imports a Google AI Studio conversation export as a speaker-attributed transcript. Drops pasted attachments and the model's internal reasoning, and re-runs incrementally (a watermark sends only new turns). Works against a direct Memory host or a Petals proxy. Try it:
pnpm run tsx scripts/import-aistudio.ts --file chat.json --dry-run. - n8n workflows for meeting transcripts, handwritten notes, and health metrics (examples coming — see issues).
The integrated MCP server provides tools for saving memories, performing searches and retrieving day summaries. Because it follows the MCP standard, any compliant client can plug in and exchange messages seamlessly.
Use Assistant Memory as a sidecar to the chat runtime. The chat host, not the LLM, owns the main orchestration loop: it sends source material to memory, fetches bounded context before model calls, and decides which memory tools are available to the assistant.
- Chat host: the application server that owns
userId,conversation.id, message IDs, model calls, and prompt assembly. - Assistant model: the LLM that sees rendered memory context or calls MCP tools. It should not talk directly to the database.
- Assistant Memory service: the HTTP/MCP service that stores sources, extracts claims, applies lifecycle, and serves search/read APIs.
- Worker queue: background jobs that process ingestion. Ingestion is accepted synchronously but memory extraction is asynchronous.
- Memory UI/debugger: optional tooling that can use raw graph endpoints for inspection and repair. This is separate from the normal chat loop.
This is the integration that works with the current code.
-
When a new chat session opens, the chat host fetches bootstrap context.
Call
POST /query/atlasbefore the first LLM call:{ "userId": "user_123", "assistantId": "assistant_abc" }The response is
{ "atlas": string }. Put that string in a developer/system context block, not in the user message. If the session is day-sensitive, also callPOST /query/day:{ "userId": "user_123", "date": "2026-04-26", "includeFormattedResult": true } -
Before an LLM call, the chat host searches only if the turn needs memory.
Call
POST /query/search. Do not run a separate LLM just to invent search keywords. The default query is the current user message text. If the host already has structured UI context, append it in a fixed template:{ "userId": "user_123", "query": "Current user message: Continue with the next part of the memory refactor.\nActive task: claims-first memory implementation plan\nSelected entity: Assistant Memory", "limit": 8, "excludeNodeTypes": ["AssistantDream", "Temporal"], "conversationId": "chat_456" }Use
formattedResultas a clearly labeled memory evidence block. Do not dumpsearchResultsdirectly into the prompt unless the caller has its own renderer. Prefer one targeted search over many broad searches.Search query construction rules:
- Use the latest user message verbatim as the required query input.
- Append only host-known context: active task title, selected project/entity labels, conversation title, or route/page context.
- Do not ask another LLM to summarize, keyword-expand, or infer the topic before every search. That adds latency and another failure mode.
- Do not include the full transcript. If the latest message is anaphoric ("yes, do that"), append at most the host-known active task/title or the immediately preceding user-visible topic.
- If the assistant is using MCP tools instead of host-side prefetch, the assistant can call
search memoryduring its normal tool-use loop with a natural-language query. That is not a separate pre-call query-rewrite step; it is a tool call made because the model decided it needs memory.
-
Expose open commitments through a deterministic host policy or a model tool rule.
The host cannot know the assistant's future sentence plan. It must use one of these two explicit integration modes:
- Host-prefetch mode: the chat host calls
POST /commitments/openand renders anopen_commitmentsmemory section before the model call. Do this unconditionally on session bootstrap. Do it again before a model call when the current UI route/surface is a task, planning, reminders, project-status, or daily-brief surface; when the user selected a Task/Project/Person node; or after an ingestion job that inserted or superseded aHAS_TASK_STATUSclaim. - Tool-use mode: the host exposes MCP
list_open_commitmentsand includes the model instruction below. In this mode the assistant model decides during normal tool use, not via a separate pre-call classifier.
Model instruction for tool-use mode:
Use `list_open_commitments` before you answer with any statement about the user's open, pending, in-progress, completed, abandoned, outstanding, next, or follow-up work, unless the current model input already contains a `<section kind="open_commitments">` rendered for this same model call. Call it for user requests such as: - "what should I do next?" - "what is still open?" - "continue with the next part" - "remind me what I owe" - "summarize pending work" - "is X done?" - "plan my day/project/week" If the user names a known assignee/person and you have their node id, pass `ownedBy`. If the user gives a date cutoff, pass `dueBefore` as YYYY-MM-DD. Do not infer pending work from semantic search results.The REST call is:
{ "userId": "user_123", "ownedBy": "node_01kq54zhdwe4a94mj9nrrnne4h", "dueBefore": "2026-04-30" }ownedByis an optional Person node ID.dueBeforeis an optional inclusiveYYYY-MM-DDcutoff; when present, undated tasks are excluded.ownerisnullfor an assignment to the user's explicitly marked self Person node, as well as for no visible assignment. Stored self assignments andownedByfilters remain intact. See self assignment. The response is:{ "commitments": [ { "taskId": "node_01kq5509kfe4a94mj5k20j5z6y", "label": "Send the spec", "status": "pending", "owner": { "nodeId": "node_01kq54zhdwe4a94mj9nrrnne4h", "label": "Marcel" }, "dueOn": "2026-04-27", "statedAt": "2026-04-01T10:00:00.000Z", "sourceId": "src_01kq54zhdye4a94mjmw0wev9jx" } ] }Prompt rendering in host-prefetch mode:
<section kind="open_commitments" as_of="2026-04-26T13:15:00.000Z" usage="Use this as the only source of pending work. Do not infer pending work from semantic search."> <commitment task_id="node_01kq5509kfe4a94mj5k20j5z6y" status="pending" owner="Marcel" due_on="2026-04-27" source_id="src_01kq54zhdye4a94mjmw0wev9jx">Send the spec</commitment> </section>
Treat this endpoint, not semantic search, as the source of truth for pending work. It reads the newest active
HAS_TASK_STATUSclaim for each Task and only returnspendingorin_progress; completed and abandoned tasks stay out even if older search hits mention them as pending. - Host-prefetch mode: the chat host calls
-
After each persisted turn, the chat host queues ingestion.
Call
POST /ingest/conversationafter a user message is saved and again after the assistant response is saved, or once per complete turn pair:{ "userId": "user_123", "conversation": { "id": "chat_456", "messages": [ { "id": "msg_001", "role": "user", "content": "Let's continue the claims refactor.", "timestamp": "2026-04-26T13:00:00.000Z" }, { "id": "msg_002", "role": "assistant", "content": "I'll inspect the plan and continue with the next slice.", "timestamp": "2026-04-26T13:00:12.000Z" } ] } }Message IDs must be stable and immutable. The ingestion path deduplicates by source external ID; changing the content for the same message ID will not reliably rewrite memory.
-
When ingesting documents, the caller must choose the scope at the boundary.
Use
POST /ingest/document:{ "userId": "user_123", "updateExisting": false, "document": { "id": "doc_stoicism_notes", "content": "Document text...", "scope": "reference", "timestamp": "2026-04-26T13:10:00.000Z" } }Use
scope: "personal"for user-specific notes andscope: "reference"for books, articles, external knowledge, and general source material. Do not rely on the extractor to infer scope from content. -
Raw graph endpoints are for tools, not ordinary chat context.
Use
POST /node/get,POST /node/neighborhood,POST /node/sources,/claim/*,/alias/*, and/node/mergefrom a memory UI, debugger, or explicit edit flow. Do not expose destructive or repair tools to an autonomous assistant without a confirmation layer.
MCP connects over GET /sse and POST /messages. Tools use snake_case names:
save_memory: document ingestion using thePOST /ingest/documentschema. HonorsupdateExistingand returns the JSON acceptance receipt in a text content block.get_source_processing: read one ingestion operation byuserId,partitionKey, andoperationId.retry_source_processing: retry a failed operation through the same recovery service as HTTP.get_source: read source metadata and, withincludeContent: true, its stored text or converted Markdown. Conversion preserves the original source payload separately.bootstrap_memory: fetch startup context before the first answer that depends on prior memory.search_memoryandsearch_reference: retrieve personal or reference material with source evidence.list_commitments: inspect trusted tasks, candidates, or both through its provenance filter.query_day_memories: calls the same path asPOST /query/day.list_open_commitments: callsPOST /commitments/opensemantics and returns currently open tasks only. The model should call it before answering about outstanding, next, pending, follow-up, completed, or abandoned work unless anopen_commitmentssection was rendered for this same model call.get_nodeandget_node_sources: raw graph and linked-source metadata inspection tools.read_scratchpad,write_scratchpad,edit_scratchpad: scratchpad operations.update_node,delete_node: raw edit tools; these should be gated by the host.
- Call
bootstrap_memoryonce at session start when the answer depends on prior memory. Pass the same user and partition used for subsequent reads. - Use
search_memoryfor personal memory andsearch_referencefor saved reference material when startup context does not answer the question. - Use
get_entityfor a known node. Useget_node_sourcesto inspect citations andget_sourcewithincludeContent: trueto read the stored text behind a source. - Use
list_open_commitmentsfor confirmed open work. Uselist_commitmentswithprovenance: "candidate"or"all"when looking for possible follow-ups, including tentative email requests. Confirmation is a separate user decision; a client can surface or prepare work while keeping it tentative. - Save new material with
save_memoryand follow its receipt when completion matters. Storage and recall do not require a task to exist.
Memory should be rendered as context with provenance, not as user text:
<assistant_memory as_of="2026-04-26T13:15:00.000Z">
<section kind="atlas" usage="Stable personal context. Prefer current user statements if they conflict.">
...
</section>
<section kind="open_commitments" usage="Pending or in-progress only. Do not reintroduce completed work as pending.">
...
</section>
<section kind="evidence" usage="Relevant sourced memories for this turn. Use cautiously and mention uncertainty on conflict.">
...
</section>
</assistant_memory>The assistant should follow these rules:
- Treat the current user message as fresher than memory when they conflict.
- Never convert reference material into a claim about the user.
- Never treat assistant-only speculation as a user fact.
- Treat
open_commitmentsas the only source of pending tasks once that section exists. - Use evidence refs for inspection, citations, and repair flows; do not expose raw claim IDs in normal prose unless the product asks for them.
The integration only works if these are true:
- Search query construction is deterministic: host-side prefetch uses the current user message plus host-known labels. It does not require an extra LLM call to rewrite queries.
- Stable source IDs: conversation and document IDs are stable. Otherwise ingestion is not idempotent.
- Async freshness is understood: a just-queued ingestion job may not be visible to the next search. The host should not assume read-after-write unless it waits for the job pipeline.
- Reference isolation is complete: default personal memory must exclude reference claims and reference-derived node cards. The current graph search path scope-bounds claims, one-hop traversal, and node similarity; the target card-shaped
search_memorymust preserve that behavior. - Lifecycle drives commitments: open tasks must come from latest
HAS_TASK_STATUS, not from old search hits. Otherwise completed work will resurface as pending. The corollary invariant: everyTasknode carries exactly one activeHAS_TASK_STATUSclaim unless it was deliberately dismissed (status retracted, awaiting pruning). The ingestion andcreateNodepaths synthesize a default candidate-band status when one would otherwise be missing, so a Task can't be minted statusless — a statusless Task is a bug (invisible to every commitment surface yet present in node/type/search), repaired byPOST /maintenance/recover-statusless-commitments. - Tool descriptions are part of behavior: MCP descriptions must tell the model when to use
search_memoryvs.search_referencevs.list_open_commitments, and those descriptions need snapshot tests. - Raw edit tools are gated: node/claim merge, update, and delete operations need a user-confirmed workflow, not free autonomous model access.
POST /ingest/conversationandPOST /ingest/document– send new information to be stored.POST /query/search– vector search to retrieve relevant nodes.POST /commitments/open– lifecycle-aware list of pending and in-progress tasks.POST /query/day– get a quick summary of a particular day.POST /query/recent-changes– "what's new in memory" feed over a time range: active claims and nodes added/updated since asincecursor, with labels and per-source provenance.POST /digest– consolidated daily rollup for a "Today" view (see below).POST /maintenance/prune-stale-nodes– deterministic, preview-then-apply "garbage collect" sweep (see below).POST /maintenance/recover-statusless-commitments– preview-then-apply repair that givesTasknodes missing aHAS_TASK_STATUSclaim a default candidate-band status, so they stop being invisible to the commitment views (see below).GET /sseandPOST /messages– MCP over HTTP using Server‑Sent Events.
Over time a graph accretes cruft: nodes from old conversations that are weakly
connected, backed only by assistant-inferred claims, or dominated by superseded
facts. POST /maintenance/prune-stale-nodes is a deterministic (no-LLM) sweep
that scores every entity/task node and prunes the disposable tail. The score is
a transparent weighted sum so a host (e.g. a "clean up my memory" button) can
preview exactly what would go and why:
score = 0.40·staleness + 0.25·isolation + 0.20·weakProvenance + 0.15·claimDecay
It is preview-then-apply. dryRun defaults to true and returns the ranked
candidates with per-node reasons plus the full candidateCount; re-call with
the same thresholds and dryRun: false to delete (deletion cascades through
claims, source links, aliases, and embeddings). Tune one knob — aggressiveness
in [0, 1], higher prunes more — or pin an explicit minScore.
{
"userId": "user_123",
"aggressiveness": 0.6,
"minIdleDays": 30,
"includeReference": false,
"dryRun": true
}The sweep never prunes nodes active within minIdleDays, nodes with a
currently-open task status, the user's self-identity node(s), or — unless
includeReference is set — reference-scope nodes (books, articles, imported
documents). Page through a large backlog by re-calling while hasMore is true.
For the narrower deterministic cases there are also
POST /maintenance/prune-orphan-nodes (evidence-free nodes) and
POST /cleanup/dedup-sweep (exact-label duplicates).
A Task node's commitment-ness is carried entirely by its HAS_TASK_STATUS
claim — every commitment read model anchors on it — so a Task with no status
claim is invisible to the open/candidate/list views even though it still appears
in node-type, search, and graph queries. Tasks minted before the write-time
guards, or whose only extracted status was dropped as off-vocabulary, can be in
exactly that state.
POST /maintenance/recover-statusless-commitments finds Task nodes with no
HAS_TASK_STATUS claim in any lifecycle state and gives them a default
candidate-band status (pending / assistant_inferred), so they resurface as
candidates the user can confirm or dismiss. Anchoring on "any state" is the
deliberate carve-out that excludes deliberately-dismissed tasks (whose status
was retracted) — those are left to orphan pruning, not resurrected. It is
dryRun-by-default (preview the count and a sample, then re-call with
dryRun: false), additive (never deletes), and idempotent, so it serves as both
a one-time backfill and an ongoing self-heal sweep.
Because it is non-destructive, it also runs automatically inside the
cleanup-graph maintenance pass (POST /cleanup), before orphan pruning, so
a statusless Task is rescued into the candidate view rather than deleted as
evidence-free. The invariant therefore self-heals on your normal cleanup
cadence; pass recoverStatuslessCommitments: false to POST /cleanup to opt
out.
POST /digest bundles everything a "daily digest" / "Today" surface needs into a single call, so consumers don't fan out across commitments/open, metrics/summaries, query/recent-changes, and context/bootstrap:
{
"userId": "user_123",
"date": "2026-05-29",
"timeZone": "America/New_York",
"since": "2026-05-28T00:00:00Z",
"upcomingWithinDays": 7,
"metricMoverLimit": 10,
"whatsNewLimit": 50,
"includePinned": true
}date, timeZone, and userId are required; everything else is optional. The response is structured data only — the consumer generates any narrative prose itself:
commitments– open tasks bucketed intooverdue/dueToday/upcomingby their due date relative todate(undated tasks are omitted;upcomingspans the nextupcomingWithinDays, default 7).metricMovers– the metrics that moved most recently, each withlatestValue,delta,direction, and thewindowthe delta was measured against.whatsNew–claims,nodes, andsourcesrecorded sincesince(defaults to the start ofdateintimeZone), with labels and provenance so no follow-upgetNodecalls are needed.pinned– the pinned/preferences subset of the bootstrap bundle, unlessincludePinnedisfalse.
Assistant Memory also stores numeric time-series readings separately from claims. Use this for values such as body weight, running distance, pace, heart rate, sleep duration, readiness scores, and steps.
Write paths:
POST /metrics/observationsrecords one explicit reading and can create or reuse a metric definition.POST /metrics/observations/bulkrecords many readings by existing metric slug. Bulk imports never create definitions; unknown slugs are returned as per-row errors.- Conversation and document ingestion can extract metrics implicitly and attach event-linked readings to Event nodes.
Single write example:
{
"userId": "user_123",
"metric": {
"slug": "body_weight",
"label": "Body weight",
"description": "Morning bathroom scale weight",
"unit": "kg",
"aggregationHint": "avg"
},
"value": 78.2,
"occurredAt": "2026-05-03T07:30:00Z",
"note": "post-run"
}Read paths:
POST /metrics/listreturns definitions with units, review state, and lightweight stats.POST /metrics/seriesreturns raw or bucketed points for one or more metric definitions.POST /metrics/summaryreturns latest value, 7d/30d/90d stats, and a coarse trend.POST /metrics/summariesreturns that same per-metric shape for many metrics in one round-trip (omitmetricIdsfor all, optionally filtered) — for "metric movers" digests/dashboards without an N+1 fan-out.
New definitions are deduplicated by exact slug first, then definition embedding similarity. Near-duplicate definitions are created with needsReview: true and surfaced as normal open Task commitments.
- Keep sensitive data on your own servers.
- Turn large transcripts and documents into a searchable graph.
- Drop in as a microservice alongside your existing assistant.
Spin it up with docker-compose up and start talking. Your assistant will finally remember everything.