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

# Memories

> Store memories synchronously or in the background, bulk-ingest history, and list, inspect, or delete what a space holds.

## Store a memory

```http theme={null}
POST /v1/record
Authorization: Bearer anona_live_YOUR_KEY
Content-Type: application/json
```

```json theme={null}
{
  "space_id": "customer-support-bot",
  "content": "Alice works at Google and specializes in distributed systems",
  "context": "onboarding call",
  "user_id": "alice_123",
  "tags": ["work", "expertise"],
  "metadata": { "source": "crm" }
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `space_id` | string | Yes\* | Which space to store in. Required unless you send `route` instead. |
| `content` | string | Yes | The memory text. |
| `org_id` | string (UUID) | No | The organization this request is meant for. Optional: an API key belongs to one organization, and naming a different one is refused with `400 org_mismatch` rather than served from the key's. Same as the `X-Anona-Org` header; send one or the other, and see [Choosing an organization](https://docs.memory.anonalabs.com/api-reference/organization.md#choosing-an-organization-per-request). |
| `route` | `"auto"` | Yes\* | Let Anona choose the space. Mutually exclusive with `space_id`; exactly one of the two is required. See [Let Anona choose the space](#let-anona-choose-the-space). |
| `fallback_space_id` | string | No | With `route`, where the memory goes when nothing fits. Defaults to the organization's default space. |
| `max_targets` | integer | No | With `route`, how many spaces the memory may be written to. Only `1` is accepted. Defaults to `1`. |
| `context` | string | No | Freeform context for the memory. |
| `timestamp` | string | No | ISO 8601 **event** time: when the thing happened, not when you are recording it. Set it when importing history. Returned as `timestamp` on retrieve, used in recency ranking, and what [`occurred_after` / `occurred_before`](https://docs.memory.anonalabs.com/api-reference/retrieve#filtering-on-event-time) match against. It is not copied into `occurred_start`, which is read from the memory's own text. Defaults to now. See [Searching over time](https://docs.memory.anonalabs.com/api-reference/retrieve.md#searching-over-time). |
| `user_id` | string | No | End user this memory belongs to. Only a search carrying the same `user_id` returns it. See [Scoping](https://docs.memory.anonalabs.com/api-reference/retrieve.md#scoping-within-a-space). |
| `agent_id` | string | No | Agent that produced or owns this memory. |
| `session_id` | string | No | Conversation or run this memory came from. |
| `tags` | string\[] | No | Visibility-scope tags; `retrieve` can filter on them. Tags starting with `anona:` are reserved. |
| `metadata` | object | No | Custom key-value data, returned on retrieval. Top-level keys starting with `anona_` are reserved and rejected with `422 reserved_metadata_key`. |
| `async` | boolean | No | When `true`, queues the write and returns a `job_id` instead of waiting. Defaults to `false`. |

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/record \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "customer-support-bot",
    "content": "Alice works at Google and specializes in distributed systems",
    "context": "onboarding call",
    "user_id": "alice_123",
    "tags": ["work", "expertise"],
    "metadata": { "source": "crm" }
  }'
```

```python Python theme={null}
result = client.record(
    space_id="customer-support-bot",
    content="Alice works at Google and specializes in distributed systems",
    context="onboarding call",
    user_id="alice_123",
    tags=["work", "expertise"],
    metadata={"source": "crm"},
)
print(result["memory_id"])
```

```typescript TypeScript theme={null}
const result = await anona.record({
  spaceId: "customer-support-bot",
  content: "Alice works at Google and specializes in distributed systems",
  context: "onboarding call",
  userId: "alice_123",
  tags: ["work", "expertise"],
  metadata: { source: "crm" },
});
console.log(result.memory_id);
```

> [!NOTE]
>
> `user_id` is a real field, not metadata. Putting a user id in `metadata` stores
> it but does **not** isolate anything: `metadata` is returned with results, never
> filtered on.

**Response** `201 Created`

```json theme={null}
{
  "memory_id": "mem_abc123def456",
  "status": "stored",
  "usage": {
    "input_tokens": 42,
    "output_tokens": 12
  }
}
```

| `status` | Meaning | `memory_id` |
| - | - | - |
| `stored` | Facts were extracted and are searchable now. | The first memory created; `memory_ids` carries all of them. |
| `nothing_extracted` | The write was accepted, the extraction ran, and it produced no facts. **Nothing is stored and there is nothing to retrieve.** `message` says what to do. | `null` |
| `processing` | Async only. Queued; poll the job for the outcome. | `null` — not known yet. |

> [!WARNING]
>
> **A `201` does not mean something was stored.** One item usually extracts to
> several facts, but it can extract to none, and the write is still accepted
> because the extraction itself did not fail — there was simply nothing durable in
> the content. Measured against production, roughly **44% of short, context-free
> statements** land there.
>
> Check `status`, not the HTTP code:
>
> ```json theme={null}
> {
>   "memory_id": null,
>   "status": "nothing_extracted",
>   "message": "No facts could be extracted from that content, so nothing was stored in space 'customer-support-bot'. Short, context-free statements often extract to nothing — re-record it with a sentence of context (what the thing is, or why it matters), which usually fixes it.",
>   "usage": { "input_tokens": 9, "output_tokens": 4 }
> }
> ```
>
> The fix is context, and the effect is large: `"Falcon retry count is 3."`
> survived about 5 times in 9, while `"Dunlin, the webhook sender, has a
>   connection pool of 25."` survived 10 out of 10. Name what the thing is, or why
> it matters, in the same sentence.
>
> This is charged either way — the extraction model ran. `message` is `null` on
> every successful write, so a caller may surface it whenever it is present.

> [!WARNING]
>
> **A synchronous write is slow, and async is several times faster.** A memory is
> analyzed by a language model and its facts extracted *before* the call returns, so
> `record` waits for that work. Measured against production with short memories:
>
> | Call | Time |
> | - | - |
> | `record` | **about 3-12 seconds** |
> | `record` with `"async": true` | **1-3 seconds** |
>
> How long a synchronous write takes depends mostly on how much you send: longer content
> means more for the model to extract, and very long content is extracted in several
> passes, so expect the top of that range or beyond for anything past a few paragraphs.
>
> Anything a person is waiting on (a chat turn, a form submit, a page transition)
> should use async mode below. Reach for a synchronous write only when the very next
> thing you do is search for what you just stored.

## Store a memory asynchronously

Set `"async": true` and the call returns in about a second with a `job_id` and
`status: "processing"` in place of the stored `memory_id`. The memory is extracted and
indexed in the background; poll the [job](#job-status) to learn when it is searchable.

**This is the recommended way to write.** The extraction work is identical and costs
the same; you simply stop waiting for it. The one thing that changes is timing: the
memory is not searchable the instant the call returns, so a flow that writes and then
immediately retrieves needs to wait for the job (or use a synchronous write).

```json Request theme={null}
{
  "space_id": "customer-support-bot",
  "content": "Alice works at Google and specializes in distributed systems",
  "async": true
}
```

```json Response theme={null}
{
  "memory_id": null,
  "job_id": "job_9f2a1c",
  "status": "processing"
}
```

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/record \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "customer-support-bot",
    "content": "Alice works at Google and specializes in distributed systems",
    "async": true
  }'
```

```python Python theme={null}
job = client.record(
    space_id="customer-support-bot",
    content="Alice works at Google and specializes in distributed systems",
    background=True,
)
print(job["job_id"])  # poll this
```

```typescript TypeScript theme={null}
const job = await anona.record({
  spaceId: "customer-support-bot",
  content: "Alice works at Google and specializes in distributed systems",
  background: true,
});
console.log(job.job_id); // poll this
```

The SDKs spell the flag `background`; on the wire it is `async`.

> [!TIP]
>
> Pair async writes with [webhooks](https://docs.memory.anonalabs.com/api-reference/webhooks.md) and you can drop polling
> entirely: Anona posts to your endpoint when the memory is stored.

## Let Anona choose the space

Send `route: "auto"` in place of `space_id` and Anona files the memory into the
space it belongs in. Exactly one of the two must be present: sending both, or
neither, is `422 route_conflict`.

```json Request theme={null}
{
  "route": "auto",
  "content": "The Q3 launch slipped to October because of the billing migration."
}
```

```json Response theme={null}
{
  "memory_id": "mem_abc123",
  "memory_ids": ["mem_abc123"],
  "status": "stored",
  "routed_to": [
    {
      "space_id": "product",
      "confidence": 0.94,
      "stage": "model",
      "reason": null
    }
  ]
}
```

`routed_to` is present only when `route` was used, and is `null` on a write that
named its space. Each entry reports where the memory went and how that was
decided.

> [!NOTE]
>
> A `reason` of `router_disabled` means automatic routing is not enabled on the
> deployment you are calling: `route: "auto"` is accepted and the write succeeds,
> but the memory goes to your fallback space. The
> [routing guide](https://docs.memory.anonalabs.com/guides/automatic-routing.md) lists every `reason` value and what
> each one means.

| Field | Type | Description |
| - | - | - |
| `space_id` | string | The space written to. A space shared by another organization appears in its qualified `owner-slug:space-name` form. |
| `confidence` | number or null | The decision model's confidence. `null` on the `rule` and `fallback` stages, which do not produce one. |
| `stage` | string | `rule`, `model` or `fallback`. There is no fourth value. |
| `reason` | string or null | Why. A rule match names the rule; a fallback names why nothing claimed the memory. |

| `stage` | Meaning |
| - | - |
| `rule` | A routing rule you declared matched. No model was called. |
| `model` | The decision model picked this space, above the confidence threshold. |
| `fallback` | Nothing claimed the memory, so it went to `fallback_space_id`, or to the organization's default space, or to a space named `default`. A memory filed this way is stored with `"anona_routed": "fallback"` in its `metadata`. |

`max_targets` accepts only `1` today. `2` or `3` is refused with
`422 validation_error` rather than accepted and quietly capped.

> [!NOTE]
>
> **The same field means something wider on a read.** A memory is filed in one
> space, so `max_targets` on a write is `1`. A *question* can legitimately be
> answered out of several, so on
> [`POST /v1/retrieve`](https://docs.memory.anonalabs.com/api-reference/retrieve#searching-more-than-one-space) the
> same field accepts 1 to 20.

| Status | Code | Meaning |
| - | - | - |
| 422 | `route_conflict` | Both `space_id` and `route` were sent, or neither was. |
| 422 | `no_route_target` | There is nowhere to put the memory: no `fallback_space_id`, no organization default space and no space named `default`. |
| 422 | `reserved_metadata_key` | A top-level `metadata` key begins with the reserved `anona_` prefix. |

Error codes are lowercase snake\_case and arrive in the standard
`{"error": {"code", "message"}}` envelope.

Which spaces can be chosen, how to give a space a description, and the rules
that answer before any model does are covered in
[Automatic space routing](https://docs.memory.anonalabs.com/guides/automatic-routing.md). Content sent to the
third-party decision model is disclosed in
[Subprocessors](https://docs.memory.anonalabs.com/legal/subprocessors.md).

A search can be routed the same way:
[`POST /v1/retrieve`](https://docs.memory.anonalabs.com/api-reference/retrieve#let-anona-choose-the-space) takes
`route: "auto"` in place of `space_id` and answers with `searched`. It behaves
differently in one respect, so read that section rather than assuming symmetry: a
routed read that fits no space returns no results rather than falling back to
your default space, because on a read the default space is the least likely place
to hold a specific answer.

## Bulk ingest

Queue many memories in a single call, which is the efficient way to backfill history.
Bulk ingest is always asynchronous: the batch is accepted and a `job_id` returned
immediately.

```http theme={null}
POST /v1/record/batch
Authorization: Bearer anona_live_YOUR_KEY
Content-Type: application/json
```

```json theme={null}
{
  "space_id": "customer-support-bot",
  "items": [
    { "content": "Alice works at Google." },
    { "content": "Bob prefers async standups.", "context": "team sync" },
    { "content": "The Q3 launch slipped to October.", "timestamp": "2026-06-01T00:00:00Z" }
  ]
}
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `space_id` | string | Yes\* | Which space to store in. Required unless you send `route` instead. |
| `org_id` | string (UUID) | No | The organization this request is meant for. Optional: an API key belongs to one organization, and naming a different one is refused with `400 org_mismatch` rather than served from the key's. Same as the `X-Anona-Org` header; send one or the other, and see [Choosing an organization](https://docs.memory.anonalabs.com/api-reference/organization.md#choosing-an-organization-per-request). |
| `route` | `"auto"` | Yes\* | Route the batch automatically. The whole batch is filed together as one unit; items cannot be routed individually. Mutually exclusive with `space_id`. |
| `fallback_space_id` | string | No | With `route`, where the batch goes when nothing fits. |
| `max_targets` | integer | No | With `route`, only `1` is accepted. Defaults to `1`. |
| `items` | array | Yes | 1 to 100 memory objects. Each takes the same fields as `record`: `content` is required; `context`, `timestamp`, `metadata`, `tags`, and the scope keys `user_id` / `agent_id` / `session_id` are optional. |
| `user_id` / `agent_id` / `session_id` | string | No | Scope applied to **every** item — a batch is usually one user's history. An item's own scope keys override these. |

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/record/batch \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "customer-support-bot",
    "items": [
      { "content": "Alice works at Google." },
      { "content": "Bob prefers async standups.", "context": "team sync" }
    ]
  }'
```

```python Python theme={null}
job = client.record_batch(
    space_id="customer-support-bot",
    items=[
        {"content": "Alice works at Google."},
        {"content": "Bob prefers async standups.", "context": "team sync"},
    ],
)
print(job["job_id"], job["accepted"])
```

```typescript TypeScript theme={null}
const job = await anona.recordBatch({
  spaceId: "customer-support-bot",
  items: [
    { content: "Alice works at Google." },
    { content: "Bob prefers async standups.", context: "team sync" },
  ],
});
console.log(job.job_id, job.accepted);
```

**Response** `202 Accepted`

```json theme={null}
{
  "job_id": "job_batch_71b3",
  "job_ids": ["job_batch_71b3"],
  "status": "processing",
  "accepted": 3
}
```

`accepted` is the number of items queued. Poll `job_id` for progress. A large batch may
be split into several operations, all listed under `job_ids`.

> [!NOTE]
>
> A routed batch returns these same four fields and nothing more: there is no
> `routed_to` on a batch response. The routing decision is made from the content
> of up to the first 20 items. If you need to know which space a batch landed in,
> route a single `record` call instead, or set the space explicitly.

> [!NOTE]
>
> To send more than 100 items, chunk them into multiple calls. Each item is charged a base
> cost when queued and reconciled to its real cost once processing finishes, exactly as a
> synchronous write is, so queuing does not make ingestion cheaper.

## Job status

Checks a job returned by an async `record` or by `record/batch`. This read is free and
consumes no credits.

```http theme={null}
GET /v1/spaces/{space_id}/jobs/{job_id}
Authorization: Bearer anona_live_YOUR_KEY
```

```json theme={null}
{
  "job_id": "job_9f2a1c",
  "status": "completed",
  "created_at": "2026-07-20T10:00:00Z",
  "completed_at": "2026-07-20T10:00:03Z",
  "error": null,
  "memory_count": 2,
  "items_submitted": 2,
  "zero_yield_items": 0,
  "memory_ids": ["mem_...", "mem_..."],
  "progress": null,
  "retry_count": 0,
  "next_retry_at": null
}
```

`memory_count` and `memory_ids` are set once the job completes — they are how you
address the memories an async write produced. While a batch is running, `progress`
carries `{stage, processed, total, parts_done, parts_total, memories_stored,
updated_at}`; `processed`/`total` count chunks in the parts that have **started**, so
`parts_done` / `parts_total` and `status` are the completion signals, not
`processed == total`.

`items_submitted` is how many content items the job was given, so `memory_count` can be
read against something. One number alone says nothing: 15 memories is a good outcome for
12 items and a destroyed corpus for 35. It is `null` for a job that has no item count,
such as a single `record` or a document upload.

`zero_yield_items` is how many of those items produced no memory at all. Extraction
retries a chunk once if it comes back with nothing before accepting the zero, so this
is not ordinary model flakiness — it is content that came back empty twice. It is
`0`, not `null`, once measured; distinguish the two before treating a missing field as
"nothing was lost." Unlike the ratio above, a non-zero `zero_yield_items` is worth
re-submitting that specific content, since nothing was written for it at all.

`retry_count` and `next_retry_at` explain a job that is still `pending`. `progress`
describes a job that is *running*; these describe one that has not started, which
otherwise looks the same whether it is queued behind other work, parked until a later
time, or has already failed and been requeued:

| `retry_count` | `next_retry_at` | What it means |
| - | - | - |
| `0` | `null` | Queued, awaiting pickup — never attempted |
| `0` | a future time | Parked on purpose until then — not stuck, and not a failure |
| `> 0` | a future time | Failed at least once, backing off before the next attempt |
| `> 0` | `null` or a past time | Failed before, due for another attempt now |

Read `retry_count: 0` with `next_retry_at: null` against `created_at`: a job that has
been waiting far longer than your other jobs take, with no attempts burned and no parked
time set, is the one worth raising with support. A job with a future `next_retry_at` is
working as intended and will resume on its own.

> [!NOTE]
>
> A long-`pending` job is never auto-failed on a deadline. Ingestion is paced by the
> extraction model and a large batch legitimately queues for tens of minutes, so a timeout
> would report healthy work as data loss. These two fields are reported so you can tell
> the cases apart; the verdict is yours. To stop a job you no longer want, `DELETE` it
> (below) — that is free.

> [!WARNING]
>
> A job that produced far fewer memories than it was given items still reports `completed`
> with `error: null`, and that is deliberate: the memories it did write are real, and a
> batch of near-identical items legitimately yields fewer memories than items. Nothing is
> wrong with the write path. What changes this ratio is usually
> [extraction settings](https://docs.memory.anonalabs.com/api-reference/extraction-settings.md): `guidance`, `custom_prompt`,
> or a label `description` phrased as extraction policy. Compare the two numbers after any
> change to them.

| `status` | Meaning | Terminal |
| - | - | - |
| `pending` | Queued, not started yet. | No |
| `processing` | Being extracted and stored. | No |
| `completed` | Stored and searchable. | Yes |
| `failed` | Processing failed. See `error`. | Yes |
| `cancelled` | The job was cancelled. | Yes |
| `not_found` | No such job in this space. | Yes |

A `404 job_not_found` is returned when the `job_id` does not belong to the space.

### Poll until complete

```bash cURL theme={null}
KEY="anona_live_YOUR_KEY"
SPACE="customer-support-bot"

# 1. Queue the write, capture the job_id
JOB=$(curl -s https://api.anonalabs.com/v1/record \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"space_id":"'"$SPACE"'","content":"Alice works at Google.","async":true}' \
  | jq -r .job_id)

# 2. Poll every 2s until it stops processing
while true; do
  STATUS=$(curl -s "https://api.anonalabs.com/v1/spaces/$SPACE/jobs/$JOB" \
    -H "Authorization: Bearer $KEY" | jq -r .status)
  echo "status: $STATUS"
  case "$STATUS" in
    completed|failed|cancelled|not_found) break ;;
  esac
  sleep 2
done
```

```python Python SDK theme={null}
import time

# 1. Queue the write (background mode returns a job, not a stored memory)
job = client.record(
    space_id="customer-support-bot",
    content="Alice works at Google.",
    background=True,
)
job_id = job["job_id"]

# 2. Poll until the job reaches a terminal state
while True:
    status = client.get_job(space_id="customer-support-bot", job_id=job_id)
    print("status:", status["status"])
    if status["status"] in ("completed", "failed", "cancelled", "not_found"):
        break
    time.sleep(2)

if status["status"] == "failed":
    raise RuntimeError(status["error"])
```

```typescript TypeScript theme={null}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 1. Queue the write (background mode returns a job, not a stored memory)
const job = await anona.record({
  spaceId: "customer-support-bot",
  content: "Alice works at Google.",
  background: true,
});

// 2. Poll until the job reaches a terminal state
let status;
for (;;) {
  status = await anona.getJob({
    spaceId: "customer-support-bot",
    jobId: job.job_id!,
  });
  console.log("status:", status.status);
  if (["completed", "failed", "cancelled", "not_found"].includes(status.status)) break;
  await sleep(2000);
}

if (status.status === "failed") throw new Error(status.error ?? "job failed");
```

> [!NOTE]
>
> A `record/batch` job is polled exactly the same way. `completed` means every item in the
> batch has been stored and is searchable.

## Cancel a job

Stops an ingestion job that has not finished. Cancelling is free, consumes no credits,
and is never refused for an empty balance: a runaway import must always be stoppable.

```http theme={null}
DELETE /v1/spaces/{space_id}/jobs/{job_id}
Authorization: Bearer anona_live_YOUR_KEY
```

**Response** `200 OK`

```json theme={null}
{
  "job_id": "job_batch_71b3",
  "status": "cancelled",
  "cancelled_parts": 4,
  "running_parts": 1
}
```

A job is split into parts that run independently, and only a part still queued can be
stopped. A part already being processed runs to completion. `cancelled_parts` is what
this call stopped; `running_parts` is what it could not, so poll the job afterwards to
see those finish.

| Response | When |
| - | - |
| `200` | At least one queued part was stopped, or the job was already cancelled. |
| `403 space_read_only` | The caller has read-only access to the space. |
| `404 job_not_found` | No job with that id in this space. |
| `409 job_not_cancellable` | Every part had already started or finished. Nothing was stopped. |

> [!NOTE]
>
> Cancelling does not undo work already done. Memories written by parts that ran stay
> stored and searchable, and the base cost charged when the job was queued is not
> refunded — but the parts that never run are never charged for the extraction they would
> have done.

```bash cURL theme={null}
curl -X DELETE "https://api.anonalabs.com/v1/spaces/$SPACE/jobs/$JOB" \
  -H "Authorization: Bearer $KEY"
```

```python Python theme={null}
result = client.cancel_job(space_id="customer-support-bot", job_id="job_batch_71b3")
print(result["cancelled_parts"], result["running_parts"])
```

```typescript TypeScript theme={null}
const result = await anona.cancelJob({
  spaceId: "customer-support-bot",
  jobId: "job_batch_71b3",
});
console.log(result.cancelled_parts, result.running_parts);
```

## List memories in a space

```http theme={null}
GET /v1/spaces/{space_id}/memories?limit=10&offset=0&q=Alice&type=fact
Authorization: Bearer anona_live_YOUR_KEY
```

| Query parameter | Type | Description |
| - | - | - |
| `limit` | integer | Maximum results to return. Default 50, maximum 200. |
| `offset` | integer | Pagination offset. |
| `q` | string | Optional text filter. |
| `type` | string | Optional memory type filter: `fact`, `note`, `experience`, or `summary`, the same labels the response returns. Any other value is `422 validation_error`. |
| `state` | string | `active` (default) lists live memories; `invalidated` lists retired ones kept for audit. |
| `prefer_observations` | boolean | Defaults to `true`: list one row per piece of knowledge. Set `false` to also list the raw facts a `note` was synthesized from. Ignored, and the facts are listed, when the filter names an `agent_id`, `session_id` or `member_id`: a note is scoped to the user only, so it could not match that filter and hiding its facts would hide everything ([scoping](https://docs.memory.anonalabs.com/guides/scoping-multi-tenant.md#what-strict-means-in-practice)). |
| `user_id` / `agent_id` / `session_id` | string | List only memories in that [scope](https://docs.memory.anonalabs.com/guides/scoping-multi-tenant.md). Strict: untagged memories never match a scoped listing. |
| `member_id` | string | Only memories written by this member; pass `me` for your own. Attribution exists only in spaces shared across organizations, so in a personal space every memory is yours and `me` matches them all. |
| `source` / `repo` / `pull_request` / `author` / `merged_by` | string / string / integer / string / string | Only memories a connector captured — the platform (`github`), the repository as that platform identifies it, the pull request or issue number, who wrote it and who merged it. Several are AND-ed, and a memory with no provenance never matches. Read the same values back in each row's `provenance`. |
| `tag_groups` | string | A [compound tag filter](https://docs.memory.anonalabs.com/api-reference/retrieve.md#compound-tag-filters), JSON-encoded. This is a GET, so the expression travels as a string. See below. |

```bash cURL theme={null}
curl -G "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories" \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  --data-urlencode "limit=10" \
  --data-urlencode "q=Alice" \
  --data-urlencode "type=fact"
```

```python Python theme={null}
page = client.list_memories(
    space_id="customer-support-bot",
    limit=10,
    q="Alice",
    memory_type="fact",
)
for item in page["items"]:
    print(item["id"], item["content"])
```

```typescript TypeScript theme={null}
const page = await anona.listMemories({
  spaceId: "customer-support-bot",
  limit: 10,
  query: "Alice",
  memoryType: "fact",
});
for (const item of page.items) {
  console.log(item.id, item.content);
}
```

**Response** `200 OK`

```json theme={null}
{
  "items": [
    {
      "id": "mem_abc123def456",
      "content": "Alice works at Google and specializes in distributed systems",
      "text": "Alice works at Google and specializes in distributed systems",
      "context": "onboarding call",
      "date": "2026-07-14T12:34:56Z",
      "type": "fact",
      "entities": "Alice, Google",
      "tags": ["team:core", "name:alice"],
      "state": "active",
      "metadata": {"user_id": "alice_123"},
      "source_ids": [],
      "user_id": "alice_123",
      "agent_id": null,
      "session_id": null,
      "member_id": null,
      "provenance": null
    }
  ],
  "total": 42,
  "limit": 10,
  "offset": 0
}
```

Every row also carries the scope it was written under and, when a connector
wrote it, where it came from. These are fields rather than tags because the
values are stored as reserved `anona:` tags, which never appear in `tags`:

| Field | Type | Description |
| - | - | - |
| `user_id` / `agent_id` / `session_id` | string or null | The [scope](https://docs.memory.anonalabs.com/guides/scoping-multi-tenant.md) this memory was written under. `null` for an unscoped write, which is the common case. |
| `member_id` | string or null | Which member of your team wrote it. Only ever set in a space shared with another organization — in a space of your own, every memory is yours. |
| `provenance` | object or null | Where the memory came from, when a connector wrote it: `platform`, `repo`, `pull_request`, `author` and `merged_by`. `null` for anything written through the API. Read-only: it is stamped server-side, so unlike the scope fields it has no counterpart on `record`. Select on it with the `source` / `repo` / `pull_request` / `author` / `merged_by` query parameters above. |

A row a connector captured carries the object instead of `null`:

```json theme={null}
{
  "id": "mem_def789abc012",
  "content": "CD swaps only the image; Terraform ships the env.",
  "provenance": {
    "platform": "github",
    "repo": "1282949371",
    "pull_request": 658,
    "author": "7001",
    "merged_by": "9001"
  }
}
```

`author` and `merged_by` are GitHub's numeric user ids, not logins, and they
are usually different people. See
[Where a memory came from](https://docs.memory.anonalabs.com/github.md#where-a-memory-came-from) for what each tag
means and for the one caveat on captures written before October 2026.

> [!NOTE]
>
> Anona stores knowledge in two layers: the raw facts you record, and the `note` it
> synthesizes from them once they settle. Both describe the same thing, so listing
> them side by side reads as duplicates, so by default this endpoint returns the
> `note` and hides the facts folded into it. `source_ids` names those facts, and
> `prefer_observations=false` lists them as rows of their own. `total` follows the
> same rule, so paging stays consistent either way.

### Filtering the listing by tags

`tag_groups` takes the same boolean expression
[`/v1/retrieve`](https://docs.memory.anonalabs.com/api-reference/retrieve#compound-tag-filters) does. Because this
is a GET there is no body to carry a nested structure, so it is JSON-encoded into
the query string:

```
GET /v1/spaces/support/memories?tag_groups=[{"not":{"tags":["status:archived"]}}]
```

URL-encode it as your HTTP client requires. Groups are AND-ed, and each is a leaf
`{"tags": [...], "match": ...}` or one of `{"and": [...]}`, `{"or": [...]}`,
`{"not": {...}}`.

There is no separate `tags` parameter on this endpoint, and there does not need
to be: a flat tag list is a single leaf, `[{"tags": ["project:alpha"]}]`.

`total` counts the filtered set, not the unfiltered one, so pagination over a
filtered listing behaves the same as over an unfiltered one.

> [!NOTE]
>
> Scope still applies on top. `user_id`, `agent_id`, `session_id` and `member_id`
> are AND-ed onto your filter, so a scoped listing stays scoped.

> [!WARNING]
>
> List results use a few different field names from the rest of the API. Map them before reusing
> the objects elsewhere.
>
> | List field | Equivalent when writing or retrieving |
> | - | - |
> | `id` | `memory_id` |
> | `date` | `created_at` |
>
> Each item carries the memory body twice, as `content` and as `text`, always with the
> same value. **Use `content`**: it is the name `record` writes and `retrieve` returns.
> `text` is deprecated and kept only so existing clients keep working. It will not be
> removed without notice, but new code should not read it.
>
> `entities` also differs in shape: the list returns a single comma-separated
> string, while retrieve returns an array of strings. `tags` does not: it is an
> array of strings on both surfaces, carrying the tags written with the memory
> plus any a [label taxonomy](https://docs.memory.anonalabs.com/api-reference/extraction-settings.md#label-taxonomy)
> stamped. Scope stays out of it and arrives as `user_id` and friends instead.

## Edit or retire a memory

Correct what was stored, or reversibly retire it. Every field is optional; only what you
send changes.

```http theme={null}
PATCH /v1/spaces/{space_id}/memories/{memory_id}
Authorization: Bearer anona_live_YOUR_KEY
Content-Type: application/json
```

```json theme={null}
{
  "text": "Alice works at Google as a staff engineer.",
  "reason": "Title corrected after the onboarding call."
}
```

| Field | Type | Description |
| - | - | - |
| `text` | string | Replacement memory text. The memory is re-embedded and re-consolidated. |
| `context` | string | Replacement context. |
| `occurred_start` / `occurred_end` | string | Corrected event time (ISO 8601). |
| `memory_type` | string | Typically `fact` or `experience`. Validated by the engine, not at the gateway. |
| `entities` | string\[] | Replaces the memory's entity set; `[]` detaches all. |
| `state` | string | `invalidated` drops the memory out of retrieve, consolidation and reasoning while keeping it for audit; `active` restores it. |
| `reason` | string | Free-text audit note, kept on the memory's [history](#memory-history). |

```bash cURL theme={null}
curl -X PATCH \
  "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories/mem_abc123def456" \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Alice works at Google as a staff engineer.",
    "reason": "Title corrected after the onboarding call."
  }'
```

```python Python theme={null}
memory = client.update_memory(
    space_id="customer-support-bot",
    memory_id="mem_abc123def456",
    text="Alice works at Google as a staff engineer.",
    reason="Title corrected after the onboarding call.",
)
```

```typescript TypeScript theme={null}
const memory = await anona.updateMemory({
  spaceId: "customer-support-bot",
  memoryId: "mem_abc123def456",
  text: "Alice works at Google as a staff engineer.",
  reason: "Title corrected after the onboarding call.",
});
```

The response is the updated memory in the same shape the list returns, including its
`state`, so an invalidate or restore can be confirmed from the reply. It is read back
after the edit is applied, so every field in it is current.

Editing `text` does not re-extract entities. The memory keeps the entity set it had,
and the response shows that set, not one derived from the new text. Nothing
recomputes it later either. If the edit changes who or what the memory is about, send
`entities` in the same request to replace the set.

Notes (`type:
"note"`) cannot be edited — they are synthesized, and the engine rejects the attempt
rather than letting a synthesis drift from its evidence.

## Memory history

Memories are living records. As new information arrives, Anona updates what it knows and
keeps the previous version. Fetch a memory's history to see what it said before and when
it changed.

```http theme={null}
GET /v1/spaces/{space_id}/memories/{memory_id}/history
Authorization: Bearer anona_live_YOUR_KEY
```

```json theme={null}
{
  "memory_id": "mem_abc123def456",
  "history": [
    {
      "previous_content": "Works at Google.",
      "changed_at": "2026-07-18T14:07:31Z",
      "previous_occurred_start": null,
      "previous_occurred_end": null
    }
  ]
}
```

| Field | Description |
| - | - |
| `previous_content` | What the memory said before the change. |
| `changed_at` | When that version was superseded. |
| `previous_occurred_start` / `previous_occurred_end` | The event time that version referred to. |
| `reason` | The audit note a [PATCH](#edit-or-retire-a-memory) supplied for the change. Null on versions rewritten by Anona's own consolidation. |

```bash cURL theme={null}
curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories/mem_abc123def456/history" \
  -H "Authorization: Bearer anona_live_YOUR_KEY"
```

```python Python theme={null}
history = client.get_memory_history(
    space_id="customer-support-bot",
    memory_id="mem_abc123def456",
)
```

```typescript TypeScript theme={null}
const history = await anona.getMemoryHistory({
  spaceId: "customer-support-bot",
  memoryId: "mem_abc123def456",
});
```

Entries are written both by Anona's own consolidation and by your
[edits](#edit-or-retire-a-memory). An empty `history` array means the memory has never
changed.

## Delete a memory

```http theme={null}
DELETE /v1/spaces/{space_id}/memories/{memory_id}
Authorization: Bearer anona_live_YOUR_KEY
```

```bash cURL theme={null}
curl -X DELETE \
  "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories/mem_abc123def456" \
  -H "Authorization: Bearer anona_live_YOUR_KEY"
```

```python Python theme={null}
client.delete_memory(space_id="customer-support-bot", memory_id="mem_abc123def456")
```

```typescript TypeScript theme={null}
await anona.deleteMemory({
  spaceId: "customer-support-bot",
  memoryId: "mem_abc123def456",
});
```

**Response** `204 No Content`. The body is empty.

The deletion is permanent and it cascades: the memory, its links, its entity
associations and every observation consolidated from it are removed, and the
other memories that fed those observations are re-consolidated without it.
There is no undo. To take a memory out of recall while keeping it for audit,
[`PATCH`](#edit-or-retire-a-memory) it to `state: "invalidated"` instead — that
is reversible.

A memory id the space does not hold is `404 memory_not_found`, so deleting the
same memory twice reports the second attempt honestly rather than as a success.

There is no endpoint for fetching a single memory by id — read one through
[listing](#list-memories-in-a-space) or [retrieve](https://docs.memory.anonalabs.com/api-reference/retrieve.md). Updating
one is [`PATCH`](#edit-or-retire-a-memory), above.

## Error responses

| Status | Code | Cause |
| - | - | - |
| 400 | `org_mismatch` | `org_id` or `X-Anona-Org` names an organization other than the key's. |
| 400 | `invalid_org_id` | `X-Anona-Org` is not a UUID, or was sent more than once. A malformed body `org_id` is `422 validation_error`. |
| 401 | `missing_auth` / `invalid_api_key` | Missing or invalid API key. |
| 403 | `space_access_denied` | You are not a member of this space. |
| 400 | `memory_not_editable` | Editing a note. Notes are synthesised from other memories; edit the facts in its `source_ids` instead. |
| 404 | `space_not_found` | The space does not exist. |
| 404 | `memory_not_found` | The space has no memory with that id. |
| 422 | `validation_error` | The body fails schema validation. |
| 429 | `rate_limited` | Credit quota or rate limit exceeded. |
| 503 | `delete_unavailable` | Deletion is briefly unavailable. The memory has **not** been deleted — retry. |

See the full [error reference](https://docs.memory.anonalabs.com/api-reference/errors.md).

## Next steps

> **[Retrieve API](https://docs.memory.anonalabs.com/api-reference/retrieve.md)**
>
> Query memories semantically.

> **[Reason API](https://docs.memory.anonalabs.com/api-reference/reason.md)**
>
> Synthesize an answer across memories.

> **[Documents](https://docs.memory.anonalabs.com/api-reference/documents.md)**
>
> Ingest files into the same space.

> **[Webhooks](https://docs.memory.anonalabs.com/api-reference/webhooks.md)**
>
> Get told when ingestion finishes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.