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

# How a memory is made

> Ingest, warm, edit, supersede, forget, undelete, purge: the full life of one node, including the part where it stops existing.

Ingestion is asynchronous. You send content, you get a job, and you read what the job made. Returned by [`GET /v1/jobs/{job_id}`](/api-reference/jobs/get-job).

```json 200 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "b1f0c6d2-8a44-4b21-9a1e-6d1f0c6d28a4",
  "jobType": "ingest",
  "status": "succeeded",
  "createdAt": "2026-07-14T09:11:58Z",
  "completedAt": "2026-07-14T09:12:11Z",
  "error": null,
  "result": {
    "nodesCreated": 1,
    "edgesCreated": 3,
    "domainsDetected": 1
  }
}
```

## What it is

**Job**, the unit of work an ingest becomes (in practice, the id you poll to find out what your content turned into, not merely whether the upload landed).

**Warm layer**, a part of the cognitive state that the background cycle fills in after ingestion (in practice, domains, phases, the identity manifold and the insight queue: each one appears on its own schedule and is empty until it does).

**Tombstone**, a node that has been forgotten but whose shell is kept for a 30-day window (in practice, the node's text is erased immediately and irreversibly while its position in the graph stays restorable).

Most memory APIs can add. Fewer can revise without losing the old version. Very few can forget in a way that survives a privacy audit, because forgetting properly means erasing the retrievable text rather than hiding a row. Exo separates those three verbs and gives each one its own semantics.

## The two clocks

```mermaid theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
flowchart TB
  A[POST /v1/ingest<br/>202 with a jobId] --> B[Worker chunks,<br/>embeds, links]
  B --> C[Node is retrievable]
  B --> D[Background<br/>cognition cycle]
  D --> E[Domains, phases,<br/>manifold, insights]
  C --> F[PATCH: edit]
  C --> G[supersede: revise]
  C --> H[DELETE: forget]
  H --> I[Tombstone<br/>30 days]
  I --> J[undelete:<br/>structure only]
  I --> K[Purged]
  classDef focus stroke-width:2px;
  class H focus;
```

The fast clock finishes in one job. The slow clock runs on its own schedule over the whole graph, which is why the recall views can be empty for a while after a successful ingest. Nothing is wrong: the content is retrievable, the discovered structure is not built yet.

## Stage by stage

### 1. Ingest

[`POST /v1/ingest`](/api-reference/ingest/post-ingest) takes text and returns 202 with a `jobId`. [`POST /v1/import`](/api-reference/ingest/post-import) takes a file the same way. Both are accept-then-work: nothing is embedded inside the request.

Send an `Idempotency-Key` header and a retry returns the original `jobId` rather than a second copy of the content. See [Idempotency](/platform/idempotency).

### 2. Poll

[`GET /v1/ingest/{job_id}/status`](/api-reference/ingest/get-ingest-status) gives progress while it runs (`status`, `phase`, `percent`, `completedSteps`). [`GET /v1/jobs/{job_id}`](/api-reference/jobs/get-job) gives the finished interpretation: what the content became in the graph. Poll the first for a progress bar and read the second for the result.

`status` moves through `pending`, `running`, then a terminal value. Treat it as a string rather than a closed set, so a future internal status does not break your reader.

<Warning>
  The two routes spell success differently for the same job. `GET /v1/jobs/{job_id}` publishes `pending | running | succeeded | failed | canceled`. `GET /v1/ingest/{job_id}/status` reports the worker's own vocabulary, where success is `done`. Poll for "no longer `pending` or `running`" and both routes agree.
</Warning>

### 3. Warm

The background cycle fills in the discovered layers. Watch them by reading the recall views rather than by waiting a fixed interval: [`GET /v1/recall/{recall_type}`](/api-reference/recall/get-recall) returns empty collections until a layer exists, and populated ones once it does. [Insights](/concepts/insights), [phases](/concepts/phase) and the [manifold](/concepts/manifold) each say what their own layer needs before it can appear.

### 4. Edit

[`PATCH /v1/graph/nodes/{node_id}`](/api-reference/graph/edit-node) changes a node in place. Setting `content` re-embeds it. `metadata` merges into the node's frontmatter, and `title` replaces the title. Use this when the stored text was wrong. Use supersession instead when the text was right at the time and the person has since changed their mind.

### 5. Supersede

[`POST /v1/graph/nodes/{node_id}/supersede`](/api-reference/graph/supersede-node) records that one belief replaced another, keeps both, and moves the authority. This is the default verb for a wrong belief. [Belief and supersession](/concepts/belief-and-supersession) covers it in full.

### 6. Forget

[`DELETE /v1/graph/nodes/{node_id}`](/api-reference/graph/forget-node) is the erasure verb, and it does two separate things.

```json 200 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2",
  "deleted": true,
  "tombstoned": true,
  "undeleteWindowDays": 30
}
```

It tombstones the node, which is auditable and reversible, and it erases the node's chunks, which is neither. The chunks are where the retrievable and searchable text lives, so erasing them is what makes the content actually unrecoverable through [`POST /v1/retrieve`](/api-reference/retrieve/post-retrieve) and [`POST /v1/search`](/api-reference/search/search) rather than merely hidden behind a filter. Graph reads, recall, retrieve and brain training all filter the tombstone, so a forgotten node stops surfacing everywhere at once.

Forgetting needs the `memory:forget` scope. A `write` key qualifies; `graph:write` alone does not, so a key minted for an integration that edits the graph cannot destroy content. See [Scopes](/scopes).

### 7. Undelete

[`POST /v1/graph/nodes/{node_id}/undelete`](/api-reference/graph/undelete-node) restores a node inside the 30-day window.

```json 200 application/json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2",
  "restored": true,
  "contentRestored": false,
  "deletedAt": "2026-07-14T10:02:44Z",
  "undeleteWindowDays": 30
}
```

`contentRestored` is always false. That is not a bug and not a degraded mode: forget deleted the text, and nothing can undo that. What comes back is the node's graph position, title, metadata and authority. The restored node is visible and linkable but contributes no retrievable text until you write new content into it with `PATCH`.

### 8. Purge

[`POST /v1/purge`](/api-reference/admin/purge-data) is the two-phase administrative erase for a whole subject or a whole org. Phase one is a dry run that counts the exact impact and mints a `confirmToken`. Phase two spends the token and reports the rows it actually deleted, under the same keys. [`DELETE /v1/subjects/{subject_id}`](/api-reference/subjects/delete-subject) is the narrower path for one end user, and [`DELETE /v1/me`](/api-reference/admin/delete-me) tears down the account.

## How to use it

Ingest, then poll until the job settles.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  JOB=$(curl -sS -X POST https://api.get-exo.com/v1/ingest \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 4f7b2c10-1c9d-4a2e-9c31-7c0a51e6d2b8" \
    -d '{"content": "We dropped the freemium tier. Support load was the deciding factor."}' \
    | python -c "import sys,json; print(json.load(sys.stdin)['jobId'])")

  curl -sS "https://api.get-exo.com/v1/jobs/$JOB" -H "X-Exo-API-Key: $EXO_KEY"
  ```

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

  H = {"X-Exo-API-Key": os.environ["EXO_KEY"]}

  accepted = requests.post(
      "https://api.get-exo.com/v1/ingest",
      headers={**H, "Idempotency-Key": "4f7b2c10-1c9d-4a2e-9c31-7c0a51e6d2b8"},
      json={"content": "We dropped the freemium tier. Support load was the deciding factor."},
  )
  accepted.raise_for_status()
  job_id = accepted.json()["jobId"]

  while True:
      job = requests.get(f"https://api.get-exo.com/v1/jobs/{job_id}", headers=H).json()
      if job["status"] in ("succeeded", "failed", "canceled"):
          break
      time.sleep(2)

  print(job["status"], job.get("result"))
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const H = { "X-Exo-API-Key": process.env.EXO_KEY! };

  const accepted = await fetch("https://api.get-exo.com/v1/ingest", {
    method: "POST",
    headers: {
      ...H,
      "Content-Type": "application/json",
      "Idempotency-Key": "4f7b2c10-1c9d-4a2e-9c31-7c0a51e6d2b8",
    },
    body: JSON.stringify({
      content: "We dropped the freemium tier. Support load was the deciding factor.",
    }),
  });
  if (!accepted.ok) throw new Error(`${accepted.status} ${await accepted.text()}`);
  const { jobId } = await accepted.json();

  let job;
  do {
    await new Promise((r) => setTimeout(r, 2000));
    job = await (
      await fetch(`https://api.get-exo.com/v1/jobs/${jobId}`, { headers: H })
    ).json();
  } while (!["succeeded", "failed", "canceled"].includes(job.status));

  console.log(job.status, job.result);
  ```
</CodeGroup>

Rather than polling, you can subscribe: `job.completed` arrives on [`GET /v1/events`](/api-reference/events/stream-events) and on webhooks. See [Reacting to change](/guides/reacting-to-change).

## Details worth knowing

<AccordionGroup>
  <Accordion title="Why my retrieve works but every recall view is empty" icon="circle-question">
    Ingestion and the cognition cycle are separate clocks. Ingestion makes content retrievable. The cycle builds the discovered layers, and each layer has its own preconditions: phases need enough dated content to cluster, the manifold needs enough identity signals to decompose, insights need pairs of high-authority nodes that are related but not already linked. Empty is the honest answer for a layer that does not exist yet, and it is why the recall views return empty collections rather than an error.
  </Accordion>

  <Accordion title="Why undelete does not bring the text back" icon="circle-question">
    Because a forget that could be undone is not an erasure. If the text were merely hidden it would still be sitting in the store, and a privacy request would not actually be satisfied. Exo erases the chunks at forget time and keeps only the shell, so `contentRestored` is false by construction. If you want a reversible hide, do not use forget: patch the node, or supersede it.
  </Accordion>

  <Accordion title="What happens after the 30 days" icon="circle-question">
    The tombstone is physically purged. Past the window, `undelete` returns exactly the same 404 as a node that never existed, whether or not the collector has removed the row yet, so the contract does not leak the difference between "gone" and "about to be gone".
  </Accordion>

  <Accordion title="Whether re-running a delete or an undelete is safe" icon="circle-question">
    Both are idempotent. Re-deleting an already-tombstoned node is a no-op success. Undeleting a node that is not tombstoned returns `restored: false` and changes nothing, so a retried call after a network timeout never surprises you.
  </Accordion>

  <Accordion title="What an ingest failure looks like" icon="circle-question">
    The job reaches `status: "failed"` with a human-readable `error`, and `result` stays null. A corrupt file or an embedding failure is terminal rather than retried forever. Cancel a job that has not started yet with [`POST /v1/jobs/{job_id}/cancel`](/api-reference/jobs/cancel-job); a job already running returns `job_not_cancelable`.
  </Accordion>
</AccordionGroup>

## Limits

* Edges removed around a node when it was forgotten stay removed. Undelete restores the node, not its former neighbourhood.
* `PATCH` with `content` collapses the node to a single chunk. Splitting long content into multiple chunks is the ingest pipeline's job, so send large replacements as a new ingest.
* Forgetting a node does not retrain the org's ExoBrain. The brain marks itself stale and retrieval says so through `degradationReason`. See [Brain path and hybrid path](/concepts/retrieval-paths).
* There is no bulk forget. Erasing everything for one end user is [`DELETE /v1/subjects/{subject_id}`](/api-reference/subjects/delete-subject); everything else is node by node.

## Next

<Columns cols={2}>
  <Card title="Belief and supersession" icon="history" href="/concepts/belief-and-supersession">
    The verb to reach for when the content was right and the person changed their mind.
  </Card>

  <Card title="Forgetting and portability" icon="trash-2" href="/guides/forgetting">
    The task guide: tombstones, the GDPR path, the purge ceremony and export.
  </Card>
</Columns>
