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

# Automatic Space Routing

> Write a memory without naming a space, and search without naming one. Anona picks the space, and tells you which one it picked.

Every write normally names its space. When the caller does not know which space a
memory belongs in, send `route: "auto"` instead of `space_id` and Anona chooses
one for you. A search can be routed the same way: see
[Searching without naming a space](#searching-without-naming-a-space), which is
the same machinery with one deliberate difference in what happens when nothing
fits.

```bash theme={null}
curl -X POST https://api.anonalabs.com/v1/record \
  -H "Authorization: Bearer anona_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "route": "auto",
    "content": "The Q3 launch slipped to October because of the billing migration."
  }'
```

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

Routing is opt in, per request. A write that names a `space_id` behaves exactly
as it always has: no routing runs, nothing extra is called, and `routed_to` is
`null`.

> [!NOTE]
>
> Automatic routing is available on `POST /v1/record`, `POST /v1/record/batch`,
> `POST /v1/retrieve`, and the MCP `record` and `retrieve` tools. The Python and
> JavaScript SDKs expose it on all three calls: `route="auto"` with
> `fallback_space_id` in Python, `route: "auto"` with `fallbackSpaceId` in
> JavaScript.

> [!NOTE]
>
> **If every routed write comes back with `reason: "router_disabled"`**, automatic
> routing is not enabled on the deployment you are calling. `route: "auto"` is
> still accepted and the write still succeeds, but the memory goes to your fallback
> space and nothing is sent to the decision provider. That reason means off, not
> broken, and needs no change on your side. A routed *read* on such a deployment is
> not so forgiving: with no `fallback_space_id` it is `422 no_route_target`, because
> a read has no default rung to fall to.

## Naming a target

Exactly one of `space_id` or `route` must be present.

| Field | Type | Description |
| - | - | - |
| `space_id` | string | The space to write to. Omit it when using `route`. |
| `route` | `"auto"` | Let Anona choose the space. Mutually exclusive with `space_id`. |
| `fallback_space_id` | string | Where the memory goes when nothing fits. Defaults to the organization's default space. |
| `max_targets` | integer | How many spaces one routed memory may be written to. Only `1` is accepted today. |

Sending both, or neither, is `422 route_conflict`. Sending `max_targets: 2` or
`3` is `422 validation_error`: the field is capped at `1`, and a value the API
would have to ignore is refused rather than accepted quietly.

## Reading `routed_to`

`POST /v1/record` returns a `routed_to` array whenever `route` was used. It is
`null` on a write that named its space.

| Field | Type | Description |
| - | - | - |
| `space_id` | string | The space the memory was written to. A shared space appears in its qualified `owner-slug:space-name` form. |
| `confidence` | number or null | The decision model's confidence. `null` for the `rule` and `fallback` stages, which do not produce one. |
| `stage` | string | Which stage of the cascade answered: `rule`, `model` or `fallback`. |
| `reason` | string or null | Why. A rule match names the rule; a fallback names why nothing else claimed the memory. |

There are three `stage` values and no others:

| `stage` | Meaning |
| - | - |
| `rule` | One of your declared rules 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 the fallback space. |

`reason` says why that stage answered. A `rule` match names the rule that
matched, a confident model pick carries `null`, and a fallback names what stopped
anything else from claiming the memory:

| `reason` | Stage | What happened |
| - | - | - |
| `rule: <field> <op>` | `rule` | A declared rule matched. The value names which one. |
| `null` | `model` | The model picked this space and cleared the confidence threshold. |
| `no_candidates` | `fallback` | You have no space eligible to be routed to yet. Your default space is never a candidate, so an account whose only space is the default always lands here. No model is called. |
| `router_disabled` | `fallback` | Automatic routing is not switched on for this deployment. See the note at the top. |
| `no_space_fits` | `fallback` | The model was asked and answered that the memory does not belong in any of your spaces. |
| `low_confidence` | `fallback` | The model picked a space but was not confident enough to act on it. |
| `answer_outside_candidates` | `fallback` | The model named a space that was not offered to it. The answer is discarded rather than acted on, so a wrong name can never create a space. |
| `model_unavailable` | `fallback` | The decision provider could not be reached. The write is never held up for it. |

Every one of these still stores the memory. A fallback changes *where* it lands,
never *whether* it lands.

`routed_to` is the only way to notice a misroute. A memory filed into the wrong
space looks exactly like a correct write from every other angle, so log it, or
surface it, on any path where routing matters.

> [!WARNING]
>
> `POST /v1/record/batch` routes the whole batch as one unit and files every item
> together, but its response carries `job_id`, `job_ids`, `status` and `accepted`
> only. There is no `routed_to` on a batch response. Items cannot be routed
> individually.

## The cascade

Routing runs three stages in order, and stops at the first that answers.

**1. Declared rules**

Your own rules are evaluated first, over your candidate spaces in
deterministic order by space id. The first match wins. This costs nothing
and calls no model.

**2. The decision model**

The memory text and each candidate space's description are sent to a
decision model, which picks one space or abstains. An abstention, or an
answer below the confidence threshold, falls through. See
[Subprocessors](https://docs.memory.anonalabs.com/legal/subprocessors.md) for what leaves Anona on this step.

**3. Fallback**

`fallback_space_id` if you sent one, then your organization's
`default_space_id`, then a space literally named `default` if you have one.
If none of those exist, the write is refused with `422 no_route_target`.

A memory that lands on the fallback stage is stored with
`"anona_routed": "fallback"` in its `metadata`, so you can list what needs
filing by hand later. That mark is only trustworthy because you cannot write it
yourself: metadata keys beginning with `anona_` are reserved and rejected with
`422 reserved_metadata_key`.

If you have no candidate spaces at all, no model is called and the memory goes
straight to the fallback. An organization whose only space is its default is in
exactly that position, and routing costs it nothing.

## Which spaces can be chosen

A space is a candidate when all of the following hold:

* It has a routing profile with `auto_route_enabled` set (the default when a
  profile exists).
* Its rendered description is not empty. A space with nothing to say about
  itself does not compete.
* It is **not** your organization's default space. The default is the sink that
  receives everything unclaimed, so it is never also a target.

Spaces shared with you by another organization are candidates only when the
owner set `shared_auto_route` on them, and your membership role has to allow
what is being routed:

| Direction | Roles that make a shared space a candidate |
| - | - |
| A routed write | `developer` only. A read-only member can never be routed a write, for the same reason they cannot write by hand. |
| A routed search | `developer` or `viewer`. A viewer can read the space by name, so a routed query can reach it too. |

That is the only difference in candidacy between the two directions. Everything
else on this list, including the exclusion of your default space, applies to
both.

The router never creates a space. Every answer, whether from a rule or from the
model, is checked against the candidate list that was sent before anything is
written, so a name nobody offered is discarded rather than acted on.

## What makes routing work well

The description a space is judged on is built primarily from terms derived from
the space's own contents. Anona reads the entity graph it already builds on
every write, refreshes each space's terms in the background, and adds the terms
it sees on writes (tag names, `agent_id` values and the top level keys of
`metadata`, never memory content and never `user_id` or `session_id` values).

A charter you write **adds to** those terms, it does not replace them. The
rendered description is your charter, then the terms. This is deliberate: a
short charter on its own ("eng", "ops", "money") describes a space far worse
than its own contents do, so nothing you write can displace what was derived.

Practical consequences:

* **A new, empty space is not routable yet.** It has no contents to derive terms
  from. Write to it by name a few times, or give it a charter and topics, and it
  joins the candidate set.
* **Write a charter in sentences.** It prefixes the terms, so a sentence about
  what the space is for adds information the terms cannot carry.
* **Use rules for the decisions that are not judgement calls.** Anything keyed
  on `agent_id`, `user_id`, `session_id` or a tag is better as a rule: it is
  free, instant and exact.

Fan-out to several spaces, an inverted index over your memories, and a tool for
calibrating the confidence threshold do not exist. `max_targets` is the field
where fan-out would live, which is why it is capped at `1` today rather than
absent.

## Searching without naming a space

`POST /v1/retrieve` takes the same pair of fields. Send `route: "auto"` in place
of `space_id` and Anona picks the space the question is most likely answered
from, searches that one space, and reports which one it was.

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

```json theme={null}
{
  "results": [
    {
      "memory_id": "mem_abc123",
      "content": "The Q3 launch slipped to October because of the billing migration.",
      "relevance_score": 0.91,
      "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"
    }
  ]
}
```

Exactly one of `space_id` or `route` must be present, as on a write. Both, or
neither, is `422 route_conflict`.

A routed read searches the spaces that look likely to hold the answer — usually
one, sometimes two. `searched` carries an entry per space, best first, and
`max_targets` caps how many there may be.

> [!WARNING]
>
> **`max_targets` is a ceiling, not a count, and each space searched is charged.**
> Raising it does not widen every search: a question that plainly belongs to one
> space still searches one. What it bounds is the worst case. Set it to `1` to keep
> a routed read to a single space, or set your organization's default in the
> dashboard under **Profile → Automatic search**.

Results are ranked across the spaces rather than concatenated, so the leading
memory need not come from the leading space — a space that scored higher as a
*space* can hold a less relevant memory than one that scored lower.

### Reading `searched`

| Field | Type | Description |
| - | - | - |
| `space_id` | string or null | The space that was searched. A shared space appears in its qualified `owner-slug:space-name` form. `null` means nothing was searched at all: see [When no space fits](#when-no-space-fits). |
| `confidence` | number or null | The decision model's confidence. `null` on the `rule` and `fallback` stages, which do not produce one. |
| `stage` | string | Which stage answered: `rule`, `model` or `fallback`. There is no fourth value. |
| `reason` | string or null | Why. On a `model` stage that chose a space this carries the per-candidate scores; `search_failed` means the space was attempted and did not contribute; on the other stages it names the rule, or what stopped a space being chosen. |

> [!WARNING]
>
> **`searched` is absent on a retrieve that named a `space_id`. It is not `null`.**
> The key does not appear in the response body at all, so a default retrieve is
> byte-for-byte what it always was. Test for the key's presence rather than for a
> null value.

`searched` is the only way to notice that a query was answered from the wrong
space. A wrong-space answer is indistinguishable from a space that simply holds
nothing, so read it, or log it, wherever routed reads matter.

The three stages mean the same thing here as on a write:

| `stage` | Meaning |
| - | - |
| `rule` | One of your declared rules matched. No model was called. |
| `model` | The decision model picked this space and cleared the read confidence floor. Also the stage reported when nothing was chosen at all. |
| `fallback` | Nothing was chosen, so the `fallback_space_id` you sent was searched instead. |

Not every rule can match a query. A rule's `field` is matched against the
request, and a retrieve request has `tags`, `user_id`, `agent_id` and
`session_id` but no `content`, so a rule keyed on `content` never fires on a
read. The query text is not a rule field; it goes to the model.

### Every `reason` a routed read can report

When a space *was* searched:

| `reason` | Stage | What happened |
| - | - | - |
| `rule: <field> <op>` | `rule` | A declared rule matched. The value names which field and operator did it. |
| `scores: <space>=<n>, ...` | `model` | The model chose this space. The value lists the highest-scoring candidates, at most five, including the abstain option under the key `__no_space__` so you can see how close the call was. |
| `null` | `model` | The model chose this space but returned no per-candidate scores. |

When no space was chosen, `reason` carries one of six values. What happens next
depends on whether you sent a `fallback_space_id`:

| `reason` | Without `fallback_space_id` | With `fallback_space_id` |
| - | - | - |
| `no_space_fits` | Empty `results`, one `searched` entry with `space_id: null`, `stage: "model"` | The fallback space is searched, `stage: "fallback"` |
| `low_confidence` | Empty `results`, one `searched` entry with `space_id: null`, `stage: "model"` | The fallback space is searched, `stage: "fallback"` |
| `no_candidates` | `422 no_route_target` | The fallback space is searched, `stage: "fallback"` |
| `router_disabled` | `422 no_route_target` | The fallback space is searched, `stage: "fallback"` |
| `answer_outside_candidates` | `422 no_route_target` | The fallback space is searched, `stage: "fallback"` |
| `model_unavailable` | `422 no_route_target` | The fallback space is searched, `stage: "fallback"` |

The split is deliberate. `no_space_fits` and `low_confidence` are *answers*: the
model looked at your spaces and reported that none of them holds this. The other
four are not answers about your memories at all. `no_candidates` means no space
of yours is eligible to be routed to, `router_disabled` means routing is not
switched on for the deployment you are calling, `answer_outside_candidates` means
the model named a space that was never offered to it, and `model_unavailable`
means the decision provider could not be reached. Reporting any of those as "no
memories match" would describe a configuration or availability problem as your
data, so they are refused instead.

### When no space fits

An abstention comes back as a `200` with an empty `results` array and exactly one
`searched` entry whose `space_id` is `null`:

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

The entry is still there, rather than `searched` being an empty array, because
the reason has to be carried somewhere.

> [!WARNING]
>
> **An abstaining read deliberately does not search your default space.** This is
> the one place read routing differs from write routing, and it is easy to mistake
> for the feature being broken.
>
> On a write, your default space is the right answer when nothing fits: it is the
> sink, so filing there is correct by definition. On a read it is the opposite. It
> is the bag of everything that fitted nowhere, which makes it the least
> topically coherent space you have and therefore the worst place to look for an
> answer to a specific question. Irrelevant memories are worse than none, so
> Anona returns none and tells you why.
>
> Widen deliberately when you want that behaviour: name a space with `space_id`,
> or pass `fallback_space_id`.

### `fallback_space_id` on a read

`fallback_space_id` is where a routed read looks when nothing was chosen. On a
read it has **no default at all**, for the reason in the warning above: on a
write the ladder continues to your organization's default space and then to a
space named `default`, and on a read neither of those rungs exists. Leave it
unset and an abstention honestly returns nothing.

Sending one turns every row of the six-reason table into a search of that space,
including the four that would otherwise be `422 no_route_target`.

> [!NOTE]
>
> **An abstained query is still 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, for consistency rather than for revenue. The usage row is
> grouped under your organization's default space so it stays attributable, or
> under `unrouted` for an organization that has no default space set.

### Only retrieve is routable

Read routing exists on `POST /v1/retrieve` and the MCP `retrieve` tool, and
nowhere else. `POST /v1/reason` requires a `space_id`, as do the SDKs'
`get_context` and `retrieve_receipt`, which are renderings of retrieve that do
not take `route`. Those calls are addressed only: there is no `route` field to
send, and sending one is a validation error.

### From the SDKs

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

for entry in results.searched or []:
    if entry["space_id"] is None:
        print("nothing searched:", entry["reason"])
    else:
        print("searched", entry["space_id"], entry["stage"])
```

`retrieve` still returns a list, so existing code is unaffected. `searched` rides
on that list as an attribute, and is `None` on an addressed read.

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

In TypeScript, `{ spaceId }` and `{ route: "auto", fallbackSpaceId }` are two
arms of a union, so sending both or neither is a compile error as well as a
runtime one.

### From MCP

The `retrieve` tool takes `route` and `fallback_space_id` too, and this is where
they matter most: a model holding the tool sees a list of space ids and none of
the reasoning behind them, so "I do not know which space holds this" is its
normal state.

A routed call prefixes its reply with the space that was chosen and the stage
that chose it, so the model can tell a wrong-space answer from a right one:

```
Searched space 'product' (chosen automatically, model).
```

An addressed call carries no such line, so an agent parsing today's replies sees
nothing new. When nothing was chosen, the tool answers in prose instead of
returning memories: it says that no space was searched, why, and that naming a
space with `space_id` or passing `fallback_space_id` will search one anyway.

## Routing settings

Each space has an optional routing profile.

```http theme={null}
GET    /v1/spaces/{space_id}/routing-settings
PUT    /v1/spaces/{space_id}/routing-settings
DELETE /v1/spaces/{space_id}/routing-settings
```

Authenticate with an API key or a dashboard session. You must be the **owner**
of the space: a shared space's visitor cannot change how it is routed into.
These endpoints are configuration, not metered work, so they cost no credits and
keep working when an organization is out of credits, which is what lets you turn
routing off at any time.

`GET` on a space that has never been configured returns the defaults rather than
a `404`. `PUT` additionally checks that the space really exists, so it needs an
active API key on the organization and answers `404 space_not_found` for a
misspelled id instead of storing a profile nothing would ever read.

### The settable fields

| Field | Type | Default | Description |
| - | - | - | - |
| `charter` | string or null | `null` | Prose describing what the space is for. Up to 2,000 characters. |
| `topics` | string\[] or null | `null` | Topic labels for the space. Up to 50. |
| `rules` | object\[] or null | `null` | Declared routing rules. Up to 100. |
| `auto_route_enabled` | boolean | `true` | Whether this space takes part in routing at all. |
| `shared_auto_route` | boolean | `false` | Whether members from other organizations may be routed into this space. |

> [!WARNING]
>
> `PUT` is a **full replace**, not a patch. A field you omit is cleared, and an
> omitted flag returns to its default. Send the complete state you want.

### Rules

```json theme={null}
{
  "charter": "Production incidents, postmortems and on-call handovers.",
  "topics": ["incident", "postmortem", "on-call"],
  "rules": [
    { "field": "agent_id", "op": "equals", "value": "pagerduty-bot", "space_id": "incidents" },
    { "field": "tags", "op": "in", "value": ["sev1", "sev2"], "space_id": "incidents" }
  ]
}
```

| Rule field | Values |
| - | - |
| `field` | `agent_id`, `user_id`, `session_id`, `tags`, `content` |
| `op` | `equals`, `contains`, `prefix`, `in` |
| `value` | A string, or an array of strings |
| `space_id` | The space to route to when the rule matches |

Matching is case insensitive. `in` tests membership of the array in `value`; the
other three operators given an array match if any element does. Regular
expressions are not an available operator.

A rule whose `space_id` names a space that is not a candidate is skipped, not
obeyed. That includes your default space, which is never a routing target.

### The derived fields

`GET` and `PUT` also return the server-owned half of the profile. These are read
only: they are absent from the request body, and sending one is rejected.

| Field | Description |
| - | - |
| `observed_terms` | Terms seen on writes to this space, with their counts. |
| `inferred_terms` | Terms derived from the space's entity graph. |
| `inferred_refreshed_at` | When those were last refreshed. |
| `rendered_criteria` | The exact description the router judges this space on. |

`rendered_criteria` is worth reading. It is what the decision model actually
sees, so it is the thing to look at when a space is being chosen too often or
never at all.

`DELETE` removes the profile entirely, which takes the space out of routing
until it is configured again. Deleting a profile that does not exist is still a
`204`.

## Setting a default space

The fallback ladder's second rung is your organization's default space.

```bash theme={null}
curl -X PATCH https://api.anonalabs.com/v1/orgs/me \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"default_space_id": "inbox"}'
```

The id is checked against your own spaces, so a typo is a `404 space_not_found`
rather than a stored id that nothing can serve. Owners and admins only.

## Errors

| Status | Code | Meaning |
| - | - | - |
| 422 | `route_conflict` | Both `space_id` and `route` were sent, or neither was. On `/v1/record`, `/v1/record/batch` and `/v1/retrieve`. |
| 422 | `no_route_target` | On a write: nowhere to put the memory, because no candidate claimed it and there is no `fallback_space_id`, no organization default and no space named `default`. On a read: no space could be selected for a reason that is not an answer about your memories (see the six-reason table above), and no `fallback_space_id` was sent. |
| 422 | `reserved_metadata_key` | A `metadata` key begins with the reserved `anona_` prefix. |
| 422 | `validation_error` | `max_targets` was not `1`, or another field failed schema validation. |

Errors use the standard envelope, with lowercase snake\_case codes:

```json theme={null}
{
  "error": {
    "code": "route_conflict",
    "message": "..."
  }
}
```

Branch on `code`. `message` is written for a human and its wording may change.

Routing itself never breaks a write. A dead rule, an unreachable decision model
or an unreadable answer all fall through to the fallback. `no_route_target` is
the one exception, and it means there is genuinely nowhere to put the memory.

The same holds for a read: a failure inside routing cannot break a retrieve that
would otherwise have worked. It falls through the same ladder, which ends in
`fallback_space_id` if you sent one, and otherwise in either empty results with a
reason or `422 no_route_target`, depending on which reason it was.


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