#Agent-to-Memory Protocol (A2M), version 0.1
Status: Draft Protocol identifier: a2m/0.1 Canonical home: https://a2m-protocol.org Base protocol: JSON-RPC 2.0
A2M gives an agent a memory it does not own. It puts a memory store behind the same kind of boundary MCP puts a tool behind, so that an agent can talk to a Python object in its own process, a subprocess, or a hosted service without any of its own code changing.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL in this document are to be interpreted as described in RFC 2119.
#1. Design intent
Two observations shape this protocol.
A memory store is not a database. Agents do not query memory, they recall from it: they hand over the situation they are in and expect back whatever is worth knowing, ranked. Ranking is the primitive, not filtering.
Most stores are not layered. A vector database can write, search and delete in an afternoon, and has no concept of tiers, consolidation or salience. A protocol that demands all of it would be implementable only by its own reference implementation. So A2M defines a small mandatory core that any store can satisfy, and layers the interesting behaviour into capabilities a server declares and a client checks.
#2. Conformance
A server is A2M 0.1 conformant if it implements every method in the core capability, declares its capabilities truthfully from memory/describe, and returns the error codes of §7 in the circumstances described.
| Capability | Methods it adds | Record fields it adds |
|---|---|---|
core (REQUIRED) | memory/describe, memory/remember, memory/recall, memory/timeline, memory/forget | id, content, created_at, role, metadata, group |
tiers | memory/promote, memory/consolidate | tier |
salience | memory/reinforce | salience, access_count, accessed_at |
scopes | — | owner |
sessions | memory/session/list, memory/session/close | session |
keys | memory/fetch | key, revision |
embeddings | — | embedding |
external | — | uri, media_type |
events | memory/events, memory/events/subscribe, memory/events/unsubscribe | — |
summarize | memory/summarize | — |
prompt | — (adds a prompt parameter and result field to memory/recall and memory/timeline) | — |
A server MUST NOT advertise a capability it does not fully implement.
A server MUST respond to a call belonging to an undeclared capability with CAPABILITY_NOT_SUPPORTED (§7). It MUST NOT respond with METHOD_NOT_FOUND, because a client cannot distinguish that from a malformed request.
A client MUST call memory/describe before any other method, and MUST NOT call a method belonging to a capability the server did not declare.
A client MUST tolerate record fields it does not recognise. A server MUST NOT reject a request carrying parameters it does not recognise; it MUST ignore them. This is what allows 0.2 clients to talk to 0.1 servers.
#2.1 Read-only servers
A server MAY refuse to accept writes. Such a server MUST answer both memory/remember and memory/forget with READ_ONLY (§7), and MUST implement memory/describe, memory/recall and memory/timeline normally. It is conformant: refusing a write with the error the specification allocates for exactly that purpose is implementing the method.
A client MUST tolerate READ_ONLY from either method. A client that treats it as a transport failure, or retries, is broken against a legitimate server.
A server that refuses writes MUST refuse them consistently — it MUST NOT accept some writes and refuse others with READ_ONLY — because a client has no way to discover which is which. A store that rejects an individual write for a reason other than being read-only has other codes for it (§7).
This is what a pre-existing corpus looks like as an A2M server: a retrieval system whose contents are loaded by its own ingestion pipeline, exposed for recall and nothing more. It is the smallest useful A2M server, and it is deliberately reachable without adopting any of the write-side model.
#3. Data model
#3.1 Record
A record is one remembered thing.
| Field | Type | Required | Capability | Notes | |
|---|---|---|---|---|---|
id | string | yes | core | Opaque. See §3.2. | |
content | string | yes | core | The text that is remembered and searched. | |
created_at | timestamp | yes | core | See §3.3. | |
role | string | no | core | Who produced it: user, assistant, tool, system, memory, or any other value. Advisory. | |
metadata | object | no | core | Arbitrary JSON. Servers MUST round-trip it unchanged. | |
group | string | no | core | See §3.4. | |
tier | string | no | tiers | Which layer the record currently lives in. | |
salience | number | no | salience | How much the record is worth keeping. Higher is more. | |
accessed_at | timestamp | no | salience | When it was last recalled. | |
access_count | integer | no | salience | How often it has been recalled. | |
owner | string \ | null | no | scopes | Which agent wrote it. See §6. |
session | string \ | null | no | sessions | Which conversation it belongs to. See §3.5. |
key | string \ | null | no | keys | A caller-chosen address. See §3.6. |
revision | integer | no | keys | How many times that key has been rewritten. | |
embedding | number[] | no | embeddings | A caller-supplied vector. See §3.7. | |
uri | string \ | null | no | external | Where the real thing lives. See §3.8. |
media_type | string \ | null | no | external | The referent's media type. |
score | number | recall only | core | See §5.3. |
Servers MUST round-trip metadata byte-for-byte in structure. A server that cannot store arbitrary JSON metadata MUST NOT silently discard it; it MUST fail the write with INVALID_PARAMS.
#3.2 Identifiers
id is an opaque string. Clients MUST NOT parse it, derive meaning from it, or assume any format, length or ordering.
A client MAY supply id when writing. If it does, the server MUST treat the write as idempotent: if a record with that id already exists, the server MUST NOT create a second one and MUST return the existing id. This is the only retry-safety mechanism in A2M, and it exists because a network failure between memory/remember and its response is otherwise indistinguishable from a failure before it.
If the client does not supply id, the server MUST assign one that is unique within that store.
#3.3 Timestamps
All timestamps are strings in RFC 3339 format, in UTC, with a Z suffix and millisecond precision:
"2026-07-28T10:15:30.123Z"
Numeric epoch timestamps are not permitted. They are ambiguous about timezone and unit, and lose precision in languages whose only number is a double.
#3.4 Groups
group ties records that MUST be kept or discarded together. Its motivating case is a tool call: an assistant message carrying tool_calls and the tool results answering it form one indivisible unit, and a store that evicts one without the other produces a transcript that most model providers reject.
The second case is a chunked document. A2M has no chunker and defines none: by the time memory/remember is called the boundaries are already decided, and the caller knows what it split where the server does not. What the protocol provides is the part that outlives the split — chunks of one document SHOULD share a group, so a store that evicts under pressure moves them whole rather than leaving a document half-present, answering questions from an introduction whose body is gone.
A server implementing tiers MUST NOT move some records of a group to a different tier while leaving others behind. A server MAY ignore group entirely if it never evicts anything.
#3.5 Sessions
group ties one turn together and owner says which agent a record belongs to. session is the level between them: one conversation.
Without it a store has no way to tell two concurrent conversations apart, and they compete for the same working-tier capacity — a busy chat evicts a quiet one's context purely by talking more. A server declaring sessions:
- MUST accept
sessiononmemory/rememberand return it on records. - SHOULD enforce the capacity of a tier marked
per_sessionwithin each session rather than across the tier, so conversations cannot evict each other. - MUST support
whereonmemory/timeline(§4.4), because a session that can be searched but not replayed is of little use in the tier that is replayed.
A record MAY have no session. Records with no session form one implicit bucket and MUST NOT be treated as belonging to any named one.
#3.6 Keys
id identifies a write; key addresses a fact. The difference decides whether a memory can be corrected or only appended to.
A key is a caller-chosen string, slash-delimited by convention (myapp/wf-42/user/city). A server declaring keys:
- MUST treat a write to an occupied key as a replacement: the record there keeps its
id, takes the new content, and incrementsrevision. - MUST scope key uniqueness per
owner, so two agents may each hold their ownuser/citywithout colliding. - MUST support
key_prefixonmemory/recall,memory/timelineandmemory/forget, matching every key at or beneath that prefix.
Prefix matching is what makes keys hierarchical, and is why A2M has no separate namespace field: myapp/wf-42/ already selects everything beneath it, and a second addressing dimension would have to be kept consistent with the first for no additional expressive power.
Replacement is the point. A superseded fact that is merely outnumbered by its successor is still there to be recalled — and will be, with exactly the same confidence as the truth.
#3.7 Embeddings
A server declaring embeddings accepts a vector on write and a vector as a query, and MUST store what it is given verbatim. It MUST NOT generate a vector for a record that already carries one, and MUST NOT replace one it was given.
This is what makes A2M model-agnostic. Two frameworks embedding with different models can share one store only if neither has its vectors silently rewritten into the other's space — and vectors from different models are not merely less accurate when compared, they are meaningless.
memory/describeMUST reportembeddingsas an object carryingdimensions(null until the first vector fixes it),metric, and optionallymodel.
metric names how two vectors are compared — cosine, dot or l2 — and it is reported rather than chosen. The metric belongs to the store's index, not to a query: an index built for one cannot answer another without being built again, so a per-call metric would oblige every server declaring embeddings to maintain several indexes. Declaring one is what a small capability can afford.
A server SHOULD also report metrics, every comparison its storage could be configured for, with metric naming the one actually in use. The two answer different questions and a caller needs both: metric says whether the vectors it holds can be compared here today, and metrics says whether this store is worth configuring differently, or worth pointing a second deployment at. A store reporting {"metric": "cosine", "metrics": ["cosine", "dot", "l2"]} can serve inner-product vectors; one reporting {"metric": "cosine", "metrics": ["cosine"]} never will, and a caller holding such vectors should look elsewhere rather than send them and be silently misranked.
Where a deployment spans several stores whose indexes differ — a federation, or a stack with a different backend per tier — metrics SHOULD report only what all of them can do, since a record may move between tiers and must remain comparable after it lands.
It is not the score a caller gets back. Scores are blended with whatever else the server ranks by (§5.3); metric describes the comparison underneath.
A caller supplying its own vectors SHOULD read metric before doing so, and this is the one mismatch the protocol cannot catch for it. A vector of the wrong width is refused with EMBEDDING_MISMATCH, because width is visible. A vector from a model trained against a different metric is not detectable at all — the server has no way to know what space it is in — so it will be compared, ranked and returned with complete confidence and no warning. Where dimensions are enforced, metric is only disclosed.
- A vector whose width disagrees with the store's MUST be rejected with
EMBEDDING_MISMATCH(§7). Cosine between vectors of different lengths is not a worse answer; it is not an answer. - Vectors are not returned by default. A client that wants them passes
embeddings: truetomemory/recall,memory/timelineormemory/fetch.
A server MAY additionally generate embeddings for records that arrive without one. That is an implementation choice and does not weaken the rule above.
#3.8 External records
A record MAY point at something rather than contain it: a file, a URL, a blob in object storage. uri holds an RFC 3986 reference and media_type optionally names what is at the other end.
content keeps its ordinary meaning: the text that is indexed. For an external record that is a title, a summary, or an extracted passage — whatever should make the reference findable. A record carrying a uri and no content is legal, and will be recallable only by its key or by metadata, because there is nothing for a scorer to rank.
#3.8.1 Documents that are not text
That distinction — content is the indexed representation, uri is the thing — is what lets A2M carry an image, an audio file, a video or a 3D model without a single field for any of them. The record is the same shape; only what goes in content changes:
| The referent | media_type | What belongs in content |
|---|---|---|
| image | image/png | caption, alt text, or extracted text |
| audio | audio/mpeg | transcript, or a summary of one |
| video | video/mp4 | transcript, plus captions of what is on screen |
| 3D model | model/gltf+json | description, part names, the metadata a search would use |
application/pdf | extracted text, per page or per section |
Producing that representation is the caller's work and A2M does not specify it: an OCR pass, a speech-to-text model, a captioner, or a human writing alt text are all the same to the protocol. What matters is that something ranked lives in content, because a scorer has nothing else to work with.
Two consequences worth stating, because both are easy to assume the other way:
- A record may be findable by a vector alone. A caller embedding an image with a multimodal model, and later embedding a text query with the same model, is comparing two vectors in one space — which is ordinary
embeddingsbehaviour (§3.7), needs no new field, and is why a store that keeps vectors verbatim can retrieve a picture from a sentence. In that casecontentmay be empty and the record is still reachable. - Bytes never travel in a record. A2M has no inline binary field and will not gain one: a protocol whose messages are single JSON objects (§8) should not carry a megabyte of base64, and the server is forbidden from fetching the
uriin any case. The media stays wherever it already is, and the record is how it is found, not how it is stored.
A server declaring external:
- MUST store and return
uriandmedia_typeunchanged. - MUST NOT dereference the
uri. Not on write, not on recall, not in the background.
That second rule is the important one. A server that fetches a caller's URI is making requests of its own choosing to addresses its caller supplied, which is a server-side request forgery primitive in a component whose whole job is to accept arbitrary strings from agents. Resolution belongs to the client, which already has the credentials, the network position and the reason.
external is deliberately not a fifth tier kind. The four kinds describe lifetime and access pattern; "points at a file" describes content. A referenced document is a fact and belongs in semantic; a referenced runbook is a procedure and belongs in procedural. Making it a kind would force a choice that is not the caller's to make.
#4. Methods
Every method name is namespaced memory/. This namespace is chosen so that one endpoint MAY serve A2M alongside MCP, whose methods live under tools/, resources/ and prompts/.
All parameters are passed by name (a JSON object). Servers MUST accept by-name parameters and MAY additionally accept by-position.
#4.1 memory/describe — core
Discovery and version negotiation. A client MUST call this first.
Params
| Name | Type | Required | Notes |
|---|---|---|---|
protocol | string | no | The protocol version the client speaks, e.g. "a2m/0.1". |
owner | string | no | Scope the counts. Requires scopes. |
Result
| Field | Type | Required | Notes |
|---|---|---|---|
protocol | string | yes | The version this server speaks. |
name | string | yes | Human-readable server name. |
capabilities | string[] | yes | MUST include "core". |
methods | string[] | yes | Every method this server accepts. |
limits | object | no | See below. |
tiers | object[] | if tiers | See §4.7. |
events | object | if events | Carries push (boolean). See §4.13. |
summarize | object | if summarize | Carries model (string or null). See §4.15. |
prompt | object | if prompt | Carries styles and methods (string[]), and optionally model. See §4.16. |
limits MAY carry max_records_per_call, max_recall_limit and max_content_length, all integers. A client SHOULD respect them; a server MUST enforce them regardless, with INVALID_PARAMS or QUOTA_EXCEEDED.
If the client declares a protocol the server cannot serve, the server MAY respond with PROTOCOL_NOT_SUPPORTED. It MUST NOT pretend to speak a version it does not.
Version compatibility: while the major version is 0, two versions are compatible only if the minor version matches exactly. From 1.0 onward, a client and server are compatible if the major version matches.
#4.2 memory/remember — core
Write records.
Params
| Name | Type | Required | Notes |
|---|---|---|---|
records | object[] | yes | One or more partial records. Each MUST carry content. |
owner | string | no | Default owner for records that do not set one. Requires scopes. |
Each entry MAY carry id, role, metadata, group, tier, salience and owner. A server MUST ignore fields belonging to capabilities it does not declare rather than failing — except tier, which MUST fail with CAPABILITY_NOT_SUPPORTED when tiers is not declared, because silently dropping a caller's placement is a correctness bug rather than a cosmetic one.
Result
| Field | Type | Required |
|---|---|---|
ids | string[] | yes — one per input record, in the same order |
Writes SHOULD be atomic across the batch. A server that cannot guarantee that MUST say so in its documentation; it MUST NOT return a partial ids array.
#4.3 memory/recall — core
Search by relevance. This is the method that matters.
Params
| Name | Type | Required | Notes |
|---|---|---|---|
query | string | no | What to rank against. |
tier | string | no | Restrict to one tier. Requires tiers. |
limit | integer | no | Maximum records to return. Server default applies when absent. |
where | object | no | Metadata filter. See §5.2. |
min_score | number | no | Drop results scoring below this. |
owner | string | no | Requires scopes. |
embedding | number[] | no | A query vector. Requires embeddings. |
key_prefix | string | no | Restrict to keys at or under this. Requires keys. |
embeddings | boolean | no | Include stored vectors in the result. |
Result
| Field | Type | Required |
|---|---|---|
records | object[] | yes — ordered by descending score |
If query is absent or empty, the server MUST return records ranked by whatever ordering it considers most useful in the absence of a query (recency is RECOMMENDED) rather than an error.
limit of 0 MUST be interpreted as "no limit" only when the server declares no max_recall_limit. Clients SHOULD NOT rely on unbounded responses over a network transport.
#4.4 memory/timeline — core
Read records in creation order, oldest first.
Params: tier (requires tiers), limit, owner (requires scopes), where (same semantics as §5.2), key_prefix (requires keys), embeddings.
where is what makes replaying one conversation possible. A server declaring sessions MUST support it; any other server SHOULD.
Result: records, an array MUST be sorted by ascending created_at. When limit is given, the server MUST return the most recent limit records, still in ascending order.
timeline exists separately from recall because they answer different questions and reversing them silently corrupts data. Relevance order is what a search wants; creation order is what rebuilding a conversation requires. A server MUST NOT implement one in terms of the other.
#4.5 memory/forget — core
Delete records.
Params: ids (string[]), query (string), tier (string), where (object), owner (string), key_prefix (string, requires keys).
A server MUST reject a call in which ids, query, tier, where and key_prefix are all absent, with INVALID_PARAMS. Deleting an entire store MUST require something more deliberate than an empty request.
Result: {"forgotten": <integer>} — how many records were removed.
#4.6 memory/promote — tiers
Move records to another tier because they earned it, rather than because something overflowed.
Params: ids (string[], REQUIRED), tier (string, REQUIRED), salience (number, OPTIONAL — how much to add on arrival).
Result: {"promoted": <integer>}.
Unknown tier MUST fail with UNKNOWN_TIER. Ids that do not exist, or are already in the target tier, MUST NOT fail; they are simply not counted.
#4.7 memory/consolidate — tiers
Ask the store to reorganise itself: move what has earned durability, evict what no longer fits.
Params: none.
Result
| Field | Type | Notes |
|---|---|---|
moved | integer | records that changed tier under capacity pressure |
promoted | integer | records that changed tier because they earned it |
dropped | integer | records deleted because there was nowhere below |
summarized | integer | groups replaced by rewritten records |
counts | object | tier name → record count, after the operation |
Consolidation MAY delete records. A client MUST NOT assume a record it wrote is still present after calling it.
memory/describe reports the tier layout under tiers, each entry carrying name (string, REQUIRED), count (integer, REQUIRED) and optionally kind, capacity, spill_to, promote_to, shared, per_session.
kind, when present, SHOULD be one of working, episodic, semantic or procedural. It tells a client what a tier is for independently of what it is named, so a client can find the tier holding the live transcript without hardcoding a name. A server MAY name its tiers anything.
What those four mean, how records flow between them, and what each is worth storing in — a list in process, pgvector, a plain fact table, files in git — is covered in implementing-a2m.md. That document is non-normative: this specification does not mandate any storage technology.
#4.8 memory/reinforce — salience
Raise the salience of records that proved useful.
Params: ids (string[], REQUIRED), amount (number, OPTIONAL).
Result: {"reinforced": <integer>}.
#4.9 memory/fetch — keys
Read the record at an address.
Params: key (string, REQUIRED), owner (OPTIONAL), embeddings (boolean, OPTIONAL).
Result: {"record": ...}, or {"record": null} when the key is unused. An unused key is not an error — asking whether a fact is known yet is ordinary.
A missing or empty key MUST fail with INVALID_PARAMS.
#4.10 memory/session/list — sessions
Params: owner (OPTIONAL).
Result: {"sessions": [...]}, each entry carrying session (string, REQUIRED), records (integer, REQUIRED), and optionally tiers (object mapping tier name to count), opened_at and touched_at (timestamps).
#4.11 memory/session/close — sessions
End a conversation. Params: session (string, REQUIRED), owner (OPTIONAL). A missing or empty session MUST fail with INVALID_PARAMS.
Result: the memory/consolidate result (§4.7) plus closed (the session) and flushed (how many records left the working tier).
Closing is not one hop down the stack. A finished conversation will never be replayed, so its records SHOULD leave the working tier immediately rather than wait for capacity pressure — and every tier below then applies its own ordinary rules to what arrives: what earned promotion is promoted, a tier over capacity spills, a consolidator rewrites what it is given. The effect is that closing a session percolates it through the whole stack in one operation.
Closing MUST NOT destroy the conversation. Records move; they are not deleted, unless a tier's own policy would have dropped them anyway.
Procedural memory is untouched, because nothing spills into procedural (§4.7). A finished conversation does not become a procedure.
Closing a session that does not exist MUST NOT fail; it is a no-op.
#4.12 memory/events — events
Read what has changed since a cursor. This is how a client watches a store without the store initiating anything: the client brings the position it has reached, the server returns everything that happened after it, in order, with the position to bring next time.
Params
| Name | Type | Required | Notes |
|---|---|---|---|
cursor | string | no | Where to read from. Opaque: clients MUST NOT parse it, and it is valid only on the server that issued it. Absent means now: the reply carries no events, only the current cursor. |
limit | integer | no | Maximum events to return. |
kinds | string[] | no | Restrict to these event kinds. |
owner | string | no | Requires scopes. |
Result
| Field | Type | Required | Notes |
|---|---|---|---|
events | object[] | yes | Oldest first. |
cursor | string | yes | The position after the last returned event — or, when events is empty, the current head. |
more | boolean | no | Retained events remain beyond limit. |
reset | boolean | no | true when the supplied cursor could not be honoured. Events may have been missed. |
An event
| Field | Type | Required | Notes |
|---|---|---|---|
kind | string | yes | One of the kinds below, or a server-defined value a client MUST tolerate. |
at | timestamp | yes | When it happened (§3.3). |
| Kind | Additional fields | Emitted when |
|---|---|---|
written | id (REQUIRED); tier, session, key, revision when the relevant capabilities apply | One per record written by memory/remember. A revision greater than 0 is how a key replacement (§3.6) is visible. |
forgotten | count (REQUIRED); ids OPTIONAL | One per memory/forget call, however many records it removed. |
promoted | ids, tier (both REQUIRED) | One per memory/promote call; tier is the destination. |
consolidated | the counts of §4.7: moved, promoted, dropped, summarized | One per memory/consolidate or memory/session/close, and one per server-internal reorganisation (a spill under capacity pressure, a background pass). |
session_closed | session (REQUIRED) | One per memory/session/close. |
Volume is answered by coalescing. A consolidation that moves ten thousand records is one consolidated event carrying counts, never ten thousand notifications. A forget is one event. Only written is per-record, because a write is what a watching client most often needs to act on record by record.
Ordering and completeness. Successive polls, each carrying the cursor the previous reply returned, MUST see every retained event exactly once, in order, with no duplicates and no gaps. A server MAY bound how many events it retains; when a supplied cursor lies before the retained window the server MUST set reset: true and continue from the oldest event it still holds. A client seeing reset knows it may have missed events and SHOULD re-read whatever state it was tracking. A cursor the server never issued MAY be refused with INVALID_PARAMS.
Scoping is recall's. A server declaring scopes MUST deliver an event only to a caller who could have recalled the record it describes, under exactly the rules of §6 — over a network transport the scope comes from the authenticated principal, never from the client. An event channel that broadcasts turns owner from partitioning into a leak, which is the question that kept events out of earlier drafts of this document.
#4.13 memory/events/subscribe — events
Push: the same events, delivered as they happen instead of when asked for.
Push is optional within the capability, because not every transport can carry it (§8). A server declaring events MUST report an events object from memory/describe carrying push (boolean). Polling (§4.12) is always available when events is declared. Push additionally requires that the server enabled it and that the connection's transport can deliver a notification; a server MUST NOT report "push": true on a connection whose transport cannot, and MUST fail memory/events/subscribe there with CAPABILITY_NOT_SUPPORTED.
Params: kinds (string[], OPTIONAL), owner (OPTIONAL, requires scopes).
Result: {"subscribed": true}.
After subscribing, the server sends each event as a JSON-RPC notification on the same connection:
{"jsonrpc":"2.0","method":"memory/event",
"params":{"kind":"written","id":"01J8Z9","tier":"episodic",
"at":"2026-07-28T10:15:30.123Z"}}
One event per notification; params is the event object of §4.12, under the same coalescing, ordering and scoping rules. A notification is not a request (§8): it carries no id and expects no reply.
Subscription is per connection and opt-in. Declaring the capability MUST NOT by itself cause notifications; a subscription ends with memory/events/unsubscribe or when the connection closes, and is never persisted. Events delivered by push remain readable by poll — the two views are one log.
#4.14 memory/events/unsubscribe — events
Params: none. Result: {"subscribed": false}. Unsubscribing when not subscribed MUST NOT fail; it is a no-op.
#4.15 memory/summarize — summarize
Rewrite a set of records into durable statements, and store the result.
A store already does this when records spill (§4.7): consolidation may replace a group with rewritten content. This method exposes the same operation on demand, for the cases where waiting for capacity pressure is the wrong trigger — closing out a topic, distilling a search result into a fact, or maintaining a page that several conversations contributed to.
Params
At least one selector is REQUIRED. A call carrying none MUST fail with INVALID_PARAMS: summarizing an entire store is expensive, and it MUST NOT be the result of an empty request.
| Name | Type | Required | Notes |
|---|---|---|---|
ids | string[] | no | Selector. Summarize exactly these. |
query | string | no | Selector. Summarize what this recalls. |
tier | string | no | Selector. Restrict the selection to one tier. Requires tiers. |
where | object | no | Selector. Metadata filter (§5.2). |
key_prefix | string | no | Selector. Requires keys. |
limit | integer | no | At most this many source records. A server MAY cap it. |
owner | string | no | Requires scopes. |
into | string | no | Destination tier for the summary. Requires tiers. |
key | string | no | Address for the summary. Requires keys. |
tier selects what is read; into says where the result is written. They are separate because summarizing episodic memory into a semantic fact is the motivating case, and one field could not say both.
Result
| Field | Type | Required | Notes |
|---|---|---|---|
records | object[] | yes | The summary records written, in wire form. Empty when the server declined. |
read | integer | yes | How many source records were considered. |
written | integer | yes | How many summary records were written. |
Rules.
- The summary is stored as ordinary records. Everything else in the protocol applies to it unchanged: it can be recalled, forgotten, promoted and keyed.
- A server MUST NOT delete the source records. This is the difference from consolidation, which MAY. A caller that wants the sources gone issues a
memory/forgetafterwards, deliberately and with the ids it has just seen. - When
keyis given, the ordinary rule for an occupied key applies (§3.6): the summary replaces what is there, keeping itsidand advancingrevision. That is how a page is maintained rather than duplicated, and it is why this method needs no "replace" flag of its own. - A server MAY decline, returning
written: 0and an emptyrecords, when there is nothing durable to say. Declining is not an error. A summary that invents content is worse than no summary. - A server that cannot summarize MUST NOT declare the capability — including a server whose summarizer is merely unconfigured.
memory/describeMUST report asummarizeobject carryingmodel, which isnullwhen the server summarizes without one. - A read-only server (§2.1) MUST answer
READ_ONLY, because this method writes. - The call is synchronous. A server that cannot produce a summary within its own timeouts SHOULD reduce
limitrather than answer asynchronously; A2M has no job model, and this method does not introduce one.
How the summary is produced is not specified. A language model, an extractive ranker, or a template are all conformant. This is the same position §5.1 takes on ranking, and for the same reason: it is where implementations should compete, and mandating a mechanism would make the capability implementable only by servers that had chosen the same one.
#4.16 prompt — rendered text, alongside the records
Recalled records usually end up in a model's prompt, and turning them into text is a step every caller writes. A server declaring prompt will do it on request.
This adds no method. It adds one optional parameter to memory/recall and memory/timeline, and one optional field to their results.
Param — prompt, either true for the server's defaults, or an object:
| Name | Type | Notes |
|---|---|---|
method | string | How the text is produced. template, model, or none. Default template. |
style | string | What shape it takes. facts, transcript, or auto to decide from the records. Servers MUST support auto and SHOULD support the other two. Advisory when method is model. |
model | string | Which model to use, when method is model and the server offers a choice. |
budget | integer | Maximum characters. The server MUST drop whole records to fit, never truncate one. |
cite | boolean | Mark each entry with its source — its uri, or its key. |
method and style are separate axes because they answer different questions: one is what it costs, the other is what it looks like. A model can emit a bulleted list and a template can emit a transcript, so a single field could not express "a transcript, without paying for inference".
method | Means |
|---|---|
template | Deterministic assembly from the records — no model, no network, same input same output. Every server declaring prompt MUST support this, and it is the default. |
model | A language model writes the text. Optional. A server MUST NOT offer it unless it can actually do it. |
none | Render nothing; the result carries no prompt field. Identical to omitting the parameter, and provided for callers whose request body is templated and cannot easily drop a field — an n8n node, a shell script. |
The default MUST be template. A client that asks for prompt: true MUST NOT trigger an inference call it did not request. Cost and latency are the caller's to opt into, and a protocol that lets a server quietly spend the caller's money on a convenience field is not one anyone should deploy.
A server asked for a method or model it does not offer MUST fail with INVALID_PARAMS. It MUST NOT silently substitute another: a caller who asked for a written summary and received a bullet list, with no indication, has been given materially different text than it requested. memory/describe says in advance what is available, so this error is always avoidable.
Result — when and only when the caller asked, the result carries:
| Field | Type | Notes |
|---|---|---|
prompt | string | The rendered block. Empty when nothing survived the budget. |
prompt_ids | string[] | The ids of the records the block actually contains. |
Rules.
recordsis still returned, in full and unchanged. The rendered text is additional, never a replacement. A server that answered with text alone would have made its records unreadable to every consumer that is not a language model, which is the failure this protocol exists to avoid.prompt_idsis the useful half. It says which records survived the budget, so a caller canmemory/reinforcewhat the model actually saw rather than everything that was recalled — including whatever was trimmed away unread.- A server MUST order and select records the same way whether or not
promptwas requested. Asking for text MUST NOT change what is recalled. - Trimming to a
budgetMUST drop whole records. Half a fact is worse than no fact, because nothing downstream can tell it was cut. - A server that does not declare
promptMUST ignore the parameter, as it ignores any other it does not recognise (§2), and return the records alone. This is safe precisely because the rendering is additive: the client has the records and can render them itself. It is not thetier-on-write case (§4.2), where silently dropping the field would lose the caller's meaning.
How the text is rendered is still not specified, beyond the style names and the two cost classes. method says whether a model is involved, not which algorithm assembles the words — exactly as describe reports what a server's scorer is while §5.1 declines to say how it should rank. A client that needs an exact shape should take the records and render them itself.
Not to be confused with memory/summarize (§4.15), which may also use a model. The difference is what survives: summarize writes a record, and the distillation becomes part of the store. Rendering a prompt changes nothing — the text is returned and forgotten. Ask for a summary when the store should be smarter afterwards; ask for a prompt when this one call needs text.
memory/describe MUST report a prompt object carrying styles and methods, and SHOULD report model naming what method: model would use. A client can then tell, before asking, both what shape it can get and whether asking will cost an inference call.
Why this is in the protocol and not only in a library. A helper can only serve callers who share its language. The client in an n8n HTTP node, a shell script, or a language nobody here has written in cannot call a Python function — and A2M's premise is that all of them reach one store.
#5. Ranking
#5.1 What a server may do
A2M does not specify how records are ranked. Lexical scoring, embeddings, hybrids and learned rankers are all conformant. This is deliberate: ranking is where implementations should compete.
#5.2 where
where is a conjunction of equality tests against record fields, falling back to metadata keys. A value that is an array means "any of". A server MAY support a richer filter language, but MUST support at least this.
{"where": {"role": "user", "tier": ["episodic", "semantic"]}}
#5.3 score
score is ranking information only.
A client MUST NOT compare scores between two servers, between two calls, or against any fixed threshold it did not obtain from the same server in the same call. Scores are not probabilities, not distances, and not calibrated. Different scorers occupy entirely different ranges, and a model that rates everything 0.9 may discriminate better than one that spreads across 0..1.
Servers SHOULD emit scores in the range [0, 1] and MUST emit them in descending order within a recall result.
min_score is therefore a server-relative knob. Clients SHOULD leave it alone unless they have calibrated against that specific server.
#6. Identity and scoping
owner is not a security boundary.
The scopes capability lets one store serve several agents: records carry an owner, and a server filters what each agent can see. This is data partitioning, and on a local transport, where the client and server are the same trust domain, it is sufficient.
It is not access control. A client asserts its own owner value, and nothing in the protocol prevents it from asserting a different one.
Therefore:
- A server on a local transport (§8.1, §8.2) MAY accept the client-supplied
ownerat face value. - A server on a network transport (§8.3) MUST NOT. It MUST derive the scope from the authenticated principal of the transport — mTLS certificate, bearer token, or equivalent — and MUST ignore any client-supplied
ownerthat disagrees with it, or reject the request withSCOPE_DENIED. - A server that cannot authenticate its callers MUST NOT advertise
scopesover a network transport. - Implementations MUST document which of these applies.
Defining the authentication mechanism itself is out of scope for 0.1. What is in scope is that no implementer mistakes owner for protection.
#6.1 Authenticating over HTTP — guidance
This subsection is non-normative. It exists because the rule above — scope comes from the transport — is only implementable if the transport authenticates, and an implementer should not have to invent the shape.
- Serve over TLS, and authenticate with
Authorization: Bearer <token>on everyPOST. The token format is the deployment's business — A2M treats it as opaque — but the server derives the caller's scope from the token's subject or claims, and per §6 ignores or refuses any client-suppliedownerthat disagrees. - A missing or invalid credential is refused with
401 Unauthorizedbefore any JSON-RPC processing. Authentication failures are transport-level, not protocol-level: they are not JSON-RPC error objects, because the request was never admitted to the protocol. - The unauthenticated deployment is the local one, and it is legitimate: a loopback-bound server with no credentials is how an agent on the same machine talks to its own memory. Its protection is §8.3.1's mandatory
Originvalidation — which is precisely why that clause is a MUST while this section is guidance.
#7. Errors
A2M uses JSON-RPC 2.0 error objects. Beyond the standard codes, it allocates from the -32000..-32099 block that JSON-RPC reserves for implementation-defined server errors.
| Code | Name | When |
|---|---|---|
-32700 | PARSE_ERROR | Malformed JSON. |
-32600 | INVALID_REQUEST | Not a valid JSON-RPC request object. |
-32601 | METHOD_NOT_FOUND | Method is not an A2M method at all. |
-32602 | INVALID_PARAMS | Missing or malformed parameters. |
-32603 | INTERNAL_ERROR | Server fault. |
-32001 | UNKNOWN_RECORD | A referenced id does not exist and the operation cannot ignore it. |
-32002 | UNKNOWN_TIER | A referenced tier does not exist. |
-32003 | CAPABILITY_NOT_SUPPORTED | A method or field of an undeclared capability was used. |
-32004 | READ_ONLY | The store does not accept writes. |
-32005 | SCOPE_DENIED | The caller may not act on that owner's records. |
-32006 | QUOTA_EXCEEDED | A documented limit was exceeded. |
-32007 | PROTOCOL_NOT_SUPPORTED | The client declared an incompatible version. |
-32008 | EMBEDDING_MISMATCH | A vector's width disagrees with the store's. |
The message field is for humans and MUST NOT be parsed by clients. The data field MAY carry structured detail.
#8. Transports
A2M is transport-agnostic. Any transport that carries JSON-RPC 2.0 request and response objects intact is conformant. Three bindings are defined.
Two rules hold on every binding.
Messages are single. A message is one JSON-RPC request, response or notification. A2M does not use JSON-RPC batches: a batch has no id of its own for a response to bind to, and no A2M method needs one — memory/remember already takes many records in one call, which is where batching actually pays. A server MUST refuse an array with -32600 INVALID_REQUEST.
Servers do not initiate requests. A server sends responses and, if it has a capability that defines them, notifications. It MUST NOT send a JSON-RPC request to a client. A client therefore never sends a response, and a transport only has to carry one direction of request.
Both rules match MCP, which removed batching in its 2025-06-18 revision and forbids server-initiated requests outright. A2M adopts them for the same reason: they are what make a binding implementable over a plain request/response channel.
#8.1 In-process
Client and server in one process, exchanging JSON-RPC objects directly.
Implementations SHOULD serialise to JSON and back even in-process. A local server that accepts Python objects a remote one could never receive is a local server that will disagree with the network in production.
A binding with no byte stream can still deliver notifications (§4.13): an in-process server SHOULD accept a caller-supplied callback and invoke it with each notification object — serialised to JSON and back like everything else, for the same reason as above.
#8.2 stdio
The server reads requests from stdin and writes responses to stdout, one JSON value per line, UTF-8 encoded, \n-terminated.
- A message MUST NOT contain an unescaped newline.
- The server MUST NOT write anything but JSON-RPC messages to stdout. Logging MUST go to stderr. Anything else corrupts the stream.
- The client MUST tolerate interleaved notifications while awaiting a response, matching responses by
id. - Closing stdin MUST terminate the server cleanly. A server SHOULD exit when stdin reaches end-of-file, which is the only portable shutdown signal.
This framing is deliberately identical to MCP's stdio transport, down to the stdout/stderr split. An implementation that already speaks one can carry the other over the same pipe machinery, and a custom byte-stream transport — a Unix socket, a TCP connection — SHOULD reuse this framing rather than invent one.
#8.3 HTTP
A single endpoint accepting POST with Content-Type: application/json.
- The body is one JSON-RPC request or notification object. A server MUST refuse an array with
-32600(§8). - A request carrying
idMUST be answered200 OKwith the JSON-RPC response object as the body,Content-Type: application/json. - A notification (no
id) MUST be answered202 Acceptedwith an empty body. - Transport-level failures use HTTP status codes; protocol-level failures use
200 OKcarrying a JSON-RPC error object. A server MUST NOT signalUNKNOWN_TIERas HTTP 404. GETon the RPC endpoint SHOULD be answered405 Method Not Allowed.- Servers MUST authenticate callers before advertising
scopes(§6). - This binding has no server-to-client channel, so it cannot deliver notifications. A server MUST NOT report
"push": trueover it and MUST failmemory/events/subscribewithCAPABILITY_NOT_SUPPORTED(§4.13). A client on HTTP watches a store withmemory/events(§4.12).
#8.3.1 Origin
A server MUST validate the Origin header. A request carrying an Origin the server does not permit MUST be refused with 403 Forbidden. A request carrying no Origin did not come from a browser and is unaffected.
A server intended for local use SHOULD bind loopback rather than every interface, and SHOULD permit no origin by default.
Without this, any page the user is browsing can drive a memory server listening on their own machine, and read back everything the agent has remembered. The protection is required precisely because the interesting deployment is local and unauthenticated — the case where §6's authentication rules do not apply.
#8.3.2 Protocol version header
A client SHOULD send A2M-Protocol-Version on every POST, carrying the same version it would pass to memory/describe:
A2M-Protocol-Version: a2m/0.1
A server receiving a version it does not speak MUST refuse the request with 400 Bad Request carrying a -32007 PROTOCOL_NOT_SUPPORTED error object. A server MUST NOT treat an absent header as an error: the header exists so that a gateway can route and reject without parsing a body, and memory/describe (§4.1) remains the negotiation that decides anything.
#8.3.3 Well-known profile
A server SHOULD serve its memory/describe result as a JSON document at:
GET /.well-known/a2m-server.json
This makes a server discoverable before it is called — a directory, a gateway or an operator can learn its protocol version, capabilities and tiers without holding an A2M client. The document is advisory and MAY be stale; a client that needs the truth calls memory/describe.
#9. Versioning
This document specifies a2m/0.1.
While the major version is 0, any minor version MAY introduce breaking changes, and compatibility requires an exact minor match. From 1.0, additions are minor version bumps and breaking changes are major version bumps.
New functionality SHOULD arrive as a new capability rather than as a change to an existing method, since a capability is invisible to clients that do not ask for it.
#9.1 Reserved for a future version
No capability names are currently reserved. events, reserved here while this document was a draft, is now specified (§4.12–§4.14): its polling form is carried by every binding in §8, which answered the objection that had deferred it.
#10. Licensing
This specification and the reference implementation accompanying it are released under the MIT License.
This is deliberate. A protocol that is expensive to implement does not get implemented. MIT imposes no obligation on an implementation, commercial or otherwise, hosted or embedded — which is the same choice MCP made, and for the same reason.
An independent implementation written from this document alone is in any case not a derivative work of the reference code, and this specification imposes no obligation on it. implementations/server_minimal.py exists partly to demonstrate that implementing from the document is achievable.
#Appendix A — A minimal conformant exchange
// →
{"jsonrpc":"2.0","id":1,"method":"memory/describe","params":{"protocol":"a2m/0.1"}}
// ←
{"jsonrpc":"2.0","id":1,"result":{
"protocol":"a2m/0.1","name":"example","capabilities":["core"],
"methods":["memory/describe","memory/remember","memory/recall",
"memory/timeline","memory/forget"]}}
// →
{"jsonrpc":"2.0","id":2,"method":"memory/remember","params":{
"records":[{"content":"the deploy key rotates every 90 days","role":"user"}]}}
// ←
{"jsonrpc":"2.0","id":2,"result":{"ids":["01J8Z9"]}}
// →
{"jsonrpc":"2.0","id":3,"method":"memory/recall","params":{
"query":"how often does the deploy key change?","limit":1}}
// ←
{"jsonrpc":"2.0","id":3,"result":{"records":[{
"id":"01J8Z9","content":"the deploy key rotates every 90 days",
"role":"user","created_at":"2026-07-28T10:15:30.123Z","score":0.72}]}}
// → a capability this server did not declare
{"jsonrpc":"2.0","id":4,"method":"memory/consolidate"}
// ←
{"jsonrpc":"2.0","id":4,"error":{
"code":-32003,"message":"Server does not implement the 'tiers' capability"}}
#Appendix B — Implementation checklist
- [ ]
memory/describereturnsprotocol,name,capabilities,methods - [ ]
capabilitiesincludescoreand nothing untrue - [ ] Undeclared capability →
-32003, never-32601 - [ ] Unknown parameters are ignored, not rejected
- [ ] Timestamps are RFC 3339 UTC strings, never numbers
- [ ]
idis opaque; client-supplied ids make writes idempotent - [ ]
metadataround-trips unchanged - [ ]
memory/timelineis ascending bycreated_at;limittakes the newest - [ ]
memory/forgetwith no selector →-32602, unless the server is read-only - [ ] A read-only server answers
-32004from bothrememberandforget - [ ]
scoredescending within a result - [ ] stdout carries only JSON-RPC; logs go to stderr
- [ ] A JSON-RPC batch is refused with
-32600 - [ ] Over HTTP, a disallowed
Originis refused with 403 - [ ] Over a network,
ownercomes from the transport, never from the client - [ ]
events: successive polls see every retained event exactly once, in order - [ ]
events: a consolidation is one coalesced event, never one per record - [ ]
events: push only after subscribe, and never advertised over HTTP - [ ]
summarize: never deletes its sources, and declining is not an error