> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Coming from Mem0 or Zep

> Map the memory-layer vocabulary you already have onto Exo's routes, move your data across, and see what changes about how you handle conflicting facts.

If you have built on a memory layer before, most of what you know transfers. You add content, you scope it to a user, you search it back, you delete on request. Exo does all four. This page maps that vocabulary onto Exo's routes and is specific about the three places where the model is genuinely different.

<Note>
  Everything below describes **Exo's own API**, verified against the published contract. It does not make claims about any other product's current behaviour. Check their documentation for theirs, then compare against what is written here.
</Note>

## Before you start

<Columns cols={3}>
  <Card title="A key with write" icon="key" href="/authentication">
    Migration writes content and provisions subjects.
  </Card>

  <Card title="Your export in hand" icon="package" href="#moving-the-data">
    Whatever your current layer gives you. Exo ingests plain text.
  </Card>

  <Card title="A user-id mapping" icon="users" href="/guides/subjects">
    One subject per end user. Your existing user ids usually work as-is.
  </Card>
</Columns>

## The vocabulary map

| What you probably call it   | In Exo                                 | Route                                         |
| --------------------------- | -------------------------------------- | --------------------------------------------- |
| Add a memory                | Ingest content                         | `POST /v1/ingest`                             |
| Add many memories           | Batch ingest, up to 500 items          | `POST /v1/ingest/batch`                       |
| Search memories             | Hybrid search, no reasoning            | `POST /v1/search`                             |
| Retrieve context for an LLM | Retrieve, with reasoning and conflicts | `POST /v1/retrieve`                           |
| A user id on every call     | A subject, selected by header          | `PUT /v1/subjects/{id}`, then `X-Exo-Subject` |
| Update a memory             | Edit a node                            | `PATCH /v1/graph/nodes/{id}`                  |
| Delete a memory             | Forget a node, tombstoned for 30 days  | `DELETE /v1/graph/nodes/{id}`                 |
| Delete a user               | Erase a subject                        | `DELETE /v1/subjects/{id}`                    |
| Export my data              | Async JSON export                      | `POST /v1/exports`                            |
| List what is stored         | Page the graph                         | `GET /v1/graph/nodes`                         |

The two habits worth unlearning:

* **Scoping is a header, not a body field.** `X-Exo-Subject` selects whose partition you read and write. A `user_id` in the JSON body does not do it, and nothing warns you: the call silently acts as the key owner.
* **Writes are asynchronous.** Every ingest returns `202` with a `jobId`. The content is durably queued, not yet searchable. Poll `GET /v1/ingest/{jobId}/status`, or subscribe to `job.completed`.

## The shortest call that works

Your "add a memory" becomes this:

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/ingest \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "X-Exo-Subject: customer_6412" \
    -H "Content-Type: application/json" \
    -d '{"content": "Prefers async review over live calls."}'
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import httpx

  r = httpx.post(
      "https://api.get-exo.com/v1/ingest",
      headers={"X-Exo-API-Key": EXO_KEY, "X-Exo-Subject": "customer_6412"},
      json={"content": "Prefers async review over live calls."},
  )
  job_id = r.json()["jobId"]
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const r = await fetch("https://api.get-exo.com/v1/ingest", {
    method: "POST",
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      "X-Exo-Subject": "customer_6412",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ content: "Prefers async review over live calls." }),
  });
  const { jobId } = await r.json();
  ```
</CodeGroup>

```json 202 Accepted theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{ "jobId": "3f9c1d84-6b02-4a71-9f5e-2c8b7d40a115", "status": "pending" }
```

## Moving the data

<Steps>
  <Step title="Provision a subject per user" icon="user-plus">
    Idempotent, no request body. Run it over your whole user list; re-running is free.

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    for user_id in your_users:
        httpx.put(
            f"https://api.get-exo.com/v1/subjects/{user_id}",
            headers={"X-Exo-API-Key": EXO_KEY},
        )
    ```

    Subject ids allow 1 to 128 characters of `A-Za-z0-9._:-`. Email addresses do not qualify, because `@` is outside that set. Normalize before you start, and keep your own mapping: Exo never returns it.
  </Step>

  <Step title="Batch the content in" icon="upload">
    `POST /v1/ingest/batch` takes up to 500 items per call, each with up to 100,000 characters of `content` plus optional `title`, `source_type` and `metadata`.

    ```python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    import httpx, uuid

    def migrate(subject_id: str, memories: list[str]) -> list[str]:
        job_ids: list[str] = []
        for i in range(0, len(memories), 500):
            chunk = memories[i : i + 500]
            r = httpx.post(
                "https://api.get-exo.com/v1/ingest/batch",
                headers={
                    "X-Exo-API-Key": EXO_KEY,
                    "X-Exo-Subject": subject_id,
                    # Deterministic, so a re-run of the same slice never duplicates.
                    "Idempotency-Key": f"migrate:{subject_id}:{i}",
                },
                json={"items": [{"content": m} for m in chunk]},
            )
            body = r.json()
            for item in body["results"]:
                if item["status"] == "skipped":
                    log.warning("skipped: %s", item["reason"])
            job_ids += body["jobIds"]
        return job_ids
    ```

    Put your old system's record id into `metadata` so you can reconcile later:

    ```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    { "content": "Prefers async review.", "metadata": { "legacyId": "mem_44182" } }
    ```
  </Step>

  <Step title="Read results, not just status codes" icon="list-checks">
    **A batch with invalid items still returns `202`.** Per-item outcomes live in `results`, in request order.

    ```json 202 Accepted theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "total": 3,
      "succeeded": 1,
      "failed": 2,
      "ingested": 1,
      "skipped": 2,
      "results": [
        { "status": "skipped", "reason": "empty content" },
        { "status": "accepted", "node_id": "", "chunks": 0, "jobId": "8a1f..." },
        { "status": "skipped", "reason": "content too large" }
      ],
      "jobIds": ["8a1f..."]
    }
    ```

    Note that `failed` is `total` minus `succeeded`, so it **includes** the skipped items. If you are counting migration losses, read `skipped`, and read `results` to find out which of your records did not make it.
  </Step>

  <Step title="Wait for the graph to build" icon="clock">
    Ingest creates nodes. The connections, domains, identity and the rest are built by a background cycle afterwards, so a migration that finished five minutes ago will not yet answer well.

    ```bash theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    curl "https://api.get-exo.com/v1/jobs?pageSize=100" -H "X-Exo-API-Key: $EXO_KEY"
    ```

    Watch for every job to reach `succeeded`, then give the cycle time before you judge retrieval quality. See [How a memory is made](/concepts/memory-lifecycle).
  </Step>

  <Step title="Switch reads over" icon="arrow-left-right">
    Point your retrieval at `POST /v1/retrieve`, and read `path` and `degraded` on the first responses. A freshly migrated organization has no trained brain, so expect `"path": "hybrid"` with `"degradationReason": "no_brain"`. That is the honest baseline, and it is comparable to a vector store. Train a brain to get the reasoning layer. See [Training and serving an ExoBrain](/guides/exobrain).
  </Step>
</Steps>

## What changes about conflicting facts

This is the difference that will actually affect your code.

A memory layer that stores facts has to decide what happens when a new fact disagrees with an old one, and the usual answer is that the newer write wins and the older one is updated or dropped. That keeps reads clean. It also means a reversal, someone changing their mind, is indistinguishable from a correction, and neither is visible to your product.

Exo does not overwrite. It keeps both and surfaces the conflict:

```json Part of a POST /v1/retrieve response theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "contradictions": [
    {
      "id": "cc_51a0f8d3b62e947c",
      "a": "usage-based only",
      "b": "seat plus usage",
      "tension": 0.55,
      "status": "open",
      "detectedAt": "2026-07-14T09:12:03Z"
    }
  ]
}
```

Three consequences to plan for:

1. **Your agent can check before it acts.** `includeContradictions` defaults to `true` on retrieve, so an agent can refuse to act on a contested belief rather than confidently picking the stale side.
2. **You resolve conflicts explicitly**, by superseding the side that lost with `POST /v1/graph/nodes/{id}/supersede`. The old belief stays in the chain and authority moves to the successor. See [Contradictions in review](/guides/contradictions-review).
3. **`status` is always `"open"`.** v1 stores no resolution lifecycle, so `?status=resolved` returns an empty page by design. Track closure on your side.

If you want last-write-wins behaviour, you can have it: supersede with the default `confidence` of `1.0` on every update, which fully demotes the previous belief. You just have to ask for it rather than get it silently.

## What Exo adds

None of these has an equivalent in a store-and-search memory layer, and each is a real object with a real payload:

<Columns cols={2}>
  <Card title="Cognition" icon="brain" href="/concepts/cognition">
    Why the answer, not just what: focal nodes, gate confidence, and whether it reasoned fast or slow.
  </Card>

  <Card title="ConditioningPack" icon="wand" href="/guides/conditioning">
    A ready-to-inject system prompt that makes your own LLM reason and write like the subject.
  </Card>

  <Card title="ManifoldState" icon="compass" href="/concepts/manifold">
    The axes a person's thinking actually varies along, discovered rather than assumed.
  </Card>

  <Card title="Honest degradation" icon="triangle-alert" href="/concepts/retrieval-paths">
    `path`, `degraded` and `degradationReason` say when you are on the fallback, instead of quietly returning worse results.
  </Card>
</Columns>

## What Exo does not do yet

Stated plainly, so you can check it against your requirements before you migrate rather than after:

| Limit                   | Detail                                                                                   |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| Export format           | JSON only. The contract pins `format` to a single value                                  |
| Connectors              | GitHub only in v1                                                                        |
| Contradiction lifecycle | No stored resolution state. Every tension reads as `open`                                |
| SSE replay              | `Last-Event-ID` is accepted but not replayed. Use a webhook for anything you cannot miss |
| Concurrent streams      | 5 per organization, then `429 too_many_streams`                                          |
| Webhook endpoints       | 20 per organization                                                                      |
| Team insights           | `AgentFailurePattern` is defined in the contract, but no route serves it in v1           |
| Undelete                | Restores a node's position, never its content. `contentRestored` is always false         |

## When it goes wrong

| What you see                           | Why                                                             | What to do                                                                                                                                       |
| -------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Everything landed on one user          | The user id went in the body instead of `X-Exo-Subject`         | Move it to the header, then erase and re-migrate the affected subject                                                                            |
| `403 permission_denied` mid-migration  | You ingested for a subject before provisioning it               | `PUT /v1/subjects/{id}` first. An unprovisioned selector is refused before the handler runs. See [permission\_denied](/errors/permission_denied) |
| Counts do not match your source        | Items were skipped, and `202` hid it                            | Read `skipped` and `results`. `failed` counts skipped items too                                                                                  |
| Retrieval is worse than your old stack | No brain yet, or the background cycle has not run               | Check `degradationReason`. `no_brain` and `cold_start` are both expected right after a migration                                                 |
| A retried batch created duplicates     | No `Idempotency-Key`, or a different key on the retry           | Use a deterministic key per slice, as in the migration snippet above                                                                             |
| `422 validation_error` on a subject id | The id contains characters outside `A-Za-z0-9._:-`, such as `@` | Normalize ids before provisioning. See [validation\_error](/errors/validation_error)                                                             |

## Next

<Columns cols={2}>
  <Card title="One memory per end user" icon="users" href="/guides/subjects">
    Provisioning, selecting and erasing the subjects you just created.
  </Card>

  <Card title="When to use Exo" icon="scale" href="/when-to-use-exo">
    The honest version of when a plain vector store is enough.
  </Card>
</Columns>
