Integration guide
Machine readable copy at /docs/integration.md — point a coding agent at it and it has the whole protocol.
Echo Memory: integration guide
Everything needed to give an agent persistent memory over the hosted API. This file is written to be implemented from directly: point a coding agent at https://echo-mem.com/docs/integration.md and it has the whole protocol, the tool schemas, and two complete clients.
- Endpoint:
https://api.echo-mem.com/mcp - Auth:
Authorization: Bearer <key>— create one at https://app.echo-mem.com/keys - Transport: MCP over streamable HTTP
- Source: https://github.com/echo-mem/echo-mem (Business Source License 1.1)
There are two ways in. If your tool already speaks MCP (Claude Code, Cursor, Codex, Claude Desktop, Windsurf) it is one command. If you are writing a service, it is three HTTP POSTs and you do not need an SDK.
1. If your tool speaks MCP
claude mcp add --transport http echo-memory https://api.echo-mem.com/mcp \
--header "Authorization: Bearer <key>"Restart the client afterwards. An MCP server is a long lived process that imports its code at spawn, so a registration nothing restarts into does nothing.
For other clients, the config is the same three fields:
{
"mcpServers": {
"echo-memory": {
"type": "http",
"url": "https://api.echo-mem.com/mcp",
"headers": { "Authorization": "Bearer <key>" }
}
}
}2. If you are writing a service
This is the case most people have: an application that should remember what it learned, like a support bot or a chat backend. You do not need an MCP SDK.
The handshake
Three POSTs to the same URL.
1. initialize — the response carries an Mcp-Session-Id header. Keep it. 2. notifications/initialized — a notification, so it has no id field. 3. tools/call — send Mcp-Session-Id on this and every later call.
The header everybody gets wrong first
Accept: application/json, text/event-streamBoth, always. The server answers some calls with JSON and some with a single Server-Sent Events frame, and a client that accepts only one gets 406 on the other. When the response is SSE, the body is one line beginning data: and the JSON is the rest of that line.
Node
One shared session is the trap here, and it is worth saying why before the code. An MCP session is a server side conversation; firing several tools/call requests down one at the same moment is not something it owes you an answer to, and the first customer to do it lost a debugging session to This operation was aborted. This client keeps a small pool instead: each in-flight call leases a session, and gives it back when it is done. Sessions are reused, so the handshake is paid once per pool slot rather than once per call.
// echoMemory.js - no dependencies, Node 18+
const ENDPOINT = "https://api.echo-mem.com/mcp";
const KEY = process.env.ECHO_MEM_API_KEY;
const MEMORY = process.env.ECHO_MEM_MEMORY; // optional, see "Memories"
const POOL = Number(process.env.ECHO_MEM_CONCURRENCY ?? 4);
const TIMEOUT_MS = Number(process.env.ECHO_MEM_TIMEOUT_MS ?? 30_000);
let nextId = 1; // JSON-RPC ids must be unique, not reused
const idle = []; // sessions ready to be leased
const queue = []; // callers waiting for one
let open = 0; // sessions created so far
async function post(body, sessionId) {
const headers = {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
const res = await fetch(ENDPOINT, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
// The ceiling counts requests, not facts, and it tells you how long to
// wait. Guessing instead is how a backoff turns into a stampede.
if (res.status === 429) {
const after = Number(res.headers.get("retry-after") ?? 1);
await new Promise((r) => setTimeout(r, after * 1000));
return post(body, sessionId);
}
if (!res.ok) throw new Error(`echo-mem ${res.status}: ${await res.text()}`);
const text = await res.text();
const id = res.headers.get("mcp-session-id");
if (!text) return { id, payload: null };
const sse = res.headers.get("content-type")?.includes("text/event-stream");
const line = sse ? text.split("\n").find((l) => l.startsWith("data:")) : null;
return { id, payload: JSON.parse(sse ? (line ?? "null").slice(5) : text) };
}
async function openSession() {
const { id } = await post({
jsonrpc: "2.0", id: nextId++, method: "initialize",
params: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "my-service", version: "1" },
},
});
await post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, id);
return id;
}
async function lease() {
const ready = idle.pop();
if (ready) return ready;
if (open < POOL) {
open += 1;
try {
return await openSession();
} catch (e) {
open -= 1; // it never existed; do not strand the slot
throw e;
}
}
return new Promise((resolve) => queue.push(resolve));
}
function release(session) {
const next = queue.shift();
if (next) next(session);
else idle.push(session);
}
function discard() {
// The session is gone, not merely busy. Free the slot so the next caller
// opens a fresh one rather than waiting on something that will never
// return.
open -= 1;
const next = queue.shift();
if (next) lease().then(next);
}
async function call(name, args) {
const session = await lease();
try {
const { payload } = await post({
jsonrpc: "2.0", id: nextId++, method: "tools/call",
params: { name, arguments: MEMORY ? { ...args, memory: MEMORY } : args },
}, session);
release(session);
return payload?.result;
} catch (e) {
discard();
throw e;
}
}
// You extract the entities and facts; this server never calls a model, which
// is why a write costs what it costs.
//
// assume_new says every entity here is one you have not written before. Drop
// it if any of them might already exist: the question it skips is the one
// that stops two nodes being created for one thing.
export async function remember(sessionId, entities, facts) {
return call("write_episode", {
scope: "shared", session_id: sessionId, entities, facts, assume_new: true,
});
}
// Each fact comes back with rank, score and matched. score is cosine
// similarity to your query and is comparable across queries, so it is the one
// to threshold on; it is null when only full text search found the fact.
export async function recall(query, topK = 10) {
return call("query_memory", { scope: "shared", query, top_k: topK });
}
// Why, rather than what looks similar. Chains of facts, nearest cause first.
export async function why(subject, direction = "both") {
return call("trace_cause", { scope: "shared", subject, direction });
}Promise.all over these is now safe: four calls run at once, a fifth waits for a slot rather than piling onto a session someone else is using. Raise ECHO_MEM_CONCURRENCY if you need more in flight; each slot is one more handshake, not one more per call.
Python
# echo_memory_client.py
import json, os, httpx
ENDPOINT = "https://api.echo-mem.com/mcp"
KEY = os.environ["ECHO_MEM_API_KEY"]
MEMORY = os.environ.get("ECHO_MEM_MEMORY")
class EchoMemory:
def __init__(self):
self._session = None
self._http = httpx.Client(timeout=30)
def _rpc(self, method, params, rid=None):
headers = {
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._session:
headers["Mcp-Session-Id"] = self._session
body = {"jsonrpc": "2.0", "method": method, "params": params}
if rid is not None:
body["id"] = rid
r = self._http.post(ENDPOINT, headers=headers, json=body)
r.raise_for_status()
self._session = self._session or r.headers.get("mcp-session-id")
if not r.text:
return None
if "text/event-stream" in r.headers.get("content-type", ""):
for line in r.text.splitlines():
if line.startswith("data:"):
return json.loads(line[5:])
return None
return r.json()
def connect(self):
self._rpc("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "my-service", "version": "1"},
}, rid=1)
self._rpc("notifications/initialized", {})
def call(self, name, **args):
if not self._session:
self.connect()
if MEMORY:
args["memory"] = MEMORY
out = self._rpc("tools/call", {"name": name, "arguments": args}, rid=2)
return (out or {}).get("result")
def remember(self, session_id, entities, facts):
# assume_new: drop it if any of them might already exist
return self.call(
"write_episode", scope="shared", session_id=session_id,
entities=entities, facts=facts, assume_new=True,
)
def recall(self, query, top_k=10):
return self.call("query_memory", scope="shared", query=query, top_k=top_k)
# Why, rather than what looks similar. Chains of facts, nearest cause first.
def why(self, subject, direction="both"):
return self.call("trace_cause", scope="shared", subject=subject,
direction=direction)The five tools
write_episode
Record what happened. You extract the entities and facts; this server never calls a model, which is why a write costs what it costs.
| argument | type | notes | |
|---|---|---|---|
scope | "shared" \ | "solo" | required, see Scopes |
session_id | string | your own id for the conversation or run | |
entities | [{name, type}] | type is free text: person, service, venue | |
facts | [{source, target, relation_type, fact, confidence, causal_hint?}] | source and target must each match an entities[].name exactly | |
entity_resolutions | object | see the trap below | |
assume_new | bool | every entity in this call is new; see the trap below | |
memory | string | optional, which graph | |
project | string | optional label |
confidence is "extracted", "inferred" or "ambiguous".
causal_hint is optional and only ever what the session said: "caused_by", "led_to", "enabled_by", "blocked_by", "contradicts". It is what trace_cause walks. Omit it when the link is merely associative, which most are — filling the graph with causality nobody asserted makes the field worth less than not having it. Never infer one: a guessed cause is indistinguishable from a real one once it is stored.
Returns {edges_created, superseded, ambiguous_entities, stored}.
query_memory
| argument | type | notes | |
|---|---|---|---|
scope | "shared" \ | "solo" | required |
query | string | natural language; omit with digest: true | |
top_k | int | default 10 | |
digest | bool | ignore query, return the most recent active facts | |
memory | string | optional |
trace_cause
Why something happened, rather than what resembles it. query_memory ranks facts by similarity and returns a flat list; the answer to a "why" question is an ordered chain, and a list is not one.
| argument | type | notes | ||
|---|---|---|---|---|
scope | "shared" \ | "solo" | required | |
subject | string | the thing to trace from | ||
direction | "upstream" \ | "downstream" \ | "both" | default "both" |
max_hops | int | 1 to 6, default 3 | ||
memory | string | optional |
Returns {anchors, causes, effects, contradictions}. causes and effects are chains: each is a list of facts, nearest cause first.
const out = await call("trace_cause", {
scope: "shared", subject: "checkout 502s", direction: "upstream",
});
for (const chain of out.causes) {
console.log(chain.map((f) => f.fact).join(" <- "));
}It follows only links somebody recorded with causal_hint, in the direction the hint says causality runs, so "A led_to B" and "B caused_by A" — the same claim written from opposite ends — assemble into the same chain. Use whichever fits the sentence you just read.
An empty answer means nobody asserted a cause, not that none exists. The reply says which kind of empty it is. Nothing here is inferred.
record_recall_save
Call it when a recalled fact saved the user from re-explaining something. Takes scope, fact_id (from a query_memory result) and note. The server reads the writer off the fact's own edge and the reader off your key, so neither can be asserted by the caller.
get_audit_log
scope, optional since (ISO timestamp), optional memory. Every recorded mutation: what changed, when, which session.
Three things that will bite you
A write that returns is not a write that stored. If two entities in your episode could be the same thing, the server defers rather than guessing, and returns them in ambiguous_entities with edges_created empty. Check edges_created, or stored, which says so in one field.
assume_new: true tells the server every entity in the call is one you have not written before, in one flag rather than one entity_resolutions entry per name — which is what the examples above do. Drop it if any of them might already be in the graph: the question it skips is the one that stops two nodes being created for one thing. It never overrides an exact name match, so a name the scope already holds still resolves to the node it already means.
A fact over 4,000 characters is refused. Facts are sentences, not documents. Split them.
An edge is identified by `(source, target, relation_type)`. Writing "Team A played_against Team B" for a second match replaces the first rather than adding to it — the old version is superseded, kept and still queryable, but it is no longer the live answer. If each occurrence is its own fact, give it a distinguishing entity, for example the match itself as source.
Rate limits
240 requests a minute per key, on the MCP endpoint. Exceeding it returns 429 with a Retry-After header saying how many seconds to wait, and the body repeats it. Back off on that header rather than guessing.
The number counts requests, not facts, and that distinction is where bulk loaders get caught: initialize and notifications/initialized are requests too. A client that handshakes once and reuses the session spends one request per fact. A client that reconnects for every write spends three, so it hits the ceiling at 80 facts a minute instead of 240.
Both clients above connect lazily and keep the session:
if (!session) await connect(); // once, not per callFor a bulk load, serial with a short stagger is the shape that finishes. Three concurrent writers with a 150ms stagger sustains roughly 1,200 requests a minute and will be limited within a few hundred items; one writer with a 400ms stagger runs indefinitely. A short test batch succeeding does not prove a pace is safe, because the limit is a rolling window and a small batch never fills it.
Writes are also bounded by the plan's monthly unit allowance, which is a different thing: the rate limit protects the service from a burst, the unit limit is what you bought. Hitting the rate limit is temporary and hitting the unit limit is not.
Scopes
shared— everything on the account reads and writes it. The default choice,
and what makes one agent recall what another wrote.
solo— private to the key's own tool and holder. Use it for notes that
should not leak between colleagues or tools.
Memories
An account can hold several separate graphs. Pass memory: "matchbrain-dev" on any call to choose one; omit it for the account's default. Names must already exist — create them at https://app.echo-mem.com/memories — because a name that is created by being used means a typo opens an empty graph and recall silently returns nothing.
A key can also carry a default memory, so an already deployed service reaches the right graph without being changed.
What you are billed for
One unit is one fact written or one fact returned. write_episode charges the edges it created, query_memory charges the facts it handed back. A call that creates nothing, or a query that matches nothing, costs nothing.
Running it yourself
The engine is source available and needs no account. It is free for development, testing and evaluation at any size, and free in production for organisations under 50 employees and under $5M annual revenue. Above that, and for offering it to third parties as a hosted service, talk to hello@echo-mem.com.
Every released version becomes Apache 2.0 four years after it ships, and versions published before 25 September 2026 are Apache 2.0 permanently.
pip install echo-mem # NOT echo-memory, that is an unrelated package
echo-memory quickstart # starts Postgres with pgvector and Apache AGESelf hosted, group ids come from ECHO_MEMORY_USER_ID and ECHO_MEMORY_AGENT_ID rather than from a key, and there is no metering.