# 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

```bash
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:

```json
{
  "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-stream
```

Both, 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.

```js
// 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

```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.

```js
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:

```js
if (!session) await connect();   // once, not per call
```

For 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.

```bash
pip install echo-mem        # NOT echo-memory, that is an unrelated package
echo-memory quickstart      # starts Postgres with pgvector and Apache AGE
```

Self hosted, group ids come from `ECHO_MEMORY_USER_ID` and
`ECHO_MEMORY_AGENT_ID` rather than from a key, and there is no metering.
