> ## Documentation Index
> Fetch the complete documentation index at: https://docs.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. Anona files it into the space it belongs in, or into your default when nothing fits.

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.

```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` and
  the MCP `record` tool. The Python and JavaScript SDKs do not expose it yet, so
  call the endpoint directly for now.
</Note>

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

`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.
</Warning>

## The cascade

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="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](/legal/subprocessors) for what leaves Anona on this step.
  </Step>

  <Step title="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`.
  </Step>
</Steps>

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 is
`developer`. A read-only member can never be routed into, for the same reason
they cannot write by hand.

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.

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

### 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.                                                                                                       |
| 422    | `no_route_target`       | Routing found nowhere to put the memory: no candidate claimed it, and there is no `fallback_space_id`, no organization default and no space named `default`. |
| 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.
