Metadata-Version: 2.4
Name: anona
Version: 0.20.3
Summary: Anona Memory SDK — managed AI memory for intelligent agents
Author: Anona Labs
License: MIT
Project-URL: Homepage, https://github.com/anonalabs/Anona-Memory-SDK
Project-URL: Repository, https://github.com/anonalabs/Anona-Memory-SDK
Keywords: ai,memory,agents,llm,rag,mcp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.24
Provides-Extra: litellm
Requires-Dist: litellm>=1.0; extra == "litellm"
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.2; extra == "mcp"
Provides-Extra: langchain
Requires-Dist: langchain>=1.3; extra == "langchain"
Requires-Dist: langchain-core>=1.4; extra == "langchain"
Provides-Extra: crewai
Requires-Dist: crewai>=1.0; extra == "crewai"
Provides-Extra: llamaindex
Requires-Dist: llama-index-core>=0.14; extra == "llamaindex"
Requires-Dist: llama-index-workflows!=2.24.0; extra == "llamaindex"
Provides-Extra: adk
Requires-Dist: google-adk>=1.21; extra == "adk"
Provides-Extra: msagent
Requires-Dist: agent-framework-core>=1.0; extra == "msagent"
Provides-Extra: strands
Requires-Dist: strands-agents>=1.0; extra == "strands"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: respx; extra == "dev"
Requires-Dist: anyio; extra == "dev"
Requires-Dist: anona[mcp]; extra == "dev"
Dynamic: license-file

# Anona Memory SDK

[![PyPI](https://img.shields.io/pypi/v/anona)](https://pypi.org/project/anona/)
[![Python](https://img.shields.io/pypi/pyversions/anona)](https://pypi.org/project/anona/)
[![npm](https://img.shields.io/npm/v/@anona-labs/memory)](https://www.npmjs.com/package/@anona-labs/memory)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Official SDKs for [Anona Memory](https://memory.anonalabs.com), managed memory
for AI agents. Record, retrieve and reason over memories scoped per user, agent
and space: from a client, a framework adapter, an MCP server, or the `anona`
command.

**Documentation** — [Python SDK](https://docs.anonalabs.com/sdk/python) ·
[TypeScript SDK](https://docs.anonalabs.com/sdk/javascript) ·
[Quickstart](https://docs.anonalabs.com/quickstart) ·
[API reference](https://docs.anonalabs.com/api-reference/overview) ·
[MCP server](https://docs.anonalabs.com/mcp-integration) ·
[Framework integrations](https://docs.anonalabs.com/integrations/langchain)

| SDK | Package | Location |
| --- | --- | --- |
| Python | [`anona`](https://pypi.org/project/anona/) on PyPI | this repository root, documented below |
| TypeScript | [`@anona-labs/memory`](https://www.npmjs.com/package/@anona-labs/memory) on npm | [`typescript/`](typescript/) |

## Contents

- [Install](#install)
- [Quickstart](#quickstart)
- **Guides** — [Automatic space routing](#automatic-space-routing) ·
  [User profiles](#user-profiles) · [Time travel](#time-travel) ·
  [Compound tag filters](#compound-tag-filters) ·
  [Extraction settings](#extraction-settings) ·
  [Rules](#rules-a-space-must-follow)
- **Integrations** — [Framework adapters](#framework-adapters) ·
  [LiteLLM](#litellm-integration) · [The `anona` command](#the-anona-command) ·
  [MCP server](#mcp-server) · [Agent Skills](#agent-skills)
- **Reference** — [API](#api-reference) ·
  [Errors, retries and rate limits](#errors-retries-and-rate-limits) ·
  [Requirements](#requirements)
- [TypeScript](#typescript)

## Install

```bash
pip install anona
```

With the LiteLLM integration (or `mcp` for the MCP server):

```bash
pip install "anona[litellm]"
```

## Quickstart

```python
from anona import AnonaClient

# base_url defaults to https://api.anonalabs.com — pass it only to override.
client = AnonaClient(api_key="anona_live_...")

# Record a memory
client.record(space_id="space_123", content="User prefers dark mode.")

# Retrieve memories
results = client.retrieve(space_id="space_123", query="UI preferences", limit=5)
for r in results:
    print(r["relevance_score"], r["content"])

# Reason: a synthesized insight across memories
summary = client.reason(space_id="space_123", query="What do we know about this user?")
print(summary)

client.close()
```

### Async ingestion (don't block on a write)

Recording runs fact extraction, so a normal `record()` takes a moment. In a
chat loop or any latency-sensitive path, queue the write with `background=True`
and poll the returned job instead:

```python
import time

job = client.record(
    space_id="space_123",
    content="User prefers dark mode.",
    background=True,        # returns a job_id, doesn't wait
)

while True:
    status = client.get_job(space_id="space_123", job_id=job["job_id"])
    if status["status"] in ("completed", "failed", "cancelled", "not_found"):
        break
    time.sleep(2)

# Backfill many memories at once (always queued, up to 100 per call):
batch = client.record_batch(
    space_id="space_123",
    items=[
        {"content": "User is on the Pro plan."},
        {"content": "Signed up in 2024.", "timestamp": "2024-03-01T00:00:00Z"},
    ],
)
print(batch["accepted"], "queued as job", batch["job_id"])
```

Async variants (`async_record`, `async_retrieve`, `async_reason`) are available on the same client, or use it as a context manager:

```python
async with AnonaClient(api_key="...") as client:
    await client.async_record(space_id="space_123", content="...")
```

## Automatic space routing

Naming a space is the normal way to work and nothing here changes it. But an
agent that files everything it learns has to decide where each thing belongs,
and `route="auto"` hands that decision to Anona, which makes it against what
each space declares it is for.

```python
result = client.record(content="Acme is on net-30.", route="auto")
for target in result["routed_to"]:
    print(target["space_id"], target["stage"], target["reason"])

memories = client.retrieve(query="what are Acme's payment terms?", route="auto")
for looked in memories.searched:
    print(looked["space_id"], looked["stage"], looked["reason"])
```

Pass **exactly one** of `space_id` or `route="auto"`. Both, or neither, raises
`AnonaError` with `code="route_conflict"` — client-side, before the request goes
out. "Neither" is an error rather than an implicit auto-route on purpose: a
caller who simply forgot `space_id` should not have their memory filed, or their
question answered, somewhere they never named.

**Read the decision.** A routed write answers with `routed_to` and a routed read
with `searched`, each a list of `{space_id, confidence, stage, reason}`, where
`stage` is `"rule"` (a rule you declared), `"model"` or `"fallback"`.
`confidence` is null on a rule or a fallback — neither is a judgement with a
score behind it. Nothing else tells a misroute apart from a correct call: both
succeed.

`retrieve` still returns a plain list — `searched` rides on it as an attribute,
so every existing call site is untouched — and that attribute is `None` on an
addressed read, because there was no decision to report.

**An abstention searches nothing.** If no space is judged to answer the
question, `searched` is a single entry whose `space_id` is `None`, and the
results are empty. That is deliberate rather than a gap: `fallback_space_id` has
no default on a read, because the fallback space is where everything that fitted
nowhere ends up — the least topically coherent space you own, and so the worst
place to look. Searching it would return irrelevant memories that read exactly
like real ones. On a *write* the fallback does default (to your organization's
default space), because a memory has to be stored somewhere.

`fallback_space_id` names the space either side falls back to.

**`max_targets` means something different on each side.** On a write it is how
many spaces one memory may reach, and it is capped at 1 server-side today, so 2
or more is a 422 rather than a fan-out — a memory is filed in one place. On a
**read** it accepts 1 to 20, because a question can legitimately be answered out
of several spaces: "was Acme charged for a bulk import we ran ourselves?" needs
the charge and the account it landed on.

> [!IMPORTANT]
> **A routed read can cost more credits than one that names its space.** Each
> space searched is charged as its own search, so a question that genuinely spans
> two spaces costs two.
>
> It is a **ceiling, not a count** — only spaces that look likely to hold the
> answer are searched, so raising `max_targets` does not make every search wider,
> and most routed reads look in one space and cost exactly what naming it would.
> But the cost is yours to bound: pass `max_targets=1` to guarantee a routed read
> never costs more than an addressed one, or set an organization-wide default
> with `PATCH /v1/orgs/me` (`route_max_targets`).
>
> `searched` lists every space that was searched, so the charge is always
> reconstructible from the response you already hold. Treat `max_targets` as a
> cost control, not a way to get better results.

When more than one space is searched the results are ranked **across** them
rather than concatenated, so the leading memory need not come from the leading
space in `searched`.

Routing matches against each space's routing profile — a charter, topics and
rules, declared per space on `/v1/spaces/{space_id}/routing-settings`. That
endpoint is REST-only for now; the SDK does not model it yet.
[`examples/space_routing.py`](examples/space_routing.py) shows the whole loop.

## User profiles

If you scope writes with `user_id`, a space accumulates a per-user history with
nowhere to address it directly. These two read it back, instead of you
reconstructing it from a wide-open search.

```python
profile = client.get_user_profile("support", "alice_123")
print(profile["memory_count"], profile["last_active"])

# Prompt-ready instead of a list
block = client.get_user_profile(
    "support", "alice_123", format="block", context_max_tokens=500
)
messages = [{"role": "system", "content": block["context"]}, ...]

answer = client.ask_about_user(
    "support", "alice_123", "How does she prefer to be contacted?"
)
print(answer["insights"], answer["model"])
```

`user_id` has to be the **same value** your writes are scoped with — a typo on
either side looks like an empty profile, not an error.

Three things worth knowing:

- **An unknown user is not a 404.** A `user_id` is a scope tag created by the
  first write naming it, not a resource you register, so there is no valid set
  for a typo to fall outside of. A user nobody has recorded under comes back
  with `memory_count` of `0` and an empty `memories` list. An unknown *space* is
  still a 404.
- **`memory_count` can go down.** The default view collapses layers: several raw
  facts become one synthesised note, and the note is what gets counted. A
  profile read during an import can genuinely go 115 → 67 → 15 while the corpus
  behind it grows the whole time. Read it as how many distinct things are
  currently known about this user, never as an ingestion counter, and never as
  the basis for a progress bar.
- **`ask_about_user` returns the whole response**, not the answer string
  `reason` returns, because `model` reports which LLM actually answered. That is
  the rate the call was charged at, and it differs from what you asked for
  whenever you asked for nothing.

## Time travel

Three arguments, and the distinction between them is the whole point: one is
about **when something happened**, the other two are about **what you knew and
when**.

```python
# Importing history: date the memory to the event, not to the import run.
client.record(
    space_id="support",
    content="Renewed the enterprise contract",
    timestamp="2025-06-14T10:00:00Z",
)

# What did the space know in June, ignoring everything learned since?
client.retrieve(space_id="support", query="contract status", as_of="2026-06-01T00:00:00Z")

# Same corpus, but score recency and resolve "last June" against a past instant.
client.retrieve(space_id="support", query="what changed last June",
                query_timestamp="2026-01-01T00:00:00Z")

# Everything that HAPPENED in June 2025, however recently it was imported.
client.retrieve(space_id="support", query="contract status",
                occurred_after="2025-06-01T00:00:00Z",
                occurred_before="2025-06-30T23:59:59Z")
```

- **`timestamp`** on `record` is when the *event* occurred. It feeds recency
  ranking, comes back on a result as `timestamp`, and is what the event-time
  window below matches against. It does not change when the memory was
  *recorded*, so it has no effect on `as_of`. It is **not** copied into
  `occurred_start` / `occurred_end`: those are filled only from a date found in
  the memory's own text, so a memory whose text names no date has both of them
  null while carrying a perfectly good `timestamp`.
- **`as_of`** on `retrieve` is a hard cutoff: only memories **recorded** at or
  before that instant come back. A backdated import is recorded today no matter
  what `timestamp` it carries.
- **`query_timestamp`** on `retrieve` only re-ranks. It moves the "now" that
  recency and relative dates are measured against, and never removes a result.
  Reach for `as_of` when you need the cutoff actually enforced.
- **`occurred_after` / `occurred_before`** on `retrieve` bound when the thing
  *happened*, which is the one question `as_of` cannot answer: a year of
  history imported this morning has one record time and twelve months of event
  time. Either bound alone is an open-ended window, and the test is an overlap,
  so an event straddling an edge is inside.

## 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, for the moment a filter has two clauses that combine differently, or a
clause that excludes.

```python
client.retrieve(
    "support",
    "what is left to do",
    tag_groups=[
        {"or": [{"tags": ["project:alpha"]}, {"tags": ["project:beta"]}]},
        {"not": {"tags": ["status:archived"]}},
    ],
)
```

Groups in the list are AND-ed. Each is either a leaf `{"tags": [...], "match":
...}` or one of `{"and": [...]}`, `{"or": [...]}`, `{"not": {...}}`, nested as
deep as you need. `match` on a leaf takes the same values as `tags_match` and
defaults to `any_strict`.

Three things worth knowing:

- **The non-strict modes treat an untagged memory as matching**, so a leaf
  written with `any` or `all` widens an expression rather than narrowing it, and
  both are refused inside a `not` for that reason. Negating "matches, including
  untagged" would exclude every untagged memory in the space.
- **The filter is compiled into the search**, not applied to its output, so an
  excluded memory never competes for a slot. You get the best *n* matches that
  passed the filter rather than whatever survived it.
- **It composes rather than replaces.** `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.

Limits: 25 leaves and 5 levels of nesting per request; 500 tags in any one leaf
and 1,000 across the whole expression. A leaf naming hundreds of tags is a
normal shape rather than abuse: one leaf is 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 them over separate
leaves. `reason` accepts the same argument, where it narrows what the reasoning
agent may look at.

### 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 is compared against the tags the space actually holds, and
the close ones are added to the filter:

```python
client.retrieve(
    "support",
    "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`.

Five things worth knowing:

- **It widens, it never narrows.** The tags you named are always kept, so a
  fuzzy leaf matches at least everything the same leaf matched exactly.
- **Resolution stays inside the tag's namespace.** The part before the first
  colon must match exactly and only what follows it is compared, so
  `name:chekcout` can reach `name:checkout` and never `status:checkout`.
- **Short candidates are matched literally.** Below four characters after the
  namespace nothing is resolved, because similarity on two or three characters
  is noise: `api` and `apis` score 0.5 and they are different things.
- **A word of a longer tag is not a misspelling of it.** A candidate has to be
  about as long as what it matches, so `name:pipeline` does not resolve to
  `name:functional pipeline`.
- **It fixes misspellings, not abbreviations.** `kubernetes` and `k8s` have
  almost no letters in common, so nothing resolves one to the other. That is
  what [labels](#labels-tag-your-memories-as-they-are-written) are for: a
  `multi-text` group records every name a thing is known by, and the exact
  filter finds them.

`resolve` may only be combined with a `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.

A filter is only as good as the tags a memory carries. If you are tagging by
hand on every write, [labels](#labels-tag-your-memories-as-they-are-written) can
have extraction do it for you.

## Extraction settings

Recording a memory is not storage. Your text goes through one pass that decides
which facts are worth keeping and how they are phrased, and only what survives
is stored — so a detail dropped there is not ranked low later, it is not there
at all. These settings point that pass at your domain.

```python
client.set_extraction_settings(
    "engineering",
    guidance=(
        "Engineering log. Always capture service names, metric values with "
        "units, and the named owner. Treat incidents as dated events. "
        "Skip standup small talk."
    ),
)
```

- **`guidance`** is *added* to the standard rules and applies in every mode.
  Reach for it first — naming your vocabulary and the fields that always matter
  is what turns a good guess into a reliable one. Max 4,000 characters.
- **`mode`** is `concise` (the default), `verbose` (keep every specific),
  `verbatim` (store the text as written, derive only the metadata around it), or
  `custom`.
- **`custom_prompt`** *replaces* the standard rules, and only applies while
  `mode` is `"custom"`. Max 8,000 characters.
- **`labels`** is different in kind. The three above steer what a write *keeps*;
  labels ask the same pass to *classify* what it kept, along dimensions you
  define. See [Labels](#labels-tag-your-memories-as-they-are-written).

Two things worth knowing. `set_extraction_settings` **replaces** the record, so
anything you leave out is cleared. And settings apply to writes made after the
call — memories already stored are never re-extracted, so changing these is safe
and never rewrites history. To see the effect, save, record a representative
piece of text, and read the memory back.

Unhelpful guidance produces no error; extraction simply keeps different things.

### Labels: tag your memories as they are written

Filtering recall is only as good as the tags a memory carries, and by default
every one of them has to be supplied by hand on the write that created it.
A **label taxonomy** hands that job to extraction: name a dimension once, and
every memory is classified along it as it is written.

```python
client.set_extraction_settings(
    "engineering",
    labels=[{
        "key": "name",
        "type": "multi-text",
        "tag": True,
        "description": (
            "Every name the subject of this memory is known by, including "
            "abbreviations, acronyms and short forms. Write them lowercase, "
            "words separated by single spaces, with no punctuation."
        ),
    }],
)
```

A group with `tag: True` is written onto the memory as the tag
`"<key>:<value>"`, so `retrieve` can filter on it through `tag_groups`. A memory
about Kubernetes then carries `name:kubernetes`, `name:k8s` and `name:kube`, and
any of the three finds it:

```python
client.retrieve(
    "engineering",
    "what did we decide about k8s",
    tag_groups=[{"tags": ["name:k8s"], "match": "any_strict"}],
)
```

There are four group types, in two pairs. `value` and `multi-values` pick from a
`values` list you supply, one or several. `text` and `multi-text` take whatever
the memory itself supplies, one value or all of them, for a vocabulary you
cannot enumerate in advance. `multi-text` is the one identity needs: a thing
rarely has a single surface form, and `text` would keep only the first.

Because tags are matched as exact strings, describe the format you want in the
group's `description` and normalise your query the same way.

A few things are refused rather than stored, because each would look saved and
do nothing: an enumerated group with no `values`; a `text`/`multi-text` group
*with* `values`; two groups sharing a `key`; a key that is not already in tag
form (lowercase letters, digits, `-`, `_`); and `free_form_entities=False` with
no labels, which would keep no entities at all rather than only labelled ones.
Twelve groups maximum, since the taxonomy rides the extraction prompt on every
write.

Labels apply to writes made after you save them, so turning a taxonomy on never
retags your history.

## Rules a space must follow

A rule is a standing instruction the space obeys whenever it answers. It is not
a preference and not a tone setting: a rule applies to **every** answer the
space gives, and it overrides what the memories say.

```python
client.create_rule(
    "accounts",
    name="Never call revenue committed",
    content=(
        "Never describe revenue as committed unless a memory records a signed "
        "document. Name the stage the deal is actually at instead."
    ),
    priority=100,
)
```

`priority` orders the rules, highest first, with ties broken by whichever was
written most recently. `name` is the label an answer reports back, so write it
for whoever reads that answer.

Rules shape the answers the API synthesises for you — `reason`,
`ask_about_user`, and the memory models a space keeps. They do not change what
`retrieve` returns, and they do not reach a completion you make yourself through
the LiteLLM integration: that prompt is yours.

A space may have **25 active** rules at once. A rule that is switched off is
stored and still editable but never applied, and does not count against the
cap — so switching one off is always a way back under it without throwing work
away:

```python
for rule in client.list_rules("accounts"):
    state = "active" if rule["is_active"] else "off"
    print(rule["priority"], rule["name"], state)

client.update_rule("accounts", "rl_1", is_active=False)  # frees a slot
client.delete_rule("accounts", "rl_1")                   # gone for good
```

`update_rule` changes only the fields you pass, so switching a rule off or
lifting its priority never means resending the text of something enforced on
every answer.

An answer says which rules shaped it. `POST /v1/reason` returns
`rules_applied` — the id and name of each rule that applied, and deliberately
not its text, which is your own configuration and already in hand. An empty
list is the useful finding that no rule applied, which is what separates "the
memories are wrong" from "a rule said otherwise". `client.reason()` returns the
synthesised text on its own; read that field from the API response if you need
it.

Rules are owner-only, and free — an organisation out of credits must still be
able to switch off a rule that is producing bad answers.

## Framework adapters

Anona plugs into the Python agent frameworks through optional extras. Every
adapter handles recall and storage for you, and scopes memories per end user.

| Framework | Install | Import |
| --- | --- | --- |
| LangChain / LangGraph | `pip install 'anona[langchain]'` | `anona.integrations.langchain` |
| CrewAI | `pip install 'anona[crewai]'` | `anona.integrations.crewai` |
| LlamaIndex | `pip install 'anona[llamaindex]'` | `anona.integrations.llamaindex` |
| Google ADK | `pip install 'anona[adk]'` | `anona.integrations.google_adk` |
| Microsoft Agent Framework | `pip install 'anona[msagent]'` | `anona.integrations.ms_agent` |
| AWS Strands | `pip install 'anona[strands]'` | `anona.integrations.strands` |

All six are built on one `MemoryBridge`, which owns scope resolution and the
failure contract:

```python
from anona.integrations import MemoryBridge
from anona.integrations.langchain import AnonaMemory

bridge = MemoryBridge(
    api_key="anona_live_...",
    space_id="my-space",
    user_id="customer-42",   # optional scope: this user's memories only
)

agent = create_agent(model="gpt-4o-mini", middleware=[AnonaMemory(bridge=bridge)])
```

**Memory failures never raise into your agent.** A failed recall or store is
logged and the agent runs on without memory, rather than taking your
application down.

Each adapter's own module docstring documents its scoping, failure behaviour
and per-call cost. Full docs: https://docs.anonalabs.com/integrations/langchain

Runnable end-to-end scripts for all six live in [`examples/`](examples/).

## LiteLLM integration

Auto-inject relevant memories into every `litellm.completion()` call, and auto-store the resulting Q&A pair:

```python
from anona.integrations.litellm import AnonaMemory

mem = AnonaMemory(
    api_key="anona_live_...",
    space_id="space_123",
    recall_limit=5,       # how many memories to retrieve per call
    inject_mode="system", # "system" or "user"
    store_after=True,     # auto-store the exchange after each call
)
mem.enable()

# All subsequent litellm.completion() calls now auto-recall + auto-store.
import litellm
litellm.completion(model="gpt-4o", messages=[{"role": "user", "content": "..."}])
```

## The `anona` command

Installing the package also installs a CLI. It exists so an agent can have
memory without an API key in a config file, and so a person can use memory from
a terminal.

```bash
anona start                                   # a temporary profile, no account
anona record   "The user prefers pnpm."       # store a memory
anona retrieve "package manager preference"   # search them
anona reason   "how does this team build?"    # answer from across them
anona login                                   # approve in a browser, keep it
anona status                                  # what is stored, and until when
```

`anona start` needs no signup: it creates a profile that holds memories for 72
hours, and `anona login` claims it into a real account with everything it holds.

`record`, `retrieve` and `reason` work **on a temporary profile only**. A login
mints a credential scoped to MCP, which the REST API rejects on purpose, so on
a signed-in account those three refuse and point at the two paths that do work:
`anona mcp` with a client, or an API key with this SDK.

`retrieve` lists the memories that match. `reason` reads across them and answers
in prose — ask it when no single memory holds the answer. It is an agent loop
rather than a lookup, so it takes tens of seconds and says so on stderr while it
works, leaving `anona reason "..." > answer.md` to capture only the answer.

`anona mcp` is a stdio MCP server for a client to launch, so the client needs no
OAuth and no key of its own:

```json
{ "mcpServers": { "anona": { "command": "anona", "args": ["mcp"] } } }
```

Full reference: [docs.anonalabs.com/agents/cli](https://docs.anonalabs.com/agents/cli).

## MCP server

The SDK ships an [MCP](https://modelcontextprotocol.io) server so any MCP client
— Claude Desktop, Claude Code, Cursor — can read and write Anona memory as native
tools: `record`, `retrieve`, `list_spaces`, and `reason`.

Install the extra:

```bash
pip install "anona[mcp]"
```

**Claude Desktop / Cursor** — add to `claude_desktop_config.json` (or
`~/.cursor/mcp.json`), then restart:

```json
{
  "mcpServers": {
    "anona": {
      "command": "uvx",
      "args": [
        "--from",
        "anona[mcp]",
        "anona-mcp"
      ],
      "env": {
        "ANONA_API_KEY": "anona_live_...",
        "ANONA_SPACE_ID": "space_123"
      }
    }
  }
}
```

**Claude Code** — one command:

```bash
claude mcp add anona \
  --env ANONA_API_KEY=anona_live_... \
  --env ANONA_SPACE_ID=space_123 \
  -- uvx --from "anona[mcp]" anona-mcp
```

`ANONA_SPACE_ID` sets the default space so you can just say "remember this"
without naming one; override it per call with the `space_id` argument. The key
is personal — the server only reaches spaces you are a member of.

## Agent Skills

Two installable [Agent Skills](https://docs.anonalabs.com/integrations/skills)
that teach a coding agent to use Anona without being told to — recall before a
task, record after it — in Claude Code, Codex, Hermes, OpenClaw, OpenCode or Cursor.

```bash
curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/skills/install.sh | bash
```

Or `npx skills add anonalabs/Anona-Memory-SDK`, or the Claude Code plugin
marketplace (`/plugin marketplace add anonalabs/Anona-Memory-SDK`). Source and
options: [`skills/`](skills/).

## API reference

```python
AnonaClient(
    api_key,
    base_url="https://api.anonalabs.com",
    timeout=120.0,
    max_retries=2,
    retry_max_wait=60.0,
)
```

| Parameter | Meaning |
| --- | --- |
| `api_key` | Your `anona_live_...` key. Required. |
| `base_url` | Defaults to the production API; pass it only to override. |
| `timeout` | Per-request ceiling in seconds. It is a ceiling, not a wait: calls return the instant the server answers, and a loaded `reason()` can legitimately run 70–115s. |
| `max_retries` | Retries for a 429, and for a 5xx on idempotent methods. `0` turns retrying off. See [Retries](#retries). |
| `retry_max_wait` | Cap, in seconds, on any single retry wait. |

`Anona` is an alias for `AnonaClient`, matching the TypeScript package's class
name. Every method below has an `async_` twin (`async_record`,
`async_retrieve`, …) taking the same arguments. `close()` / `aclose()` release
the underlying HTTP clients, or use the client as a context manager. Full
signatures and parameter docs are in each method's docstring and at
[docs.anonalabs.com/sdk/python](https://docs.anonalabs.com/sdk/python).

### Writing and reading memories

| Method | Description |
| --- | --- |
| `record(space_id, content, …)` | Store a memory. `background=True` queues it and returns a `job_id`. `context` adds framing (where it came from, who said it) without becoming the memory's text. `timestamp` (ISO 8601) is when the *event* happened, for importing history. `user_id` / `agent_id` / `session_id` scope it inside the space. Pass `route="auto"` instead of `space_id` to let Anona choose: see [Automatic space routing](#automatic-space-routing). |
| `record_batch(space_id, items)` | Bulk-ingest up to 100 items. Always queued; returns a `job_id`. Also takes `route="auto"`, which routes the whole batch as one unit. |
| `get_job(space_id, job_id)` | Poll a queued job. Free. `status` is `pending`, `processing`, `completed`, `failed`, `cancelled` or `not_found`. |
| `cancel_job(space_id, job_id)` | Cancel a queued ingestion job. |
| `retrieve(space_id, query, …)` | Search. `limit`, `mode` (`"accurate"` or `"fast"`), the scope arguments, `memory_type`, `min_score` and `top_k` narrow it; `tags` / `tags_match` and `tag_groups` filter on tags ([Compound tag filters](#compound-tag-filters)); `prefer_observations=False` also returns the raw evidence behind a synthesised memory; `member_id="me"` returns only what you wrote in a shared space. Temporal arguments: [Time travel](#time-travel). `route="auto"` and `max_targets`: [Automatic space routing](#automatic-space-routing). |
| `get_context(space_id, query, …)` | The same search, returned as one prompt-ready string. Takes the same filters as `retrieve`. `max_tokens` bounds it; `block_order="stable"` keeps the block byte-identical between turns so a provider's prompt cache can hit it. |
| `retrieve_receipt(space_id, query, …)` | `retrieve`, plus a receipt explaining that search. Returns `.memories` and `.receipt_id`. |
| `get_receipt(request_id)` | The manifest for one earlier search: what was returned, what was considered, what was cut. |
| `explain(request_id, memory_id)` | Account for one specific memory against an earlier search. |
| `reason(space_id, query, …)` | A synthesised answer across a space, as a string. `depth` is `"fast"` or `"thorough"`; `model` picks the LLM; `tag_groups` narrows what the reasoning agent may look at. |
| `reason_receipt(space_id, query, …)` | `reason`, plus what the answer was built from: `.sources`, `.rules_applied` and the `.model` that actually answered. `str()` of the result is the answer. |

### Managing memories

| Method | Description |
| --- | --- |
| `list_memories(space_id, …)` | Page through a space's memories. Filters by `q`, `memory_type`, `state` and scope. |
| `get_memory_history(space_id, memory_id)` | Every recorded version of one memory, newest first. |
| `update_memory(space_id, memory_id, …)` | Correct one memory in place; only the fields you pass change. `state="invalidated"` retires a memory reversibly, and `reason` is kept on its history. |
| `delete_memory(space_id, memory_id)` | Delete one memory. Irreversible: prefer `update_memory(..., state="invalidated")`. |

### Spaces and documents

| Method | Description |
| --- | --- |
| `create_space(name, description=None, *, space_type=None)` | Create a space; a space's id *is* its name. `space_type` seeds it from a preset (mission, extraction guidance, disposition and a couple of standing questions). The returned `space_type` is null whenever nothing was applied, including when a type was asked for and applying it failed. |
| `list_space_types()` | The types `create_space` accepts. Free; read it rather than hardcoding. |
| `list_spaces()` / `get_space(space_id)` | List spaces, or fetch one. |
| `delete_space(space_id)` | Delete a space and every memory in it. Irreversible. |
| `upload_file(space_id, file, …)` | Upload a path, bytes or file object so retrieval can draw on it. Ingested asynchronously; returns `job_ids`. Accepts `strategy`, `tags` and the scope arguments. Files over 25 MB are rejected client-side. |
| `list_documents(space_id, …)` / `get_document(space_id, document_id)` | List documents, or fetch one with its source and memory count. |
| `delete_document(space_id, document_id)` | Remove a document and the memories extracted from it. |
| `get_graph(space_id, limit=500, min_count=1)` | The entity relationship graph: nodes plus co-occurrence edges. |
| `list_entities(space_id, …)` / `get_entity(space_id, entity_id)` | Entities, and one entity with its observations. |

### End users

| Method | Description |
| --- | --- |
| `get_user_profile(space_id, user_id, …)` | Everything the space has learned about one end user. See [User profiles](#user-profiles). |
| `ask_about_user(space_id, user_id, query, *, model=None)` | One synthesised answer, drawn from that user's memories only. |

### Space configuration

| Method | Description |
| --- | --- |
| `get_extraction_settings` / `set_extraction_settings` / `reset_extraction_settings` | Steer what a write keeps. A set **replaces** the record. See [Extraction settings](#extraction-settings). |
| `list_rules` / `create_rule` / `update_rule` / `delete_rule` | Standing instructions every answer must follow. See [Rules](#rules-a-space-must-follow). |
| `get_space_profile` / `set_space_profile` / `reset_space_profile` | A space's mission and disposition. Both arguments to the setter are required: the PUT is a full replace, so an omitted one clears that half. Owner-only. |
| `get_reason_settings` / `set_reason_settings` / `reset_reason_settings` | The model and depth `reason` uses for a space. Both are required on set, for the same full-replace reason. Owner-only. |
| `get_chat_settings` / `set_chat_settings` / `reset_chat_settings` | Per-space defaults for the drop-in proxy endpoints. |
| `list_memory_models` / `create_memory_model` / `get_memory_model` / `update_memory_model` / `delete_memory_model` | Standing questions a space keeps an answer to. |
| `refresh_memory_model` / `clear_memory_model` / `get_memory_model_history` | Re-answer a memory model from current memories, wipe its content but keep its definition, or read earlier versions. |
| `list_catalog_models()` | Every LLM this deployment will answer with, and what each costs. |

### Webhooks and usage

| Method | Description |
| --- | --- |
| `create_webhook(space_id, url, event_types=None, enabled=True)` | The response carries `secret`, returned only on create. |
| `list_webhooks` / `update_webhook` / `delete_webhook` | Manage a space's webhooks. |
| `list_webhook_deliveries(space_id, webhook_id, limit=50, cursor=None)` | Recent attempts, for debugging a receiver. |
| `get_usage()` | Credits and rate limit for the organization this key belongs to. |

## Errors, retries and rate limits

### Errors

Errors raise `AnonaError(status_code, detail)`. The exception also carries `code`
(the API's stable machine-readable handle, e.g. `space_not_found`), `request_id`
(what support needs to find the call in the logs) and, on a 429, `retry_after`
in seconds. A 503 with no `request_id` may be a Cloudflare-mangled 502 or 504:
the edge strips the body of those two statuses, so the API rewrites them to 503
before they leave. Report such a failure with a timestamp rather than treating
it as a malformed response.

### Retries

A 429 is retried for every method, `record` included: the limiter refuses the
request before anything runs, so re-sending cannot double-write. A 5xx is
retried only for idempotent methods (GET, PUT, DELETE), because a 5xx on a write
may arrive after the work landed, and replaying it would store the memory
twice. Uploads are never retried. `max_retries=0` turns it off.

**A 429 waits as long as the server says to.** The rate-limit window is a whole
minute, so a client backing off on its own schedule tops out in the low seconds,
spends its retries landing on the same full bucket, and fails anyway. The client
reads `Retry-After`, falling back to the `window_seconds` the rate-limit body
carries, and waits that out, capped at `retry_max_wait` (60s by default). A 5xx
with no hint backs off briefly with jitter. `AnonaError.retry_after` carries the
same number if you would rather schedule it yourself.

### Staying inside the rate limit

Every metered response reports the budget it left you, so a bulk job can pace
itself instead of discovering the ceiling by hitting it. `client.rate_limit` is
updated in place on every call (`limit`, `remaining`, `window_seconds`, `credits_remaining`, `retry_after`):

```python
client.retrieve("support", "billing")
rl = client.rate_limit
if rl.remaining is not None and rl.remaining < 5:
    time.sleep(rl.window_seconds or 60)
```

Fields are `None` until a metered call has been made. The unmetered routes
(spaces, settings, webhooks) report no budget and leave the last reading in
place rather than blanking it.

## Requirements

- Python >= 3.10
- `httpx >= 0.24`
- `litellm >= 1.0` (optional, only for the LiteLLM integration)
- `mcp >= 1.2` (optional, only for the MCP server)
- one of `langchain`, `crewai`, `llama-index-core`, `google-adk`,
  `agent-framework-core`, `strands-agents` (optional, only for the matching
  framework adapter — see the extras above for the verified version floors)

## Documentation

Full guides and the complete API reference live at
[docs.anonalabs.com](https://docs.anonalabs.com):

| Page | What it covers |
| --- | --- |
| [Python SDK](https://docs.anonalabs.com/sdk/python) | Every `AnonaClient` method, with examples |
| [TypeScript SDK](https://docs.anonalabs.com/sdk/javascript) | The `Anona` client and its adapters |
| [Quickstart](https://docs.anonalabs.com/quickstart) | First key to first memory |
| [Concepts](https://docs.anonalabs.com/concepts) | Spaces, scoping, observations, credits |
| [API reference](https://docs.anonalabs.com/api-reference/overview) | The REST API these SDKs wrap |
| [MCP server](https://docs.anonalabs.com/mcp-integration) | Local stdio and remote transports |
| [Framework integrations](https://docs.anonalabs.com/integrations/langchain) | LangChain, CrewAI, LlamaIndex, Google ADK and more |

## TypeScript

[`@anona-labs/memory`](https://www.npmjs.com/package/@anona-labs/memory) has zero
dependencies and runs on Node 18+, Bun, Deno, Cloudflare Workers and the
browser, with adapters for the Vercel AI SDK and the OpenAI Agents SDK.

```bash
npm install @anona-labs/memory
```

```typescript
import { Anona } from "@anona-labs/memory";

const anona = new Anona({ apiKey: process.env.ANONA_API_KEY! });
await anona.record({ spaceId: "support", content: "Alice prefers email" });
const hits = await anona.retrieve({ spaceId: "support", query: "how to contact Alice" });
```

See [`typescript/README.md`](typescript/README.md) and
[docs.anonalabs.com/sdk/javascript](https://docs.anonalabs.com/sdk/javascript).

## License

MIT
