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

# Retrieve

> Rank a space's memories against a natural-language query, with type filters, tag filters, and latency modes.

Retrieve understands meaning rather than keywords. "Where does Alice work?", "Alice's
employer", and "What company is Alice at?" all surface the same memory. Your query is
embedded and compared against stored memory embeddings, then results are ranked by
`relevance_score`.

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

```json theme={null}
{
  "space_id": "customer-support-bot",
  "query": "Where does Alice work?",
  "top_k": 10,
  "memory_type": ["fact"],
  "tags": ["work"],
  "tags_match": "any",
  "mode": "accurate"
}
```

## Parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `space_id` | string | One of | Which space to search. Exactly one of `space_id` or `route` must be present. |
| `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` | string | One of | `"auto"`, to have Anona pick the space. Mutually exclusive with `space_id`. See [Let Anona choose the space](#let-anona-choose-the-space). |
| `fallback_space_id` | string | No | Which space to search when routing chooses none. Used only with `route`, and it has no default: leave it unset and an unroutable query returns no results and says why. |
| `max_targets` | integer | No | The most spaces a routed read may search, 1 to 20. Used only with `route` — sending it alongside `space_id` is `422 route_conflict`. A ceiling, not a count: only spaces likely to hold the answer are searched. Unset means your organization's setting. |
| `query` | string | Yes | Natural-language query. |
| `limit` | integer | No | Maximum results, 1 to 100. Defaults to 10. |
| `top_k` | integer | No | Alias for `limit`, 1 to 100. Takes precedence when both are sent. |
| `memory_type` | string\[] | No | Filter by type: `fact`, `note`, `experience`, or `summary`. |
| `user_id` | string | No | Only memories written under this end user. See [Scoping](#scoping-within-a-space). |
| `agent_id` | string | No | Only memories written under this agent. |
| `session_id` | string | No | Only memories written under this conversation or run. |
| `member_id` | string | No | Only memories this member of your team wrote; `me` for your own. Writers are recorded only in spaces shared across organizations, so in a personal space every memory is yours and `me` matches them all. Forces strict tag matching where it applies. |
| `tags` | string\[] | No | Return only memories carrying these tags. |
| `tags_match` | string | No | How `tags` combine: `any` (default), `all`, `any_strict`, `all_strict`, or `exact`. |
| `tag_groups` | object\[] | No | Boolean tag filter: OR, AND and NOT, nested. Use when `tags` plus one match mode cannot say what you mean. See [Compound tag filters](#compound-tag-filters). |
| `source` / `repo` / `pull_request` / `author` / `merged_by` | string / string / integer / string / string | No | Return 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. These are fields rather than tags because the values become reserved `anona:*` tags, which a caller may not name in a filter. Read them back in `provenance`. |
| `tag_groups[].resolve` | string | No | `exact` (default) or `fuzzy`. A fuzzy leaf also matches the tags your space already holds that resemble its candidates, so a half-remembered name still finds its memory. See [Matching a name you are not sure of](#matching-a-name-you-are-not-sure-of). |
| `min_score` | number | No | Drop results whose `relevance_score` falls below this floor. **Read the note under `relevance_score` before setting it** — the score is close to two-valued, so this behaves as a switch rather than a dial. |
| `expand_query` | boolean | No | For a question that joins two subjects ("how long between finishing the migration **and** starting the rewrite"), also retrieve each subject on its own and rank all the candidates together. Off by default. See [Multi-subject questions](#multi-subject-questions). |
| `prefer_observations` | boolean | No | When `true` (default), returns a distilled `note` in place of the raw facts behind it. Set `false` to get both. See [Memory types](#memory-types). |
| `as_of` | string | No | ISO 8601 instant. Point-in-time recall: drops every memory **recorded** after it, so you get what the space knew then. See [Searching over time](#searching-over-time). |
| `query_timestamp` | string | No | ISO 8601 instant that recency scoring and relative dates in the query ("last June") are measured against, instead of now. Re-ranks only, and never removes a result. |
| `occurred_after` | string | No | ISO 8601 instant. Keep only memories describing something that **happened** at or after it. See [Filtering on event time](#filtering-on-event-time). |
| `occurred_before` | string | No | ISO 8601 instant. Keep only memories describing something that **happened** at or before it. An event spanning either edge counts as inside. |
| `mode` | string | No | `accurate` (default) or `fast`. See [Latency modes](#latency-modes). |
| `format` | string | No | `results` (default) returns only the array. `block` also returns one prompt-ready string. See [Prompt-ready context](#prompt-ready-context). |
| `context_max_tokens` | integer | No | Approximate token ceiling for that block. |
| `block_order` | string | No | `relevance` (default) or `stable`. How that block is ordered. See [Keeping the block prompt-cache friendly](#keeping-the-block-prompt-cache-friendly). |
| `include_source` | boolean | No | `false` (default). When `true`, each result also carries `source`: the passage of original text the memory was extracted from. See [Source passages](#source-passages). |
| `source_max_tokens` | integer | No | Token budget for those passages across the whole response. 256 to 32768, default 8192. Only read when `include_source` is `true`. |
| `receipt` | boolean | No | When `true`, return this call's [context receipt](https://docs.memory.anonalabs.com/api-reference/context-receipts.md) id as `receipt_id`. A receipt is built and stored for **every** retrieve either way, so one is fetchable later for a call that never set this; the flag only saves you digging the request id out of your own logs. Defaults to `false`, in which case the response gains no new field at all. |
| `receipt_detail` | string | No | How deep that receipt goes. `"basic"` (default) records the cuts made after search returned. `"full"` also records the ones search made internally, so a memory that never made it out of ranking is accounted for rather than absent. `"full"` may cost some latency on the first call for a given query. See [Context Receipts](https://docs.memory.anonalabs.com/api-reference/context-receipts.md). |

## Response

```json theme={null}
{
  "results": [
    {
      "memory_id": "mem_abc123def456",
      "content": "Alice works at Google and specializes in distributed systems",
      "context": "onboarding call",
      "relevance_score": 1.0982,
      "memory_type": "fact",
      "entities": ["Alice", "Google"],
      "tags": ["team:core", "name:alice"],
      "occurred_start": "2024-01-15T10:30:00Z",
      "occurred_end": "2024-01-15T10:30:00Z",
      "timestamp": "2024-01-15T10:30:00Z",
      "metadata": {"user_id": "alice_123"},
      "document_id": "doc_9f2c1a",
      "source_ids": [],
      "created_at": "2026-07-14T12:34:56Z",
      "user_id": "alice_123",
      "agent_id": null,
      "session_id": null,
      "member_id": null
    },
    {
      "memory_id": "mem_ghi789jkl012",
      "content": "Alice leads the distributed systems work and is the person to ask about it",
      "context": null,
      "relevance_score": 0.00012,
      "memory_type": "note",
      "entities": ["Alice"],
      "occurred_start": null,
      "occurred_end": null,
      "timestamp": "2024-01-15T10:30:00Z",
      "metadata": {},
      "document_id": null,
      "source_ids": ["mem_abc123def456", "mem_2b8f55e310"],
      "created_at": "2026-07-14T12:40:02Z",
      "user_id": "alice_123",
      "agent_id": null,
      "session_id": null,
      "member_id": null
    }
  ],
  "usage": null,
  "context": null,
  "token_estimate": null
}
```

`usage` is always `null` on retrieve: a search reports no token counts, and it is
charged a flat rate rather than by tokens. `context` and `token_estimate` are filled only when you ask for
`"format": "block"` (see [Prompt-ready context](#prompt-ready-context)) and are `null` otherwise.

| Field | Type | Description |
| - | - | - |
| `memory_id` | string | Stable id for the memory. |
| `content` | string | The memory text. |
| `context` | string or null | What was recorded about the circumstance this memory came from. The same field you can set as `context` on [record](https://docs.memory.anonalabs.com/api-reference/memories.md). |
| `relevance_score` | number or null | Higher means more relevant, and it is **not a probability or a percentage**. `null` for results returned outside a ranked recall. See the note below before comparing or thresholding it. |
| `memory_type` | string | `fact`, `note`, `experience`, or `summary`. |
| `entities` | string\[] | The people, places, and things this memory is about. |
| `tags` | string\[] | The memory's own tags: the ones you wrote with it, and the ones a [label taxonomy](https://docs.memory.anonalabs.com/api-reference/extraction-settings.md#label-taxonomy) had extraction stamp. These are exactly what `tags` and `tag_groups` match on, so this is how you check what a filter will select. Scope is not among them: `user_id` and the fields below carry that. Empty when a memory carries none. |
| `occurred_start` | string or null | When the event began, read out of the memory's **own text**. `null` whenever that text names no date, which is the common case. See [Searching over time](#searching-over-time). |
| `occurred_end` | string or null | When the event ended. `null` under the same conditions. |
| `timestamp` | string or null | Event time as **you** expressed it: the `timestamp` you sent on [record](https://docs.memory.anonalabs.com/api-reference/memories.md), or the moment of the write if you sent none. This is the field to read for "when did this happen", and the one [`occurred_after` / `occurred_before`](#filtering-on-event-time) fall back to. |
| `metadata` | object | Whatever you attached at write time. |
| `document_id` | string or null | The document this memory was extracted from, under the same name [Documents](https://docs.memory.anonalabs.com/api-reference/documents.md) uses, so the two join directly. `null` for a memory not written from a file. |
| `source_ids` | string\[] | The memories this one was built from. Populated on a `note`, which is synthesized from raw facts; empty on a `fact`, which is the evidence itself. These are `memory_id` values, so a distilled answer can be cited back to its evidence. To read the sources themselves, set `prefer_observations: false` here (it returns the facts alongside their note) or [list the space](https://docs.memory.anonalabs.com/api-reference/memories.md) with `prefer_observations=false` and match on `id`. See [Memory types](#memory-types). |
| `source` | string or null | The passage of original text this memory was extracted from. Only set when the request had `include_source: true`. `null` otherwise, and when the passage fell outside `source_max_tokens`. A `note` carries the passage of the facts it was built from. See [Source passages](#source-passages). |
| `created_at` | string | When the memory was recorded. |
| `user_id` | string or null | End user this memory was written under. `null` if it was written without scoping. See [Scoping](#scoping-within-a-space). |
| `agent_id` | string or null | Agent it was written under. |
| `session_id` | string or null | Conversation or run it was written under. |
| `member_id` | string or null | Which member of your team wrote it. Only ever set in a space shared with another organization. |
| `provenance` | object or null | Where the memory came from, when a connector wrote it: `platform`, `repo`, `pull_request`, `author` and `merged_by` (both GitHub's numeric user id, not the login). `null` for anything written through the API. **Not** the `source` field above, which is the extracted passage. Select on these values with the `source` / `repo` / `pull_request` / `author` / `merged_by` request fields. |

> [!WARNING]
>
> **`relevance_score` ranks well and thresholds badly.** In the default
> `accurate` mode it comes from a cross-encoder whose output is close to
> two-valued in practice: a near-verbatim match scores around **1.1**, and
> everything else — including a correct answer phrased differently from the
> query — scores around **0.0001 or less**. Very little lands in between, so any
> floor between those two values keeps near-verbatim matches only.
>
> Ordering is still right. Asking "package manager preference" of a space holding
> "The user prefers pnpm over npm." returns that memory **first** — it just scores
> `0.00006`.
>
> So:
>
> * **Do not set `min_score` to a round number like `0.1` or `0.5`** expecting to
>   shave off the weakest results. Any floor in that range drops every
>   paraphrase match and keeps only near-verbatim ones. In the example above,
>   `min_score: 0.01` returns **nothing at all**.
> * **Do not compare scores across queries.** The scale moves with how the
>   question is worded, not only with how good the match is.
> * **Use `limit` to control how much comes back.** That is the dial; `min_score`
>   is closer to a switch for "near-verbatim only".
>
> In `mode: "fast"` the number means something different again: there is no
> cross-encoder, so the score is derived from a result's rank in the fusion
> ordering and is spread evenly down the page. It is a position, not a measure of
> how well the memory matches, so a floor there cuts by rank.

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "customer-support-bot",
    "query": "Where does Alice work?",
    "limit": 10
  }'
```

```python Python theme={null}
results = client.retrieve(
    space_id="customer-support-bot",
    query="Where does Alice work?",
    limit=10,
)

for r in results:
    print(r["content"], r["relevance_score"])
```

```typescript TypeScript theme={null}
const results = await anona.retrieve({
  spaceId: "customer-support-bot",
  query: "Where does Alice work?",
  limit: 10,
});

for (const r of results) {
  console.log(r.content, r.relevance_score);
}
```

Both SDKs return the `results` list directly, and an empty list when nothing matches.
Response fields keep the API's own `snake_case` names in TypeScript too, so a result is
`r.relevance_score` rather than `r.relevanceScore`.

## Let Anona choose the space

Send `route: "auto"` in place of `space_id` when you know the question but not
which space holds the answer. Anona picks one space, searches it, and reports
which one in `searched`. Exactly one of the two fields must be present: sending
both, or neither, is `422 route_conflict`.

```json Request theme={null}
{
  "route": "auto",
  "query": "why did the Q3 launch slip?"
}
```

```json Response theme={null}
{
  "results": [
    {
      "memory_id": "mem_abc123",
      "content": "The Q3 launch slipped to October because of the billing migration.",
      "relevance_score": 1.0974,
      "memory_type": "note"
    }
  ],
  "usage": null,
  "context": null,
  "token_estimate": null,
  "searched": [
    {
      "space_id": "product",
      "confidence": 0.86,
      "stage": "model",
      "reason": "scores: product=0.86, billing=0.09, __no_space__=0.03"
    }
  ]
}
```

`searched` carries one entry per space that was searched, best first.

| Field | Type | Description |
| - | - | - |
| `space_id` | string or null | The space that was searched. A space shared by another organization appears in its qualified `owner-slug:space-name` form. `null` means nothing was searched. |
| `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 `model` pick carries the per-candidate scores, at most five. Where a single space is chosen those scores are relative and include the abstain option under the key `__no_space__`; where several spaces may be searched they are independent per-space scores and there is no abstain key, because a space clearing no threshold is itself the abstention. A `rule` match names the field and operator that matched. `search_failed` means that space was attempted and did not contribute. Otherwise it names what stopped a space being chosen. |

> [!WARNING]
>
> **On a retrieve that named a `space_id`, `searched` is absent from the response
> body, not `null`.** The key does not appear at all, so a call that does not use
> routing is byte-for-byte what it always was. Check whether the key is present
> rather than comparing against `null`.

Read `searched`. A query answered from the wrong space looks exactly like a space
that holds nothing, and this is the only field that tells the two apart.

### Searching more than one space

Some questions are answered out of two spaces at once — "was Acme charged for a
bulk import we ran ourselves?" needs the charge and the account it landed on.
A routed read can search several spaces and return one ranked list.

How many it may search is capped, and the cap has three levels: `max_targets` on
the request, then your organization's setting, then the platform default. Each
level overrides the one after it.

> [!WARNING]
>
> **`max_targets` is a ceiling, not a count.** Raising it does not make every
> search wider: only spaces that look likely to hold the answer are searched at
> all, and a question that plainly belongs to one space still searches one. What
> the cap does is bound the worst case.
>
> **Each space searched is charged.** That is what makes this a cost control rather
> than a relevance setting — reach for it when a question spans spaces, not to
> improve results generally.

Results are ranked **across** the spaces, not concatenated, so the leading memory
need not come from the leading space in `searched`. A space that scored higher as
a *space* can still hold a less relevant memory than a space that scored lower.

If one space fails while others succeed, the read still returns what the others
found: the failed space keeps its `searched` entry, with `reason` set to
`search_failed`. A read whose only space fails is an error, because there is
nothing to return and an empty `200` would be indistinguishable from a space that
holds nothing.

Set `max_targets` to `1` to keep a routed read to a single space.

### When no space fits

An abstention is a `200` with an empty `results` array and one `searched` entry
whose `space_id` is `null`. The entry is present, rather than `searched` being
empty, because the reason has to be carried somewhere.

```json theme={null}
{
  "results": [],
  "usage": null,
  "context": null,
  "token_estimate": null,
  "searched": [
    {
      "space_id": null,
      "confidence": null,
      "stage": "model",
      "reason": "no_space_fits"
    }
  ]
}
```

`reason` is `no_space_fits` when the model reported that none of your spaces holds
the answer, and `low_confidence` when it picked one but not confidently enough to
act on.

> [!WARNING]
>
> **Your default space is deliberately not searched here.** On a write the default
> space is the sink and filing there is correct; on a read it is the bag of
> everything that fitted nowhere, which makes it the least topically coherent space
> you have and so the worst place to look for a specific answer. Irrelevant
> memories are worse than none. Widen deliberately instead: name a `space_id`, or
> pass `fallback_space_id`.

> [!NOTE]
>
> **An abstained query is charged.** A retrieve that names a space and matches
> nothing is charged today, and a routed retrieve that matches nothing is charged
> the same way. The `usage: null` above is not a sign that nothing was spent:
> retrieve is charged a flat rate rather than by tokens, so that field is null on
> every search, abstaining or not. The usage row groups under your organization's
> default space, or under `unrouted` if you have none set.

Four other reasons are not answers about your memories, and are refused rather
than reported as an empty result set. Without a `fallback_space_id` each is
`422 no_route_target`: `no_candidates` (no space of yours is eligible for
routing), `router_disabled` (routing is not switched on for this deployment),
`answer_outside_candidates` (the model named a space never offered to it) and
`model_unavailable` (the decision provider could not be reached). Sending a
`fallback_space_id` searches that space instead, with `stage: "fallback"` and the
reason carried along.

### Routing applies to retrieve only

`route` is accepted on `POST /v1/retrieve` and the MCP `retrieve` tool.
[`POST /v1/reason`](https://docs.memory.anonalabs.com/api-reference/reason) requires a `space_id`, and so do the
SDKs' `get_context` and `retrieve_receipt`, which are renderings of this endpoint
that do not take `route`. Those calls are addressed only.

Which spaces are eligible, and how to describe a space so it gets chosen, are in
[Automatic space routing](https://docs.memory.anonalabs.com/guides/automatic-routing.md). One difference from the
write side is worth knowing here: a shared space you hold as a `viewer` is
eligible for a routed *read*, because a viewer can read it, while a routed write
needs `developer`.

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "query": "why did the Q3 launch slip?", "route": "auto" }'
```

```python Python theme={null}
results = client.retrieve(query="why did the Q3 launch slip?", route="auto")
print(results.searched)
```

```typescript TypeScript theme={null}
const results = await anona.retrieve({
  query: "why did the Q3 launch slip?",
  route: "auto",
});
console.log(results.searched);
```

`retrieve` still returns a list, so nothing about existing code changes.
`searched` rides along as an attribute on it, and is `None` on an addressed read.

## Memory types

Every result carries a `memory_type`. The same content can exist at more than one level,
as raw evidence and as a distilled version of it, so filtering by type asks for exactly
the layer you want.

| Type | What it is |
| - | - |
| `fact` | Raw evidence: a single piece of information as it was recorded. Append-only. |
| `note` | A consolidated synthesis distilled from related facts, deduped and stated at a higher level. One note can stand in for several raw facts. |
| `experience` | An episodic memory: something that happened, from the recorder's point of view. |
| `summary` | A broad pattern learned across many memories, rather than a single event. |

## Multi-subject questions

A question that joins two subjects is matched as a single piece of text, so it
lands nearest whichever subject dominates the wording. Ask

> how many weeks between finishing the payments migration and starting the mobile rewrite

and the memories about the migration can crowd out every memory about the
rewrite — the answer is then built from half the evidence it needed, and nothing
in the response says so.

Set `expand_query: true` and each subject is also retrieved on its own:

```json theme={null}
{
  "space_id": "default",
  "query": "how many weeks between finishing the payments migration and starting the mobile rewrite",
  "expand_query": true
}
```

Everything else is unchanged — same `limit`, same token budget, same ranking —
so what moves is which memories are considered, not how many come back. The
response shape is identical, and results are still a single ranked list.

Worth knowing:

* **It only acts on questions that join subjects.** The connectives it splits on
  are "and", "versus"/"vs" and ", then". A single-subject question is retrieved
  exactly as it is today, so leaving the flag on costs nothing on questions it
  cannot help.
* **"or" is not a split.** Alternatives are usually two phrasings of one
  subject, so searching both finds the same memories twice.
* **It costs latency.** Each subject is a further search, so a question that
  splits is slower than one that does not. That is why it is off by default:
  turn it on for the questions that need it, or measure before turning it on
  everywhere.
* **At most four subjects** are retrieved separately; beyond that the extra
  searches stop paying for themselves.

By default (`prefer_observations: true`) a `note` is returned in place of the facts it
was built from, so you see one distilled result instead of the same content several
times. Set `prefer_observations: false` to receive the raw facts alongside their note.

A `note` names its evidence either way. Every result carries `source_ids`, the
`memory_id` of each memory it was synthesized from, so a distilled answer can still be
attributed, cited or audited back to the raw writes behind it. `source_ids` is empty on
a `fact`, which is evidence rather than a synthesis.

A note's own `metadata` is empty and its `document_id` is null, because both belong to
the facts it was built from rather than to the synthesis. To read them, set
`prefer_observations: false`: the raw facts come back alongside their note, each
carrying the metadata you wrote it with, and `source_ids` tells you which of them
belong to which note.

## Source passages

A memory is a summary of what was said. Set `include_source: true` to also get what was actually said: each result's `source` is the passage of the original write it came from. Use it to quote or cite a memory back to the user, or when an exact number, name or phrasing matters more than the summary.

```json theme={null}
{
  "space_id": "my-space",
  "query": "What is our holiday gift budget?",
  "include_source": true
}
```

```json theme={null}
{
  "memory_id": "mem_7c1e9a",
  "content": "Holiday gift budget is $450.",
  "memory_type": "fact",
  "source": "User: I've adjusted our holiday gift budget to $450, can you help me plan how to split it across the family?"
}
```

* **Off by default.** Without it, nothing changes except that `source` is `null`.
* **It costs context.** Passages run about 750 tokens each, and several memories often share one, so a response can roughly double in size. `source_max_tokens` caps the total.
* **A `note` carries its evidence's passage.** A note is a synthesis, so its `source` is the passage of the first fact it was built from. Add `prefer_observations: false` to get each of those facts with its own passage.
* **Results only.** The `format: "block"` context string does not include passages.
* **Redaction carries over.** A passage is cut from the text as it was stored, so redaction applied on write applies to it too.

## Entities and time

Each result carries structured signal beyond the raw text.

| Signal | Detail |
| - | - |
| **Entities** | Retrieval extracts the people, places, and things a memory is about and returns them in `entities`. Because memories are linked through shared entities, a query about one entity can surface related memories about the others it connects to. |
| **Event time** | `timestamp` carries when the event happened, separately from `created_at`, which is when you recorded it. `occurred_start` and `occurred_end` narrow that to a window when the memory's own text named a date. Ranking is recency-aware, so more recent and more time-relevant memories score higher. |

The same entities are exposed as a browsable graph. See [Graph](https://docs.memory.anonalabs.com/api-reference/graph.md).

## Searching over time

Every memory carries **two** independent times, and knowing which is which is
most of the work:

| | Set by | Returned as | Meaning |
| - | - | - | - |
| **Event time** | `timestamp` on [record](https://docs.memory.anonalabs.com/api-reference/memories.md) | `timestamp` | When the thing happened. |
| **Event window** | a date written in the memory's own text | `occurred_start` / `occurred_end` | The span that text described, when it described one. |
| **Record time** | the system, at write | `created_at` | When you told us about it. |

They differ whenever you import history: a memory about last June that you
upload today has an event time of last June and a record time of today.

> [!NOTE]
>
> The `timestamp` you send is returned as `timestamp`. It is **not** copied into
> `occurred_start`. Those two are filled only from a date found in the memory's
> own text, so this write:
>
> ```json theme={null}
> {"content": "Acme signed the contract with Globex.",
>  "timestamp": "2024-06-15T10:00:00Z"}
> ```
>
> comes back with `timestamp: "2024-06-15T10:00:00Z"` and **both `occurred_*`
> fields null**. That is not a gap in the data. `occurred_start: null` is the
> honest answer to "what event window did the text describe", and the window
> filter below falls back to `timestamp`, so this memory still matches a June 2024
> window. Read `timestamp` when you want to know when something happened; read
> `occurred_start` only when you specifically want a span the text spelled out.

`as_of` and `query_timestamp` are not variants of each other: one filters, the
other ranks, and both work on **record** time.

**`as_of` — point-in-time recall.** What did the space know on June 1st? Anything
recorded after that instant is dropped, however relevant.

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "support",
    "query": "what plan is this customer on?",
    "as_of": "2026-06-01T00:00:00Z"
  }'
```

```python Python theme={null}
results = client.retrieve(
    space_id="support",
    query="what plan is this customer on?",
    as_of="2026-06-01T00:00:00Z",
)
```

```typescript TypeScript theme={null}
const results = await anona.retrieve({
  spaceId: "support",
  query: "what plan is this customer on?",
  asOf: "2026-06-01T00:00:00Z",
});
```

**`query_timestamp` — move "now".** Rank as though it were June 1st, and read "last
quarter" in the query relative to that date. Nothing is removed.

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "support",
    "query": "what changed last quarter?",
    "query_timestamp": "2026-06-01T00:00:00Z"
  }'
```

```python Python theme={null}
results = client.retrieve(
    space_id="support",
    query="what changed last quarter?",
    query_timestamp="2026-06-01T00:00:00Z",
)
```

```typescript TypeScript theme={null}
const results = await anona.retrieve({
  spaceId: "support",
  query: "what changed last quarter?",
  queryTimestamp: "2026-06-01T00:00:00Z",
});
```

Use `as_of` to reproduce a past answer or audit a decision: "why did the agent
say that on June 1st". Use `query_timestamp` when the query itself contains a
relative date, or when you want older-but-more-time-relevant memories to rank
higher without excluding anything.

Both take an ISO 8601 instant. A malformed one is rejected as `422` before the
search runs, so a typo costs you nothing.

> [!WARNING]
>
> `as_of` filters on when a memory was **recorded**, not on when the event
> happened. A bulk import is recorded today no matter what `timestamp` each item
> carries, so `as_of` a year ago returns nothing from it. To filter on when the
> event happened, use `occurred_after` / `occurred_before` below.

> [!NOTE]
>
> Under `as_of`, retrieval skips its graph-expansion pass and answers from
> semantic, keyword and temporal retrieval only. Expansion follows entity links
> without a time bound, so it could pull in a memory recorded after the cutoff:
> a wrong answer for point-in-time recall rather than a ranking artifact. The
> practical effect is that an `as_of` search is slightly narrower than the same
> search without it.

### Filtering on event time

`occurred_after` and `occurred_before` bound **when the thing happened**, which
is what `as_of` cannot address: history you import today all shares one record
time and spans years of event time.

```bash cURL theme={null}
# Everything that happened in June 2024, however
# recently it was imported.
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "support",
    "query": "contract signings",
    "occurred_after": "2024-06-01T00:00:00Z",
    "occurred_before": "2024-06-30T23:59:59Z"
  }'
```

```python Python theme={null}
# Everything that happened in June 2024, however
# recently it was imported.
results = client.retrieve(
    space_id="support",
    query="contract signings",
    occurred_after="2024-06-01T00:00:00Z",
    occurred_before="2024-06-30T23:59:59Z",
)
```

```typescript TypeScript theme={null}
// Everything that happened in June 2024, however
// recently it was imported.
const results = await anona.retrieve({
  spaceId: "support",
  query: "contract signings",
  occurredAfter: "2024-06-01T00:00:00Z",
  occurredBefore: "2024-06-30T23:59:59Z",
});
```

Each bound is optional, so either one alone is an open-ended window. Both take
an ISO 8601 instant, and a malformed one is rejected as `422` before the search
runs. `occurred_after` later than `occurred_before` is rejected the same way
rather than answered with an empty `200`, which would send you looking for
missing memories instead of at your own arguments.

A memory is matched on the event window its text described, falling back to the
`timestamp` you recorded it with. That fallback is the important half: most
memories carry no `occurred_start` at all (see the note above), and they are
filtered correctly regardless. An event that spans either edge of your window
counts as inside it.

## Prompt-ready context

Most callers take the results array and join it into a system prompt. Ask for
`format: "block"` and that is done for you, including the token budget, which
a hand-written loop usually skips.

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "support",
    "query": "what plan are they on?",
    "format": "block",
    "context_max_tokens": 500
  }'
```

```python Python theme={null}
context = client.get_context(space_id="support", query=user_message)

messages = [
    {"role": "system", "content": context},
    {"role": "user", "content": user_message},
]
```

```typescript TypeScript theme={null}
const context = await anona.getContext({ spaceId: "support", query: userMessage });

const messages = [
  { role: "system", content: context },
  { role: "user", content: userMessage },
];
```

`get_context` / `getContext` return the rendered block as a plain string. Over the raw
API:

```json theme={null}
{
  "space_id": "support",
  "query": "what plan are they on?",
  "format": "block",
  "context_max_tokens": 500
}
```

```json theme={null}
{
  "context": "1. Customer is on the Scale plan\n2. Their team has four seats",
  "token_estimate": 18,
  "results": [ "…unchanged…" ],
  "usage": null
}
```

* `results` stays populated, so wanting both costs one round trip, not two.
* The block is numbered plain text with no header, so you compose your own
  prompt around it.
* `context_max_tokens` drops **whole memories**, lowest-ranked first. Text is
  never cut mid-sentence: half a fact still reads as a fact.
* `token_estimate` is an estimate, derived from a characters-per-token
  approximation rather than a tokenizer. Treat it as a guide when budgeting a
  context window, not an exact count.

Omitting `format` behaves exactly as before.

### Keeping the block prompt-cache friendly

Providers discount a prompt prefix they have already seen, and that discount ends at the
first byte that differs from last time. A block ordered by relevance reshuffles between
turns (relevance is a property of the query, not of the memory), so its first line
usually changes and the discount ends there.

`block_order: "stable"` orders by record time and bullets the lines instead of numbering
them:

```json theme={null}
{
  "space_id": "support",
  "query": "what plan are they on?",
  "format": "block",
  "block_order": "stable"
}
```

```json theme={null}
{
  "context": "- Their team has four seats\n- Customer is on the Scale plan",
  "token_estimate": 18,
  "results": [ "…unchanged…" ]
}
```

A newly recorded memory appends at the tail, so everything above it is unchanged, and a
memory dropping out of recall no longer renumbers the lines below it.

* It changes the rendered `context` **only**. `results` stays relevance-ordered, and no
  memory is added or removed.
* It protects the prefix, which means memories older than any that changed. If the
  recalled set changes completely, no ordering can keep the block the same.
* The proxy endpoints take the same knob, where it also moves the block after your system
  prompt and reports how much of it your provider had already seen. See
  [Prompt caching](https://docs.memory.anonalabs.com/api-reference/chat.md#prompt-caching).

## Compound tag filters

`tags` is a flat list under a single match mode, so it can say "any of these"
or "all of these" and nothing else. `tag_groups` takes a boolean expression
instead, which is what you need the moment a filter has two clauses that
combine differently, or a clause that excludes.

Groups in the list are AND-ed. Each group is either a leaf:

```json theme={null}
{ "tags": ["project:alpha"], "match": "any_strict" }
```

or one of `and`, `or`, `not`, nested however deep you need:

```json theme={null}
{
  "space_id": "customer-support-bot",
  "query": "what is left to do",
  "tag_groups": [
    { "or": [{ "tags": ["project:alpha"] }, { "tags": ["project:beta"] }] },
    { "not": { "tags": ["status:archived"] } }
  ]
}
```

That reads as: tagged `project:alpha` **or** `project:beta`, and **not**
tagged `status:archived`.

`match` on a leaf takes the same five values as `tags_match` and defaults to
`any_strict`. The two non-strict modes (`any`, `all`) treat an **untagged**
memory as matching, so a leaf written with `any` widens an otherwise narrow
expression rather than narrowing it.

> [!WARNING]
>
> For that reason `any` and `all` are **rejected inside a `not`**, with
> `422 validation_error`. Negating "matches, including untagged" excludes every
> untagged memory in the space, so `{"not": {"tags": ["status:archived"], "match":
>   "any"}}` reads as "exclude archived" and would actually mean "exclude archived
> and everything that carries no tags at all". Use `any_strict` or `all_strict`,
> or leave `match` unset.

The filter is compiled into the search itself, not applied to its output. An
excluded memory never competes for a slot in your results, so what you get back
is the best *n* matches that passed the filter rather than whatever survived it.

> [!NOTE]
>
> `tag_groups` composes with everything else rather than replacing it. `tags`,
> `user_id`, `agent_id` and `session_id` are all AND-ed onto your expression, so
> a scoped query stays scoped when you add a filter, and you can keep a flat
> `tags` list and add a single `not` beside it.

Limits: at most 25 leaves in total and 5 levels of nesting per request; at most
500 tags in any one leaf, and 1,000 across the whole expression. Every tag list
must be non-empty, as must every `and` / `or`.

A leaf naming hundreds of tags is a normal shape, not abuse. One leaf compiles
to a single set-containment test, so naming every candidate you might mean and
letting the filter decide which of them the space actually has is cheaper than
spreading the same tags over separate leaves. The flat `tags` field keeps its
own, much smaller limit of 50, because that one bounds what a single memory may
carry, which is a different question.

### Matching a name you are not sure of

Tags match as exact strings, which is right when your own code wrote them and
wrong when it did not. An agent building a filter out of something a user typed
has to guess the exact string a memory was stamped with, and one typo means the
memory is unreachable.

Set `resolve` to `"fuzzy"` on a leaf and its tags become candidates instead of
requirements. Each one is compared against the tags your space actually holds,
and the close ones are added to the filter:

```json theme={null}
{
  "space_id": "customer-support-bot",
  "query": "what did we decide about the checkout rewrite",
  "tag_groups": [
    { "tags": ["name:typsecript", "name:chekcout"], "resolve": "fuzzy" }
  ]
}
```

`name:typsecript` matches nothing on its own. Resolved, it also matches
`name:typescript`, because the two are 0.47 similar by trigram overlap and the
threshold is 0.45.

Five things are worth knowing before you rely on it:

> **It widens, it never narrows**
>
> A fuzzy leaf always keeps the tags you named and adds to them, so it matches at
> least everything the same leaf would have matched exactly. Turning it on cannot
> take a result away from you.

> **Resolution stays inside the tag's namespace**
>
> The part before the first colon has to match exactly, and only what follows it
> is compared. `name:chekcout` can reach `name:checkout` and can never reach
> `status:checkout`. Without that rule the shared `name:` prefix alone would make
> every name 0.38 similar to every other name, and the filter would stop
> narrowing anything.

> **Short candidates are matched literally**
>
> A candidate of fewer than four characters after its namespace is never resolved
> fuzzily, because trigram similarity on two or three characters is noise:
> `api` and `apis` score 0.5, and they are different things. Short names still
> match exactly, so `name:ts` finds `name:ts`.

> **A word of a longer tag is not a misspelling of it**
>
> A candidate has to be about as long as what it matches, within 30%. Without
> that, `name:pipeline` would resolve to `name:functional pipeline`: almost all of
> the shorter string is inside the longer one, so it scores as well as a real typo
> does. Resolution is for names typed imperfectly, not for partial name search.

> **It fixes misspellings, not abbreviations**
>
> `kubernetes` and `k8s` are 0.07 similar, so no threshold reaches one from the
> other. Abbreviations and alternative spellings come from the write side
> instead: a `multi-text`
> [label group](https://docs.memory.anonalabs.com/api-reference/extraction-settings.md#label-taxonomy) has extraction
> record every name a thing is known by, and then the exact filter finds them.

`resolve` may only be combined with `match` of `any` or `any_strict`, or left
unset. A resolved leaf is a set of alternatives, so `all_strict` would ask one
memory to carry every alternative at once, which matches nothing; that
combination is `422 validation_error` rather than a filter that silently
returns empty.

Each candidate contributes at most 10 resolved tags, and the vocabulary it is
resolved against is the 5,000 most used tags in the space. Resolution costs one
extra lookup on a request that uses it and nothing at all on a request that
does not.

The same filter is accepted by [`/v1/reason`](https://docs.memory.anonalabs.com/api-reference/reason), where it
narrows what the reasoning agent may look at, and by the `retrieve` tool on the
[MCP server](https://docs.memory.anonalabs.com/mcp-integration.md). The rules above apply identically on all three.

> [!TIP]
>
> A filter is only as good as the tags it has to work with. If you are tagging by
> hand on every write, a
> [label taxonomy](https://docs.memory.anonalabs.com/api-reference/extraction-settings.md#label-taxonomy) can have
> extraction do it for you: name a dimension once, and every memory is classified
> along it at write time and filterable here.

## Debugging what was cut

Every call already computes which results got dropped (by dedup, by
`min_score`, by `limit`, or by the token budget above), whether or not you ask
to see it. Pass `receipt: true` to get a fetchable id for that record. See
[Context Receipts](https://docs.memory.anonalabs.com/api-reference/context-receipts.md).

## Scoping within a space

A space is the coarse boundary. `user_id`, `agent_id` and `session_id` partition
it, so one space can serve many end users without their memories mixing, and you do
not need a space per user.

```bash cURL theme={null}
# Write under a user
curl -X POST https://api.anonalabs.com/v1/record \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "support",
    "content": "Prefers email over phone.",
    "user_id": "alice"
  }'

# Only Alice's memories come back
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "support",
    "query": "how should we contact them?",
    "user_id": "alice"
  }'
```

```python Python theme={null}
# Write under a user
client.record(
    space_id="support",
    content="Prefers email over phone.",
    user_id="alice",
)

# Only Alice's memories come back
results = client.retrieve(
    space_id="support",
    query="how should we contact them?",
    user_id="alice",
)
```

```typescript TypeScript theme={null}
// Write under a user
await anona.record({
  spaceId: "support",
  content: "Prefers email over phone.",
  userId: "alice",
});

// Only Alice's memories come back
const results = await anona.retrieve({
  spaceId: "support",
  query: "how should we contact them?",
  userId: "alice",
});
```

Scoping is **strict**, and that is the point:

* A scoped search returns only memories written under the *same* scope. Bob's
  memories can never appear in Alice's results.
* Memories stored **without** a scope are not returned to a scoped search
  either. If you turn scoping on for a space that already has history, that
  history stays visible to unscoped searches but not to scoped ones, so backfill
  the scope on those memories if you need them.
* Passing several keys ANDs them: `user_id` + `session_id` returns only that
  user's memories from that session.
* Consolidated memories are built **per user**, so a synthesis never mixes two
  users' facts. Sessions roll up into the user, which is what lets the memory
  improve across conversations.

Tags beginning with `anona:` are reserved for this and rejected with
`422 reserved_tag`, since otherwise a hand-written tag could impersonate another
user's scope. Use the scope fields instead.

> [!NOTE]
>
> Scoping costs nothing when unused. A request without these fields behaves
> exactly as it did before they existed.

## Latency modes

`mode` trades relevance quality against speed.

| Mode | Behavior | When to use |
| - | - | - |
| `accurate` | Neurally reranks candidates for best relevance. The default. | Most calls, where result quality matters most. |
| `fast` | Skips the neural rerank pass and ranks by retrieval fusion instead. | Latency-sensitive paths such as agent loops and autocomplete-style lookups, where slightly lower precision is an acceptable tradeoff. |

```bash cURL theme={null}
curl -X POST https://api.anonalabs.com/v1/retrieve \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "space_id": "customer-support-bot",
    "query": "Where does Alice work?",
    "mode": "fast"
  }'
```

```python Python theme={null}
results = client.retrieve(
    space_id="customer-support-bot",
    query="Where does Alice work?",
    mode="fast",
)
```

```typescript TypeScript theme={null}
const results = await anona.retrieve({
  spaceId: "customer-support-bot",
  query: "Where does Alice work?",
  mode: "fast",
});
```

## Error responses

| Status | Code | Cause |
| - | - | - |
| 400 | `bad_request` | Missing `query`. A body naming neither `space_id` nor `route` is `422 route_conflict` instead. |
| 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. |
| 404 | `space_not_found` | The space does not exist. |
| 422 | `reserved_tag` | A tag in `tags` or anywhere inside `tag_groups` starts with `anona:`, which is reserved for scoping. Use `user_id` / `agent_id` / `session_id`. |
| 422 | `route_conflict` | `space_id` and `route` were both sent, or neither was. Exactly one is required. |
| 422 | `no_route_target` | `route: "auto"` could not select a space for a reason that is not an answer about your memories, and no `fallback_space_id` was sent. See [When no space fits](#when-no-space-fits). |
| 422 | `validation_error` | `resolve: "fuzzy"` combined with `match` of `all`, `all_strict` or `exact`. A resolved leaf is a set of alternatives, so those modes match nothing. |
| 429 | `rate_limited` | Credit quota or rate limit exceeded. |

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

## Next steps

> **[Reason API](https://docs.memory.anonalabs.com/api-reference/reason.md)**
>
> Get a synthesized answer instead of a ranked list.

> **[Graph API](https://docs.memory.anonalabs.com/api-reference/graph.md)**
>
> Inspect the entities behind your results.

> **[Context Receipts](https://docs.memory.anonalabs.com/api-reference/context-receipts.md)**
>
> See exactly what was cut from a call, and why.


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