# Docs for agents Source: https://docs.anonalabs.com/compute/agents/docs-for-agents Machine-readable docs: llms.txt and a Markdown copy of every page. These docs are built to be read by agents as well as people. | What | Where | | - | - | | Index of every page, with descriptions | [`/llms.txt`](https://docs.anonalabs.com/llms.txt) | | Every page in one file | [`/llms-full.txt`](https://docs.anonalabs.com/llms-full.txt) | | Any page as Markdown | Add `.md` to its URL, for example `/quickstart.md` | | One prompt that sets an agent up | [Self-onboarding prompt](/compute/agents/onboarding-prompt) | | The tool list an agent gets | [Agents via MCP](/compute/guides/mcp#tools) | | The route list | [`/api-reference/anona-openapi.yaml`](/compute/api-reference/introduction) and `e2b-openapi.yaml` | Start from `llms.txt`: it is small, and each line links to a Markdown page. Fetch `llms-full.txt` only when you need to search across everything. ## Tips for agents using the docs * Prefer the `.md` URL to the HTML page; it has no navigation or scripts. * Code blocks are runnable. Each one is run against a local stack before the docs ship, so copy them as written. * A **Coming soon** note means the feature is not available; do not call it. * Check [Limits](/compute/concepts/limits) before planning work that creates many sandboxes. # Self-onboarding prompt Source: https://docs.anonalabs.com/compute/agents/onboarding-prompt One prompt that connects a coding agent to Anona Compute and has it prove it works. Paste this into Claude Code, Codex or Cursor. The agent reads the prompt below, asks you to approve one sign-in and creates a sandbox to check the setup. ```text paste-into-your-agent theme={null} Set up Anona Compute for me. Fetch https://docs.anonalabs.com/compute/agents/onboarding-prompt.md and follow it. ``` ## The prompt This is the page the agent reads. It is written for the agent. ```text onboarding-prompt theme={null} You are connecting yourself to Anona Compute, which gives you disposable Linux microVM sandboxes. Work through these steps in order and stop to ask the human only where marked. 1. Check what you already have. - If you can already call tools named create_sandbox and run_command, skip to step 5. - Run `anona-compute whoami`. If it prints a user and organization, ask the human whether to use that account. Do not assume. 2. Install the CLI. - Run `anona-compute version`. If the command is missing, tell the human that release binaries are not published yet and ask for an API key (dashboard: https://app.compute.anonalabs.com, "API keys"). Then use the stock E2B SDK: set E2B_DOMAIN=compute.anonalabs.com and E2B_API_KEY, and stop here. 3. Sign in. ASK THE HUMAN to approve. - Run `anona-compute login --no-browser`. Show the human the URL and code it prints and wait. They sign in, check the code, pick the organization and approve. - Run `anona-compute whoami` and confirm it succeeds. 4. Register the MCP server, then ask the human to restart you. - Claude Code: `claude mcp add anona-compute -- anona-compute mcp` - Codex: `codex mcp add anona-compute -- anona-compute mcp` - Other clients: command `anona-compute`, argument `mcp`. - Tools load when the client starts, so a restart is normal. 5. Prove it works. - Call create_sandbox with template "base" and timeout_seconds 300. - Call run_command with `uname -a && whoami`. - Call kill_sandbox. Report the output to the human. Rules while you work: - Delete every sandbox you create when you finish with it. Running sandboxes are billed. - Never print or store an API key in a file you commit. - Docs index: https://docs.anonalabs.com/llms.txt ``` ## What the human sees One browser page asking them to approve a code. Nothing else needs their input unless the CLI is not installed or the agent needs a restart. > [card] [How it works](/compute/agents/overview) > > The sign-in flow, the ways to connect and the limits. # Agent self-onboarding Source: https://docs.anonalabs.com/compute/agents/overview A coding agent sets up Anona Compute from the terminal. You approve one browser sign-in. > For agents: fetch [`/llms.txt`](/compute/agents/docs-for-agents) first. It indexes every page as Markdown. Give Claude Code, Codex, Cursor or any MCP client its own computers. The agent installs the CLI, asks you to approve a sign-in, registers the MCP server and creates a sandbox to prove it works. You do one thing: approve the sign-in in your browser. > [card] [Paste one prompt](/compute/agents/onboarding-prompt) > > Hand this to your agent and it does the rest. > [card] [Docs for agents](/compute/agents/docs-for-agents) > > `llms.txt` and a Markdown copy of every page. ## The flow ```text theme={null} agent you Anona Compute | anona-compute login --no-browser | |------------------------------------------------------------->| device code request | prints URL + code | |-------------------------------> opens URL, checks code, | | picks organization, approves | | |--------------------------->| | CLI now holds a session for this machine | | claude mcp add anona-compute -- anona-compute mcp | | tools/call create_sandbox, run_command, kill_sandbox | |------------------------------------------------------------->| ``` The sign-in is a standard device flow (RFC 8628). The agent never sees your password or an API key. The CLI trades its session for gateway tokens that last 15 minutes, and `anona-compute logout` revokes the session. ## Ways to connect | Path | Needs | Good for | Credential | | - | - | - | - | | **A. CLI and MCP** | The `anona-compute` binary | Claude Code, Codex, Cursor, any stdio MCP client | Browser sign-in held by the CLI | | **B. CLI with an API key** | The binary and an `e2b_` key | Headless machines and CI | A scoped, expiring [API key](/compute/guides/ci) | | **C. SDK in the agent's code** | `pip install anona` or `npm install @anona/sdk` | An agent you wrote | An API key in `ANONA_API_KEY` | | **D. Stock E2B SDK** | `pip install e2b` and two environment variables | Code that already uses E2B | An API key as `E2B_API_KEY` | > [!NOTE] > > **Coming soon:** downloadable `anona-compute` binaries and the `anona` and `@anona/sdk` packages. Until they ship, path D works today; see [Migrate from E2B](/compute/migrate-from-e2b). ## Setup per path > [tab] A. CLI and MCP > > ```bash agent_login.sh theme={null} > anona-compute login --no-browser # prints a URL and a code; the human approves > anona-compute whoami # user, organization, role > ``` > > Then register the server with the agent's client: > > ```bash claude_code.sh theme={null} > claude mcp add anona-compute -- anona-compute mcp > ``` > > ```bash codex.sh theme={null} > codex mcp add anona-compute -- anona-compute mcp > ``` > > Cursor and other clients: see [Agents via MCP](/compute/guides/mcp). MCP tools usually load when the client starts, so restart the agent once. > [tab] B. API key > > Create a key in the [dashboard](https://app.compute.anonalabs.com) under **API keys**, with the least access the job needs. Then: > > ```bash agent_key.sh theme={null} > printenv ANONA_API_KEY | anona-compute login --with-api-key > anona-compute whoami > ``` > [tab] C and D. SDK > > ```bash agent_env.sh theme={null} > export E2B_DOMAIN=compute.anonalabs.com > export E2B_API_KEY=e2b_YOUR_API_KEY > ``` > > The [quickstart](/compute/quickstart) has the first sandbox in Python and TypeScript. ## What the agent gets Over MCP the agent can create, pause, resume, extend and delete sandboxes; run commands and background processes; read and write files; get a live-view link for you to watch, a preview link for a dev server it started, an SSH config and a desktop screenshot; and read its own usage. The full list is in [Agents via MCP](/compute/guides/mcp#tools). ## Limits * The agent can do what your role and credential allow, no more. Start it with `--read-only` after `mcp` to offer only the tools that do not change sandboxes. * A browser sign-in is a session for one machine. It cannot be used with the SDK or the REST API; those need an API key. * Sandbox limits (count, size, lifetime) come from your plan. See [Limits](/compute/concepts/limits). * Billing is per active CPU and resident memory; an agent that leaves sandboxes running is billed. Tell it to delete them (the MCP tool is `kill_sandbox`) or let it [pause when idle](/compute/concepts/lifecycle#pause-when-idle). ## Troubleshooting **The agent says the tools are missing.** The client loads MCP tools at start. Restart it, then ask it to list its tools. **`login` waits forever.** The code expires. Run it again, open the URL it prints and check that the code matches. **`unauthorized` after a while.** The session was revoked, or the key expired. Run `anona-compute whoami`, then sign in again. **A create fails with a limit error.** Your plan's sandbox, memory or lifetime limit is reached. The message names it; see [Troubleshooting](/compute/troubleshooting). # Create an API key Source: https://docs.anonalabs.com/compute/api-reference/anona/api-keys/create-an-api-key /compute/api-reference/anona-openapi.yaml post /v1/api-keys Creates an API key and returns its secret once. Dashboard sessions with the owner or admin role only. # List API keys Source: https://docs.anonalabs.com/compute/api-reference/anona/api-keys/list-api-keys /compute/api-reference/anona-openapi.yaml get /v1/api-keys Lists the organization's API keys (never their secrets). Dashboard sessions only: API keys cannot list or manage keys. Every role may list. # Revoke an API key Source: https://docs.anonalabs.com/compute/api-reference/anona/api-keys/revoke-an-api-key /compute/api-reference/anona-openapi.yaml delete /v1/api-keys/{keyID} Revokes a key at once. Dashboard sessions with the owner or admin role only. # Delete a secret Source: https://docs.anonalabs.com/compute/api-reference/anona/credential-vault/delete-a-secret /compute/api-reference/anona-openapi.yaml delete /v1/vault/secrets/{name} # List secrets Source: https://docs.anonalabs.com/compute/api-reference/anona/credential-vault/list-secrets /compute/api-reference/anona-openapi.yaml get /v1/vault/secrets Names, versions and bindings of the organization's secrets. Values are never returned. # Revoke a binding Source: https://docs.anonalabs.com/compute/api-reference/anona/credential-vault/revoke-a-binding /compute/api-reference/anona-openapi.yaml delete /v1/vault/bindings/{bindingID} Stops one secret from being injected for one host and header. # Rotate a secret Source: https://docs.anonalabs.com/compute/api-reference/anona/credential-vault/rotate-a-secret /compute/api-reference/anona-openapi.yaml post /v1/vault/secrets/{name}/versions Adds a new version. Egress proxies cache values for up to 60 seconds. # Store a secret Source: https://docs.anonalabs.com/compute/api-reference/anona/credential-vault/store-a-secret /compute/api-reference/anona-openapi.yaml post /v1/vault/secrets Stores a secret, encrypted at rest. Reference it from a network rule as `${vault.}`; the egress proxy adds it to requests to that host, and the sandbox never sees the value. Owners and admins, or unrestricted keys. # Latest thumbnail Source: https://docs.anonalabs.com/compute/api-reference/anona/live-view/latest-thumbnail /compute/api-reference/anona-openapi.yaml get /v1/sandboxes/{sandboxID}/thumbnail The sandbox's latest 320 px WebP thumbnail (its screen, or its active Chrome page). The sandbox grabs frames only while someone asks. Same access as a view token. # Mint a viewer token Source: https://docs.anonalabs.com/compute/api-reference/anona/live-view/mint-a-viewer-token /compute/api-reference/anona-openapi.yaml post /v1/sandboxes/{sandboxID}/viewer-tokens Mints a short-lived, signed viewer token for one sandbox and returns the embed URL that opens the live view with it. The token travels in the URL fragment (`#t=...`), which browsers never send to a server. `control` scope needs an API key, or a dashboard role of member or above. The key needs the `sandboxes:use` scope and, if it is limited to a list of sandboxes, this sandbox must be on it. # Revoke every viewer Source: https://docs.anonalabs.com/compute/api-reference/anona/live-view/revoke-every-viewer /compute/api-reference/anona-openapi.yaml post /v1/sandboxes/{sandboxID}/viewer-tokens:revoke-all Closes every open live view of the sandbox and invalidates every viewer token issued for it so far, by moving the sandbox's revocation epoch forward. Needs the same access as a `control` token. # Viewer token keys Source: https://docs.anonalabs.com/compute/api-reference/anona/live-view/viewer-token-keys /compute/api-reference/anona-openapi.yaml get /.well-known/stream-jwks.json The Ed25519 public keys (JWKS) that verify viewer tokens. # Effective policy of a sandbox Source: https://docs.anonalabs.com/compute/api-reference/anona/network/effective-policy-of-a-sandbox /compute/api-reference/anona-openapi.yaml get /v1/sandboxes/{sandboxID}/network/effective The policy the sandbox runs with, and which layer it came from. # Get team egress settings Source: https://docs.anonalabs.com/compute/api-reference/anona/network/get-team-egress-settings /compute/api-reference/anona-openapi.yaml get /v1/network The organization's default egress policy for new sandboxes (used when neither the create request nor the template has one), what it compiles to under your plan, and the plan's limits. Any role or key may read it. # Set a template's egress policy Source: https://docs.anonalabs.com/compute/api-reference/anona/network/set-a-templates-egress-policy /compute/api-reference/anona-openapi.yaml put /v1/templates/{templateID}/network The default policy of sandboxes created from one of your own templates. The built-in templates are read-only (403); custom templates are coming soon. # Set team egress settings Source: https://docs.anonalabs.com/compute/api-reference/anona/network/set-team-egress-settings /compute/api-reference/anona-openapi.yaml put /v1/network Replaces the team egress settings. The body is a network policy in the E2B shape (the fields of a create's `network` object, plus `allow_internet_access`). Owners and admins, or API keys without scope or sandbox limits. Header values in `rules` may only be vault placeholders such as `Bearer ${vault.openai_key}`. # Access settings Source: https://docs.anonalabs.com/compute/api-reference/anona/sandbox-access/access-settings /compute/api-reference/anona-openapi.yaml get /v1/sandboxes/{sandboxID}/access Public traffic, public ports and the idle-pause time of a sandbox. Needs `sandboxes:read`. # Create a preview link Source: https://docs.anonalabs.com/compute/api-reference/anona/sandbox-access/create-a-preview-link /compute/api-reference/anona-openapi.yaml post /v1/sandboxes/{sandboxID}/preview-links A signed link that opens one port of the sandbox in a browser until it expires. The edge exchanges it for a cookie scoped to that port's host. Port 49983 (envd) cannot be shared. Needs `sandboxes:use`. # Make a port public or private Source: https://docs.anonalabs.com/compute/api-reference/anona/sandbox-access/make-a-port-public-or-private /compute/api-reference/anona-openapi.yaml put /v1/sandboxes/{sandboxID}/ports/{port} # Revoke every preview link Source: https://docs.anonalabs.com/compute/api-reference/anona/sandbox-access/revoke-every-preview-link /compute/api-reference/anona-openapi.yaml post /v1/sandboxes/{sandboxID}/preview-links:revoke-all Ends every preview link and every live-view token of the sandbox. # Set pause when idle Source: https://docs.anonalabs.com/compute/api-reference/anona/sandbox-access/set-pause-when-idle /compute/api-reference/anona-openapi.yaml put /v1/sandboxes/{sandboxID}/auto-pause Pause the sandbox after this many minutes with no traffic, sessions or envd processes. 0 turns it off. # Gateway version Source: https://docs.anonalabs.com/compute/api-reference/anona/service/gateway-version /compute/api-reference/anona-openapi.yaml get /version # Health check Source: https://docs.anonalabs.com/compute/api-reference/anona/service/health-check /compute/api-reference/anona-openapi.yaml get /healthz # Sign an SSH certificate Source: https://docs.anonalabs.com/compute/api-reference/anona/ssh/sign-an-ssh-certificate /compute/api-reference/anona-openapi.yaml post /v1/sandboxes/{sandboxID}/ssh-certs Signs an OpenSSH user certificate for the given public key. It is valid for 10 minutes, for this sandbox only, and logs in as `user`. It permits a PTY and port forwarding, so `ssh -L/-R`, `scp`, `rsync` and editor remote extensions work. Connect through the stream relay with `ProxyCommand anona-compute ssh --proxy `. Needs an API key or a dashboard role of member or above, with the `sandboxes:use` scope. # SSH user CA public key Source: https://docs.anonalabs.com/compute/api-reference/anona/ssh/ssh-user-ca-public-key /compute/api-reference/anona-openapi.yaml get /.well-known/anona-ssh-user-ca.pub The public key of the CA that signs sandbox SSH certificates, in authorized_keys form. # Build from a Dockerfile Source: https://docs.anonalabs.com/compute/api-reference/anona/templates/build-from-a-dockerfile /compute/api-reference/anona-openapi.yaml post /v1/templates/{templateID}/builds/{buildID}/dockerfile Starts a build from a Dockerfile and a build context uploaded through the E2B file links, instead of E2B's step list. This is what `anona-compute template build` uses. # Stream build events Source: https://docs.anonalabs.com/compute/api-reference/anona/templates/stream-build-events /compute/api-reference/anona-openapi.yaml get /v1/templates/{templateID}/builds/{buildID}/events Newline-delimited JSON: one object per log line and status change, ending when the build is ready or failed. `anona-compute template build` and the dashboard follow a build with it. # Plan, rates and invoices Source: https://docs.anonalabs.com/compute/api-reference/anona/usage-and-billing/plan-rates-and-invoices /compute/api-reference/anona-openapi.yaml get /v1/billing Dashboard owners and admins; API keys with the `billing:read` scope. # Usage and cost Source: https://docs.anonalabs.com/compute/api-reference/anona/usage-and-billing/usage-and-cost /compute/api-reference/anona-openapi.yaml get /v1/usage Metered usage and its cost for a billing period: meters, totals (rated, not yet rated, credit, due) with a projection for the current month, a daily breakdown, and the most expensive sandboxes and templates. Dashboard sessions of any role; API keys with the `usage:read` scope. # Create a webhook endpoint Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/create-a-webhook-endpoint /compute/api-reference/anona-openapi.yaml post /v1/webhooks Returns the signing secret (`whsec_...`) once. URLs must be https with a public host. # Delete a webhook endpoint Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/delete-a-webhook-endpoint /compute/api-reference/anona-openapi.yaml delete /v1/webhooks/{webhookID} # Delivery log Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/delivery-log /compute/api-reference/anona-openapi.yaml get /v1/webhooks/{webhookID}/deliveries Newest first, with every attempt. # Get a webhook endpoint Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/get-a-webhook-endpoint /compute/api-reference/anona-openapi.yaml get /v1/webhooks/{webhookID} # Get the OpenTelemetry export Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/get-the-opentelemetry-export /compute/api-reference/anona-openapi.yaml get /v1/otel-export # List webhook endpoints Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/list-webhook-endpoints /compute/api-reference/anona-openapi.yaml get /v1/webhooks The organization's endpoints and the event catalog. Owners and admins, or keys with `integrations:manage`. # Remove the OpenTelemetry export Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/remove-the-opentelemetry-export /compute/api-reference/anona-openapi.yaml delete /v1/otel-export # Resend a delivery Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/resend-a-delivery /compute/api-reference/anona-openapi.yaml post /v1/webhooks/{webhookID}/deliveries/{deliveryID}/resend Queues the delivery again, also a dead one; the retry budget starts over. # Rotate the signing secret Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/rotate-the-signing-secret /compute/api-reference/anona-openapi.yaml post /v1/webhooks/{webhookID}/rotate-secret Returns the new secret once; the previous one keeps signing for 24 hours. # Send a test event Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/send-a-test-event /compute/api-reference/anona-openapi.yaml post /v1/webhooks/{webhookID}/test Queues a `webhook.test` event to this endpoint. # Set the OpenTelemetry export Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/set-the-opentelemetry-export /compute/api-reference/anona-openapi.yaml put /v1/otel-export The OTLP/HTTP base URL (protobuf to `/v1/metrics` and `/v1/traces`), headers (encrypted at rest, never returned; omit to keep the stored ones) and whether it is enabled. # Update a webhook endpoint Source: https://docs.anonalabs.com/compute/api-reference/anona/webhooks-and-telemetry/update-a-webhook-endpoint /compute/api-reference/anona-openapi.yaml patch /v1/webhooks/{webhookID} Any of `name`, `url`, `events`, `enabled`. # API reference Source: https://docs.anonalabs.com/compute/api-reference/introduction The REST API behind the SDKs and the CLI. Base URL: `https://api.compute.anonalabs.com` Most users never call it directly: the E2B SDK, the Anona extras and the CLI do. Two groups of routes: | Group | What | Source | | - | - | - | | [Sandboxes and templates (E2B)](/compute/api-reference/sandboxes/sandboxes/create-sandbox-v2) | Create, list, get, delete (`kill` in the E2B SDK), pause, resume, connect, timeout, network update, template builds | The E2B OpenAPI spec (SDK 2.61.0), cut to the operations Anona serves | | [Anona](/compute/api-reference/anona/live-view/mint-a-viewer-token) | Viewer tokens, SSH certificates, network policy, credential vault, usage and billing, API keys | Written from the gateway's handlers | Both files are checked against a running gateway: every operation is called and each answer's status and body are validated against the spec. ## Authentication Send your API key in the `X-API-Key` header, or as `Authorization: Bearer e2b_…`: ```bash api_auth.sh theme={null} curl -sS https://api.compute.anonalabs.com/v2/sandboxes -H "X-API-Key: $E2B_API_KEY" curl -sS https://api.compute.anonalabs.com/v2/sandboxes -H "Authorization: Bearer $E2B_API_KEY" ``` The `/v1/api-keys` routes accept only dashboard sessions; manage keys in the dashboard. Changing the team network settings and the credential vault needs an owner or admin session (API keys can read them). ## Errors Errors have the E2B shape, so the SDKs raise their usual exceptions: ```json theme={null} { "code": 429, "message": "concurrent sandbox limit reached (100)", "error_code": "sandbox_quota_exceeded" } ``` | Status | Meaning | | - | - | | 400 | Invalid request, or a feature not supported (the message says which) | | 401 | Missing, unknown, revoked or expired API key | | 403 | The key's scopes or your role do not allow this; or the organization is suspended | | 404 | Not found, or not yours (the same answer) | | 409 | The sandbox is in the wrong state (for example, a request to a paused sandbox's URL) | | 429 | A plan limit (`error_code` `sandbox_quota_exceeded`) | | 503 | No capacity yet, for example during a cold start; retry after `Retry-After` seconds | ## Sandbox traffic Commands, files and terminals do not go to the API host. They go to the sandbox's own `envd` at `https://49983-.sbx.compute.anonalabs.com` (Connect RPC, E2B's `envd` protocol), with the sandbox's access token from the create response. Live view and SSH use `wss://stream.compute.anonalabs.com`. # Connect sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/connect-sandbox /compute/api-reference/e2b-openapi.yaml post /sandboxes/{sandboxID}/connect Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. Use POST /v2/sandboxes/{sandboxID}/connect instead. # Connect sandbox (v2) Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/connect-sandbox-v2 /compute/api-reference/e2b-openapi.yaml post /v2/sandboxes/{sandboxID}/connect Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. The request body is optional; an omitted timeout defaults to 300 seconds. # Create sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/create-sandbox /compute/api-reference/e2b-openapi.yaml post /sandboxes Create a sandbox from the template. All system communication with the sandbox is secured. Use POST /v2/sandboxes instead. Anona: `timeout` also accepts `-1` for no deadline when your plan has no maximum lifetime. `autoPause` pauses the sandbox at its timeout instead of deleting it. `volumeMounts`, `iam` and `autoPauseMemory: false` are refused with 400. # Create sandbox (v2) Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/create-sandbox-v2 /compute/api-reference/e2b-openapi.yaml post /v2/sandboxes Create a sandbox from the template. All system communication with the sandbox is secured. Anona: `timeout` also accepts `-1` for no deadline when your plan has no maximum lifetime. `lifecycle.onTimeout: pause` (sent by the SDK as `autoPause`) pauses instead of deleting. A sandbox without a deadline reports `endAt` `9999-12-31T23:59:59Z`. `network.egressProxy`, `volumeMounts`, `iam` and `autoPauseMemory: false` are refused with 400, and the `Idempotency-Key` header is not supported. # Fork sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/fork-sandbox /compute/api-reference/e2b-openapi.yaml post /sandboxes/{sandboxID}/fork Fork the sandbox: checkpoint the running sandbox in place (it is briefly paused, snapshotted with its full memory state, and resumed on its node, keeping its ID and expiration untouched) and create count new sandboxes from that snapshot. Returns one result per requested fork, each carrying either the created sandbox or the error that prevented it from starting. A non-201 status means the request failed before any fork was attempted. Anona: the sandbox is frozen only while its memory delta is written (about 0.1 s, longer right after it has touched a lot of memory) and then keeps running with its own ID and expiration. Each fork is a new sandbox: it takes a slot of your concurrent-sandbox limit and is billed like any other sandbox, and the forks inherit the source sandbox's template, metadata, timeout policy and network policy, with the `timeout` of this request. `count` above the room left in your plan is not an error for the whole request: the forks that fit are created and each of the others carries `429` `sandbox_quota_exceeded` in its own result entry, while a platform at capacity reports `503` per fork. A source sandbox that is not running is `409`, and nothing is forked. # Kill sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/kill-sandbox /compute/api-reference/e2b-openapi.yaml delete /sandboxes/{sandboxID} Kill a sandbox # List running sandboxes Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/list-running-sandboxes /compute/api-reference/e2b-openapi.yaml get /sandboxes List all running sandboxes. Use GET /v2/sandboxes instead. # List sandboxes (v2) Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/list-sandboxes-v2 /compute/api-reference/e2b-openapi.yaml get /v2/sandboxes List all sandboxes # Pause sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/pause-sandbox /compute/api-reference/e2b-openapi.yaml post /sandboxes/{sandboxID}/pause Pause the sandbox # Refresh sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/refresh-sandbox /compute/api-reference/e2b-openapi.yaml post /sandboxes/{sandboxID}/refreshes Refresh the sandbox extending its time to live # Resume sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/resume-sandbox /compute/api-reference/e2b-openapi.yaml post /sandboxes/{sandboxID}/resume Resume the sandbox # Sandbox Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/sandbox /compute/api-reference/e2b-openapi.yaml get /sandboxes/{sandboxID} Get a sandbox by id # Set sandbox timeout Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/set-sandbox-timeout /compute/api-reference/e2b-openapi.yaml post /sandboxes/{sandboxID}/timeout Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration. Anona: `-1` removes the deadline when your plan allows it. # Update sandbox network Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/sandboxes/update-sandbox-network /compute/api-reference/e2b-openapi.yaml put /sandboxes/{sandboxID}/network Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting field clears it. Anona: the update replaces the sandbox's policy and goes through the same platform floor and plan limits as a create. An update without `allow_internet_access` keeps the sandbox's current internet setting. # Check template alias Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/check-template-alias /compute/api-reference/e2b-openapi.yaml get /templates/aliases/{alias} Check if template with given alias exists # Create template (v3) Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/create-template-v3 /compute/api-reference/e2b-openapi.yaml post /v3/templates Create a new template Anona builds the image with BuildKit in an isolated builder, adds the Anona runtime layer (envd), pushes it and registers it. Every `FROM` is pinned by digest, and `EXPOSE 49983` (envd) is refused. # Delete template Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/delete-template /compute/api-reference/e2b-openapi.yaml delete /templates/{templateID} Delete a template Anona: sandboxes already running from the template keep running; new creates no longer resolve its name. # List template builds Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/list-template-builds /compute/api-reference/e2b-openapi.yaml get /templates/{templateID} List all builds for a template # List templates Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/list-templates /compute/api-reference/e2b-openapi.yaml get /templates List all templates # List templates (v2) Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/list-templates-v2 /compute/api-reference/e2b-openapi.yaml get /v2/templates List all templates # Start template build (v2) Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/start-template-build-v2 /compute/api-reference/e2b-openapi.yaml post /v2/templates/{templateID}/builds/{buildID} Start the build # Template build file upload URL Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/template-build-file-upload-url /compute/api-reference/e2b-openapi.yaml get /templates/{templateID}/files/{hash} Get an upload link for a tar file containing build layer files # Template build logs Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/template-build-logs /compute/api-reference/e2b-openapi.yaml get /templates/{templateID}/builds/{buildID}/logs Get template build logs # Template build status Source: https://docs.anonalabs.com/compute/api-reference/sandboxes/templates/template-build-status /compute/api-reference/e2b-openapi.yaml get /templates/{templateID}/builds/{buildID}/status Get template build info # Changelog Source: https://docs.anonalabs.com/compute/changelog What changed, newest first. **2026-10-31** Delete, not Kill ## "Kill" is now "Delete" * The dashboard, the CLI and these docs say **Delete** where they said Kill. `anona-compute delete` is the command; `anona-compute kill` and `rm` still work, and the JSON they print is unchanged. * Nothing in the API changed: the E2B-compatible `DELETE /sandboxes/{id}`, the SDK's `kill()`, the MCP tool `kill_sandbox` and the `sandbox.lifecycle.killed` webhook event keep their names, so existing code runs as before. **2026-10-30** Fork ## Fork a running sandbox * **Fork**: copy a running sandbox, with its memory, files and running processes, into up to 100 new sandboxes (`POST /sandboxes/{id}/fork`, the stock E2B SDK's `fork`, `anona-compute fork`, and **Fork** on the dashboard's sandbox page). The original is frozen only while its memory delta is written - about 0.1 s - and then keeps running with the same ID and deadline. * Each fork is a new sandbox: it takes a slot of your concurrent-sandbox limit, inherits the original's template, metadata, timeout policy and network policy, and is billed like any other sandbox. A batch larger than your plan has room for creates the forks that fit and reports the reason for each of the others, rather than failing as a whole. * The dashboard's dialog asks how many copies and how long they live, prices the batch from your own rates before you commit, and lists the outcome of every copy. See [Fork](/compute/concepts/lifecycle#fork). **2026-10-29** Custom templates ## Build your own templates * **Template builds**: `anona-compute template build` from a Dockerfile, and the stock E2B SDK's `Template.build` and `e2b` CLI commands, served by the gateway (`/v3/templates`, file uploads, build status, logs, list, get, delete, aliases). Each build runs in its own rootless BuildKit container with no credentials and only proxied egress, gets the Anona runtime layer, and is registered for `create -t `. * **Templates page** in the dashboard: list, build status, build log, delete. MCP tool `list_templates`. * Template, build-concurrency and context-size quotas per organization. **2026-10-28** Preview links, idle pause ## Preview links, public ports, pause when idle * **Preview links**: signed, expiring links to one port of a sandbox (`anona-compute preview`, `POST /v1/sandboxes/{id}/preview-links`, MCP `get_preview_url`, the dashboard's **Share preview**), revocable at once. * **Public ports**: make one port public without opening the rest (`anona-compute preview --public`). * **Pause when idle**: a sandbox pauses itself after a set time with no traffic, sessions or processes (`PUT /v1/sandboxes/{id}/auto-pause`, `autoPause: {"idleMinutes": n}` on create, or the dashboard's **No limit (pauses when idle)**). **2026-10-28** Webhooks, telemetry, DOM view ## Webhooks, OpenTelemetry export, DOM live view, thumbnails * **Webhooks**: signed (Standard Webhooks) callbacks for sandbox lifecycle events, with retries, a delivery log and resend. See [Webhooks and telemetry](/compute/guides/webhooks). * **OpenTelemetry export**: per-sandbox CPU, memory and network metrics and lifecycle spans to your OTLP/HTTP endpoint. * **DOM live view**: the page open in a sandbox's Chrome, replayed in your browser. * **Thumbnails**: a WebP of each sandbox every 3 seconds, on the dashboard and at `GET /v1/sandboxes/{id}/thumbnail`. * New API key scope `integrations:manage` for the webhook and export settings. **2026-10-28** Network, vault, usage ## Network settings, credential vault, usage * **Team egress settings**: the default network policy of new sandboxes, on the dashboard's **Network** page or `GET`/`PUT /v1/network`, with `GET /v1/sandboxes/{id}/network/effective` to see which layer a sandbox's policy came from. * **Change a running sandbox's network**: E2B's `update_network` (`PUT /sandboxes/{id}/network`) now works. * **Credential vault**: store API tokens once and reference them in header rules as `${vault.}`; the egress proxy injects them and the sandbox never sees the value. * **Usage and billing**: `anona-compute usage`, the MCP `get_usage` tool, and `GET /v1/usage` and `/v1/billing` with the new `usage:read` and `billing:read` API key scopes. * **CLI & agent sessions**: a dashboard page lists every machine signed in with `anona-compute login` and revokes them; sign-outs, revocations and role changes now reach the API within seconds. **2026-10-28** Preview ## Developer preview **Sandboxes** * E2B-compatible API: create (v1 and v2), list, get, kill, connect, pause, resume, timeout and refresh, with E2B-shaped errors. Checked with the stock `e2b` SDK 2.20.3, 2.51.0 and 2.61.0. * Long-running sandboxes: `timeout=-1` for no deadline on plans without a maximum lifetime, and pause instead of kill at timeout. * Templates `base`, `python`, `node`, `code-interpreter`, `desktop` and `coding-agent`. * Egress policy at create: plan defaults, allow and deny lists, and a platform floor that always blocks private ranges and cloud metadata. * Every sandbox request passes the sandbox edge, which checks a per-sandbox token. **Access** * Live view of the terminal and the desktop, embeddable in your page, with short-lived viewer tokens and revoke-all. * SSH with 10-minute certificates: `anona-compute ssh`, `scp`, `--print-config` for editors. **Tools** * `anona-compute` CLI: browser sign-in, lifecycle, `exec`, `run`, `connect`, `ssh`, `scp`, `view`, `desktop`, profiles. * `anona-compute mcp`: an MCP server for Claude Code, Codex, Cursor and other clients, over stdio or loopback HTTP. * SDK extras `anona` (Python) and `@anona/sdk` (TypeScript): `sandbox.live` and `sandbox.ssh`. * Dashboard: organizations and roles, scoped API keys with expiry and sandbox lists, sandboxes with live view, usage and invoice drafts. ## Coming soon | Area | What | | - | - | | Distribution | `anona` on PyPI, `@anona/sdk` on npm, `anona-compute` release downloads | | Sandboxes | Fork, named snapshots, sandbox logs and metrics, resume on incoming traffic | | Access | SSH host certificates | | Account | Payments and issued invoices, self-set spend caps, team invitations | | Platform | More regions | # Configuration and profiles Source: https://docs.anonalabs.com/compute/cli/configuration Where the CLI keeps settings and credentials, and the variables it reads. Every setting resolves in this order: the flag, then the environment, then the selected profile in the config file. | Setting | Flag | Environment | Profile key | Default | | - | - | - | - | - | | API key | `--api-key` | `ANONA_COMPUTE_API_KEY`, `E2B_API_KEY` (and `ANONA_API_KEY` when it holds an `e2b_` key) | `api_key` | browser sign-in | | Domain | | `ANONA_COMPUTE_DOMAIN`, `E2B_DOMAIN` | `domain` | `compute.anonalabs.com` | | API URL | `--api-url` | `ANONA_COMPUTE_API_URL`, `E2B_API_URL` | `api_url` | `https://api.` | | Dashboard URL | `--web-url` (login) | `ANONA_COMPUTE_WEB_URL` | `web_url` | `https://app.` | | Default template | `-t` | | `default_template` | `base` | | Profile | `--profile` | `ANONA_COMPUTE_PROFILE` | | `default` | ```bash cli_config.sh theme={null} anona-compute config set default_template python anona-compute config get default_template anona-compute --profile staging config set domain compute.anonalabs.com anona-compute config list # every profile; API keys are masked ``` ## Files | What | Where | | - | - | | Settings | `$XDG_CONFIG_HOME/anona-compute/config.toml` (usually `~/.config/anona-compute/config.toml`; `ANONA_COMPUTE_CONFIG` overrides), mode 0600 | | Browser sign-in secret | The OS keychain (macOS Keychain, Windows Credential Manager, Secret Service on Linux), else `credentials.json` (mode 0600) next to `config.toml`. `ANONA_COMPUTE_CREDENTIAL_STORE=file` or `keyring` forces one | | SSH keys for `anona-compute ssh` | `$XDG_RUNTIME_DIR/anona-compute/ssh/`, ephemeral | The browser sign-in keeps a dashboard session for the machine (shown on the dashboard's **CLI & agent sessions** page as `CLI on `) and trades it for an API token that lasts 15 minutes. Signing out, revoking the session on that page, removal from the organization and role changes reach the API within seconds, also for tokens already issued; after a role change the CLI (and a running MCP server) fetches a new token on its own. The CLI never reads Anona Memory's settings or keys, and never sends an Anona Memory key to the compute API. # anona-compute CLI Source: https://docs.anonalabs.com/compute/cli/overview Sign in, manage sandboxes, run commands, SSH, live view and an MCP server for agents. `anona-compute` is one static binary for Linux, macOS and Windows (amd64 and arm64). It is called `anona-compute` because `anona` is the Anona Memory CLI; the two have separate sign-ins and settings. > [!NOTE] > > **Coming soon:** downloadable release binaries. ## Sign in ```text theme={null} anona-compute login # browser sign-in (device code); --no-browser prints the URL only anona-compute whoami # user, organization, role anona-compute logout # revokes this machine's session on the dashboard ``` Or use an API key, which wins over a browser sign-in: ```bash cli_key.sh theme={null} printenv ANONA_API_KEY | anona-compute login --with-api-key # saves it in the profile anona-compute whoami ANONA_COMPUTE_API_KEY=$ANONA_API_KEY anona-compute list # or E2B_API_KEY, or --api-key ``` ## A tour ```bash cli_tour.sh theme={null} SANDBOX_ID=$(anona-compute create -t base --timeout 30m --json | jq -r .sandboxID) anona-compute list anona-compute info "$SANDBOX_ID" anona-compute exec "$SANDBOX_ID" -- 'echo hello > /tmp/x && cat /tmp/x' anona-compute timeout "$SANDBOX_ID" 1h anona-compute pause "$SANDBOX_ID" anona-compute resume "$SANDBOX_ID" anona-compute view "$SANDBOX_ID" --mode terminal --control anona-compute delete "$SANDBOX_ID" ``` | Task | Commands | | - | - | | Lifecycle | [`create`](/compute/cli/reference/create), [`list`](/compute/cli/reference/list), [`info`](/compute/cli/reference/info), [`pause`](/compute/cli/reference/pause), [`resume`](/compute/cli/reference/resume), [`timeout`](/compute/cli/reference/timeout), [`delete`](/compute/cli/reference/delete) | | Run things | [`exec`](/compute/cli/reference/exec), [`run`](/compute/cli/reference/run), [`connect`](/compute/cli/reference/connect), [`logs`](/compute/cli/reference/logs) | | Access | [`ssh`](/compute/cli/reference/ssh), [`scp`](/compute/cli/reference/scp), [`view`](/compute/cli/reference/view), [`desktop`](/compute/cli/reference/desktop), [`preview`](/compute/cli/reference/preview) | | Agents | [`mcp`](/compute/cli/reference/mcp) | | Account | [`login`](/compute/cli/reference/login), [`logout`](/compute/cli/reference/logout), [`whoami`](/compute/cli/reference/whoami), [`usage`](/compute/cli/reference/usage), [`config`](/compute/cli/reference/config) | | Templates | [`template build`](/compute/cli/reference/template-build), [`list`](/compute/cli/reference/template-list), [`info`](/compute/cli/reference/template-info), [`logs`](/compute/cli/reference/template-logs), [`delete`](/compute/cli/reference/template-delete) | | Coming soon | [`fork`](/compute/cli/reference/fork), [`keys`](/compute/cli/reference/keys), [`limits`](/compute/cli/reference/limits), [`push`](/compute/cli/reference/push), [`recover`](/compute/cli/reference/recover) | `sandbox` (alias `sbx`) groups the lifecycle commands: `anona-compute sbx ls` is `anona-compute list`. ## Output and exit codes `--json` (or `-o json`) prints one JSON object per line on stdout; messages go to stderr. `exec`, `run` and `connect` exit with the remote command's exit code, so the CLI composes in shell scripts and CI. # anona-compute Source: https://docs.anonalabs.com/compute/cli/reference/anona-compute Manage Anona Compute sandboxes from the terminal ```text theme={null} anona-compute manages Anona Compute sandboxes through the E2B-compatible API. Configuration comes from flags, then the environment (ANONA_COMPUTE_API_KEY or E2B_API_KEY, ANONA_COMPUTE_DOMAIN or E2B_DOMAIN, ANONA_COMPUTE_API_URL or E2B_API_URL), then the profile in $XDG_CONFIG_HOME/anona-compute/config.toml. ``` ## Usage ```text theme={null} anona-compute [flags] anona-compute [command] ``` ## Commands | Command | Description | | - | - | | [`config`](/compute/cli/reference/config) | Read and write profile settings | | [`connect`](/compute/cli/reference/connect) | Open an interactive shell in a sandbox | | [`create`](/compute/cli/reference/create) | Create sandboxes | | [`delete`](/compute/cli/reference/delete) | Delete sandboxes | | [`desktop`](/compute/cli/reference/desktop) | Open a sandbox desktop | | [`exec`](/compute/cli/reference/exec) | Run a command in a sandbox | | [`fork`](/compute/cli/reference/fork) | Fork a running sandbox into new sandboxes | | [`info`](/compute/cli/reference/info) | Show one sandbox | | [`keys`](/compute/cli/reference/keys) | Manage API keys (not available yet) | | [`limits`](/compute/cli/reference/limits) | Show plan limits and usage (not available yet) | | [`list`](/compute/cli/reference/list) | List sandboxes | | [`login`](/compute/cli/reference/login) | Sign in through the browser (or save an API key with --with-api-key) | | [`logout`](/compute/cli/reference/logout) | Sign out: revoke the CLI session on the dashboard and remove local credentials | | [`logs`](/compute/cli/reference/logs) | Show sandbox logs | | [`mcp`](/compute/cli/reference/mcp) | Run an MCP server so AI agents can use Anona Compute | | [`pause`](/compute/cli/reference/pause) | Pause a sandbox | | [`preview`](/compute/cli/reference/preview) | Share a sandbox port with a signed preview link | | [`push`](/compute/cli/reference/push) | Push a git working tree into a sandbox (not available yet) | | [`recover`](/compute/cli/reference/recover) | Recover a sandbox from its last snapshot (not available yet) | | [`resume`](/compute/cli/reference/resume) | Resume a paused sandbox | | [`run`](/compute/cli/reference/run) | Create a sandbox, run one command, then delete the sandbox | | [`sandbox`](/compute/cli/reference/sandbox) | Sandbox commands (same as the top-level create, list, info, ...) | | [`scp`](/compute/cli/reference/scp) | Copy files to or from a sandbox over SSH | | [`ssh`](/compute/cli/reference/ssh) | SSH into a sandbox | | [`template`](/compute/cli/reference/template) | Build and manage templates | | [`timeout`](/compute/cli/reference/timeout) | Set how long a sandbox lives from now | | [`usage`](/compute/cli/reference/usage) | Show usage and cost for a billing period | | [`version`](/compute/cli/reference/version) | Print the CLI version | | [`view`](/compute/cli/reference/view) | Open a live view of a sandbox | | [`whoami`](/compute/cli/reference/whoami) | Show who the CLI is signed in as | ## Flags | Flag | Type | Description | | - | - | - | | `--api-key` | string | API key (env ANONA\_COMPUTE\_API\_KEY, E2B\_API\_KEY) | | `--api-url` | string | API URL (env ANONA\_COMPUTE\_API\_URL, E2B\_API\_URL; default [https://api.\<E2B\_DOMAIN](https://api.\<E2B_DOMAIN)>) | | `--help`, `-h` | | help for anona-compute | | `--json` | | JSON output: one object per line on stdout (same as -o json) | | `--output`, `-o` | string | output format: text or json (default "text") | | `--profile` | string | config profile to use (env ANONA\_COMPUTE\_PROFILE; default "default") | | `--request-timeout` | duration | timeout for each API request (0 = none) | | `--verbose`, `-v` | | verbose output on stderr | # anona-compute config Source: https://docs.anonalabs.com/compute/cli/reference/config Read and write profile settings ```text theme={null} Read and write settings of the selected profile (--profile, default "default"). Keys: api_url, api_key, domain, default_template, web_url. The file is written with mode 0600. ``` ## Usage ```text theme={null} anona-compute config [flags] anona-compute config [command] ``` ## Commands | Command | Description | | - | - | | [`config get`](/compute/cli/reference/config-get) | Print one setting | | [`config list`](/compute/cli/reference/config-list) | List every profile and its settings (API keys are masked) | | [`config set`](/compute/cli/reference/config-set) | Change one setting | ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for config | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute config get Source: https://docs.anonalabs.com/compute/cli/reference/config-get Print one setting ```text theme={null} Print one setting ``` ## Usage ```text theme={null} anona-compute config get KEY [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for get | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute config list Source: https://docs.anonalabs.com/compute/cli/reference/config-list List every profile and its settings (API keys are masked) ```text theme={null} List every profile and its settings (API keys are masked) ``` ## Usage ```text theme={null} anona-compute config list [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for list | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute config set Source: https://docs.anonalabs.com/compute/cli/reference/config-set Change one setting ```text theme={null} Change one setting ``` ## Usage ```text theme={null} anona-compute config set KEY VALUE [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for set | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute connect Source: https://docs.anonalabs.com/compute/cli/reference/connect Open an interactive shell in a sandbox ```text theme={null} Open an interactive login shell (/bin/bash -i -l) on a PTY through envd, the same channel the E2B SDK's sandbox.pty uses; no sshd is needed. The local terminal is put in raw mode and resizes follow it. Type Ctrl-] then d to detach (the shell keeps running). anona-compute exits with the shell's exit code. ``` ## Usage ```text theme={null} anona-compute connect SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--cwd`, `-w` | string | working directory | | `--help`, `-h` | | help for connect | | `--user`, `-u` | string | user to run the shell as (default: the sandbox's default user) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute create Source: https://docs.anonalabs.com/compute/cli/reference/create Create sandboxes ```text theme={null} Create one or more sandboxes (POST /v2/sandboxes) and print their IDs. CPU and memory come from the template; the E2B create request has no fields for them. ``` ## Usage ```text theme={null} anona-compute create [flags] ``` **Aliases:** `create`, `new` ## Flags | Flag | Type | Description | | - | - | - | | `--auto-pause` | | pause instead of delete when the timeout ends | | `--count` | int | number of sandboxes to create (1-100) (default 1) | | `--env`, `-e` | stringArray | environment variable KEY=VALUE for the sandbox (repeatable) | | `--fail-fast` | | with --count, stop at the first failure | | `--help`, `-h` | | help for create | | `--metadata` | stringArray | metadata KEY=VALUE (repeatable) | | `--no-internet` | | create the sandbox without internet access | | `--template`, `-t` | string | template ID or alias (default: profile default\_template, else "base") | | `--timeout` | string | sandbox lifetime before it is deleted or paused (e.g. 300s, 1h) (default "300s") | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute delete Source: https://docs.anonalabs.com/compute/cli/reference/delete Delete sandboxes ```text theme={null} Delete sandboxes ``` ## Usage ```text theme={null} anona-compute delete SANDBOX_ID... [flags] ``` **Aliases:** `delete`, `kill`, `rm` ## Flags | Flag | Type | Description | | - | - | - | | `--all` | | delete every running and paused sandbox | | `--help`, `-h` | | help for delete | | `--yes` | | confirm --all | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute desktop Source: https://docs.anonalabs.com/compute/cli/reference/desktop Open a sandbox desktop ```text theme={null} Reach the desktop of a sandbox (desktop template). anona-compute desktop ID print the live-view URL (control) anona-compute desktop ID --vnc 127.0.0.1:5901 serve the desktop as plain VNC (RFB) on a local port for any VNC client; each client gets its own session through the relay anona-compute desktop ID --mode novnc print the E2B-compatible noVNC URL on port 6080 (started by the e2b-desktop SDK) ``` ## Usage ```text theme={null} anona-compute desktop SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for desktop | | `--mode` | string | live (Anona relay) or novnc (E2B noVNC URL) (default "live") | | `--view-only` | | with --vnc: view scope (the sandbox serves a view-only VNC server) | | `--vnc` | string | serve the desktop as VNC on this local address (for example 127.0.0.1:5901) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute exec Source: https://docs.anonalabs.com/compute/cli/reference/exec Run a command in a sandbox ```text theme={null} Run a command in a running sandbox and stream its output. anona-compute exits with the command's exit code. With one argument the command is a shell string (anona-compute exec ID -- 'ls | wc -l'); several arguments are quoted and joined. With --json, output is one object per line: {"type":"stdout"|"stderr","data":...} and finally {"type":"exit","exitCode":N}. ``` ## Usage ```text theme={null} anona-compute exec SANDBOX_ID -- COMMAND [ARGS...] [flags] ``` **Aliases:** `exec`, `ex` ## Flags | Flag | Type | Description | | - | - | - | | `--background`, `-b` | | start the command, print its PID and return | | `--cmd-timeout` | duration | stop waiting for the command after this long (0 = no limit) | | `--cwd`, `-w` | string | working directory | | `--env`, `-e` | stringArray | environment variable KEY=VALUE for the command (repeatable) | | `--help`, `-h` | | help for exec | | `--user`, `-u` | string | user to run as (default: the sandbox's default user) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute fork Source: https://docs.anonalabs.com/compute/cli/reference/fork Fork a running sandbox into new sandboxes > [!NOTE] > > Forking freezes the sandbox for a moment while its memory is written (about 0.1 s), then it keeps running with the same ID and lifetime. Each fork is a new sandbox: it counts towards your concurrent-sandbox limit and is billed like any other. `--count` above what your plan has room for is not an error for the whole batch - the forks that fit are made and the rest are reported one by one. ```text theme={null} Fork a sandbox (POST /sandboxes/{id}/fork): checkpoint it with its memory and start --count new sandboxes from that checkpoint. Prints one line per fork. ``` ## Usage ```text theme={null} anona-compute fork SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--count` | int | number of forks (1-20) (default 1) | | `--help`, `-h` | | help for fork | | `--timeout` | string | lifetime of each fork (empty = server default, 15s in the E2B spec) (default "300s") | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute info Source: https://docs.anonalabs.com/compute/cli/reference/info Show one sandbox ```text theme={null} Show one sandbox ``` ## Usage ```text theme={null} anona-compute info SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for info | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute keys Source: https://docs.anonalabs.com/compute/cli/reference/keys Manage API keys (not available yet) > [!WARNING] > > **Coming soon.** This command is a placeholder: it prints what it needs and exits 1. ```text theme={null} Manage API keys (not available yet) ``` ## Usage ```text theme={null} anona-compute keys [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for keys | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute limits Source: https://docs.anonalabs.com/compute/cli/reference/limits Show plan limits and usage (not available yet) > [!WARNING] > > **Coming soon.** This command is a placeholder: it prints what it needs and exits 1. ```text theme={null} Show plan limits and usage (not available yet) ``` ## Usage ```text theme={null} anona-compute limits [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for limits | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute list Source: https://docs.anonalabs.com/compute/cli/reference/list List sandboxes ```text theme={null} List sandboxes ``` ## Usage ```text theme={null} anona-compute list [flags] ``` **Aliases:** `list`, `ls` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for list | | `--limit` | int | show at most N sandboxes (0 = all) | | `--metadata` | stringArray | only sandboxes with metadata KEY=VALUE (repeatable) | | `--state`, `-s` | string | running, paused or all (default "running") | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute login Source: https://docs.anonalabs.com/compute/cli/reference/login Sign in through the browser (or save an API key with --with-api-key) ```text theme={null} Sign in to Anona Compute. Without flags, anona-compute login starts a device sign-in (RFC 8628): it prints a URL and a code, opens the browser, and waits while you sign in to the dashboard, check the code, pick the organization and approve. The CLI then holds a dashboard session for this machine and trades it for gateway tokens that last 15 minutes. The session secret is stored in the OS keychain when one is available, else in credentials.json (mode 0600) next to config.toml; ANONA_COMPUTE_CREDENTIAL_STORE=file|keyring forces one. anona-compute logout revokes it. --with-api-key reads an API key (e2b_ + lowercase hex) from stdin and saves it to the profile instead. The dashboard is --web-url, else ANONA_COMPUTE_WEB_URL, else the profile's web_url, else https://app.. ``` ## Usage ```text theme={null} anona-compute login [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for login | | `--no-browser` | | print the sign-in URL but do not open a browser | | `--org` | string | organization slug to act in (default: the one picked in the browser) | | `--web-url` | string | dashboard URL (env ANONA\_COMPUTE\_WEB\_URL; default [https://app.\<domain](https://app.\<domain)>) | | `--with-api-key` | | read an API key from stdin instead of signing in through the browser | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute logout Source: https://docs.anonalabs.com/compute/cli/reference/logout Sign out: revoke the CLI session on the dashboard and remove local credentials ```text theme={null} Sign out of the selected profile. A browser login's session is deleted on the dashboard first (gateway tokens already issued from it stop being renewed and expire within 15 minutes), then removed from the keychain or credentials file. An API key saved with --with-api-key is removed from the profile (revoke the key itself in the dashboard). ``` ## Usage ```text theme={null} anona-compute logout [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for logout | | `--local` | | only remove local credentials; do not contact the dashboard | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute logs Source: https://docs.anonalabs.com/compute/cli/reference/logs Show sandbox logs ```text theme={null} Show sandbox logs (GET /v2/sandboxes/{id}/logs), oldest first. --follow keeps polling for new lines until the sandbox ends or you press Ctrl-C (exit 0). ``` ## Usage ```text theme={null} anona-compute logs SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--follow`, `-f` | | keep printing new lines | | `--help`, `-h` | | help for logs | | `--level` | string | minimum level: debug, info, warn or error | | `--search` | string | only lines containing this text (case-sensitive) | | `--since` | string | only lines newer than this (e.g. 10m, 1h) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute mcp Source: https://docs.anonalabs.com/compute/cli/reference/mcp Run an MCP server so AI agents can use Anona Compute ```text theme={null} Serve the Model Context Protocol so AI agents (Claude Code, Codex, Cursor and other MCP clients) can create sandboxes, run commands, move files and delete sandboxes with this CLI's credentials (anona-compute login, or an API key). By default the server speaks MCP over stdio; the agent starts it: claude mcp add anona-compute -- anona-compute mcp codex mcp add anona-compute -- anona-compute mcp --http 127.0.0.1:8765 serves streamable HTTP at http://127.0.0.1:8765/mcp instead. Only loopback addresses are allowed, and every request must carry Authorization: Bearer , where the token is ANONA_COMPUTE_MCP_TOKEN or a random one printed on stderr at start. Every tool call goes through the gateway with your credential, so it can do exactly what you can. A viewer (or --read-only) gets the read-only tools only. ``` ## Usage ```text theme={null} anona-compute mcp [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for mcp | | `--http` | string | serve streamable HTTP on this loopback address (for example 127.0.0.1:8765) instead of stdio | | `--read-only` | | offer only the tools that do not change sandboxes | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute pause Source: https://docs.anonalabs.com/compute/cli/reference/pause Pause a sandbox > [!NOTE] > > `--mode filesystem` is refused by the gateway: filesystem-only snapshots are not available, so pause always keeps memory. ```text theme={null} Pause a sandbox (POST /sandboxes/{id}/pause). --mode full keeps memory, so running processes continue after resume. --mode filesystem keeps only the disk; resume cold-boots the sandbox. ``` ## Usage ```text theme={null} anona-compute pause SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for pause | | `--mode` | string | full (memory and disk) or filesystem (disk only) (default "full") | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute preview Source: https://docs.anonalabs.com/compute/cli/reference/preview Share a sandbox port with a signed preview link ```text theme={null} Print a shareable link to one port of a sandbox, for example a dev server on 3000. Anyone with the link can open that port in a browser until it expires (--ttl, default 1h, at most 24h): the sandbox edge exchanges it for a cookie scoped to that port's host. Other ports and envd stay private. anona-compute preview 3000 anona-compute preview 3000 --ttl 15m anona-compute preview --revoke end every link (and live-view token) of the sandbox anona-compute preview 3000 --public serve the port to anyone, no link needed anona-compute preview 3000 --private back to links only ``` ## Usage ```text theme={null} anona-compute preview SANDBOX_ID [PORT] [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for preview | | `--private` | | make the port private again | | `--public` | | make the port public instead of printing a link | | `--revoke` | | revoke every preview link and live-view token of the sandbox | | `--ttl` | duration | link lifetime, 1m to 24h (default 1h) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute push Source: https://docs.anonalabs.com/compute/cli/reference/push Push a git working tree into a sandbox (not available yet) > [!WARNING] > > **Coming soon.** This command is a placeholder: it prints what it needs and exits 1. ```text theme={null} Push a git working tree into a sandbox (not available yet) ``` ## Usage ```text theme={null} anona-compute push [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for push | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute recover Source: https://docs.anonalabs.com/compute/cli/reference/recover Recover a sandbox from its last snapshot (not available yet) > [!WARNING] > > **Coming soon.** This command is a placeholder: it prints what it needs and exits 1. ```text theme={null} Recover a sandbox from its last snapshot (not available yet) ``` ## Usage ```text theme={null} anona-compute recover [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for recover | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute resume Source: https://docs.anonalabs.com/compute/cli/reference/resume Resume a paused sandbox ```text theme={null} Resume a paused sandbox (POST /v2/sandboxes/{id}/connect). A running sandbox is left running; its timeout is only ever extended. ``` ## Usage ```text theme={null} anona-compute resume SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for resume | | `--timeout` | string | sandbox lifetime from now (default "300s") | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute run Source: https://docs.anonalabs.com/compute/cli/reference/run Create a sandbox, run one command, then delete the sandbox ```text theme={null} Create a sandbox, run one command in it, print a summary on stderr, and delete the sandbox (also on error or Ctrl-C). anona-compute exits with the command's exit code. Cost is printed when the API reports it; the current API does not, so the summary says "unknown" rather than guessing. ``` ## Usage ```text theme={null} anona-compute run [flags] -- COMMAND [ARGS...] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--auto-pause` | | pause instead of delete when the timeout ends | | `--cmd-timeout` | duration | stop waiting for the command after this long (0 = no limit) | | `--cwd`, `-w` | string | working directory | | `--env`, `-e` | stringArray | environment variable KEY=VALUE for the sandbox (repeatable) | | `--help`, `-h` | | help for run | | `--keep` | | do not delete the sandbox after the command | | `--metadata` | stringArray | metadata KEY=VALUE (repeatable) | | `--no-internet` | | create the sandbox without internet access | | `--template`, `-t` | string | template ID or alias (default: profile default\_template, else "base") | | `--timeout` | string | sandbox lifetime before it is deleted or paused (e.g. 300s, 1h) (default "300s") | | `--user`, `-u` | string | user to run as (default: the sandbox's default user) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute sandbox Source: https://docs.anonalabs.com/compute/cli/reference/sandbox Sandbox commands (same as the top-level create, list, info, ...) ```text theme={null} Sandbox commands (same as the top-level create, list, info, ...) ``` ## Usage ```text theme={null} anona-compute sandbox [flags] anona-compute sandbox [command] ``` **Aliases:** `sandbox`, `sbx` ## Commands | Command | Description | | - | - | | [`sandbox create`](/compute/cli/reference/create) | Create sandboxes | | [`sandbox delete`](/compute/cli/reference/delete) | Delete sandboxes | | [`sandbox exec`](/compute/cli/reference/exec) | Run a command in a sandbox | | [`sandbox fork`](/compute/cli/reference/fork) | Fork a running sandbox into new sandboxes | | [`sandbox info`](/compute/cli/reference/info) | Show one sandbox | | [`sandbox list`](/compute/cli/reference/list) | List sandboxes | | [`sandbox logs`](/compute/cli/reference/logs) | Show sandbox logs | | [`sandbox pause`](/compute/cli/reference/pause) | Pause a sandbox | | [`sandbox resume`](/compute/cli/reference/resume) | Resume a paused sandbox | | [`sandbox timeout`](/compute/cli/reference/timeout) | Set how long a sandbox lives from now | ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for sandbox | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute scp Source: https://docs.anonalabs.com/compute/cli/reference/scp Copy files to or from a sandbox over SSH ```text theme={null} Copy files with the system scp through the same certificate and relay as anona-compute ssh. Write sandbox paths with a leading colon: anona-compute scp sbx_123 ./data.csv :/home/user/ anona-compute scp sbx_123 -- -r :/home/user/out ./out ``` ## Usage ```text theme={null} anona-compute scp SANDBOX_ID [SCP_FLAGS...] SOURCE... TARGET [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for scp | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute ssh Source: https://docs.anonalabs.com/compute/cli/reference/ssh SSH into a sandbox ```text theme={null} Open an OpenSSH session to a sandbox. anona-compute makes an ephemeral Ed25519 key under $XDG_RUNTIME_DIR/anona-compute/ssh/, has the gateway sign it (a 10-minute certificate for the sandbox user), and runs the system ssh with ProxyCommand "anona-compute ssh --proxy %n", which carries the connection over the stream relay. Arguments after -- go to ssh (a remote command, -L, -R, ...). --print-config prints an ssh_config block, so scp, rsync and editors can use "Host " directly; the certificate in it expires after 10 minutes, so run it again (or anona-compute ssh) to renew. ``` ## Usage ```text theme={null} anona-compute ssh SANDBOX_ID [-- SSH_ARGS...] [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for ssh | | `--print-config` | | print an ssh\_config block for the sandbox instead of connecting | | `--proxy` | | act as the OpenSSH ProxyCommand: pipe stdin/stdout to the sandbox sshd | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute template Source: https://docs.anonalabs.com/compute/cli/reference/template Build and manage templates ```text theme={null} Build and manage templates ``` ## Usage ```text theme={null} anona-compute template [flags] anona-compute template [command] ``` **Aliases:** `template`, `templates`, `tpl` ## Commands | Command | Description | | - | - | | [`template build`](/compute/cli/reference/template-build) | Build a template from a Dockerfile | | [`template delete`](/compute/cli/reference/template-delete) | Delete a template (running sandboxes keep running) | | [`template info`](/compute/cli/reference/template-info) | Show a template and its builds | | [`template list`](/compute/cli/reference/template-list) | List your templates | | [`template logs`](/compute/cli/reference/template-logs) | Print a build's log (the newest build unless --build), following it while it runs | ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for template | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute template build Source: https://docs.anonalabs.com/compute/cli/reference/template-build Build a template from a Dockerfile ```text theme={null} Builds a template from a Dockerfile and a build context (default: the current directory), streaming the build log. The gateway builds it with BuildKit in an isolated builder, adds the Anona runtime (envd), pushes it and registers it; sandboxes then start from it by name: anona-compute template build -f Dockerfile --name my-env anona-compute create -t my-env Every FROM image is pinned by digest. EXPOSE 49983 (envd) is refused. The image's CMD runs at sandbox start only when the last stage declares CMD or ENTRYPOINT, or with --start-cmd. .dockerignore in the context is honored. ``` ## Usage ```text theme={null} anona-compute template build [CONTEXT_DIR] [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--cpu` | int | vCPUs of sandboxes from this template (default 2) | | `--file`, `-f` | string | Dockerfile (default CONTEXT\_DIR/Dockerfile) | | `--help`, `-h` | | help for build | | `--memory` | int | memory of sandboxes from this template, MiB (default 2048) | | `--name`, `-n` | string | template name (default: the context directory's name) | | `--no-cache` | | build without the build cache | | `--start-cmd` | string | command to run when a sandbox starts (after envd) | | `--tag` | stringArray | tag for this build (repeatable) | | `--wait` | | stream the build log and wait until it is ready (--wait=false returns at once) (default true) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute template delete Source: https://docs.anonalabs.com/compute/cli/reference/template-delete Delete a template (running sandboxes keep running) ```text theme={null} Delete a template (running sandboxes keep running) ``` ## Usage ```text theme={null} anona-compute template delete NAME|TEMPLATE_ID [flags] ``` **Aliases:** `delete`, `rm` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for delete | | `--yes`, `-y` | | confirm the deletion | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute template info Source: https://docs.anonalabs.com/compute/cli/reference/template-info Show a template and its builds ```text theme={null} Show a template and its builds ``` ## Usage ```text theme={null} anona-compute template info NAME|TEMPLATE_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for info | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute template list Source: https://docs.anonalabs.com/compute/cli/reference/template-list List your templates ```text theme={null} List your templates ``` ## Usage ```text theme={null} anona-compute template list [flags] ``` **Aliases:** `list`, `ls` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for list | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute template logs Source: https://docs.anonalabs.com/compute/cli/reference/template-logs Print a build's log (the newest build unless --build), following it while it runs ```text theme={null} Print a build's log (the newest build unless --build), following it while it runs ``` ## Usage ```text theme={null} anona-compute template logs NAME|TEMPLATE_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--build` | string | build ID (default: the newest) | | `--help`, `-h` | | help for logs | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute timeout Source: https://docs.anonalabs.com/compute/cli/reference/timeout Set how long a sandbox lives from now > [!NOTE] > > The CLI takes positive durations. To remove the deadline (`-1`), use the API, the dashboard or the MCP `extend_sandbox` tool; see [Lifecycle](/compute/concepts/lifecycle). ```text theme={null} Set the sandbox timeout (POST /sandboxes/{id}/timeout). The sandbox ends DURATION from now (e.g. 600, 10m, 2h); each call replaces the previous value. ``` ## Usage ```text theme={null} anona-compute timeout SANDBOX_ID DURATION [flags] ``` **Aliases:** `timeout`, `extend` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for timeout | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute usage Source: https://docs.anonalabs.com/compute/cli/reference/usage Show usage and cost for a billing period ```text theme={null} Show the organization's usage and cost for a billing period (GET /v1/usage on the gateway): meters, totals with the part not yet rated, a projection for the current month, and the most expensive sandboxes. Browser logins of any role may read usage; API keys need the usage:read scope. --billing prints the plan, rates and invoices instead (GET /v1/billing): owners and admins, or keys with billing:read. --json prints the gateway's whole document. ``` ## Usage ```text theme={null} anona-compute usage [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--billing` | | show plan, rates and invoices instead (owners, admins, keys with billing:read) | | `--help`, `-h` | | help for usage | | `--period` | string | billing period: current or YYYY-MM (default "current") | | `--top` | int | list this many of the most expensive sandboxes (0 = none) (default 5) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute version Source: https://docs.anonalabs.com/compute/cli/reference/version Print the CLI version ```text theme={null} Print the CLI version ``` ## Usage ```text theme={null} anona-compute version [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for version | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute view Source: https://docs.anonalabs.com/compute/cli/reference/view Open a live view of a sandbox ```text theme={null} Mint a live-view token and print the embed URL (the token is in the URL fragment, which browsers never send to a server). Modes: dom (browser agents), terminal (a new shell; needs --control) and desktop. ``` ## Usage ```text theme={null} anona-compute view SANDBOX_ID [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--control` | | allow input (control scope) | | `--help`, `-h` | | help for view | | `--mode` | string | dom, terminal or desktop (default "dom") | | `--ttl` | duration | token lifetime, 30s to 10m (default 10m0s) | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # anona-compute whoami Source: https://docs.anonalabs.com/compute/cli/reference/whoami Show who the CLI is signed in as ```text theme={null} Show the account behind the current credentials. For a browser login this asks the dashboard (user, organization, role); for an API key it checks the key against the gateway. ``` ## Usage ```text theme={null} anona-compute whoami [flags] ``` ## Flags | Flag | Type | Description | | - | - | - | | `--help`, `-h` | | help for whoami | Global flags (`--api-key`, `--api-url`, `--json`, `--output`, `--profile`, `--request-timeout`, `--verbose`) work on every command; see [anona-compute](/compute/cli/reference/anona-compute). # Lifecycle and long-running sandboxes Source: https://docs.anonalabs.com/compute/concepts/lifecycle Timeouts, no-limit sandboxes, pause and resume. ```mermaid theme={null} stateDiagram-v2 [*] --> running: create running --> paused: pause, idle, or timeout with on_timeout=pause paused --> running: connect / resume running --> [*]: delete, or timeout (default) paused --> [*]: delete ``` ## Timeouts Every sandbox has a timeout: the number of seconds from now until its timeout action runs. The action is **delete** by default, or **pause** if you ask for it. Each call to `set_timeout` replaces the previous value, counted from that moment. | Where | Default | | - | - | | SDK `Sandbox.create()` | 300 s | | CLI `create`, `run` | `--timeout 300s` | | API `POST /v2/sandboxes` | 300 s | | API `POST /sandboxes` (v1) | 15 s | ```python timeouts.py theme={null} from anona import Sandbox sbx = Sandbox.create("base", timeout=600) # 10 minutes sbx.set_timeout(3600) # now one hour from this call print(sbx.get_info().end_at) sbx.kill() ``` ```typescript timeouts.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base", { timeoutMs: 600_000 }); // 10 minutes await sbx.setTimeout(3_600_000); // one hour from now console.log((await sbx.getInfo()).endAt); await sbx.kill(); ``` ```bash timeouts.sh theme={null} SANDBOX_ID=$(anona-compute create -t base --timeout 10m --json | jq -r .sandboxID) anona-compute timeout "$SANDBOX_ID" 1h anona-compute delete "$SANDBOX_ID" ``` ## No time limit On plans without a maximum lifetime, a timeout of `-1` means **no deadline**: the sandbox runs until you pause or delete it. It reports `endAt` as `9999-12-31T23:59:59Z`, because the E2B schema requires a date. A sandbox without a deadline that you pause and resume keeps having no deadline. ```python no_limit.py theme={null} from anona import Sandbox sbx = Sandbox.create("base", timeout=-1) print(sbx.get_info().end_at) # 9999-12-31 23:59:59+00:00 sbx.kill() ``` ```bash no_limit_api.sh theme={null} # The REST API takes -1 directly; so do the dashboard and the MCP create_sandbox tool. curl -sS https://api.compute.anonalabs.com/v2/sandboxes \ -H "X-API-Key: $E2B_API_KEY" -H 'Content-Type: application/json' \ -d '{"templateID": "base", "timeout": -1}' | jq -r .sandboxID > sandbox.id curl -sS -X DELETE "https://api.compute.anonalabs.com/sandboxes/$(cat sandbox.id)" -H "X-API-Key: $E2B_API_KEY" ``` A plan with a maximum lifetime (the free plan's is one hour) refuses `-1` and any longer timeout with `400`, naming the limit. The `anona-compute` CLI takes positive durations only. > [!WARNING] > > A sandbox without a deadline is billed for as long as it runs. Let it [pause when idle](#pause-when-idle): paused sandboxes cost only snapshot storage. ## Pause and resume Pausing snapshots the sandbox's memory and disk and stops the VM. Resuming restores it: running processes, open files and `/dev/shm` carry on where they were. Paused sandboxes are kept until you delete them; they count against your plan's paused-sandbox limit, and their snapshot storage is billed per GB-month. ```python pause_resume.py theme={null} from anona import Sandbox sbx = Sandbox.create("base") sbx.commands.run("echo kept > /dev/shm/marker") sbx.pause() sbx = Sandbox.connect(sbx.sandbox_id) # resumes a paused sandbox print(sbx.commands.run("cat /dev/shm/marker").stdout) sbx.kill() ``` ```typescript pause_resume.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base"); await sbx.commands.run("echo kept > /dev/shm/marker"); await sbx.pause(); const again = await Sandbox.connect(sbx.sandboxId); // resumes a paused sandbox console.log((await again.commands.run("cat /dev/shm/marker")).stdout); await again.kill(); ``` ```bash pause_resume.sh theme={null} SANDBOX_ID=$(anona-compute create -t base --json | jq -r .sandboxID) anona-compute pause "$SANDBOX_ID" anona-compute list --state paused anona-compute resume "$SANDBOX_ID" anona-compute delete "$SANDBOX_ID" ``` On the engine's test hosts, a pause took 0.1 to 0.3 s and a resume 0.8 to 0.9 s, with memory, uptime and a detached process kept. Production numbers are not published yet. ## Pause on timeout Ask for pause instead of delete when the timeout runs out. The sandbox then waits, paused, until you resume it: ```python pause_on_timeout.py theme={null} from anona import Sandbox sbx = Sandbox.create("base", timeout=3600, lifecycle={"on_timeout": "pause"}) print(sbx.get_info().state) sbx.kill() ``` ```typescript pause_on_timeout.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base", { timeoutMs: 3_600_000, lifecycle: { onTimeout: "pause" } }); console.log((await sbx.getInfo()).state); await sbx.kill(); ``` ```bash pause_on_timeout.sh theme={null} SANDBOX_ID=$(anona-compute create -t base --timeout 1h --auto-pause --json | jq -r .sandboxID) anona-compute info "$SANDBOX_ID" anona-compute delete "$SANDBOX_ID" ``` `auto_resume` (resume when a request reaches the sandbox's URL) is accepted with pause-on-timeout, but waking on traffic is **Coming soon**: a request to a paused sandbox's URL gets `409` today. Resume with `connect`. ## Pause when idle A sandbox can pause itself once nothing has used it for a while: no request to any of its ports, no open terminal, live view, SSH session or byte pipe, and no running process started through `envd`. Starting to run again (resume) restarts the clock. This is what the dashboard's lifetime **No limit (pauses when idle)** sets up, with 15 minutes. ```python idle_pause.py theme={null} import os import httpx from anona import Sandbox sbx = Sandbox.create("base", timeout=-1) # no deadline ... api = httpx.Client(base_url="https://api.compute.anonalabs.com", headers={"X-API-Key": os.environ["E2B_API_KEY"]}) r = api.put(f"/v1/sandboxes/{sbx.sandbox_id}/auto-pause", json={"idleMinutes": 15}) # ... but pause after 15 idle minutes r.raise_for_status() print(r.json()) # {"sandboxID": ..., "allowPublicTraffic": ..., "publicPorts": [], "idlePauseMinutes": 15} sbx.kill() ``` ```bash idle_pause_api.sh theme={null} # Or at create time, over the API: autoPause takes {"idleMinutes": n} (0 = off; {} = the team default, else 15). curl -sS https://api.compute.anonalabs.com/v2/sandboxes -H "X-API-Key: $E2B_API_KEY" -H 'Content-Type: application/json' \ -d '{"templateID": "base", "timeout": -1, "autoPause": {"idleMinutes": 30}}' | jq -r .sandboxID > sandbox.id curl -sS "https://api.compute.anonalabs.com/v1/sandboxes/$(cat sandbox.id)/access" -H "X-API-Key: $E2B_API_KEY" | jq .idlePauseMinutes curl -sS -X DELETE "https://api.compute.anonalabs.com/sandboxes/$(cat sandbox.id)" -H "X-API-Key: $E2B_API_KEY" ``` `idleMinutes` ranges from 1 to 10080 (one week); `0` turns it off. The SDK's `lifecycle={"on_timeout": "pause"}` (`autoPause: true` on the wire) pauses at the timeout, and idles only if your organization has a default idle time. Resume a paused sandbox with `Sandbox.connect(id)`. ## Delete Delete a sandbox with `anona-compute delete`, the dashboard, or `kill()` in the SDK: the SDK keeps the E2B method name, so code written for E2B works unchanged. It returns as soon as the gateway has the request (it waits up to 2 s for the engine) and finishes in the background with retries. The sandbox disappears from `list` at once. ## Fork Fork copies a running sandbox - memory, files and running processes - into new sandboxes. Each copy starts from the exact state the original was in, which is the point: get an agent to an interesting state once, then try ten things from there. The original is **briefly frozen** while its memory is written (about 0.1 s, longer right after it has touched a lot of memory), then keeps running on its own node with the same ID and the same deadline. The snapshot is taken once however many copies you ask for. ```python fork.py theme={null} from anona import Sandbox sbx = Sandbox.create("base", timeout=300) sbx.files.write("/root/state.txt", "reached an interesting state") sbx.commands.run("nohup sh -c 'while true; do date >> /root/ticks; sleep 1; done' >/dev/null 2>&1 &") # Each entry is a Sandbox or the exception that stopped that one fork. forks = sbx.fork(count=3, timeout=300) for f in forks: if isinstance(f, Exception): print("fork failed:", f) continue print(f.sandbox_id, f.files.read("/root/state.txt")) # the parent's file print(f.commands.run("wc -l < /root/ticks").stdout) # the parent's process, still ticking f.kill() sbx.kill() ``` ```typescript fork.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base", { timeoutMs: 300_000 }); await sbx.files.write("/root/state.txt", "reached an interesting state"); const forks = await sbx.fork({ count: 3, timeoutMs: 300_000 }); for (const f of forks) { if (f instanceof Error) { console.log("fork failed:", f.message); continue; } console.log(f.sandboxId, await f.files.read("/root/state.txt")); await f.kill(); } await sbx.kill(); ``` ```bash fork.sh theme={null} SANDBOX_ID=$(anona-compute create -t base --json | jq -r .sandboxID) anona-compute exec "$SANDBOX_ID" -- sh -c 'echo "reached an interesting state" > /root/state.txt' anona-compute fork "$SANDBOX_ID" --count 3 --timeout 5m # one sandbox ID per line anona-compute delete "$SANDBOX_ID" ``` Each fork is a new sandbox and behaves like one: it takes a slot of your concurrent-sandbox limit, it is **billed like any other sandbox** for as long as it runs, and it inherits the original's template, metadata, timeout policy and network policy, with the lifetime you pass to `fork`. A fork of ten on a plan with room for three is not an all-or-nothing failure. The copies that fit are created, and every other entry of the result says why it was not - `429` when your plan's limit is reached, `503` when the platform is at capacity. The SDK gives you those as exceptions in the same list, in request order. A non-`201` from the API means nothing was forked at all: the sandbox was not running (`409`), or there was no room for even one copy (`429`), in which case the original is not frozen either. > [!NOTE] > > Forking needs the `sandboxes:create` scope, and an API key restricted to particular sandboxes cannot fork. In the dashboard, **Fork** on a running sandbox's page asks how many copies and how long they should live, shows what the batch will cost before you commit, and lists the outcome of each copy. > [!WARNING] > > Each fork request keeps a **checkpoint** of the source sandbox, which the copies read from, and this engine cannot delete one yet - deleting the copies does not free it. So forking is capped per team (32 checkpoints by default); past that, `fork` answers `429` and says so. Ask support to raise it, and tell us what you are building with fork: the cap exists only until checkpoints can be reclaimed. ## Snapshots **Coming soon:** named snapshots you can keep and create from later (`POST /sandboxes/{id}/snapshots`, `GET /snapshots`). Fork takes a snapshot for you and uses it immediately. # Regions and limits Source: https://docs.anonalabs.com/compute/concepts/limits Where sandboxes run and how many you can have. ## Region Sandboxes run in AWS `us-east-1` (N. Virginia). The API, the dashboard and live view are served from the same region. **Coming soon:** more regions. ## Plan limits > [!NOTE] > > Preview values. They will change before general availability. | Limit | Free | Paid | | - | - | - | | Running sandboxes at once | 2 | 100 | | Paused sandboxes kept | 5 | 200 | | Longest timeout | 1 hour | No maximum (`timeout=-1` allowed) | | Public internet egress | Off | On by default | | API keys | 2 | 20 | | Templates you build | 20 | 20 | | Template builds at once | 2 | 2 | | Included credit | \$5 | none | What the gateway answers when you hit one: | Situation | Response | | - | - | | Creating more running sandboxes than your plan allows | `429` with `error_code` `sandbox_quota_exceeded` | | Pausing beyond the paused limit | `429` | | A timeout above your plan's maximum | `400`, naming the limit | | Creating an API key beyond the limit | `429` | Per-sandbox size limits, per-minute create rates and request rates exist in the plans but are not enforced by the gateway yet. Sandbox size comes from the [template](/compute/concepts/templates). ## Cold starts When no sandbox has run for a while, the sandbox servers are stopped to save cost. The next create waits while one starts (up to 60 s); if that takes longer, the API answers `503` with `Retry-After: 30` and the SDK retries. Paused sandboxes do not need a running server until you resume them. > [!NOTE] > > How long a cold start takes on the production fleet has not been measured yet, so the preview has no published create or resume latency. On the engine's test hosts a resume took 0.8 to 0.9 s. # Networking and egress Source: https://docs.anonalabs.com/compute/concepts/networking What a sandbox can reach, and who can reach it. ## Egress Every create compiles a network policy from three layers, strictest first: 1. **The platform floor**, which no setting can open: private and internal ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, loopback, link-local including the cloud metadata address `169.254.169.254`, multicast and reserved ranges, and their IPv6 equivalents), names such as `localhost`, `*.local` and `*.internal`, and the mail and DNS-over-TLS ports 25, 465, 587, 2525 and 853. 2. **Your plan's limits.** Paid plans allow the public internet by default; the free plan allows no public egress. 3. **The sandbox's own policy**: the E2B fields below on create, else your team's egress settings (see [Which policy applies](#which-policy-applies)). | Field | Meaning | | - | - | | `allow_internet_access` | `false` cuts all public egress for this sandbox | | `network.deny_out` | Block these IPv4 addresses or CIDRs. `0.0.0.0/0` blocks all public egress, the same as `allow_internet_access=false` | | `network.allow_out` | Allow these destinations (IPs, CIDRs or host names) even when the rest is blocked. Allow entries win over deny entries | | `network.allow_public_traffic` | Whether the sandbox's ports are reachable without a token (see [Ingress](#ingress)) | ```python egress.py theme={null} from anona import Sandbox offline = Sandbox.create("base", allow_internet_access=False) offline.kill() only_pypi = Sandbox.create( "base", network={"deny_out": ["0.0.0.0/0"], "allow_out": ["pypi.org", "files.pythonhosted.org"]}, ) only_pypi.kill() no_example = Sandbox.create("base", network={"deny_out": ["93.184.215.14/32"]}) no_example.kill() ``` ```typescript egress.ts theme={null} import { Sandbox } from "@anona/sdk"; const offline = await Sandbox.create("base", { allowInternetAccess: false }); await offline.kill(); const onlyPypi = await Sandbox.create("base", { network: { denyOut: ["0.0.0.0/0"], allowOut: ["pypi.org", "files.pythonhosted.org"] }, }); await onlyPypi.kill(); ``` ```bash egress.sh theme={null} anona-compute run --no-internet -t base -- 'echo this sandbox has no internet' ``` Rules the gateway enforces when it compiles your policy: * A destination inside the platform floor is refused with `400`, and so is a host name that resolves into it (this blocks DNS-rebinding tricks). * Host names in `allow_out` need public egress off (`deny_out: ["0.0.0.0/0"]` or `allow_internet_access=false`), as on E2B. Wildcard names and names that do not resolve are refused for now. * `deny_out` takes IPv4 addresses and CIDRs only. * Unknown keys inside `network` are refused, so a typo cannot silently drop part of a policy. * Per plan: up to 512 allow entries and 1,024 deny entries on paid plans (64 and 256 on the free plan), and at most 10 DNS servers. ## Which policy applies A new sandbox gets the first of these that exists, then the platform floor and your plan's limits on top: 1. The `network` and `allow_internet_access` fields of its create request. 2. Its template's policy (built-in templates have none). 3. Your organization's **team egress settings**: set them on the dashboard's **Network** page (owners and admins), or with `PUT /v1/network`. 4. Your plan's default. Ask the API which policy a sandbox runs with, and where it came from: ```bash effective_policy.sh theme={null} curl -sS "https://api.compute.anonalabs.com/v1/sandboxes/$SANDBOX_ID/network/effective" \ -H "X-API-Key: $E2B_API_KEY" | jq '{source, tier, internet: .policy.allow_internet_access}' curl -sS https://api.compute.anonalabs.com/v1/network -H "X-API-Key: $E2B_API_KEY" | jq '{tier, revision, limits}' ``` ## Change a running sandbox `update_network` replaces a running sandbox's policy. It goes through the same floor and limits as a create. An update that does not mention `allow_internet_access` keeps the sandbox's current setting. ```python update_network.py theme={null} from anona import Sandbox with Sandbox.create("base") as sbx: # lock it down to one API after setup sbx.update_network({"deny_out": ["0.0.0.0/0"], "allow_out": ["1.1.1.1"]}) ``` ```typescript update_network.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base"); await sbx.updateNetwork({ denyOut: ["0.0.0.0/0"], allowOut: ["1.1.1.1"] }); await sbx.kill(); ``` The caller needs to be able to control the sandbox: an API key with `sandboxes:use` (and the sandbox on its list, if it has one), or a dashboard member or above. ## Credentials without exposing them The **credential vault** keeps API tokens out of sandboxes. Store a secret on the dashboard's **Credential vault** page (owners and admins), then name it in a header rule for one host. The egress proxy adds the header to the sandbox's HTTPS requests to that host; the sandbox, its environment and its disk never contain the value. ```python vault_rule.py theme={null} from anona import Sandbox # "github_token" was stored in the dashboard's Credential vault first. sbx = Sandbox.create( "base", network={ "deny_out": ["0.0.0.0/0"], "allow_out": ["api.github.com"], "rules": {"api.github.com": [{"transform": {"headers": {"Authorization": "Bearer ${vault.github_token}"}}}]}, }, ) print(sbx.commands.run("env | grep -ci token || true").stdout.strip(), "token variables in the sandbox") sbx.kill() ``` ```typescript vault_rule.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base", { network: { denyOut: ["0.0.0.0/0"], allowOut: ["api.github.com"], rules: { "api.github.com": [{ transform: { headers: { Authorization: "Bearer ${vault.github_token}" } } }] }, }, }); await sbx.kill(); ``` * A header value must be exactly `${vault.}`, optionally after `Bearer ` or `Basic `. Literal values are refused, so no credential ends up in a policy. * The first use binds the secret to that host and header. The vault page lists every binding and revokes one. Proxies cache a value for up to 60 seconds, so a rotation or revocation reaches running sandboxes within a minute. * The same rules work in the team egress settings, so every new sandbox gets them. * Secret names are lowercase letters, digits and `_`, up to 63 characters; values up to 8 KiB. The vault is included in every plan at no charge. ## Ingress All traffic to a sandbox goes through the Anona gateway's sandbox edge at `https://-.sbx.compute.anonalabs.com`: | Port | Who gets in | | - | - | | 49983 (`envd`: commands, files) | Only requests with the sandbox's access token, which the SDK holds; signed file URLs for `GET`/`POST /files` | | Any other port | Anyone, when the sandbox allows public traffic or the port is public; otherwise a valid preview link (or its cookie), or the `e2b-traffic-access-token` header | Single ports can also be made public, or shared with signed, expiring links; see [Preview links](/compute/guides/preview-links). Sandbox URLs live on their own domain, so content served from a sandbox never shares cookies with the API or the dashboard. ## DNS Sandboxes resolve names through public resolvers (1.1.1.1 and 1.0.0.1), so they cannot resolve names inside Anona's network. # Sandboxes Source: https://docs.anonalabs.com/compute/concepts/sandboxes What a sandbox is, how to find it again, and what runs inside. A sandbox is a KVM microVM started from a [template](/compute/concepts/templates). It has its own Linux kernel, a writable root filesystem, and its own network policy. Inside it, E2B's `envd` daemon serves commands, files and terminals on port 49983; the SDKs and the CLI talk to it through the Anona gateway. | Property | Value | | - | - | | ID | An opaque string such as `i3k2…`; use it to connect, delete or view the sandbox | | OS | Ubuntu 24.04 | | User | `user` (uid 1000), home `/home/user`, passwordless `sudo` | | Size | CPU and memory come from the template (for example 2 vCPU and 1 GiB for `base`); the create request has no size fields | | State | `running` or `paused`; a deleted sandbox is gone | | Lifetime | A timeout from now, or no deadline; see [Lifecycle](/compute/concepts/lifecycle) | ## Metadata and listing Attach string metadata at create time, then filter on it when you list: ```python metadata.py theme={null} from anona import Sandbox from e2b import SandboxQuery sbx = Sandbox.create("base", metadata={"job": "nightly-eval", "owner": "ci"}) for info in Sandbox.list(query=SandboxQuery(metadata={"job": "nightly-eval"})).next_items(): print(info.sandbox_id, info.template_id, info.metadata) info = sbx.get_info() print(info.state, info.started_at, info.end_at) sbx.kill() ``` ```typescript metadata.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base", { metadata: { job: "nightly-eval", owner: "ci" } }); const page = await Sandbox.list({ query: { metadata: { job: "nightly-eval" } } }).nextItems(); for (const info of page) console.log(info.sandboxId, info.templateId, info.metadata); const info = await sbx.getInfo(); console.log(info.state, info.startedAt, info.endAt); await sbx.kill(); ``` ```bash metadata.sh theme={null} SANDBOX_ID=$(anona-compute create -t base --metadata job=nightly-eval --json | jq -r .sandboxID) anona-compute list --metadata job=nightly-eval anona-compute info "$SANDBOX_ID" anona-compute delete "$SANDBOX_ID" ``` Metadata keys starting with `anona.` are reserved and refused. ## Connect from anywhere A sandbox outlives the process that created it. Any process holding an API key of the same organization can reconnect by ID: ```python connect.py theme={null} import os from anona import Sandbox sbx = Sandbox.connect(os.environ["SANDBOX_ID"]) print(sbx.commands.run("hostname").stdout) ``` ```typescript connect.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.connect(process.env.SANDBOX_ID!); console.log((await sbx.commands.run("hostname")).stdout); ``` Another organization's sandbox answers "not found", exactly like a sandbox that does not exist. # Security and isolation Source: https://docs.anonalabs.com/compute/concepts/security How sandboxes are kept apart from each other and from Anona. | Layer | What it does | | - | - | | **A microVM per sandbox** | Each sandbox is a KVM virtual machine with its own guest kernel. Escaping it takes a hypervisor escape, not a container escape. | | **Network policy per sandbox** | An eBPF filter (layer 3) and an egress proxy (layer 7) enforce the [egress policy](/compute/concepts/networking). Private ranges and cloud metadata are always blocked. | | **A gateway in front of everything** | The engine's own API is never exposed. Every byte to or from a sandbox passes the gateway's sandbox edge, which checks a per-sandbox token. | | **Per-sandbox tokens** | The `envd` access token and the traffic token are derived per sandbox; a token from another organization, or from a deleted sandbox that had the same ID, never matches. | | **Tenant isolation** | A sandbox of another organization answers "not found", the same as one that does not exist, so IDs cannot be probed. | | **Scoped API keys** | Keys carry scopes (read, use, create, delete, usage, billing, integrations) and can be limited to a list of sandbox IDs. See [API keys in CI](/compute/guides/ci). | | **Revocable sessions** | Dashboard and CLI sessions can be revoked on the dashboard; the API stops accepting them within seconds. | | **Short-lived access** | Live-view tokens last at most 10 minutes and can all be revoked at once; SSH certificates last 10 minutes and name one sandbox. | ## Inside the sandbox * Commands run as `user` (uid 1000), with passwordless `sudo` inside the VM. Root inside the VM is not root on the host. * Templates start no daemons at boot beyond `envd` and the Anona guest agent (which carries live view and SSH). The SSH server starts on first use and listens on loopback only. * Sandboxes have public DNS resolvers and no route to Anona's internal network. ## Credentials Secrets passed as environment variables (`envs=`, or per command) live in the sandbox's memory and disk like any other data, and a pause snapshots them too. For API tokens, use the **credential vault** instead: the egress proxy adds the token to requests to one host and the sandbox never sees it. See [Networking](/compute/concepts/networking#credentials-without-exposing-them). # Templates Source: https://docs.anonalabs.com/compute/concepts/templates The images sandboxes start from. A template is the image a sandbox boots from, plus its default size. Every template runs Ubuntu 24.04 with E2B's `envd` 0.5.13 on port 49983, the user `user` (uid 1000) and `tini`, and starts nothing else at boot unless it says so. That keeps idle sandboxes small, which is what keeps them cheap. | Template | Adds | vCPU | Memory | Disk | | - | - | - | - | - | | `base` (default) | git, less, unzip, xz-utils, OpenSSH client and an on-demand SSH server | 2 | 1 GiB | 1 GiB | | `python` | `base` + Python 3.12, build-essential, uv, a venv at `/opt/venv` with numpy, pandas, requests, httpx | 2 | 2 GiB | 2 GiB | | `node` | `base` + Node.js 24 LTS, npm, pnpm (`npm i -g` goes to `~/.npm-global`) | 2 | 2 GiB | 2 GiB | | `code-interpreter` | `python` + Jupyter Server and E2B's code-interpreter server on port 49999 | 2 | 2 GiB | 2 GiB | | `desktop` | Xvfb, XFCE, xdotool, scrot, x11vnc, noVNC on 6080, Google Chrome. Nothing runs until you start the desktop | 2 | 2 GiB | 4 GiB | | `coding-agent` | `python` + Node 24, Bun, Rust, Java 21, gh, Docker CLI (no daemon), Chrome, ripgrep, jq and seven coding-agent CLIs: Claude Code, Codex, OpenCode, pi, Kimi Code, Mistral Vibe, Cursor Agent | 2 | 4 GiB | 8 GiB | Pick one by name: ```python template.py theme={null} from anona import Sandbox sbx = Sandbox.create("python") print(sbx.get_info().template_id) sbx.kill() ``` ```typescript template.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("python"); console.log((await sbx.getInfo()).templateId); await sbx.kill(); ``` ```bash template.sh theme={null} anona-compute run -t python -- 'echo running in a python sandbox' anona-compute config set default_template python # the CLI's default for create and run ``` ## code-interpreter The `code-interpreter` template runs E2B's own code-interpreter server (a Python kernel behind Jupyter) on port 49999, the server the `e2b-code-interpreter` SDK talks to. Create it with `Sandbox.create(template="code-interpreter")` from that SDK. Running the `e2b-code-interpreter` SDK end to end against Anona is not part of the published compatibility checks yet. ## coding-agent Each agent CLI in `coding-agent` has the `anona-computer-use` MCP server registered in its own config, and an `anona` skill installed. The computer-use server is a placeholder today: its `screenshot`, `mouse_move`, `click`, `type_text` and `key` tools call `scrot` and `xdotool` and need a running X display. The agents need their own provider credentials; pass them as environment variables at create time. ## Build your own Build a template from a Dockerfile with the CLI, or from the E2B SDK's `Template.build`. The gateway builds the image with BuildKit in an isolated builder, adds the Anona runtime layer (`envd`, the entrypoint and the guest agent), pushes it and registers it; sandboxes then start from it by name. ```bash template_build.sh theme={null} cat > Dockerfile <<'EOF' FROM python:3.12-slim RUN pip install --no-cache-dir polars EOF anona-compute template build --name my-env # builds the current directory, streams the log anona-compute template list anona-compute template info my-env anona-compute template logs my-env anona-compute create -t my-env # a sandbox from it anona-compute template delete my-env ``` ```python template_build.py theme={null} from e2b import Template # the stock SDK's build flow works unchanged template = ( Template() .from_image("python:3.12-slim") .run_cmd("pip install --no-cache-dir polars") ) Template.build(template, alias="my-env", cpu_count=2, memory_mb=2048) ``` What the builder enforces: * Every `FROM` is pinned by digest at build time, so a rebuild gets the same base. * `EXPOSE 49983` (envd) is refused; the Anona runtime owns that port. * The build runs with no credentials, no privileges and its own CPU, memory, PID and time limits, on a network whose only way out is a proxy that applies the same [platform floor](/compute/concepts/networking) as a sandbox. * The image's `CMD`/`ENTRYPOINT` runs at sandbox start only when the last stage declares one, or with `--start-cmd`. * `.dockerignore` in the build context is honoured. Defaults and limits: 2 vCPU and 2 GiB per sandbox unless `--cpu`/`--memory` say otherwise (at most 8 vCPU and 16 GiB), 20 templates per organization, 2 builds at a time, and a build context up to 1 GiB. Deleting a template leaves sandboxes that already run from it alone; new creates stop resolving the name. The dashboard's **Templates** page lists templates, build status and build logs, and deletes them. > [!WARNING] > > Template builds have been exercised end to end against a real gateway, BuildKit and a local registry (`tests/templates/run_e2e.sh`), with sandbox creation through the fake engine. Registration against CubeSandbox on AWS has not been run yet. > [!NOTE] > > Template sizes are the preview defaults and may change. The create request has no CPU or memory fields, in E2B as here; a sandbox gets its template's size. # Dashboard Source: https://docs.anonalabs.com/compute/dashboard app.compute.anonalabs.com: sign-in, organizations, keys and sandboxes. The dashboard at [app.compute.anonalabs.com](https://app.compute.anonalabs.com) is where you sign in, manage your organization and API keys, and look after sandboxes. | Page | What you do there | | - | - | | **Sign in** | Email magic link or GitHub | | **Onboarding** | Create your organization | | **Sandboxes** | List running and paused sandboxes with live thumbnails; create one with a template and a lifetime (up to 30 days, or no limit with pause when idle) | | **Sandbox** | State, template and size; pause, resume, extend and delete; **Share preview** links to a port; the live terminal and desktop; connection snippets for the SDKs and the CLI | | **API keys** | Create keys with a preset or custom scopes, an expiry and an optional sandbox list; revoke them | | **Network** | Team egress settings: the default network policy of new sandboxes (owners and admins change it) | | **Credential vault** | Secrets that sandboxes use through header rules without seeing them (owners and admins add, rotate and delete) | | **CLI & agent sessions** | Every machine signed in with `anona-compute login`; revoke one or all. Owners and admins also see members' sessions | | **Webhooks** | Endpoints for lifecycle events, their signing secrets, the delivery log, test events and resend (owners and admins) | | **Telemetry** | OpenTelemetry export of sandbox metrics and lifecycle spans to your endpoint (owners and admins) | | **Usage** | This month's metered usage by day, priced at your plan's rates | | **Billing** | Invoice drafts (owners and admins) | | **Device** | Approve an `anona-compute login` from the terminal | ## Roles | Role | Can | | - | - | | Owner, admin | Everything, including API keys, billing, network settings and the credential vault | | Member | Create, use, pause and delete sandboxes; mint control live views and SSH certificates | | Viewer | Read only: list sandboxes and watch live views | Sign-outs, revoked sessions, removals from the organization and role changes reach the API within seconds, also for tokens already issued. **Coming soon:** team invitations from the dashboard. ## The CLI sign-in `anona-compute login` uses the browser device flow: the terminal prints a URL and a code such as `ABCD-EFGH`, the dashboard's **Device** page asks you to confirm the code and pick the organization, and the CLI receives a session for that machine. `anona-compute logout`, or **Revoke** on the **CLI & agent sessions** page, ends it. # FAQ Source: https://docs.anonalabs.com/compute/faq Short answers to common questions. > [accordion] Do I need to rewrite my E2B code? > > No. Set `E2B_DOMAIN=compute.anonalabs.com` and an Anona API key. See [Migrate from E2B](/compute/migrate-from-e2b) for the features that are not available yet. > [accordion] Is a sandbox a container? > > No. Each sandbox is a KVM microVM with its own kernel. See [Security](/compute/concepts/security). > [accordion] How long can a sandbox run? > > On paid plans, as long as you want: create it with `timeout=-1`, and let it pause when idle. On the free plan, up to one hour. A paused sandbox costs no CPU or memory. See [Lifecycle](/compute/concepts/lifecycle). > [accordion] What happens to running processes when I pause? > > They are frozen with the sandbox's memory and carry on after resume. > [accordion] Can a sandbox reach the internet? > > On paid plans yes, by default; on the free plan no. Private networks and cloud metadata are always blocked. You can restrict each sandbox further. See [Networking](/compute/concepts/networking). > [accordion] Can I run Docker inside a sandbox? > > The `coding-agent` template has the Docker CLI but no daemon. Running a Docker daemon in a sandbox is not supported yet. > [accordion] Can I bring my own image? > > Yes: build one from a Dockerfile with `anona-compute template build`, or with the E2B SDK's `Template.build`. See [Templates](/compute/concepts/templates#build-your-own). > [accordion] Can I choose the CPU and memory? > > Not per sandbox: a sandbox gets its template's size. See [Templates](/compute/concepts/templates). > [accordion] How is it billed? > > By active vCPU time, resident memory, snapshot storage and egress. During the preview nothing is charged. See [Pricing](/compute/pricing). > [accordion] Where does it run? > > AWS `us-east-1`. See [Regions and limits](/compute/concepts/limits). # CI and API keys Source: https://docs.anonalabs.com/compute/guides/ci Use sandboxes from CI with a scoped, expiring API key. ## Create a key for CI Create keys in the [dashboard](https://app.compute.anonalabs.com) under **API keys** (owners and admins). Give CI its own key, with the least access it needs and an expiry: | Preset | Scopes | For | | - | - | - | | Read only | `sandboxes:read`, `templates:read`, `usage:read` | Dashboards, monitoring, cost reports | | Use existing | `sandboxes:read`, `sandboxes:use` | Driving sandboxes someone else created | | Create and use | `sandboxes:read`, `sandboxes:use`, `sandboxes:create`, `sandboxes:delete`, `templates:read` | CI jobs that create and clean up their own sandboxes | | Full access | `sandboxes:*`, `templates:*`, `usage:read`, `billing:read` | Trusted automation | | Custom | Any of `sandboxes:read`, `sandboxes:use`, `sandboxes:create`, `sandboxes:delete`, `templates:read`, `templates:write`, `usage:read`, `billing:read`, `integrations:manage` | `integrations:manage` (webhooks, telemetry export) is only in custom keys | Options on every key: * **Expiry**: 7, 30, 90 or 365 days, or never. * **Sandbox list**: limit the key to specific sandbox IDs. Other sandboxes answer "not found" to it. The secret is shown once. Revoking a key stops it at once. Keys created before the `usage:read` and `billing:read` scopes existed do not have them. Key management is a dashboard action: API keys cannot create, list or revoke keys (the `/v1/api-keys` routes accept dashboard sessions only, see the [API reference](/compute/api-reference/introduction)). ## Use it in a job Store the key as a CI secret and expose it as `E2B_API_KEY` (or `ANONA_API_KEY`) together with `E2B_DOMAIN`: ```yaml theme={null} # .github/workflows/sandbox.yml name: sandbox on: [push] jobs: test-in-sandbox: runs-on: ubuntu-latest env: E2B_DOMAIN: compute.anonalabs.com E2B_API_KEY: ${{ secrets.ANONA_COMPUTE_API_KEY }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install e2b - run: python ci/run_in_sandbox.py ``` A job script that always cleans up, even when the test fails: ```python run_in_sandbox.py theme={null} import sys from anona import Sandbox from e2b import CommandExitException with Sandbox.create("base", timeout=900, metadata={"ci": "true"}) as sbx: # deleted on exit sbx.files.write("/home/user/test.sh", "set -e\necho running tests\ntest 2 -gt 1\n") try: r = sbx.commands.run("bash /home/user/test.sh", timeout=600) print(r.stdout) except CommandExitException as e: print(e.stdout, e.stderr, file=sys.stderr) sys.exit(e.exit_code) ``` ```bash run_in_sandbox.sh theme={null} # anona-compute run creates, runs, and deletes the sandbox (also on failure or Ctrl-C), # and exits with the command's exit code. export ANONA_COMPUTE_API_KEY="$E2B_API_KEY" anona-compute run -t base --timeout 15m --metadata ci=true -- 'echo running tests && test 2 -gt 1' ``` Set a timeout on every CI sandbox, so a cancelled job cannot leave one running. Tag CI sandboxes with metadata, then sweep leftovers: ```bash sweep.sh theme={null} anona-compute list --metadata ci=true --json | jq -r .sandboxID | while read -r id; do anona-compute delete "$id" done ``` ## The CLI with a key The CLI takes `--api-key`, else `ANONA_COMPUTE_API_KEY` or `E2B_API_KEY`, else the profile; a key wins over a browser sign-in. To keep one in a profile: ```bash key_profile.sh theme={null} printenv E2B_API_KEY | anona-compute --profile ci login --with-api-key anona-compute --profile ci whoami ``` # Desktop and computer use Source: https://docs.anonalabs.com/compute/guides/desktop A Linux desktop your agent can see and drive, and you can watch. The `desktop` template has an XFCE desktop on Xvfb, Google Chrome, `xdotool` and `scrot`, and VNC servers. Nothing runs until you start it, so an idle desktop sandbox costs the same as any other. It is compatible with E2B's `e2b-desktop` SDK, which starts the desktop on create and gives you screenshots, mouse and keyboard. ## Drive it from code ```python desktop.py theme={null} from anona.desktop import Sandbox # e2b_desktop.Sandbox plus live view and SSH desk = Sandbox.create() # template "desktop", starts Xvfb and XFCE try: print(desk.get_screen_size()) desk.move_mouse(200, 150) desk.left_click() desk.write("hello") png = desk.screenshot() open("screen.png", "wb").write(png) print("screenshot", len(png), "bytes", png[:4]) print("watch it:", desk.live_url()) # Anona live view, control scope finally: desk.kill() ``` ```typescript desktop.ts theme={null} import { Sandbox as Desktop } from "@e2b/desktop"; import { Sandbox } from "@anona/sdk"; const desk = await Desktop.create(); // template "desktop", starts Xvfb and XFCE try { console.log(await desk.getScreenSize()); await desk.moveMouse(200, 150); await desk.leftClick(); const png = await desk.screenshot(); console.log("screenshot", png.length, "bytes"); const sbx = await Sandbox.connect(desk.sandboxId); // the Anona extras on the same sandbox console.log("watch it:", await sbx.live.desktopUrl()); } finally { await desk.kill(); } ``` Install the Python extra with `pip install "anona[desktop]"`; for TypeScript add `@e2b/desktop` next to `@anona/sdk`. ## Watch it There are two ways to see the screen: | | Anona live view | E2B noVNC stream | | - | - | - | | How | `desk.live_url()` (Python), `sbx.live.desktopUrl()` (TypeScript), `anona-compute desktop ` | `desk.stream.start()` then `desk.stream.get_url()` | | Transport | Anona's stream relay; the sandbox opens no port | noVNC on port 6080 of the sandbox URL | | Access | Short-lived viewer token; view-only viewers get a separate view-only VNC server | Port URL; see [Preview links](/compute/guides/preview-links) | | Embedding | See [Live view](/compute/guides/live-view) | E2B's own page | ## From the terminal ```text theme={null} anona-compute desktop $SANDBOX_ID # print the live-view URL (control) anona-compute desktop $SANDBOX_ID --vnc 127.0.0.1:5901 # serve the desktop as plain VNC for any VNC client anona-compute desktop $SANDBOX_ID --vnc 127.0.0.1:5901 --view-only anona-compute desktop $SANDBOX_ID --mode novnc # the E2B noVNC URL on port 6080 ``` ## For agents * Over MCP, `desktop_screenshot` returns the screen as an image; see [Agents via MCP](/compute/guides/mcp). * The `coding-agent` template has a computer-use MCP server registered with each agent CLI. It is a placeholder today (`screenshot`, `mouse_move`, `click`, `type_text`, `key` through `scrot` and `xdotool`). **Coming soon:** a computer-use API in the Anona guest agent. # Files Source: https://docs.anonalabs.com/compute/guides/files Read, write, list and watch files; upload and download with signed URLs. The `files` API reads and writes the sandbox filesystem as the sandbox user. Paths are absolute, or relative to `/home/user`. ```python files.py theme={null} from anona import Sandbox with Sandbox.create("base") as sbx: sbx.files.make_dir("/home/user/project") sbx.files.write("/home/user/project/notes.txt", "first line\n") sbx.files.write("/home/user/project/blob.bin", bytes(range(256))) print(sbx.files.read("/home/user/project/notes.txt")) print(len(sbx.files.read("/home/user/project/blob.bin", format="bytes"))) for entry in sbx.files.list("/home/user/project"): print(entry.type, entry.name, entry.size) sbx.files.rename("/home/user/project/notes.txt", "/home/user/project/README.txt") print(sbx.files.exists("/home/user/project/notes.txt"), sbx.files.get_info("/home/user/project/README.txt").size) sbx.files.remove("/home/user/project/blob.bin") ``` ```typescript files.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base"); await sbx.files.makeDir("/home/user/project"); await sbx.files.write("/home/user/project/notes.txt", "first line\n"); console.log(await sbx.files.read("/home/user/project/notes.txt")); for (const entry of await sbx.files.list("/home/user/project")) console.log(entry.type, entry.name); await sbx.files.rename("/home/user/project/notes.txt", "/home/user/project/README.txt"); console.log(await sbx.files.exists("/home/user/project/notes.txt")); await sbx.kill(); ``` ## Watch a directory ```python watch.py theme={null} import time from anona import Sandbox with Sandbox.create("base") as sbx: sbx.files.make_dir("/tmp/inbox") handle = sbx.files.watch_dir("/tmp/inbox") sbx.files.write("/tmp/inbox/job.json", "{}") time.sleep(1) print([(e.type.name, e.name) for e in handle.get_new_events()]) handle.stop() ``` ## Signed upload and download URLs Hand a URL to a browser or another service so it can move a file without your API key. The URL is signed for one path and expires. ```python signed_urls.py theme={null} import httpx from anona import Sandbox with Sandbox.create("base", secure=True) as sbx: up = sbx.upload_url("/home/user/report.csv", use_signature_expiration=300) httpx.post(up, files={"file": ("report.csv", b"a,b\n1,2\n")}).raise_for_status() down = sbx.download_url("/home/user/report.csv", use_signature_expiration=300) print(httpx.get(down).text) ``` ## Copy files with the CLI Over SSH (see [SSH](/compute/guides/ssh)): ```bash scp.sh theme={null} echo "local data" > data.txt anona-compute scp "$SANDBOX_ID" ./data.txt :/home/user/ anona-compute scp "$SANDBOX_ID" :/home/user/data.txt ./copy.txt cat copy.txt ``` **Coming soon:** `anona-compute push`, which copies a git working tree into a sandbox. # Live view Source: https://docs.anonalabs.com/compute/guides/live-view Watch or take over a sandbox's terminal or desktop from your own page. Live view streams a sandbox to a browser: its terminal, or its desktop. Your backend mints a short-lived **viewer token** with your API key, and your page shows the returned embed URL in an iframe. Many people can watch the same sandbox; the sandbox serves one stream per view, whatever the number of viewers. | Mode (`channels`) | Shows | Status | | - | - | - | | `pty` (embed mode `terminal`) | A shell in the sandbox (xterm.js). Needs `control` scope: it starts a shell | Available | | `desktop` | The `desktop` template's screen (noVNC) | Available | | `dom` | The page open in the sandbox's Chrome, recorded with rrweb and replayed in your browser: light, sharp at any size, passwords masked. Templates with Chrome (`desktop`, `coding-agent`); Chrome is started with DevTools on first use if it is not running | Available | | `thumb` | A 320 px WebP of the screen (or of the active Chrome page) every 3 seconds | Available | Scopes: `view` watches; `control` also sends keystrokes, mouse and resize. Tokens last 30 to 600 seconds (default 600). ## Mint a token ```python live_view.py theme={null} import os from anona import Sandbox sbx = Sandbox.connect(os.environ["SANDBOX_ID"]) url = sbx.live.url(mode="pty", scope="control", ttl=300) # terminal you can type into print(url) tok = sbx.live.token(scope="view", channels=["desktop"], ttl=120) print(tok.expires_at, tok.claims()["ch"]) sbx.live.revoke_all() # closes every open viewer, invalidates issued tokens ``` ```typescript live_view.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.connect(process.env.SANDBOX_ID!); const url = await sbx.live.url({ mode: "pty", scope: "control", ttl: 300 }); console.log(url); const tok = await sbx.live.token({ scope: "view", channels: ["desktop"], ttl: 120 }); console.log(tok.expiresAt, tok.claims().ch); await sbx.live.revokeAll(); ``` ```bash live_view.sh theme={null} anona-compute view "$SANDBOX_ID" --mode terminal --control --ttl 5m anona-compute view "$SANDBOX_ID" --mode desktop --json ``` The embed URL looks like `https://stream.compute.anonalabs.com/embed/v1/?mode=terminal#t=`. The token is in the URL fragment, which browsers never send to a server, so it does not end up in logs. ## Embed it Mint the token on your server (never ship your API key to a browser) and put the URL in an iframe: ```html theme={null} ``` ### Keep a long session alive A token expires after at most 10 minutes. The embed asks its host page for a new one shortly before that, with `postMessage`. Answer it with a fresh token from your backend: ```js theme={null} const frame = document.querySelector("iframe"); const streamOrigin = "https://stream.compute.anonalabs.com"; window.addEventListener("message", async (event) => { if (event.origin !== streamOrigin || event.data?.source !== "anona-viewer") return; if (event.data.type === "token-request") { const { token } = await fetch("/api/viewer-token").then((r) => r.json()); // your backend mints it frame.contentWindow.postMessage( { source: "anona-viewer-host", type: "token", id: event.data.id, token }, streamOrigin, ); } if (event.data.type === "state") console.log("viewer", event.data.state); }); ``` The viewer reconnects on its own after network drops. It stops for good when the token is revoked, the sandbox is gone, or the token does not allow the channel. ## Thumbnails For lists and dashboards, fetch the latest thumbnail directly. It is a WebP while the sandbox has a screen or a Chrome page to show, and `204 No Content` otherwise: ```bash thumbnail.sh theme={null} code=$(curl -sS -o thumb.webp -w '%{http_code}' \ "https://api.compute.anonalabs.com/v1/sandboxes/$SANDBOX_ID/thumbnail" -H "X-API-Key: $E2B_API_KEY") echo "thumbnail: HTTP $code" # 200 with image/webp, or 204 when there is nothing to show ``` The sandbox grabs frames only while someone asks for them, and every viewer shares the same frames. Send `If-None-Match` with the last `ETag` to get `304` when nothing changed. ## Who may mint | Caller | `view` | `control` | | - | - | - | | API key with `sandboxes:use` | Yes | Yes | | Dashboard owner, admin, member | Yes | Yes | | Dashboard viewer | Yes | No | A key limited to certain sandboxes can mint only for those. A sandbox of another organization answers `404`. The dashboard's sandbox page has the same live view built in. # Agents via MCP Source: https://docs.anonalabs.com/compute/guides/mcp Give Claude Code, Codex, Cursor or any MCP client its own sandboxes. `anona-compute mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. It acts with the CLI's credential, so install the CLI, sign in once, and register it with your agent: ```bash mcp_install.sh theme={null} npm install -g @anona-labs/anona-compute anona-compute login # browser sign-in; or: printenv ANONA_API_KEY | anona-compute login --with-api-key ``` > [tab] Claude Code > > ```bash claude_code.sh theme={null} > claude mcp add anona-compute -- anona-compute mcp > ``` > [tab] Codex > > ```bash codex.sh theme={null} > codex mcp add anona-compute -- anona-compute mcp > ``` > > Or in `~/.codex/config.toml`: > > ```toml theme={null} > [mcp_servers.anona-compute] > command = "anona-compute" > args = ["mcp"] > ``` > [tab] Cursor > > `~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project: > > ```json theme={null} > { > "mcpServers": { > "anona-compute": { "command": "anona-compute", "args": ["mcp"] } > } > } > ``` > [tab] Other clients > > Any client that starts stdio servers: command `anona-compute`, arguments `mcp`. Put `--profile ` before `mcp` to use another [profile](/compute/cli/configuration), and `--read-only` after it to offer only the read-only tools. ## Tools | Tool | What it does | | - | - | | `whoami` | User, organization and role (or the masked API key) | | `list_sandboxes`, `get_sandbox` | State, template, end time (empty means no limit), metadata | | `list_templates` | The organization's templates, with their build status | | `create_sandbox` | Template, `timeout_seconds` (`-1` for no limit), `internet` on or off, metadata, environment variables, auto-pause | | `kill_sandbox`, `pause_sandbox`, `resume_sandbox`, `extend_sandbox` | Lifecycle; `extend_sandbox` takes `-1` for no limit | | `run_command` | Runs `bash -l -c`, returns stdout, stderr and exit code; `timeout_seconds` (default 60) | | `start_process`, `read_process_output`, `kill_process` | Background processes; output is read by offset, optionally waiting for more | | `read_file`, `write_file`, `list_files` | Text or base64 content, directory listings | | `get_live_view_url` | A short-lived view (or control) link for the desktop, DOM or terminal | | `get_preview_url` | A shareable link to one port of a sandbox (a dev server the agent started), 1 minute to 24 hours | | `get_usage` | Usage and cost for a billing period: meters, totals, a projection for the current month, the most expensive sandboxes | | `get_ssh_config` | Signs a 10-minute SSH certificate and returns an `ssh_config` block | | `desktop_screenshot` | PNG of the `desktop` template's screen, as image content | Every call goes to the API with your credential, so your role and your API key's scopes decide what works. A dashboard viewer, or `--read-only`, gets `whoami`, `list_sandboxes`, `get_sandbox`, `get_usage` and view-only `get_live_view_url`. `get_usage` needs the `usage:read` scope on an API key. Background-process output is kept by the MCP server process (the last 4 MiB per stream), so it is lost if the agent restarts the server. ## Check it without an agent Talk to the server over stdio yourself, the way an agent does: ```python mcp_check.py theme={null} import json, subprocess srv = subprocess.Popen(["anona-compute", "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) def call(id, method, params=None): srv.stdin.write(json.dumps({"jsonrpc": "2.0", "id": id, "method": method, "params": params or {}}) + "\n") srv.stdin.flush() return json.loads(srv.stdout.readline()) call(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "check", "version": "0"}}) srv.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") print([t["name"] for t in call(2, "tools/list")["result"]["tools"]]) created = call(3, "tools/call", {"name": "create_sandbox", "arguments": {"template": "base", "timeout_seconds": 600}}) sandbox_id = created["result"]["structuredContent"]["sandbox_id"] ran = call(4, "tools/call", {"name": "run_command", "arguments": {"sandbox_id": sandbox_id, "command": "echo hi from mcp"}}) print(ran["result"]["structuredContent"]) call(5, "tools/call", {"name": "kill_sandbox", "arguments": {"sandbox_id": sandbox_id}}) srv.stdin.close() srv.wait() ``` ## Over HTTP For clients that connect to a URL instead of starting a process, serve streamable HTTP on a loopback address. Every request needs the bearer token: ```text theme={null} ANONA_COMPUTE_MCP_TOKEN=$(openssl rand -hex 24) anona-compute mcp --http 127.0.0.1:8765 # endpoint http://127.0.0.1:8765/mcp, header Authorization: Bearer $ANONA_COMPUTE_MCP_TOKEN ``` Only loopback addresses are accepted, because the server acts with your credentials. Without `ANONA_COMPUTE_MCP_TOKEN` a random token is printed on start. ## Inside a sandbox The `coding-agent` template ships Claude Code, Codex, OpenCode, pi, Kimi Code, Mistral Vibe and Cursor Agent, each wired to a computer-use MCP server and an `anona` skill. Give the agent its provider key as an environment variable when you create the sandbox. See [Templates](/compute/concepts/templates#coding-agent). # Ports and preview links Source: https://docs.anonalabs.com/compute/guides/preview-links Share a server running inside a sandbox: signed links, public ports, or the traffic token. Every port of a sandbox has a URL: `https://-.sbx.compute.anonalabs.com`. The SDK builds it for you with `get_host`. Who can open it depends on how you share it: | Way | Who gets in | Good for | | - | - | - | | **Preview link** | Anyone with the link, for one port, until it expires (1 minute to 24 hours) | Showing a dev server or app to a person | | **Public port** | Anyone, on that port, until you make it private again | Webhook receivers, demos | | **Traffic token** | Requests carrying the `e2b-traffic-access-token` header | Your own backend or proxy | | **Public sandbox** (`allow_public_traffic`) | Anyone, on every port | Throwaway previews | Port 49983 (`envd`, which runs commands and files) is never shared: it always needs the sandbox's own access token, which the SDK holds. ## Preview links ```bash preview.sh theme={null} anona-compute preview "$SANDBOX_ID" 3000 # prints a link, valid 1 hour anona-compute preview "$SANDBOX_ID" 3000 --ttl 15m anona-compute preview "$SANDBOX_ID" --revoke # ends every link (and live-view token) of the sandbox ``` ```python preview_link.py theme={null} import os import httpx api = httpx.Client(base_url="https://api.compute.anonalabs.com", headers={"X-API-Key": os.environ["E2B_API_KEY"]}) sid = os.environ["SANDBOX_ID"] link = api.post(f"/v1/sandboxes/{sid}/preview-links", json={"port": 3000, "ttlSeconds": 900}).json() print(link["url"].split("?")[0], "expires", link["expiresAt"]) api.post(f"/v1/sandboxes/{sid}/preview-links:revoke-all").raise_for_status() ``` The link looks like `https://3000-.sbx.compute.anonalabs.com/?anona_sig=…&anona_exp=…`. On the first visit the sandbox edge exchanges it for a cookie that is scoped to that one port's host (`Secure`, `HttpOnly`, `SameSite=Lax`) and redirects to the same URL without the signature, so the app inside never sees it. Other ports and other sandboxes stay private. Revoking ends every link and every live-view token of the sandbox within a second. The dashboard's sandbox page has **Share preview**, and agents get links from the MCP tool `get_preview_url`. ## Public ports ```bash public_port.sh theme={null} anona-compute preview "$SANDBOX_ID" 8080 --public # anyone can open port 8080, no link needed curl -sS "https://api.compute.anonalabs.com/v1/sandboxes/$SANDBOX_ID/access" -H "X-API-Key: $E2B_API_KEY" | jq -c . anona-compute preview "$SANDBOX_ID" 8080 --private ``` Up to 64 public ports per sandbox. `PUT /v1/sandboxes/{id}/ports/{port}` with `{"public": true}` does the same over the API. ## Traffic token For your own services, keep the port private and send the sandbox's traffic token: ```python private_port.py theme={null} import httpx from anona import Sandbox with Sandbox.create("python", network={"allow_public_traffic": False}) as sbx: sbx.commands.run("cd /tmp && python3 -m http.server 8000", background=True) sbx.commands.run("for i in $(seq 50); do curl -s localhost:8000 >/dev/null && break; sleep 0.1; done") url = "https://" + sbx.get_host(8000) print(httpx.get(url).status_code) # refused without the token print(httpx.get(url, headers={"e2b-traffic-access-token": sbx.traffic_access_token}).status_code) ``` A request to a paused sandbox's URL gets `409`; resume it with `Sandbox.connect` first. # Processes and streaming Source: https://docs.anonalabs.com/compute/guides/processes Stream output as it happens, run servers in the background, talk to stdin, open a terminal. ## Stream output Pass callbacks to receive output while the command runs: ```python stream.py theme={null} from anona import Sandbox with Sandbox.create("base") as sbx: sbx.commands.run( "for i in 1 2 3; do echo tick $i; sleep 0.5; done; echo done >&2", on_stdout=lambda line: print("out:", line.strip()), on_stderr=lambda line: print("err:", line.strip()), ) ``` ```typescript stream.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base"); await sbx.commands.run("for i in 1 2 3; do echo tick $i; sleep 0.5; done; echo done >&2", { onStdout: (line) => console.log("out:", line.trim()), onStderr: (line) => console.log("err:", line.trim()), }); await sbx.kill(); ``` ## Background processes `background=True` returns a handle at once. The process keeps running after your script exits, for as long as the sandbox lives; reconnect to it by PID from anywhere. ```python background.py theme={null} from anona import Sandbox with Sandbox.create("base") as sbx: server = sbx.commands.run("sleep 300", background=True) print("pid", server.pid) for p in sbx.commands.list(): print(p.pid, p.cmd, p.args) sbx.commands.kill(server.pid) ``` ```typescript background.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base"); const proc = await sbx.commands.run("sleep 300", { background: true }); console.log("pid", proc.pid); console.log((await sbx.commands.list()).map((p) => p.pid)); await sbx.commands.kill(proc.pid); await sbx.kill(); ``` ```bash background.sh theme={null} PID=$(anona-compute exec "$SANDBOX_ID" --background -- 'sleep 300') echo "started $PID" anona-compute exec "$SANDBOX_ID" -- "kill $PID" ``` ## Write to stdin ```python stdin.py theme={null} from anona import Sandbox with Sandbox.create("base") as sbx: proc = sbx.commands.run("read name; echo hello $name", background=True, stdin=True) sbx.commands.send_stdin(proc.pid, "world\n") print(proc.wait().stdout) ``` ## Terminals (PTY) A PTY gives you a real terminal: colours, line editing and full-screen programs. ```python pty.py theme={null} from anona import Sandbox from e2b import PtySize with Sandbox.create("base") as sbx: out = [] term = sbx.pty.create(PtySize(rows=24, cols=80)) sbx.pty.send_stdin(term.pid, b"echo $TERM; exit\n") term.wait(on_pty=out.append) print(b"".join(out).decode(errors="replace")) ``` From the terminal, `anona-compute connect` opens an interactive login shell in the sandbox over the same channel, with no SSH server involved: ```text theme={null} anona-compute connect $SANDBOX_ID # Ctrl-] then d detaches; the shell keeps running ``` To watch a terminal in a browser instead, see [Live view](/compute/guides/live-view). # Run code Source: https://docs.anonalabs.com/compute/guides/run-code Run commands, read exit codes, set the environment and the working directory. `commands.run` runs a shell command (`/bin/bash -l -c`) in the sandbox and waits for it. It returns stdout, stderr and the exit code; a non-zero exit raises an exception you can catch. ```python run_code.py theme={null} from anona import Sandbox from e2b import CommandExitException with Sandbox.create("base") as sbx: r = sbx.commands.run("echo $GREETING from $(pwd)", envs={"GREETING": "hi"}, cwd="/tmp") print(r.stdout.strip(), r.exit_code) sbx.files.write("/tmp/fib.sh", "a=0; b=1; for i in $(seq 10); do echo $a; t=$((a+b)); a=$b; b=$t; done\n") print(sbx.commands.run("bash /tmp/fib.sh | tail -1").stdout.strip()) print(sbx.commands.run("whoami", user="root").stdout.strip()) try: sbx.commands.run("exit 3") except CommandExitException as e: print("exit code", e.exit_code) ``` ```typescript run_code.ts theme={null} import { Sandbox } from "@anona/sdk"; import { CommandExitError } from "e2b"; const sbx = await Sandbox.create("base"); const r = await sbx.commands.run("echo $GREETING from $(pwd)", { envs: { GREETING: "hi" }, cwd: "/tmp" }); console.log(r.stdout.trim(), r.exitCode); console.log((await sbx.commands.run("whoami", { user: "root" })).stdout.trim()); try { await sbx.commands.run("exit 3"); } catch (e) { if (e instanceof CommandExitError) console.log("exit code", e.exitCode); else throw e; } await sbx.kill(); ``` ```bash run_code.sh theme={null} # anona-compute exec exits with the command's exit code anona-compute exec "$SANDBOX_ID" -e GREETING=hi -w /tmp -- 'echo $GREETING from $(pwd)' anona-compute exec "$SANDBOX_ID" -u root -- whoami anona-compute exec "$SANDBOX_ID" -- 'exit 3' || echo "exit code $?" # one-shot: create, run, delete (also on error or Ctrl-C) anona-compute run -t base -- uname -a ``` | Option | Python | TypeScript | CLI | | - | - | - | - | | Environment | `envs={...}` | `envs: {...}` | `-e KEY=VALUE` | | Working directory | `cwd=` | `cwd:` | `-w` | | User | `user="root"` | `user: "root"` | `-u root` | | Stop waiting after | `timeout=` (seconds, default 60, `0` = none) | `timeoutMs:` | `--cmd-timeout 5m` | Sandbox-wide environment variables go on create: `Sandbox.create("base", envs={"API_TOKEN": "…"})` or `anona-compute create -e API_TOKEN=…`. ## Install packages Every template has `sudo` for `user`. Install what you need at runtime: ```python install.py theme={null} from anona import Sandbox with Sandbox.create("base") as sbx: r = sbx.commands.run("sudo apt-get update -qq && sudo apt-get install -y -qq cowsay >/dev/null && /usr/games/cowsay moo", timeout=300) print(r.stdout) ``` For Python and Node work, start from the `python` or `node` template so the toolchain is already there. Pause the sandbox between runs to keep what you installed. ## JSON output from the CLI `--json` prints one JSON object per line, which suits scripts: ```bash exec_json.sh theme={null} anona-compute exec "$SANDBOX_ID" --json -- 'echo out; echo err >&2' # {"type":"stdout","data":"out\n"} # {"type":"stderr","data":"err\n"} # {"type":"exit","exitCode":0} ``` # SSH and VS Code Remote Source: https://docs.anonalabs.com/compute/guides/ssh Plain OpenSSH into a sandbox: shells, scp, rsync, port forwarding and editors. Every template that includes `base` has an OpenSSH server that starts on the first connection. You log in with a **certificate** that the Anona gateway signs for one sandbox and 10 minutes; there are no passwords and no long-lived keys to manage. The connection is carried over Anona's stream relay, so the sandbox needs no public port. ## With the CLI `anona-compute ssh` makes a throwaway key, has it signed, and runs your system `ssh` through the relay: ```bash ssh.sh theme={null} anona-compute ssh "$SANDBOX_ID" -- 'uname -a; id' ``` Everything after `--` goes to `ssh`: a remote command, or flags such as `-L 8080:localhost:8080` to forward a port. Interactive sessions work too: `anona-compute ssh $SANDBOX_ID`. Copy files with `scp` semantics; sandbox paths start with a colon: ```bash scp_tree.sh theme={null} mkdir -p out && echo hi > out/a.txt anona-compute scp "$SANDBOX_ID" -- -r ./out :/home/user/ anona-compute ssh "$SANDBOX_ID" -- 'ls -R /home/user/out' ``` ## ssh\_config for other tools `--print-config` prints an `ssh_config` block with a fresh certificate, so `ssh`, `rsync` and editors can use `Host ` directly: ```bash ssh_config.sh theme={null} anona-compute ssh "$SANDBOX_ID" --print-config > anona-sandbox.conf ssh -F anona-sandbox.conf "$SANDBOX_ID" 'echo connected with plain ssh' ``` To use it from every tool, append it to `~/.ssh/config` instead: `anona-compute ssh --print-config >> ~/.ssh/config`. The certificate in the block expires after 10 minutes. Run `anona-compute ssh --print-config` again (or any `anona-compute ssh`) to renew it; connections already open stay open. ### VS Code Remote-SSH 1. Install the Remote-SSH extension. 2. Run `anona-compute ssh --print-config >> ~/.ssh/config`. 3. In VS Code, run **Remote-SSH: Connect to Host…** and pick the sandbox ID. VS Code installs its server in `/home/user/.vscode-server` inside the sandbox, so the sandbox needs internet egress to download it. If you reconnect later than 10 minutes after step 2, repeat step 2 first. Editors that read `~/.ssh/config` (Cursor, JetBrains Gateway, Zed) take the same block. > [!NOTE] > > The plain `ssh` connection above is part of the docs' automated checks; the editor flow itself has not been run in them yet. ## With the SDK Sign your own key and write a config block, for tools that manage their own SSH: ```python ssh_sdk.py theme={null} import os, pathlib, subprocess from anona import Sandbox key = pathlib.Path.home() / ".ssh" / "anona_ed25519" key.parent.mkdir(exist_ok=True) if not key.exists(): subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) sbx = Sandbox.connect(os.environ["SANDBOX_ID"]) cert = sbx.ssh.certificate(f"{key}.pub") cert.write(f"{key}-cert.pub") print(cert.user, cert.host, cert.expires_at) print(sbx.ssh.config(identity_file=str(key))) ``` ```typescript ssh_sdk.ts theme={null} import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { Sandbox } from "@anona/sdk"; const key = `${homedir()}/.ssh/anona_ed25519`; mkdirSync(`${homedir()}/.ssh`, { recursive: true }); if (!existsSync(key)) execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", key]); const sbx = await Sandbox.connect(process.env.SANDBOX_ID!); const cert = await sbx.ssh.certificate(readFileSync(`${key}.pub`, "utf8")); writeFileSync(`${key}-cert.pub`, cert.cert + "\n", { mode: 0o600 }); console.log(cert.user, cert.host, cert.expiresAt); console.log(sbx.ssh.config({ identityFile: key })); ``` The block uses `ProxyCommand anona-compute ssh --proxy %h`, so the CLI must be on your `PATH` and signed in. ## Details | | | | - | - | | Login user | `user` | | Certificate | Ed25519 CA, valid 10 minutes, for one sandbox (principal `sbx:`), allows a PTY and port forwarding | | Who can get one | API keys with `sandboxes:use`, dashboard members and above | | Host key | Generated in the sandbox; clients use `StrictHostKeyChecking accept-new`. **Coming soon:** host certificates | | CA public key | `https://api.compute.anonalabs.com/.well-known/anona-ssh-user-ca.pub` | Without SSH, `anona-compute connect ` opens a shell over the sandbox's own terminal API; see [Processes](/compute/guides/processes#terminals-pty). # Webhooks and telemetry export Source: https://docs.anonalabs.com/compute/guides/webhooks Signed callbacks for sandbox lifecycle events, and sandbox metrics in your own OpenTelemetry backend. ## Webhooks Anona Compute sends an HTTPS `POST` to your endpoint when a sandbox is created, paused, resumed, deleted or runs into trouble. Set endpoints up on the dashboard's **Webhooks** page, or with the API. ### Events | Type | When | | - | - | | `sandbox.lifecycle.created` | Create finished; the sandbox runs | | `sandbox.lifecycle.paused` | Paused, memory and disk kept | | `sandbox.lifecycle.resumed` | Resumed from pause | | `sandbox.lifecycle.killed` | Deleted, by request or at its timeout | | `sandbox.lifecycle.updated` | Its timeout changed | | `sandbox.error` | Create failed, or the sandbox or its host was lost | | `sandbox.degraded` | Its host is being evacuated | | `sandbox.recovered` | Running again after an evacuation | | `webhook.test` | Only from **Send test event** | The body follows E2B's `SandboxEvent` shape: ```json theme={null} { "id": "6a0d5b0e-3f0c-4f6e-9a55-1b2f0c7d9e11", "version": "v1", "type": "sandbox.lifecycle.paused", "timestamp": "2026-10-28T12:00:04Z", "sandboxId": "i3f9a...", "sandboxExecutionId": "...", "sandboxTemplateId": "base", "sandboxBuildId": "", "sandboxTeamId": "...", "eventData": { "cpuCount": 2, "memoryMB": 2048, "timeoutSeconds": 300 } } ``` `eventData` can also carry `endReason` (`killed`, `timeout`, `failed`, `lost`) and `reason`. ### Manage endpoints with the API Owners and admins manage webhooks in the dashboard. For automation, create a **custom** API key with the `integrations:manage` scope; no preset includes it. ```python webhooks_api.py theme={null} import os import httpx api = httpx.Client(base_url="https://api.compute.anonalabs.com", headers={"X-API-Key": os.environ["E2B_API_KEY"]}) r = api.post("/v1/webhooks", json={ "name": "lifecycle to my app", "url": "https://example.com/anona/webhooks", "events": ["sandbox.lifecycle.created", "sandbox.lifecycle.killed"], }) r.raise_for_status() hook, secret = r.json()["webhook"], r.json()["secret"] # secret: whsec_..., shown once print(hook["id"], hook["events"], secret[:6] + "...") api.post(f"/v1/webhooks/{hook['id']}/test").raise_for_status() # queue a webhook.test event print([d["eventType"] for d in api.get(f"/v1/webhooks/{hook['id']}/deliveries").json()["deliveries"]]) api.patch(f"/v1/webhooks/{hook['id']}", json={"enabled": False}).raise_for_status() api.delete(f"/v1/webhooks/{hook['id']}").raise_for_status() ``` URLs must be `https://`, with a public host name: private, loopback and cloud-metadata addresses are refused when you save the URL, and again at every send (a name that later resolves inside is blocked). Redirects are never followed. Up to 10 endpoints per organization. ### Delivery * **At least once.** Deduplicate on the `webhook-id` header (the event ID; the same on every retry and every endpoint). * **No ordering.** Use `timestamp`. * **Success** is any `2xx` within 10 seconds; anything else, including a redirect, is a failed attempt. * **Retries** back off exponentially from 30 seconds up to 2 hours between attempts. After 10 attempts (about 4 hours) a delivery is dead. Resend it from the dashboard or with `POST /v1/webhooks/{id}/deliveries/{deliveryId}/resend`. * Events and the delivery log are kept for 30 days. ### Verify the signature Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com), so its libraries (`standardwebhooks` on PyPI and npm, and others) verify them. By hand: | Header | Value | | - | - | | `webhook-id` | The event ID | | `webhook-timestamp` | Unix seconds when this attempt was signed | | `webhook-signature` | `v1,`, where `key` is the base64 part of your `whsec_` secret | Verify the **raw** body before parsing it, reject timestamps more than 5 minutes off, compare in constant time, and deduplicate on `webhook-id`. For 24 hours after you rotate a secret, the header carries two signatures separated by a space; accept the request if either matches. ```python receiver.py theme={null} import base64, hashlib, hmac, json, os, time from flask import Flask, request, abort SECRET = os.environ["ANONA_WEBHOOK_SECRET"] # whsec_... KEY = base64.b64decode(SECRET.removeprefix("whsec_")) app = Flask(__name__) @app.post("/anona/webhooks") def hook(): msg_id = request.headers.get("webhook-id", "") ts = request.headers.get("webhook-timestamp", "0") body = request.get_data() # raw bytes if not msg_id or abs(time.time() - int(ts)) > 300: abort(400) mac = hmac.new(KEY, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest() want = "v1," + base64.b64encode(mac).decode() if not any(hmac.compare_digest(s, want) for s in request.headers.get("webhook-signature", "").split(" ")): abort(401) event = json.loads(body) # deduplicate on event["id"] print(event["type"], event["sandboxId"]) return "", 200 ``` ```js receiver.mjs theme={null} import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const secret = process.env.ANONA_WEBHOOK_SECRET; // whsec_... const app = express(); app.post("/anona/webhooks", express.raw({ type: "application/json" }), (req, res) => { const id = req.header("webhook-id"); const ts = req.header("webhook-timestamp"); const sigs = (req.header("webhook-signature") ?? "").split(" "); if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400); const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const want = "v1," + createHmac("sha256", key).update(`${id}.${ts}.`).update(req.body).digest("base64"); const ok = sigs.some((s) => s.length === want.length && timingSafeEqual(Buffer.from(s), Buffer.from(want))); if (!ok) return res.sendStatus(401); const event = JSON.parse(req.body); // deduplicate on event.id console.log(event.type, event.sandboxId); res.sendStatus(200); }); app.listen(8080); ``` Rotate a secret with **Rotate secret** in the dashboard or `POST /v1/webhooks/{id}/rotate-secret`; the old secret keeps signing for 24 hours. ## OpenTelemetry export Send every sandbox's metrics and lifecycle spans to your own OTLP/HTTP backend (Grafana, Honeycomb, Datadog, an OpenTelemetry Collector, ...). Set it on the dashboard's **Telemetry** page, or: ```bash otel_export.sh theme={null} curl -sS -X PUT https://api.compute.anonalabs.com/v1/otel-export \ -H "X-API-Key: $E2B_API_KEY" -H 'Content-Type: application/json' \ -d '{"endpoint": "https://example.com/otlp", "headers": {"Authorization": "Bearer my-backend-token"}, "enabled": true}' \ | jq '{endpoint: .export.endpoint, headers: .export.headerNames, every: .intervalSeconds}' curl -sS -X DELETE https://api.compute.anonalabs.com/v1/otel-export -H "X-API-Key: $E2B_API_KEY" -o /dev/null -w '%{http_code}\n' ``` Payloads (protobuf) go to `/v1/metrics` and `/v1/traces` every 60 seconds. Header values are encrypted at rest and never returned; leave `headers` out of an update to keep them. | Metric | Type | Unit | | - | - | - | | `anona.sandbox.cpu.utilization` | Gauge, per minute | fraction of the sandbox's vCPUs | | `anona.sandbox.cpu.time` | Sum, delta | s | | `anona.sandbox.memory.usage` | Gauge (mean resident over the minute) | By | | `anona.sandbox.memory.limit` | Gauge | By | | `anona.sandbox.vcpus` | Gauge | `{vcpu}` | | `anona.sandbox.network.io` | Sum, delta, `network.io.direction` = `transmit` / `receive` | By | Resource attributes: `service.name=anona-sandbox`, `anona.tenant.id`, `anona.sandbox.id`, `anona.template.id`. Each lifecycle event is a span from the sandbox's previous event to this one; all spans of a sandbox share one trace. `sandbox.error` spans have status ERROR. If your endpoint is down, metrics older than an hour and events older than a day are dropped rather than replayed; the dashboard shows the last error. The same access rules apply as for webhooks: owners and admins, or a custom key with `integrations:manage`. # Migrate from E2B Source: https://docs.anonalabs.com/compute/migrate-from-e2b Point the stock E2B SDK at Anona Compute, and what differs. Anona Compute serves the E2B sandbox API, so existing code that uses the `e2b` SDK keeps working. Change two environment variables: ```bash migrate-env.sh theme={null} export E2B_DOMAIN=compute.anonalabs.com # the SDK derives https://api.compute.anonalabs.com export E2B_API_KEY=e2b_YOUR_API_KEY # an Anona Compute key from the dashboard ``` Set `E2B_DOMAIN`, not `E2B_API_URL`: recent SDKs ignore or deprecate `E2B_API_URL`. Sandbox traffic then goes to `https://-.sbx.compute.anonalabs.com`, which the SDK builds from the domain the API returns. ```python e2b_unchanged.py theme={null} from e2b import Sandbox # the stock SDK, unchanged with Sandbox.create() as sbx: # default template: base print(sbx.commands.run("echo hello from e2b code").stdout) sbx.files.write("/home/user/data.csv", "a,b\n1,2\n") print([e.name for e in sbx.files.list("/home/user")]) ``` ```typescript e2b_unchanged.ts theme={null} import { Sandbox } from "e2b"; // the stock SDK, unchanged const sbx = await Sandbox.create(); // default template: base console.log((await sbx.commands.run("echo hello from e2b code")).stdout); await sbx.files.write("/home/user/table.csv", "a,b\n1,2\n"); console.log((await sbx.files.list("/home/user")).map((e) => e.name)); await sbx.kill(); ``` The Anona SDK extras (`anona`, `@anona/sdk`) are subclasses of the E2B `Sandbox`: swap the import to get `sandbox.live` and `sandbox.ssh` and the Anona domain as default. See [SDK extras](/compute/sdk/overview). ## Compatible Checked with the stock SDK (e2b 2.20.3, 2.51.0 and 2.61.0) against the gateway: | Area | Works | | - | - | | Sandboxes | `create` (v1 and v2 API), `connect`, `list`, `get_info`, `kill`, `set_timeout`, `is_running`, metadata, environment variables | | Lifecycle | `pause`, `connect` / resume with memory kept, `lifecycle={"on_timeout": "pause"}` | | Commands | `commands.run`, streaming callbacks, background commands, `send_stdin`, `kill`, `list`, `connect` | | Files | `read`, `write`, `list`, `make_dir`, `remove`, `rename`, `exists`, `get_info`, `watch_dir`, signed `upload_url` / `download_url` | | Terminal | `pty.create`, `send_stdin`, `resize` | | Ports | `get_host(port)`; see [Preview links](/compute/guides/preview-links) | | Network | `allow_internet_access`, `network` (`allow_out`, `deny_out`, `rules` with vault placeholders) on create, and `update_network` on a running sandbox; see [Networking](/compute/concepts/networking) | | Desktop | The `e2b-desktop` SDK on the `desktop` template, including the noVNC stream on port 6080 | | Templates | `Template.build` and the `e2b` CLI's template commands: build from steps or a Dockerfile, list, get, logs, delete, aliases; see [Templates](/compute/concepts/templates#build-your-own) | | Errors | E2B-shaped error bodies, so the SDK raises its usual exceptions | ## Differences | Topic | Anona Compute | | - | - | | API keys | Created in the Anona dashboard. They use the E2B format (`e2b_` + hex), so the SDK accepts them. E2B keys do not work here, and Anona keys do not work on E2B. | | Templates | The built-in templates are `base`, `python`, `node`, `code-interpreter`, `desktop` and `coding-agent`. E2B template names you built yourself do not exist here. See [Templates](/compute/concepts/templates). | | Lifetime | `timeout=-1` means no deadline on plans without a maximum lifetime. Paused sandboxes are kept until you delete them. See [Lifecycle](/compute/concepts/lifecycle). | | Default user | Commands run as `user` (uid 1000) with passwordless `sudo`, as on E2B. | | Metrics and logs | `get_metrics` and the sandbox log routes are served from the sandbox's own envd, so they need no log collector. Logs are envd's structured log: every process's stdout and stderr, process starts and exits, and file operations. The default minimum level is `info`; pass `level=debug` for envd's own internals. A paused sandbox returns an empty page, since its envd is frozen. | | Default egress | Depends on your plan: paid plans allow public internet by default, the free plan has none. Private ranges and cloud metadata are always blocked. See [Networking](/compute/concepts/networking). | ## Not available yet Calls to these return an error from the gateway today. They are **Coming soon**: | E2B feature | Status | | - | - | | `fork`, `create_snapshot`, `list_snapshots` | Coming soon | | Filesystem-only pause (`mode="filesystem"`, `autoPauseMemory: false`) | Not supported: a pause always keeps memory | | Volumes, IAM, E2B secrets, team and admin APIs | Not supported; `volume_mounts` and `iam` are refused with 400. For secrets, use the Anona [credential vault](/compute/concepts/networking#credentials-without-exposing-them) | | Resume on incoming traffic (`auto_resume`) | Accepted on create; waking a paused sandbox on a request to its URL is Coming soon. Call `Sandbox.connect(id)` to resume. | | SOCKS egress proxy (`network.egress_proxy`) | Not supported | # Anona Compute Source: https://docs.anonalabs.com/compute/overview Secure microVM sandboxes for AI agents. Works with the E2B SDK. Anona Compute gives every AI agent its own computer: a KVM microVM with its own kernel, filesystem and network policy. Agents run code, move files, start long-lived processes, drive a desktop, and pause and resume with memory intact. ## Set up with your agent Paste this into Claude Code, Codex or Cursor. It installs the CLI, asks you to approve one sign-in, registers the MCP server and proves it works with a sandbox. ```text paste-into-your-agent theme={null} Set up Anona Compute for me. Fetch https://docs.anonalabs.com/compute/agents/onboarding-prompt.md and follow it. ``` > [card] [Agent setup](/compute/agents/overview) > > How the sign-in works and what the agent gets. > [card] [Manual setup](/compute/quickstart) > > Your first sandbox from Python, TypeScript or the CLI. ## Or point the E2B SDK at Anona The API is compatible with the E2B SDK. Point the stock `e2b` package at Anona with two environment variables, or use the Anona SDK extras for live view and SSH. ```bash setup.sh theme={null} export E2B_DOMAIN=compute.anonalabs.com export E2B_API_KEY=e2b_YOUR_API_KEY ``` ## Where to go next > [card] [Quickstart](/compute/quickstart) > > Create a sandbox, run a command, clean up. > [card] [Migrate from E2B](/compute/migrate-from-e2b) > > What carries over unchanged, and what differs. > [card] [Recipes](/compute/recipes/index) > > Start from what you are building. > [card] [Concepts](/compute/concepts/sandboxes) > > Sandboxes, templates, lifecycle, security. > [card] [CLI](/compute/cli/overview) > > Lifecycle, exec, ssh, live view and MCP. > [card] [API reference](/compute/api-reference/introduction) > > Every route the gateway serves. ## What you get | | | | - | - | | **Isolation** | One KVM microVM per sandbox, behind a gateway that checks every request. See [Security](/compute/concepts/security). | | **E2B compatibility** | The `e2b` Python and JavaScript SDKs (checked with 2.20.3, 2.51.0 and 2.61.0): create, connect, commands, files, PTY, pause and resume. See [Migrate from E2B](/compute/migrate-from-e2b). | | **Long-running sandboxes** | No lifetime limit on paid plans, and pause on timeout instead of delete. See [Lifecycle](/compute/concepts/lifecycle). | | **Live view** | Watch or take over a sandbox's terminal or desktop in your own page. See [Live view](/compute/guides/live-view). | | **SSH** | `anona-compute ssh`, `scp`, port forwarding and VS Code Remote, with 10-minute certificates. See [SSH](/compute/guides/ssh). | | **Egress control** | Per-sandbox allow and deny lists on top of a platform floor that always blocks private ranges and cloud metadata. See [Networking](/compute/concepts/networking). | | **Usage pricing** | You pay for active vCPU time and resident memory, not wall-clock time. See [Pricing](/compute/pricing). | ## Docs for agents | | | | - | - | | Index of every page | [`/llms.txt`](https://docs.anonalabs.com/llms.txt) | | Everything in one file | [`/llms-full.txt`](https://docs.anonalabs.com/llms-full.txt) | | Any page as Markdown | Add `.md` to its URL | | Tools over MCP | [Agents via MCP](/compute/guides/mcp) | See [Docs for agents](/compute/agents/docs-for-agents). ## Pieces | Piece | What it is | | - | - | | API | `https://api.compute.anonalabs.com`, the E2B-compatible gateway | | Dashboard | [app.compute.anonalabs.com](https://app.compute.anonalabs.com): sign-in, API keys, sandboxes, usage | | CLI | `anona-compute`: sign in, sandbox lifecycle, exec, ssh, live view, and an MCP server for agents | | SDK extras | `anona` (Python) and `@anona/sdk` (TypeScript): subclasses of the E2B `Sandbox` with `live` and `ssh` | > [!NOTE] > > Anona Compute is in preview. Pages mark anything not available yet as **Coming soon**, and anything built but not yet proven on the production fleet says so where it matters. Preview pricing is not charged. # Pricing (preview) Source: https://docs.anonalabs.com/compute/pricing A small per-sandbox-hour charge, plus active CPU and resident memory. > [!WARNING] > > **Preview pricing.** These are the rates the metering pipeline uses today. They may change before general availability, and **no payments are taken during the preview**: usage is metered and shown in the dashboard, and invoices are drafts. | Meter | Price | | - | - | | Running sandbox-hour | **\$0.012** | | Active vCPU-hour | **\$0.012** | | Resident memory, GiB-hour | **\$0.008** | | Snapshot storage (paused sandboxes), GB-month | \$0.03 | | Egress, GB | \$0.12 | The three hourly meters add up. A running sandbox pays the sandbox-hour charge whatever it is doing, plus whatever CPU and memory it actually uses. **Active CPU, not wall-clock.** A sandbox that waits for an LLM response uses almost no CPU, so beyond the sandbox-hour it costs mostly its memory. A sandbox with 2 vCPU busy at 20% is billed 0.4 vCPU-hours per hour. **Running means sampled running**, so a quarter of an hour is a quarter of a sandbox-hour. **Paused costs only storage.** A paused sandbox pays no sandbox-hour, CPU or memory charge; you pay for its snapshot until you resume or delete it. ## Examples Per hour, for a sandbox with 2 vCPU and 4 GiB: | Pattern | Usage | Price per hour | | - | - | - | | Busy (CPU-bound work) | 2 active vCPU, 4 GiB | $0.012 + $0.024 + $0.032 = **$0.068\*\* | | Typical agent (mostly waiting) | 0.4 active vCPU, 1.5 GiB | $0.012 + $0.0048 + $0.012 = **$0.029\*\* | | Idle-heavy | almost no CPU, little memory resident | about **\$0.014** | | Paused | snapshot only | **\$0.03 per GB-month** | The free plan has the same rates with \$5 of included credit and the [free plan limits](/compute/concepts/limits). The credential vault is included at no charge. See [Usage and billing](/compute/usage-and-billing) for where to see what you used: the dashboard, `anona-compute usage`, or `GET /v1/usage`. # Quickstart Source: https://docs.anonalabs.com/compute/quickstart Create a sandbox, run a command and clean up, in about two minutes. **1. Get an API key** Sign in to the [dashboard](https://app.compute.anonalabs.com), open **API keys** and create a key. Keys look like `e2b_` followed by hex; the secret is shown once. ```bash env.sh theme={null} export ANONA_API_KEY=e2b_YOUR_API_KEY ``` The Anona SDK extras and the CLI read `ANONA_API_KEY` (or `E2B_API_KEY`) and default to `compute.anonalabs.com`. **2. Install** ```text Python theme={null} pip install anona ``` ```text TypeScript theme={null} npm install @anona/sdk e2b ``` ```text CLI theme={null} # anona-compute: a single binary for Linux, macOS and Windows (amd64, arm64) ``` > [!NOTE] > > **Coming soon:** the `anona` and `@anona/sdk` packages on PyPI and npm, and downloadable `anona-compute` binaries. Until they are published, the stock E2B SDK works on its own (`pip install e2b` or `npm install e2b`, then `from e2b import Sandbox` with `E2B_DOMAIN=compute.anonalabs.com`); see [Migrate from E2B](/compute/migrate-from-e2b). **3. Run code in a sandbox** ```python quickstart.py theme={null} from anona import Sandbox sbx = Sandbox.create("base") # a KVM microVM, ready in about a second print("sandbox", sbx.sandbox_id) result = sbx.commands.run("uname -a && whoami") print(result.stdout) sbx.files.write("/home/user/hello.txt", "hello from Anona\n") print(sbx.files.read("/home/user/hello.txt")) sbx.kill() ``` ```typescript quickstart.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.create("base"); // a KVM microVM, ready in about a second console.log("sandbox", sbx.sandboxId); const result = await sbx.commands.run("uname -a && whoami"); console.log(result.stdout); await sbx.files.write("/home/user/hello.txt", "hello from Anona\n"); console.log(await sbx.files.read("/home/user/hello.txt")); await sbx.kill(); ``` ```bash quickstart.sh theme={null} printenv ANONA_API_KEY | anona-compute login --with-api-key # or: anona-compute login (browser) SANDBOX_ID=$(anona-compute create -t base --json | jq -r .sandboxID) anona-compute exec "$SANDBOX_ID" -- 'uname -a && whoami' anona-compute list anona-compute delete "$SANDBOX_ID" ``` ## Next > [card] [Run code](/compute/guides/run-code) > > Commands, exit codes, environment and working directories. > [card] [Long-running sandboxes](/compute/concepts/lifecycle) > > Timeouts, no-limit sandboxes, pause and resume. > [card] [Templates](/compute/concepts/templates) > > `base`, `python`, `node`, `code-interpreter`, `desktop`, `coding-agent`. > [card] [Agents over MCP](/compute/guides/mcp) > > Let Claude Code, Codex or Cursor drive sandboxes. # Test every push in a sandbox Source: https://docs.anonalabs.com/compute/recipes/ci-per-push A scoped, expiring key and a sandbox per job. **You want** CI jobs that run tests in a clean machine and leave nothing behind. **1. Make a key for CI** In the [dashboard](https://app.compute.anonalabs.com), create a key with the **Create and use** preset and a 30 or 90 day expiry. It can create, use and delete sandboxes, and nothing else. See [CI and API keys](/compute/guides/ci). **2. Store it as a secret** Expose it as `E2B_API_KEY` with `E2B_DOMAIN=compute.anonalabs.com`. The stock `e2b` SDK works as is. **3. Create, run, delete** Create a sandbox per job, copy the repository in, run the tests, and delete the sandbox in a step that always runs. The CLI's `exec` and `run` exit with the remote command's exit code, so a failing test fails the job. **Next:** [CI and API keys](/compute/guides/ci) has a complete GitHub Actions workflow. # A dev box for a coding agent Source: https://docs.anonalabs.com/compute/recipes/coding-agent-box Claude Code or Codex inside a sandbox, with previews you can open. **You want** a coding agent to work in its own machine: install packages, run servers, break things. **Pieces.** The `coding-agent` [template](/compute/concepts/templates#coding-agent) ships seven agent CLIs (Claude Code, Codex, OpenCode, pi, Kimi Code, Mistral Vibe, Cursor Agent), Node, Python, Bun, Rust, Java, `gh`, Chrome, `ripgrep` and `jq`, with 2 vCPU, 4 GiB and 8 GiB of disk. **1. Create the sandbox with the agent's key** Pass the provider key as an environment variable at create time. The agents need their own credentials. ```bash coding_box.sh theme={null} SANDBOX_ID=$(anona-compute create -t coding-agent --timeout 1h --json | jq -r .sandboxID) echo "$SANDBOX_ID" anona-compute delete "$SANDBOX_ID" ``` **2. Work in it** Open a terminal with [`anona-compute ssh`](/compute/guides/ssh) or watch and take over from the browser with [live view](/compute/guides/live-view). **3. Show what it built** When the agent starts a dev server on a port, share it with a [preview link](/compute/guides/preview-links): `anona-compute preview "$SANDBOX_ID" 3000`. The link works for anyone who has it, until it expires (1 minute to 24 hours). **Driving it from your own agent.** Register the [MCP server](/compute/guides/mcp) so an outer agent can create these boxes and hand work to the one inside. # Your own environment Source: https://docs.anonalabs.com/compute/recipes/custom-environment Build a template from a Dockerfile and start sandboxes from it. **You want** sandboxes that start with your dependencies already installed. **1. Write a Dockerfile** Any base image works. `EXPOSE 49983` is refused because the Anona runtime owns that port, and every `FROM` is pinned by digest at build time. **2. Build it** ```text build_template theme={null} anona-compute template build --name my-env anona-compute template logs my-env ``` **3. Start from it** ```text start_from_template theme={null} anona-compute create -t my-env ``` Limits: 20 templates per organization, 2 builds at a time, a build context up to 1 GiB. Details and the SDK route: [Templates](/compute/concepts/templates#build-your-own). > [!WARNING] > > Template builds are verified against a real gateway and BuildKit, but registration against CubeSandbox on AWS has not been run yet. # Let an agent use a desktop Source: https://docs.anonalabs.com/compute/recipes/desktop-agent Screenshots, mouse and keyboard, and you watching. **You want** a computer-use agent: it looks at a screen, clicks, types, and you can see what it does. **Pieces.** The `desktop` [template](/compute/concepts/templates) has XFCE on Xvfb, Chrome, `xdotool` and `scrot`. Nothing runs until you start the desktop, so an idle one costs the same as any other sandbox. **1. Start a desktop and take a screenshot** ```python desktop_agent.py theme={null} from anona.desktop import Sandbox desk = Sandbox.create() try: print(desk.get_screen_size()) open("screen.png", "wb").write(desk.screenshot()) finally: desk.kill() ``` **2. Loop: look, decide, act** Send the screenshot to your model, then call `move_mouse`, `left_click`, `write` or the other actions it returns. [Desktop](/compute/guides/desktop) lists them. **3. Watch it** `desk.live_url()` returns a live-view link. With `control` scope you can take over. For web pages only, the `dom` channel replays the page with passwords masked: [Live view](/compute/guides/live-view). # Recipes Source: https://docs.anonalabs.com/compute/recipes/index Start from what you are building: each recipe is a short path through the docs. Each recipe says what you want, which pieces to use and in what order. The details live in the guides it links to. > [card] [Run code an agent wrote](/compute/recipes/run-untrusted-code) > > One throwaway microVM per task, deleted when it finishes. > [card] [A dev box for a coding agent](/compute/recipes/coding-agent-box) > > Claude Code or Codex inside a sandbox, with previews you can open. > [card] [An agent that runs for days](/compute/recipes/long-running-agent) > > No deadline, pause when idle, resume with memory intact. > [card] [Let an agent use a desktop](/compute/recipes/desktop-agent) > > Screenshots, mouse and keyboard, and you watching. > [card] [Test every push in a sandbox](/compute/recipes/ci-per-push) > > A scoped, expiring key and a sandbox per job. > [card] [Your own environment](/compute/recipes/custom-environment) > > Build a template from a Dockerfile and start sandboxes from it. # An agent that runs for days Source: https://docs.anonalabs.com/compute/recipes/long-running-agent No deadline, pause when idle, resume with memory intact. **You want** an agent sandbox that survives overnight and over weekends without paying for the hours nobody uses it. **How it works.** Pricing counts active CPU and resident memory, and a paused sandbox costs only snapshot storage ([Pricing](/compute/pricing)). So give the sandbox no deadline and let it pause itself. **1. Create with no deadline** A timeout of `-1` means no deadline, on plans without a maximum lifetime. The free plan allows one hour at most. See [Lifecycle](/compute/concepts/lifecycle#no-time-limit). **2. Pause when idle** Set idle minutes (1 to 10080) on create or later. The sandbox pauses once nothing has used it: no request, no open terminal, live view or SSH, no running process started through `envd`. The dashboard's **No limit (pauses when idle)** does the same with 15 minutes. **3. Come back** ```python come_back.py theme={null} from anona import Sandbox sbx = Sandbox.create("base", timeout=-1) sbx.commands.run("echo kept > /dev/shm/note") sbx.pause() sbx = Sandbox.connect(sbx.sandbox_id) # resumes it print(sbx.commands.run("cat /dev/shm/note").stdout) sbx.kill() ``` Running processes, open files and `/dev/shm` carry on where they were. > [!WARNING] > > A sandbox without a deadline is billed for as long as it runs. Always set idle pause, or delete it. # Run code an agent wrote Source: https://docs.anonalabs.com/compute/recipes/run-untrusted-code One throwaway microVM per task, deleted when it finishes. **You want** to execute code you did not write, such as an LLM's output, without risking your machine or other users. **Why a sandbox fits.** Each sandbox is a KVM microVM with its own kernel, so a bad script can fill its own disk and nothing else. See [Security](/compute/concepts/security). **1. Create one sandbox per task** Pick the smallest [template](/compute/concepts/templates) that has what the code needs: `python` for scripts, `node` for JavaScript. Set a short timeout so a forgotten sandbox is deleted for you. **2. Run, read, and clean up** ```python run_untrusted.py theme={null} from anona import Sandbox with Sandbox.create("python", timeout=120) as sbx: sbx.files.write("/home/user/task.py", "print(sum(range(10)))\n") result = sbx.commands.run("python /home/user/task.py") print(result.stdout, result.exit_code) ``` The `with` block deletes the sandbox on exit. **3. Decide what it may reach** Sandboxes can reach the internet by default, minus private ranges and cloud metadata, which are always blocked. Turn egress off or allow only the hosts you name: [Networking](/compute/concepts/networking). Give API tokens through the credential vault so the code never sees them. **Next:** [Run code](/compute/guides/run-code) for exit codes and environment, [Processes](/compute/guides/processes) for streaming output. # SDK extras Source: https://docs.anonalabs.com/compute/sdk/overview The E2B SDK plus Anona's live view, SSH and defaults. Anona Compute works with the stock E2B SDKs. The Anona packages are thin extras on top of them: | | Python | TypeScript | | - | - | - | | Package | `anona` (needs `e2b>=2.61,<3`; extra `anona[desktop]` adds `e2b-desktop`) | `@anona/sdk` (peer dependency `e2b ^2.61.0`; optional `@e2b/desktop`) | | `Sandbox` | Subclass of `e2b.Sandbox` | Subclass of e2b's `Sandbox` | | Adds | `sandbox.live`, `sandbox.ssh`, `anona.configure()`, `anona.desktop.Sandbox` | `sandbox.live`, `sandbox.ssh`, `configure()` | | Defaults | Domain `compute.anonalabs.com`; reads `ANONA_API_KEY` | Same | Everything else (`commands`, `files`, `pty`, `pause`, `connect`, …) is the E2B SDK, unchanged. Its own documentation applies. > [!NOTE] > > **Coming soon:** `anona` on PyPI and `@anona/sdk` on npm. ## Environment | Anona variable | Fills | Notes | | - | - | - | | `ANONA_API_KEY` | `E2B_API_KEY` | used when `E2B_API_KEY` is unset | | `ANONA_DOMAIN` | `E2B_DOMAIN` | default `compute.anonalabs.com` | | `ANONA_API_URL` | `E2B_API_URL` | E2B deprecates it; fallback only | `E2B_*` values always win, so an environment already set up for the stock SDK keeps working unchanged. * [Python reference](/compute/sdk/python) * [TypeScript reference](/compute/sdk/typescript) # Python: anona Source: https://docs.anonalabs.com/compute/sdk/python anona.Sandbox, live view tokens and SSH certificates. ```text theme={null} pip install anona # or: pip install "anona[desktop]" ``` ## `anona.configure` ```python Signature theme={null} anona.configure(api_key=None, *, domain=None, api_url=None, debug=None) -> None ``` Sets the `E2B_*` variables of this process so the stock SDK, and the extras, talk to Anona. Missing values fall back to `ANONA_*` variables, then to `E2B_DOMAIN=compute.anonalabs.com`. ```python configure.py theme={null} import os import anona from e2b import Sandbox # the stock SDK picks up the configuration anona.configure(api_key=os.environ["ANONA_API_KEY"]) with Sandbox.create() as sbx: print(sbx.commands.run("echo configured").stdout) ``` ## `anona.Sandbox` A subclass of `e2b.Sandbox`. Every E2B method works unchanged; `create`, `connect` and `list` default to the Anona domain. Two properties are added: | Property | Type | | - | - | | `sandbox.live` | `anona.Live` | | `sandbox.ssh` | `anona.Ssh` | ## `sandbox.live` | Method | Returns | | | - | - | - | | `token(scope="view", channels=None, ttl=600)` | `ViewerToken` | `scope`: `"view"` or `"control"`. `channels`: any of `"dom"`, `"pty"`, `"desktop"`, `"thumb"` (default `["dom"]`). `ttl`: 30 to 600 seconds | | `url(mode="dom", scope="view", ttl=600)` | `str` | Mints a token for one channel and returns its embed URL | | `revoke_all()` | `None` | Closes every open viewer and invalidates issued tokens | `ViewerToken` has `token`, `expires_at` (`datetime`), `embed_url`, and `claims()` (decodes the payload without verifying it). Its `repr` never shows the token. ```python live_tokens.py theme={null} import os from anona import Sandbox sbx = Sandbox.connect(os.environ["SANDBOX_ID"]) tok = sbx.live.token(scope="control", channels=["pty"], ttl=120) print(tok) # ViewerToken(expires_at=...) print(tok.claims()["scp"], tok.claims()["sbx"] == sbx.sandbox_id) print(sbx.live.url(mode="pty", scope="control").split("#")[0]) ``` Invalid arguments raise `e2b.InvalidArgumentException` before any request. Gateway errors map to the E2B exceptions: 400 `InvalidArgumentException`, 401/403 `AuthenticationException`, 404 `NotFoundException`, 429 `RateLimitException`. ## `sandbox.ssh` | Method | Returns | | | - | - | - | | `certificate(public_key)` | `SshCertificate` | `public_key`: an OpenSSH public key string, or the path of a `.pub` file (`ssh-ed25519`, `ecdsa-sha2-nistp256`, `ssh-rsa`). Refuses private keys | | `config(identity_file="~/.ssh/anona_ed25519", certificate_file=None, alias=None, user="user", proxy_command="anona-compute ssh --proxy %h")` | `str` | An `ssh_config` block. Makes no request | `SshCertificate` has `cert`, `user`, `host`, `proxy_command`, `expires_at`, and `write(path)` (writes with mode 0600). See [SSH](/compute/guides/ssh) for a full example. ## `anona.desktop.Sandbox` A subclass of `e2b_desktop.Sandbox` (install `anona[desktop]`) with the same `live` and `ssh`, plus: | Method | Returns | | | - | - | - | | `live_url(control=True, ttl=600)` | `str` | Embed URL of the desktop channel. Control scope by default, since the desktop channel carries input | See [Desktop](/compute/guides/desktop). ## Constants `anona.DEFAULT_DOMAIN` (`"compute.anonalabs.com"`), `anona.DEFAULT_API_URL`, `anona.ENV_MAP`, `anona.__version__`. # TypeScript: @anona/sdk Source: https://docs.anonalabs.com/compute/sdk/typescript Sandbox, live view tokens and SSH certificates for Node.js. ```text theme={null} npm install @anona/sdk e2b ``` ESM only; Node.js 22.19 or newer. ## `configure` ```ts Signature theme={null} configure(opts?: { apiKey?: string; domain?: string; apiUrl?: string; debug?: boolean }): void ``` Sets the `E2B_*` variables of this process so the stock `e2b` package, and the extras, talk to Anona. ```typescript configure.ts theme={null} import { configure } from "@anona/sdk"; import { Sandbox } from "e2b"; // the stock SDK picks up the configuration configure({ apiKey: process.env.ANONA_API_KEY }); const sbx = await Sandbox.create(); console.log((await sbx.commands.run("echo configured")).stdout); await sbx.kill(); ``` ## `Sandbox` A subclass of e2b's `Sandbox`. Every e2b method works unchanged and defaults to the Anona domain. Added: | Member | Type | | - | - | | `sandbox.live` | `Live` | | `sandbox.ssh` | `Ssh` | | `Sandbox.connectionDefaults(opts?)` | the resolved connection options (diagnostics) | ## `sandbox.live` | Method | Returns | | | - | - | - | | `token({ scope?, channels?, ttl? })` | `Promise` | `scope`: `"view"` (default) or `"control"`. `channels`: `"dom"`, `"pty"`, `"desktop"`, `"thumb"` (default `["dom"]`). `ttl`: 30 to 600 s (default 600) | | `url({ mode?, scope?, ttl? })` | `Promise` | Embed URL for one channel | | `desktopUrl({ control?, ttl? })` | `Promise` | Embed URL for the desktop; control scope unless `control: false` | | `revokeAll()` | `Promise` | Closes every viewer and invalidates issued tokens | `ViewerToken` has `token`, `expiresAt` (`Date`), `embedUrl` and `claims()`. `toString()` and `toJSON()` never include the token. ```typescript live_tokens.ts theme={null} import { Sandbox } from "@anona/sdk"; const sbx = await Sandbox.connect(process.env.SANDBOX_ID!); const tok = await sbx.live.token({ scope: "control", channels: ["pty"], ttl: 120 }); console.log(String(tok), JSON.stringify(tok)); console.log(tok.claims().scp, tok.claims().sbx === sbx.sandboxId); ``` Errors map to e2b's classes: 400 `InvalidArgumentError`, 401/403 `AuthenticationError`, 404 `NotFoundError`, 429 `RateLimitError`, others `SandboxError`. ## `sandbox.ssh` | Method | Returns | | | - | - | - | | `certificate(publicKey)` | `Promise` | The OpenSSH public key **text** (not a path) | | `config({ identityFile?, certificateFile?, alias?, user?, proxyCommand? })` | `string` | An `ssh_config` block. No request | `SshCertificate` is `{ cert, user, host, proxyCommand, expiresAt? }`. See [SSH](/compute/guides/ssh). ## Desktop There is no desktop subclass in TypeScript: create with `@e2b/desktop`, then `Sandbox.connect(desk.sandboxId)` from `@anona/sdk` for `live.desktopUrl()`. See [Desktop](/compute/guides/desktop). ## Exports `Sandbox`, `configure`, `applyEnv`, `anonaDefaults`, `Live`, `ViewerToken`, `Ssh`, `CHANNELS`, `SCOPES`, `MIN_TTL`, `MAX_TTL`, `DEFAULT_DOMAIN`, `DEFAULT_API_URL`, `DOMAIN_ENV`, `ENV_MAP`, and the types `ConfigureOpts`, `ViewerScope`, `ViewerChannel`, `ViewerTokenOpts`, `ViewerTokenClaims`, `SshCertificate`, `SshConfigOpts`. # Troubleshooting Source: https://docs.anonalabs.com/compute/troubleshooting Common errors and what to do about them. > [accordion] 401 Unauthorized, or the SDK says the API key is invalid > > * Check that `E2B_API_KEY` (or `ANONA_API_KEY`) holds an **Anona Compute** key from the dashboard. E2B keys and Anona Memory keys do not work here. > * The key may be revoked or expired; see **API keys** in the dashboard. > * Keys are `e2b_` followed by lowercase hex. The SDK refuses other formats before sending anything. > [accordion] The SDK talks to api.e2b.app instead of Anona > > Set `E2B_DOMAIN=compute.anonalabs.com`. Recent SDKs ignore `E2B_API_URL`. Or use `anona.Sandbox` / `@anona/sdk`, which default to the Anona domain. > [accordion] 404 sandbox not found, but I just created it > > * The sandbox may have reached its timeout and been deleted (the default is 300 s from the SDK). Use a longer `timeout`, `set_timeout`, or `lifecycle={"on_timeout": "pause"}`. > * The key may belong to another organization, or be limited to a list of sandbox IDs. Both look exactly like "not found". > [accordion] 403 your access cannot open this sandbox > > Your key lacks `sandboxes:use` (needed for commands, live view and SSH), or your dashboard role is viewer. Create a key with **Create and use**, or ask an admin for the member role. > [accordion] 403 this API key lacks the usage:read scope > > `anona-compute usage`, the MCP `get_usage` tool and `GET /v1/usage` need a key with `usage:read` (the **Read only** and **Full access** presets have it); billing needs `billing:read` (**Full access**). Create a new key; existing keys keep their scopes. > [accordion] 403 only organization owners and admins ... may change network settings and vault secrets > > Team egress settings and the credential vault are changed by dashboard owners and admins. Any role and any key can read them. > [accordion] 400 no vault secret named ... is bound to this sandbox / store it in the credential vault first > > Store the secret on the dashboard's **Credential vault** page under exactly the name the rule uses (`${vault.}`), then create the sandbox again. > [accordion] 429 concurrent sandbox limit reached > > Delete sandboxes you no longer need (`anona-compute list`, then `anona-compute delete`), or pause them: paused sandboxes do not count as running. See [limits](/compute/concepts/limits). > [accordion] 400 timeout -1 (no limit) is not available > > Your plan has a maximum lifetime (the free plan's is one hour). Use a timeout up to that maximum. > [accordion] 400 volumeMounts / iam / autoPauseMemory=false are not supported > > These E2B features are not available on Anona Compute; remove them from the create call. See [Migrate from E2B](/compute/migrate-from-e2b#not-available-yet). > [accordion] 400 on create with network.allow\_out > > Host names in `allow_out` need public egress off: add `deny_out: ["0.0.0.0/0"]`. Destinations in private or metadata ranges, and names that resolve into them, are always refused. See [Networking](/compute/concepts/networking). > [accordion] 503 with Retry-After on create > > A sandbox server is starting after an idle period. The SDK retries; if you call the API yourself, wait `Retry-After` seconds and retry. > [accordion] 409 when opening a sandbox URL > > The sandbox is paused, for example by its idle timer. Resume it with `Sandbox.connect(id)` or `anona-compute resume `. > [accordion] ssh: Permission denied (publickey) > > The certificate is older than 10 minutes, or was minted for another sandbox. Run `anona-compute ssh ` again, or refresh the block with `anona-compute ssh --print-config`. > [accordion] The live view says the token expired or closes at once > > Tokens last at most 10 minutes. Answer the embed's `token-request` message with a fresh token ([Live view](/compute/guides/live-view#keep-a-long-session-alive)). After `revoke_all`, mint new tokens. The `dom` and `thumb` channels are not available yet. > [accordion] A template build fails with EXPOSE 49983 or an unpinned FROM > > Port 49983 belongs to the Anona runtime (`envd`), so a template cannot expose it. Every `FROM` is pinned by digest at build time; a tag that cannot be resolved fails the build. See [Templates](/compute/concepts/templates#build-your-own). > [accordion] anona-compute logs / fork / keys say not available > > These commands exist in the CLI ahead of their server side. See the [CLI overview](/compute/cli/overview) for what is coming. ## Get more detail * `anona-compute -v …` prints verbose detail on stderr. * Every API error body has `code` and `message`, and often `error_code`. # Usage and billing Source: https://docs.anonalabs.com/compute/usage-and-billing What is metered, and where to read it: dashboard, CLI, MCP and API. Usage is metered per sandbox and priced at your plan's [rates](/compute/pricing). During the preview no payment method is needed and nothing is charged; invoices are drafts. ## Where to see it | Where | What | Who | | - | - | - | | Dashboard **Usage** page | The month by day, per meter, per sandbox and per template | Every role | | Dashboard **Billing** page | Plan, rates and invoice drafts | Owners and admins | | `anona-compute usage` | Meters, totals, the part not yet rated, a projection for the current month, the most expensive sandboxes | Browser sign-in of any role, or a key with `usage:read` | | `anona-compute usage --billing` | Plan, rates and invoices | Owners and admins, or a key with `billing:read` | | MCP tool `get_usage` | The same as `anona-compute usage`, for agents | As above | | `GET /v1/usage`, `GET /v1/billing` | The JSON documents behind all of the above | As above | API keys need the scope: the **Read only** preset has `usage:read`, **Full access** has both. Keys created before these scopes existed do not have them. ```bash usage.sh theme={null} anona-compute usage # this month anona-compute usage --period 2026-10 --top 10 anona-compute usage --json | jq '{period: .period.key, total: .totals.amountUsd, meters: [.meters[] | {meter, quantity}]}' anona-compute usage --billing --json | jq '{provider, rates: [.rates[] | {meter, unitPriceUsd}]}' ``` ```python usage_api.py theme={null} import os import httpx r = httpx.get( "https://api.compute.anonalabs.com/v1/usage", params={"period": "current"}, headers={"X-API-Key": os.environ["E2B_API_KEY"]}, ) r.raise_for_status() usage = r.json() print(usage["period"]["key"], usage["totals"]["amountUsd"], usage["totals"].get("projectedUsd")) for m in usage["meters"]: print(f'{m["meter"]:<20} {m["quantity"]:>10.3f} {m["unit"]:<10} ${m["amountUsd"]:.4f}') ``` ## What counts | Meter | Counted when | | - | - | | Active vCPU-hours | The sandbox's CPU is busy, while it runs | | Resident GiB-hours | Memory the sandbox actually holds, while it runs | | Snapshot storage | A paused sandbox's snapshot exists | | Egress | Bytes leave the sandbox to the internet | Deleting a sandbox stops all four. Pausing stops the first two. Usage is rated hourly; until then the totals show it as `pendingUsd`, estimated at the same rates. ## Coming soon Card payments and issued invoices, and spend caps you set yourself. For per-sandbox metrics in your own monitoring, see [OpenTelemetry export](/compute/guides/webhooks#opentelemetry-export). # Self-Onboarding Prompt Source: https://docs.anonalabs.com/memory/agents/onboarding-prompt One prompt you paste into a coding agent. It connects itself to Anona Memory, then uses it. Give your coding agent this prompt: ``` Fetch the full text of https://docs.anonalabs.com/memory/agents/onboarding-prompt.md with curl (not a tool that summarizes it) and follow it to set up and use Anona Memory. ``` The `curl` matters. Some agents' web-fetch tools hand back a short model-written summary instead of the page, and the details this page exists for (exact commands, what an error means, what to tell you at the end) do not survive summarizing. If your agent only has such a tool, ask it to quote the page verbatim. > [!WARNING] > > The temporary-profile path below (`anona start`) requires pip `anona` 0.19.0 > or later, or npm `@anona-labs/memory` 0.16.0 or later, and the server feature is > live. If `anona start` is not a command in the version you have, or the server > refuses to create a profile, fall back to the signed-in path in section 2 > ("If `anona start` is refused"). The flow is explained for people in > [Temporary profiles](/memory/agents/temp-profiles). > [!NOTE] > > This is not the same thing as [Agent Skills](/memory/integrations/skills). Those are > installed files that teach an agent the *habit* of remembering. This page is a > single prompt that gets an agent *connected* in the first place, with no files > and no API key, and with nothing to sign in to first. Use this to onboard, then install the skills if you want the > habit too. That URL is this page, in the plain-text form Mintlify serves for every doc. Fetching it is the whole install: no separate skill file to download, no per-client setup script, nothing to keep in sync. What follows is written for the agent that fetches it, in the second person, so read it as instructions for yourself. ## 1. Setup check This page works in any coding agent that can run shell commands: Claude Code, Codex, OpenCode, Goose, Gemini CLI, Cursor, Windsurf, Cline and the like. Where a step depends on the client, it says so and gives each client's spelling. Work out which client you are before you start, and use only that client's commands. Check by **calling a tool, not by looking for a configuration entry.** The test is one question: **do you have a tool named `record` (and `list_spaces`) in this session that you can call, and does `list_spaces` return?** Only a tool that is in your own tool list counts. An entry in your client's server list (`claude mcp list`, `codex mcp list`, `opencode mcp list`, `gemini mcp list`, a Goose extension, an `mcp.json` entry) is not that, even when it says `connected`. If `list_spaces` returns, you are connected, **but to an account the human has not yet chosen for this work**: ask first (below), then bring the CLI up to date (the last part of this section) and skip to Usage, section 4. Do not treat a server list as proof. A server can be listed, and even say `connected`, while its tools are absent from your session, and in that state you have nothing to call. Reading the list and stopping there is the single most likely way to get this wrong: you will report success and then be unable to record anything. So there are five situations, and they need different answers: | What you observe | What it means | Go to | | - | - | - | | `list_spaces` is in your tools and returns | Connected to an existing account | Ask the human (below), then section 4 | | No `list_spaces` tool, and the only Anona entry is one your client does not own (in Claude Code, a line prefixed `claude.ai`) | Not connected *here*. See [Claude Code only](#claude-code-only) | Section 2, set it up | | An Anona server is configured but **disabled** (for this project, or `enabled: false` / `enabled = false` in the config) | Disabled. A restart alone will not fix it | Section 3, enable first | | No Anona tools, and an Anona server is listed and enabled | Added but not loaded into this session | Section 3, restart | | No Anona tools and no Anona server listed | Not set up yet | Section 2 | Check the disabled case before the others, because it changes the order of the fix. Telling someone to restart first, when the server they need is disabled, costs them a restart that cannot work. ### Found an existing account? Ask before you use it **Never pick an account for the human, and never write before they have picked.** A machine can already hold one or more Anona credentials, and each one is somebody's account: possibly the human's, possibly a colleague's, possibly a work account they would not want a side project written into. Writing to one they did not choose spends its credits and puts memories where they did not expect them, and it looks exactly like success. So first look for every credential, **without writing anything**: * Anona tools already in your session. Note **which server each comes from**: the tool name or prefix says so (`mcp__anona__…`, `mcp__anona-oauth__…` and `mcp__claude_ai_anona__…` in Claude Code, `anona__…` in Goose, `mcp_anona_…` in Gemini CLI, the `mcp__anona` namespace in Codex, `tools.anona…`). Codex does not list MCP tools up front: search for them with its `tool_search` tool (query `anona`). Call `list_spaces` on each and note two or three space names. * `anona status`: a signed-in account, a temporary profile, or `Not logged in`. * An `ANONA_API_KEY` in the environment, or a key in this project's config. If you found **nothing**, go on: install the CLI (below), then section 2. If you found **anything**, stop and ask, naming what you found. For example: > I found an Anona account already connected on this machine (spaces > `acme-notes`, `research`, …). Should I use that account, and if so which > space? Or should I start a separate temporary profile, which is empty, has > 1,000 credits and expires in 72 hours unless you claim it? Wait for the answer. Do not record, and do not run `anona start`, until you have it. **Starting a temporary profile is not the safe default here; it is one of the two answers**, and choosing it for the human is the same mistake as choosing the existing account for them. If you run unattended and cannot ask, do neither: do not install a profile, do not record, report what you found and the two choices, and stop. Installing or updating the CLI is still fine. An existing temporary profile (`anona status` shows one) is the exception, since it was made for this machine: use it, and never create a second one beside it. If your client has no `anona` server registered yet, register one (the table in section 2), so the next session has the tools as well as the CLI. **If the human picks the temporary profile while other Anona tools are in your session, those tools are still the other account.** Starting a profile does not change what they point at. On the temporary profile, write only through: * the `anona` CLI (`anona record`, `anona retrieve`, `anona reason`), which uses the profile `anona start` stored, or * the tools of the server **you** register in section 2 (`anona`, launched as `anona mcp`), once your client has loaded them. Every other Anona tool in the session (`anona-oauth`, a `claude.ai` connector, a server with an API key in its header) writes to the other account. Do not call it for this work. Before the first write through an MCP tool, check it reaches the profile: its `list_spaces` must return the same spaces as `anona spaces list` (on a temporary profile, only `default`). If they differ, it is the wrong account. **If they match, that does not prove it is the same account**: every temporary profile has exactly one space called `default`, so two different profiles look identical this way. The server name decides: only the server you registered, launched as plain `anona mcp` with no other environment or home directory, uses the profile the CLI holds. Treat every other Anona server as a different account even when its spaces match. If the human picks the existing account, use it: do not run `anona start`, and ask which space before writing (section 4). ### Install the CLI, or update it **Do this every time you follow this page, connected or not.** New versions of the `anona` CLI ship often, and the MCP server your client launches is that same CLI (`anona mcp`), so an old copy means old tools and missing commands even when everything reports connected. Never assume the copy on the machine is current. First find out what is there: ```bash theme={null} anona --version # prints the installed version; "command not found" means it is not installed which -a anona # every copy on PATH; the first one is the one that runs ``` **If it is not installed**, install it with one of these, in this order of preference. You need only one: ```bash theme={null} pipx install anona # isolated, preferred where pipx exists pip install -U anona # into the current Python npm install -g @anona-labs/memory # with Node and no Python ``` **If it is installed, update it with the same tool that installed it.** Check which one with `pipx list`, `pip show anona` or `npm ls -g @anona-labs/memory`, then: ```bash theme={null} pipx upgrade anona # installed with pipx pip install -U anona # installed with pip npm install -g @anona-labs/memory@latest # installed with npm ``` Do not install a second copy with a different tool to "update" it: the old one may still be first on `PATH` and keep running. A copy used through `uvx` has no install to update, but uvx reuses what it cached, so ask for the newest explicitly with `uvx --from anona@latest anona ...`. Then run `anona --version` again. **If the version did not change, you updated a copy that is not the one on `PATH`**: compare against `which -a anona` and update, or remove, the copy that is first. If an update fails, say so and carry on with the installed version rather than stopping; the rest of this page names the minimum version each command needs. If the client was already running the proxy before you updated, it keeps the old copy until it restarts. Memory still works in the meantime; mention to the human that the update takes effect on their next restart. ## 2. Start a temporary profile Do not make the human sign in first. Create a temporary profile and start using memory now; claiming the account is the last step, in section 7, and it is optional. With the CLI installed and current (end of section 1), start the profile: ```bash theme={null} anona start # a profile with no identity, no email, nobody owning it ``` **Ways the install or update step fails quietly, all worth checking before you rely on a command:** * `pip install anona` **without `-U`** over an existing copy reports success and leaves the old version in place. If `anona --help` does not list the command you are about to use, that is why. * If pip answers `error: externally-managed-environment`, it is refusing to write to a system Python — the default on Debian, Ubuntu and Homebrew. * If pip answers "No virtual environment found; run uv venv", then `pip` on this machine is a uv shim and wants a virtualenv it cannot see. A `pip show` in that shim also reports the package missing even when it is installed elsewhere, so do not read that as proof of anything. Either way, do not fight it. `uvx --from anona@latest anona start` installs nothing and runs the newest release; `pipx install anona` is the isolated alternative. `pip install -U --break-system-packages anona` also works and is what pip itself suggests, but it writes into the OS Python, so offer it only if neither of the others is available. **Learn the CLI from the CLI, once, before you use it.** Run `anona guide`: it prints a compact reference written for you — every command and flag, the credential order, the exit codes and what `--json` returns — generated from the copy that is installed, so it cannot describe a command you do not have. `anona` with no arguments prints a shorter where-to-start. Both need pip `anona` 0.25.0 or npm `@anona-labs/memory` 0.19.0 (the two packages are numbered separately, so these are the same release); on an older copy `anona guide` is an unknown command, so fall back to `anona --help`. Do not assume a command exists because this page names it; the copy on the machine decides. Use whichever of the two the machine has; you need only one. Both CLIs share `start`, `login`, `logout`, `status` and `mcp`, and one credential file (`~/.anona/credentials.json`), so `anona start` from one and `anona login` from the other is fine. The data commands (`record`, `retrieve`, `reason`, `spaces`, `memories`, `documents`, `jobs` and the settings commands) are pip 0.20.0 or later, and npm 0.19.0 or later, with the same flags and output. If the machine has neither Python nor Node, use the signed-in path under "If `anona start` is refused" below. `anona start` prints the deadline and what to do about it. Its output looks like this (the date shown is an example; use the one **your** run prints): ``` Temporary profile created. It expires 2026-10-06 15:22 UTC (in 71 hours), after which its memories are deleted. The only reminder comes in the last 24 hours: run `anona status` to check, and `anona login` to claim it and keep what it holds. Its space, `default`, is normally created now; if a retrieve reports no space, the first memory you write creates it. ``` **Read the expiry off that output and keep it.** You will need the date and time again in section 7. The CLI reminds you only inside the last 24 hours, on stderr, so do not rely on it. The facts that matter: * The profile expires **72 hours after it was created**. (The CLI counts down from creation, so it prints "in 71 hours" a moment later; that is the same deadline, rounded down.) After that its memories are deleted. * **`anona status` is the only warning channel.** No email address is attached, so nothing else can warn anyone before the deadline. * While unclaimed it holds **1,000 credits**. Claiming raises that to the normal 5,000. It is a ceiling, not a grant: credits already consumed carry over. * **Reads spend credits too**, not only writes: `retrieve`, `reason` and the listing commands all draw on the balance, and at zero they are refused (exit `6`, `credits_exhausted`) like a write. A 100-item `record --batch` can cost on the order of 150-200 credits, so on a 1,000-credit profile try `--batch` with a few lines first. * The profile has **exactly one space, `default`**. `anona spaces create`, and a write with `--space `, are refused with exit `4`, `temp_profile_single_space`; a read naming another space exits `5`, `space_not_found`, because no such space exists. Omit `--space`. * `anona logout` refuses to discard a live unclaimed profile unless you pass `--force`. Do not pass it. Throwing away a profile deletes what it holds. The profile's token is stored in `~/.anona/credentials.json` (mode 600), under your home directory. Do not print it. **Tell the human what they now have, straight away**, before registering anything, in your own words. They asked for memory and will otherwise not know what it can do or what it costs. Cover: * **What it is:** a temporary profile with no account behind it, one space (`default`), 1,000 credits, deleted on `` unless claimed with `anona login`. * **What you can do with it for them now:** remember what they tell you (`record`), find it again later by meaning (`retrieve`), answer a question from everything stored (`reason`), take in files (`anona documents upload`) and many memories at once (`anona record --batch`). Through MCP tools once their client loads them, and through the CLI right away. * **What it costs:** every read and write spends credits; one memory is roughly 10-15. * **What it cannot do:** more than one space; the SDK or REST API with this credential (section 6). Keep it to a few lines, then carry on. For example: > I've started a temporary Anona profile for you: no sign-up, 1,000 credits, > one space. I can now remember things you tell me, find them again later and > answer questions from them, and take in files. It is deleted on > `` unless you run `anona login` to keep it. Then register the proxy as a stdio server. **First make sure the `anona` on your `PATH` is a working one:** `anona status` must run and print something. Before `anona start` or a sign-in, that something is `Not logged in` with exit `1`, which is a working copy, not a broken one. Every client registers whatever the name resolves to, so a stale or broken `anona` (for example a leftover shim that fails with `ModuleNotFoundError`) is registered without complaint and only fails later, when the client loads its tools, far from the cause. If it is broken, register `uvx --from anona@latest anona mcp` instead of `anona mcp` everywhere below: it needs no installed command. The server is the same everywhere: a **stdio** server named `anona`, launched as `anona mcp`, with no arguments, environment or API key. Only the way you register it differs. Use your own client's row: | Client | Register it | | - | - | | Claude Code | `claude mcp add anona -- anona mcp` | | Codex | `codex mcp add anona -- anona mcp` | | Gemini CLI | `gemini mcp add -s user anona anona mcp`. Without `-s user` it writes to the project's settings, which Gemini refuses in a folder it does not trust. Gemini also runs no MCP server in an untrusted folder: if `gemini mcp list` shows `anona` as `Disabled`, ask the human to trust the folder when Gemini starts | | OpenCode 2.x (`opencode --version`) | `opencode mcp add anona -- anona mcp`, or in `opencode.json(c)` (project, or `~/.config/opencode/` for global): `"mcp": {"servers": {"anona": {"type": "local", "command": ["anona", "mcp"]}}}` | | OpenCode 1.x | no `mcp add` for a local server; in `opencode.json`: `"mcp": {"anona": {"type": "local", "command": ["anona", "mcp"], "enabled": true}}`. The two versions nest differently, so check the version first | | Goose | `goose configure` → Add Extension → Command-line Extension, name `anona`, command `anona mcp`. It is interactive, so ask the human to run it, or add this entry under `extensions:` in `~/.config/goose/config.yaml`: `anona: {name: anona, type: stdio, cmd: anona, args: [mcp], enabled: true, timeout: 300, envs: {}}` (as a block mapping if the file uses that style) | | Cursor, Windsurf, Cline, Claude Desktop and other JSON-configured clients | add `"anona": {"command": "anona", "args": ["mcp"]}` under `mcpServers` in the client's MCP config file (Cursor: `~/.cursor/mcp.json` or `.cursor/mcp.json`; `cursor-agent mcp list` should then show `anona: ready`) | When you edit a config file rather than run a command, **merge into it**: read it first, add the one `anona` entry, and leave every other server as it was. Never overwrite the file. If you do not recognise your client, find its MCP documentation and register the same stdio command; if it has no MCP support, skip to the shell commands in section 4. For Codex, `codex mcp add` writes `[mcp_servers.anona]` with `command = "anona"` and `args = ["mcp"]` to `~/.codex/config.toml`; edit that file directly if the `mcp` subcommand is missing from the installed version. Codex starts MCP servers in the background and keeps their tools out of its tool list: the model finds them with `tool_search`, under the `mcp__anona` namespace. A headless `codex exec` given its prompt at launch searches before the server has started and finds nothing; add `required = true` under `[mcp_servers.anona]` if this machine runs Codex that way, or use the shell commands in section 4. There is no sign-in step inside the client: the credential already exists when the client starts, and the proxy renews it. Details are in [the anona CLI](/memory/cli/overview). Do not poll any login or auth endpoint at any point in this flow. Those are rate-limited and a polling loop will lock the attempt out. `anona status` shows a stored credential, but that only proves it exists, so the first memory tool call is the real check. **If `anona start` is refused.** A profile can be refused because one is already live, because the installed CLI is too old, or if the feature is ever switched off again; in that last case the server answers that it refused to create a profile. If it happens, here is what to do. Do not retry in a loop. Tell the human, and offer the signed-in path instead: run `anona login`, **show the authorization URL it prints to the human**, and tell them to open it. It also tries to open a browser itself, but over SSH or in a container that fails, so never assume a window appeared. Say: > Open this link and approve access. Check that the client name shown is the > one you expect before you approve it. Tell me when you are done. That client-name check is the entire security model for this step. If they have no account, they create one on that same screen. `anona login` exits on its own once the approval lands. Then register the proxy exactly as in the table above. This path needs approval once, and again only after 30 days of total silence or if the human revokes the app under **MCP → Connected apps**. In this case there is no deadline and section 7's deadline wording does not apply; tell the human the account is already theirs. **The alternative, for a client that drives its own OAuth.** If the client has a native OAuth implementation, skip the CLI and add the remote server at `https://memory.anonalabs.com/mcp`: ```bash theme={null} claude mcp add --transport http anona-oauth https://memory.anonalabs.com/mcp opencode mcp add anona-oauth --global --url https://memory.anonalabs.com/mcp ``` No `--header`, no key. This path does not sign anyone in by itself, and a failed tool call does not open a browser. The human has to start it from the client (`/mcp` in Claude Code, `/mcps` in OpenCode: pick the Anona server and authenticate), and you cannot do that for them because it is an interactive picker. Only Claude Code has been verified completing this flow; for every other client use the CLI path above. Use it only when you cannot run the CLI. One limit of the proxy to know about: it handles one message at a time, and a slow `reason` can take up to 120 seconds, during which your other memory calls wait. ## 3. Restarting, and waiting **If the Anona tools are not in your session, no amount of waiting will add them.** In nearly every client, MCP tools load when the client starts (or, in an editor such as Cursor, when the server is enabled or the window reloaded). Say this to the human plainly, naming your client: > Anona is configured, but its tools only load when `` starts. > Restart it in this directory, then ask me again. The temporary profile is > already created, so there is nothing more to approve. Then stop. Do not keep probing, and do not report that memory is working. **The session that starts after the restart has not read this page.** It will not know that a temporary profile exists, when it expires, or that `anona login` claims it, so a human who later says "keep it" or "claim it" gets a blank. Before you ask for the restart, on a temporary profile, leave that session a note in memory: the setup-check record below already says it, and if you skip that check, record the same sentence with `anona record`. And whenever you are in a session that knows nothing of Anona's setup, `anona status` is the place to look: it names the deadline and the claim command. **If you cannot ask for a restart** (you run unattended, or the human is not there), you may still verify the setup by driving the stdio proxy yourself: run `anona mcp` and send it JSON-RPC over stdin, **one JSON object per line**, then close stdin; it prints one reply line per request. The sequence is `initialize`, then the `notifications/initialized` notification (it gets no reply), then `tools/call`: ```bash theme={null} printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"record","arguments":{"content":"Anona Memory was connected on this machine and its setup check passed. It uses a temporary profile: anona status shows when it expires, and anona login claims it."}}}' \ '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"retrieve","arguments":{"query":"Anona Memory setup check"}}}' \ | anona mcp ``` Read the `result` of ids 2 and 3; an `error` object means it failed. That is a genuine check, since it is the same server your client will launch. Two limits. It proves the profile works, not that your session has the tools, so say which of the two you checked. And it writes real memories: the record above is true once the check passes, so keep it as written rather than recording an example preference the human never stated. On a signed-in account, drop its second sentence: there is no temporary profile to claim. Do not claim the tools are available in this session until they load. If the server is configured but **disabled**, say this first, before any mention of restarting: > The Anona server is disabled. Enable it, then restart ``. > Enabling is a choice only you can make, and restarting before it is enabled > will not help. Tell them where: in Claude Code, `/mcp` and pick the server (`anona` if it was added with `anona mcp`, or `anona-oauth` for the remote server); in Codex, OpenCode and Goose, the server's `enabled` setting in the config file named in section 2; in Cursor and other editors, the toggle in the MCP settings page. ### Claude Code only **A `claude.ai` entry is not a Claude Code server.** `claude mcp list` shows both: servers configured in Claude Code, and connectors synced from claude.ai, the latter prefixed `claude.ai`. A line reading `claude.ai anona: https://memory.anonalabs.com/mcp - Connected` means the connector is healthy in claude.ai. It does not mean this session has Anona tools, and `claude mcp remove` cannot touch it, because the CLI does not own it. So if the only Anona line you see is a `claude.ai` one, treat it as **not set up** and go to section 2. Adding the Claude Code server alongside it is correct and is not a duplicate; they are two different integrations that happen to point at the same URL. ### Every client The general rule holds: judge by whether `list_spaces` works, never by what is listed as connected. Once the tools are present and any browser approval is pending: do not poll any login or auth endpoint. Those are rate-limited and a polling loop will lock the attempt out. If you used the signed-in path and the human says they have approved, do not take that as proof. Retry the MCP tool call you were going to make anyway. Succeeding is the signal; a human saying "done" is not, and acting on it leaves you reporting success for a connection you never established. If the retry still comes back unauthenticated, say so plainly rather than retrying in a loop. ## 4. Usage Once connected, you have these MCP tools. **Whether a tool needs a `space_id` depends on what you are signed in as.** The contents in the examples below show the shape of each call only. Record what the human actually told you, never an example sentence from this page. * **On a temporary profile** (you ran `anona start`), leave `space_id` out of every tool. Its only space is `default`, and the call uses it: ``` record {"content": "The user prefers pnpm."} retrieve {"query": "package manager preference"} reason {"query": "how does this team like to build?"} ``` * **On a signed-in account**, name the space. An account may hold several, and a memory written to the wrong one looks exactly like success, so there is no default. Pass exactly one of `space_id` or `route: "auto"`, never both and never neither, or the call fails with `route_conflict`: ``` record {"content": "The user prefers pnpm.", "space_id": "my-space"} retrieve {"query": "package manager preference", "space_id": "my-space"} ``` `route: "auto"` lets Anona choose the space instead, for when you do not know which one a memory belongs in. Call `list_spaces` first if you do not know the names. * **`record`** stores a memory. Use it when the human tells you something worth remembering across sessions, a preference, a fact, a decision. Do not record your own answers or lookups, least of all "nothing is stored about X": that sentence is itself a memory about X, and it is what the next lookup finds. * **`retrieve`** searches memories semantically. Use it when you need context you might already have stored, before assuming you don't. * **`reason`** synthesizes an answer from accumulated memories rather than returning raw matches. Use it for a question whose answer is spread across several past memories. * **`list_spaces`** lists the memory spaces available to this credential. **If your harness cannot do MCP at all, or has not restarted yet**, and you are on a temporary profile, the same three operations are shell commands. Use them rather than reporting that you have no memory: ```bash theme={null} anona record "The user prefers pnpm." anona retrieve "package manager preference" anona reason "how does this team like to build?" ``` No space argument, same as the MCP calls on a temporary profile. On a signed-in account they use the credential `anona login` stores (pip `anona` 0.24.0 or later), and then they need a space: pass `--space`, and `anona spaces list` shows the account's spaces. An older CLI, or a login made with `anona login --mcp` only, has no command-line credential and they refuse. The CLI also does what the MCP tools cannot, so reach for it even alongside MCP when the work calls for it: uploading files (`anona documents upload`), ingesting many memories at once (`anona record --batch FILE`, JSON Lines, then `anona jobs get` or `--wait`), and reading or changing a space's settings. When you script any of it: * **Pass `--json`.** You get one JSON document on stdout, and nothing on stdout when the command fails. * **Branch on the exit code, not on the wording.** `0` worked, `1` failed for another reason (read stderr), `2` you used it wrong (nothing was sent), `3` nothing was stored, `4` the credential was refused, `5` not found, `6` rate limit or credits, `7` the server or network failed, `8` `--wait` gave up. `anona guide` lists them. * **Errors and progress notices go to stderr** (`Reasoning over space ...`, retry notices), errors as `anona: : (request_id )`. Parse stdout on its own; if your tool merges the two streams, redirect stderr (`2>/dev/null` or to a file) before parsing. Quote the request id if you report a failure to the human. * **A write is never retried after a server error or a timeout**, so a `7` on `record` means it may or may not have landed. Retrieve before writing it again rather than recording it twice. Three behaviours to expect, so you do not mistake them for faults. **The MCP `record` tool returns no memory id**, only a confirmation (`anona record --json` does return `memory_ids`). **An uploaded document's job can finish as `completed` with no memory count**, so confirm an upload by retrieving from it, not from the job. And **`retrieve` can return more entries than the number of `record` calls you made**: stored text is split into separate facts, so three records may come back as five entries. Report what you recorded (three), not the entry count. A space is the isolation boundary, one per application or tenant. Within a space, `user_id`, `agent_id` and `session_id` scope individual memories further: a scoped search returns only memories written under that same scope, so tag consistently if you're serving more than one end user through one connection. **Keep the deadline in front of the human.** A temporary profile deletes its memories when it expires, and a single mention at setup will be long forgotten. Whenever you store something significant (a decision, a preference, a fact the human would be sorry to lose), say in one line that it lives in a temporary profile that expires at the date and time from `anona start`, and that `anona login` keeps it. Check `anona status` for the current deadline if you no longer have it. The server helps: while the profile is unclaimed, the MCP `initialize` reply carries instructions naming the deadline and the claim command, and a tool result carries a short `[Anona notice for the agent]` about once an hour (every 15 minutes in the last 24 hours). Pass it on in one line; do not repeat it every turn. `anona mcp` also prints a reminder at startup in the last 24 hours, but on **stderr**, which most humans never see. Reminding them is your job. The full tool list, including three remote-only tools this credential also grants (`get_profile`, `get_model`, `get_user_profile`), is in [MCP Integration](/memory/mcp-integration). ## 5. Offer to install the skills Connecting gave you the tools. The skills give you the habit: recall before a task, record after it, without being asked each time. The installer adds three: `anona-memory` (the habit), `anona-memory-sdk` (for writing code against the API) and `anona-cli` (the CLI's commands, `--json` output and exit codes, for a session that works through the shell). Offer it, and if the human agrees, run: ```bash theme={null} curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/skills/install.sh | bash ``` **`bash`, not `sh`.** The installer uses `set -o pipefail`, which dash rejects, and `/bin/sh` is dash on Debian and Ubuntu — so piping to `sh` dies on line 8 with `Illegal option -o pipefail` before anything is installed. No API key is needed for this. The installer detects the MCP server you just connected and the skills call Anona through it. Skills load at session start, so tell the human to restart you once. ## 6. Limits The credentials `anona login` issues work only for the `anona` CLI on this machine: one over MCP, one for the CLI's own commands. Neither is an API key, so neither works with the SDK or with raw HTTP calls to the REST API. (A temporary profile's `anona_tmp_` token does also authenticate the data plane until it is claimed or expires, but treat that as a secret you are holding, not something to wire into code.) That boundary is worth stating plainly rather than discovering: **do not offer to install the Python or JavaScript SDK as part of this setup.** It would install fine and then have nothing to authenticate with. The SDK is for writing code that calls Anona directly, which is a separate decision, and it needs an API key the human mints from the dashboard under **API keys**. Everything in this page works without one. For the full flow this page starts, including troubleshooting a stuck approval, see [Agent Self-Onboarding](/memory/agents/self-onboarding). ## 7. Tell the human, and offer to keep it When setup is done, and again at the end of the work, **report to the human in your own words.** Do not paste this page back at them and do not leave them to find the deadline in terminal output they never saw. Cover all of this: * A **temporary profile** was created and memory is working now. * **What has been stored so far**, if anything. If nothing yet, say so, and that the space is empty. * **The deadline, as a date and time**, and that the data is **deleted** then. Use the exact expiry from `anona start`, not "in a few days". * That running **`anona login`** claims the profile and keeps everything in it, and that **doing nothing is a valid choice**: the profile simply expires. * That claiming works with **a new or an existing Anona account**. A new account takes over this profile's workspace. An existing account keeps its own spaces and gets this profile's space **copied in as a new space**, usually named `default-claimed` because the account has its own `default`. * That credits already spent carry over: claiming raises the ceiling and does not reset what was used. Give the **real** figures, read them first (see below), and never a number from this page. Claiming with a new account moves nothing: it is attached to the workspace that already holds the data, so the memories written during the trial are the same ones afterwards, and the limit rises from 1,000 to the normal 5,000 credits. Claiming with an existing account copies the space across and leaves that account's credits as they were. **Read the real numbers before you write the summary.** Everything in the example below in angle brackets is a placeholder that you must replace with what you actually observed. None of its figures describe the human's profile. * **Expiry:** from `anona status` (or the `anona start` output). * **Credits:** run `anona usage --json`; it returns `credits_limit`, `credits_used` and `credits_remaining`. Do not read the token out of `~/.anona/credentials.json` to call the API yourself: the CLI already holds it. If the command fails, say you do not know the balance rather than estimating it. * **Memories stored:** the ones you actually recorded. A summary with the right shape (every `<...>` is a placeholder): > Anona Memory is set up and working. I created a temporary profile, so there > was nothing to sign in to. So far I have stored `` memories: > ``. > > This profile has used `` of its `` credits. It > expires on ``, and its memories are > deleted then, so I will mention the date again when I store anything > important. To keep everything, run `anona login` (I can start it and give you > the link), and sign up or sign in on the page it opens: a new account or one > you already have both work. Nothing is lost on claiming. If you would rather > not keep it, you do not need to do anything. **If the human says to claim it,** run `anona login` yourself with your shell tool (claiming is a command, not an MCP tool; do not tell them you cannot), show them the URL it prints, and wait. The page it opens is the sign-up page, with a sign-in link for a human who already has an account. `anona login` does not exit until the approval lands, and the URL only works while it is running, because it carries a local callback port. So start it in the background with its output going to a file (`anona login --no-browser > anona-login.log 2>&1 &`) and read the URL from that file. Do not pipe it into `head` (which waits for lines that never come), and do not run it under a short timeout (which kills the callback and leaves a dead link). It asks for one approval, which also signs in `anona mcp`. Do not poll. When they say they have approved, confirm with `anona status` and a memory tool call before you tell them it is claimed. After an existing-account claim, the memories are in a **new space**, not in the account's own `default`: call `list_spaces` (or `anona spaces list`), find the copy (usually `default-claimed`), tell the human its name, and pass it as the space from then on. If you get `claim_space_limit`, say so plainly: their plan has no room for another space, so they need to delete one or upgrade and then approve again before the profile expires. ## 8. Where the rest of the documentation is You have working memory now. You do not need the rest of this to use it, so do not go and read it; this is a map for when a question actually comes up. **The tools you just connected** * [MCP Integration](/memory/mcp-integration) is the full tool list, including the three remote-only tools, and the connection options. * [Agent Skills](/memory/integrations/skills) is what section 5 installs, and what each skill actually changes about how you behave. * [Temporary profiles](/memory/agents/temp-profiles) is the full flow behind sections 2 and 7, for the human. * [the anona CLI](/memory/cli/overview) documents every command and which package has which. [The CLI for AI agents](/memory/cli/agents) covers `anona guide`, the `anona-cli` skill and [the limits of `anona mcp`](/memory/cli/agents#give-an-mcp-client-memory-with-the-cli); [Scripting](/memory/cli/scripting) has the exit codes and `--json` shapes, and [Troubleshooting](/memory/cli/troubleshooting) the failures you are likely to hit. **Concepts worth knowing before you design around them** * [Concepts](/memory/concepts) covers spaces, scope, the three operations, API keys and tenant isolation. Read it before assuming what `retrieve` returns. For the two memory layers — raw facts and the notes synthesized from them, which is why a search can return one where you wrote several — see [Memory types](/memory/api-reference/retrieve#memory-types). * [Scoping for multi-tenant apps](/memory/guides/scoping-multi-tenant) is the one to read if a single connection serves more than one end user, because a scoped read is strict and will not match memories written unscoped. * [Tuning recall](/memory/guides/tuning-recall) and [Temporal search](/memory/guides/temporal-search) for when results are not what you expected. **The REST API and the SDKs, which need an API key, not this credential** As section 6 says, the credentials you hold work only through the `anona` CLI. These are for writing code that calls Anona directly, and the human mints a key from the dashboard under **API keys** first. * [API overview](/memory/api-reference/overview), [authentication](/memory/api-reference/authentication) and [error codes](/memory/api-reference/errors). * The data plane: [record and list](/memory/api-reference/memories), [retrieve](/memory/api-reference/retrieve), [reason](/memory/api-reference/reason), [documents](/memory/api-reference/documents) and [model selection](/memory/api-reference/model-selection). * [Python SDK](/memory/sdk/python) and [JavaScript SDK](/memory/sdk/javascript). * The drop-in LLM proxy, if the human wants memory added to an existing OpenAI or Anthropic client without changing their call sites: [chat](/memory/api-reference/chat), [responses](/memory/api-reference/responses), [messages](/memory/api-reference/messages), and [migrating to the proxy](/memory/guides/migrating-to-proxy). **Framework adapters**, if the human is already using one: [LangChain](/memory/integrations/langchain), [CrewAI](/memory/integrations/crewai), [LlamaIndex](/memory/integrations/llamaindex), [Google ADK](/memory/integrations/google-adk), [Microsoft Agent Framework](/memory/integrations/microsoft-agent-framework) and [Strands](/memory/integrations/strands). The [integrations overview](/memory/integrations/overview) lists them all. Everything above is on `docs.anonalabs.com`, and every page is fetchable as Markdown by appending `.md` to its URL, the same way you fetched this one. # Agent Self-Onboarding Source: https://docs.anonalabs.com/memory/agents/self-onboarding Let a coding agent set up Anona Memory from the terminal. One browser approval from you, no dashboard tour. ## What this is A coding agent can connect itself to Anona Memory without you navigating the dashboard beyond one approval screen. You approve the connection once, in a browser. You never copy an API key, and never paste a password. Which route it takes depends on your client, and [The four ways to connect](#the-four-ways-to-connect) below is the single answer to that. The rest of this page is the native OAuth route in detail. ## The one thing you do A browser window opens. You sign in with Google. You see the name of the client that is asking for access, and you press Approve. That approval is the whole security model, so read the client name before you press it. ## The flow ``` agent: POST /oauth/register {redirect_uris:["http://localhost:PORT/cb"]} agent: print .../oauth/authorize?client_id=…&code_challenge=… then wait human: opens it -> /login?return_to=/consent?req=… -> Continue with Google -> Clerk -> POST /auth/social creates org + project + quota, email_verified=true -> /consent?req=… -> reads client name -> Approve agent: code -> POST /oauth/token -> mcp_access token (+ refresh) agent: record / retrieve / reason / list_spaces / get_profile / get_model / get_user_profile ``` One human action. No inbox trip, because Clerk verified the address. No password anywhere. No dashboard navigation beyond the consent screen itself. ## The four ways to connect This is the one table to read; every other page links here rather than restating it. **An API key is not required to connect over MCP** — two of these paths need no key at all, and a third needs no account. A key is required only to call the REST API or an SDK directly. | Path | What it needs | Which clients | Account | | - | - | - | - | | **A. Native MCP OAuth** | a client that implements OAuth 2.1 and can drive the browser hop itself | **Claude Code** today, and nothing else verified (per-client status in tab A below) | Created for you on the approval screen | | **B. `anona login` + `anona mcp`** | pip `anona` 0.19.0+ or npm `@anona-labs/memory` 0.16.0+, and a client that can launch a **stdio** server | any such client — Codex, Kimi, Cursor, OpenCode, and Claude Code too | Created for you on the approval screen | | **C. Temporary profile** | pip `anona` 0.19.0+ or npm 0.16.0+ | any stdio client; and with pip 0.20.0+, no MCP client at all | **None.** 1,000 credits, 72 hours, claimable later | | **D. API key** | a key minted in the dashboard under **API keys** | any MCP client, over an `Authorization` header | Yours already | Paths A, B and C all end with the same MCP tools. They differ only in who does the OAuth dance: your client (A), the CLI (B), or nobody (C). **The credential paths A and B mint is MCP-scoped, and the REST API refuses it** — see [Limits](#limits). For REST or an SDK you need either an API key (path D) or a temporary profile's token (path C), which does work wherever a key does. This is also why `anona record` / `retrieve` / `reason` run on a temporary profile and refuse on a signed-in account. ### Setup per path > [tab] A. Native MCP OAuth > > ```bash theme={null} > claude mcp add --transport http anona-oauth https://memory.anonalabs.com/mcp > ``` > > That one command is the agent's whole job: it drives discovery, client > registration, the browser hop and the token exchange natively. No API key, no > header, nothing else to configure. The rest of this page describes this path. > > | Client | Status | > | - | - | > | Claude Code | Flow verified end to end with a conforming OAuth client (see the note below); Claude Code's own client not separately exercised | > | Codex | Not yet verified completing the OAuth flow — use path B | > | Kimi | Not yet verified completing the OAuth flow — use path B | > [tab] B. anona login > > ```bash theme={null} > pip install -U anona # or: npm install -g @anona-labs/memory > anona login # one browser approval, before any client starts > claude mcp add anona -- anona mcp # Claude Code; other clients below > ``` > > `anona login` does the approval up front, so the client has nothing left to > authenticate: no `/mcp` step and no interactive picker. The last line is Claude > Code's spelling; any client that can launch a stdio server takes the same > `anona mcp` command, for example `codex mcp add anona -- anona mcp`, > `gemini mcp add -s user anona anona mcp` or `opencode mcp add anona -- anona mcp`. > Goose, Cursor and the rest are in [the per-client table](/memory/agents/onboarding-prompt#2-start-a-temporary-profile). See [the anona CLI](/memory/cli/overview). > [tab] C. Temporary profile > > ```bash theme={null} > anona start # no account, no email address > claude mcp add anona -- anona mcp # Claude Code; other clients below > ``` > > A memory workspace nobody owns, with 1,000 credits and 72 hours to live. A human > claims it later with `anona login`, which keeps everything it holds. With pip > `anona` 0.20.0 or later it also answers `anona record`, `anona retrieve` and > `anona reason` directly, so it is the only path that works with no MCP client at > all. Registering the server in any other client is the same as in path B. See [Temporary profiles](/memory/agents/temp-profiles). > [tab] D. API key > > Mint a key in the dashboard under **API keys**, then follow the header-based > setup in [MCP Integration](/memory/mcp-integration). This is also the credential the > REST API and both SDKs take — see [Authentication](/memory/api-reference/authentication). > [!NOTE] > > **The flow itself is proven.** On 2026-10-09 a conforming OAuth 2.1 client ran > it against production start to finish: protected-resource discovery, the > `WWW-Authenticate` hint on an unauthenticated call, authorization-server > metadata, RFC 7591 dynamic registration, PKCE S256 with a loopback redirect on > an ephemeral port, a human approving on the consent screen, the code exchange, > a `tools/call` that returned real spaces, and a refresh that rotated the token. > > What that does not prove is any particular client's own implementation. The > table above says which ones have been exercised. > [!WARNING] > > **Only a client that implements OAuth 2.1 natively can complete *this* flow**, > and today that means Claude Code. It is not the only keyless path, though: on > any other client, `anona login` + `anona mcp` (path B above) does the same > approval from the command line and still needs no API key. Reach for a > dashboard key only if you cannot run a command, or if you need the REST API. ## What you get Once connected, the agent has the MCP tools: `record`, `retrieve`, `reason`, `list_spaces`, `get_profile`, `get_model`, and `get_user_profile`. See [MCP Integration](/memory/mcp-integration) for what each one does and how transports differ. ## Limits An OAuth credential minted this way is not an API key. It works over MCP, and only over MCP. It does not work with the SDK or with raw HTTP calls to the REST API. For those, mint a key from the dashboard (path D), or use a temporary profile's token (path C), which the REST API does accept. This is also why `anona record`, `anona retrieve` and `anona reason` never use the MCP login: they call the REST API, and this credential is the one thing the REST API will not take. They use an API key, the separate command-line login that `anona login` also stores, or a temporary profile. See [Sign in & credentials](/memory/cli/sign-in). ## Troubleshooting **Nothing happened when the agent tried to use memory, and no browser opened.** Adding the MCP server does not sign you in, and a failed tool call does not start the flow. You have to begin it: run `/mcp` in Claude Code (or `/mcps` in OpenCode), pick the Anona server, and authenticate. The browser opens from there. An agent cannot do this for you; it is an interactive picker in your own terminal. **Your agent says Anona is connected, but it has no memory tools.** Adding the MCP server does not load its tools into a session that is already running; Claude Code reads its server list at startup. Restart Claude Code in the same directory and ask again. The browser approval opens on the first memory call after that, not when the server is added. If the server shows as disabled for this project, run `/mcp` and enable it first. **The approval link expired.** It is valid for 15 minutes. Start the flow again from the beginning rather than retrying the dead link. **You approved, and the first tool call still fails with "No active API key for this organization."** The account was created and the approval worked; the failure is on our side, not yours. A brand-new account holds no API key by design, and the gateway needs a way to tell the engine which organization you are without one. If you see this, the deployment you are talking to does not have that configured yet. Report it rather than minting a key to work around it. The workaround hides exactly the thing we need to know is broken. **The agent says it is connected but no memory is stored.** Ask it to run `retrieve` on something you know it recorded. An agent that treats your "I approved it" as proof of connection will report success it has not verified; a tool call that returns data is the only evidence that counts. **Do not poll the login endpoint.** It allows 10 attempts per 5 minutes per address, so a polling loop locks you out within a minute. Wait for the human to act instead of retrying. **Signed up with a password instead of Google, and the approval broke.** The password path requires verifying your email address first, and starting that detour interrupts the consent flow you had open. Verify your address, then start the flow again from the beginning. **A verification email cannot be resent.** If it expired before you clicked it, sign up again with the same address rather than waiting for a second email. **What the new-account response is telling you.** It names verifying your address as the next step, and that is the only thing it actually requires. # Temporary profiles Source: https://docs.anonalabs.com/memory/agents/temp-profiles Let an agent store memories before anyone has an account, then claim the result later. ## What it is Normally you sign up, then connect an agent. A temporary profile inverts that. `anona start` gives an agent a working memory **before any account exists**. A person claims the result afterwards with `anona login`, and the memories the agent stored while it waited are theirs. This is path **C** of [The four ways to connect](/memory/agents/self-onboarding#the-four-ways-to-connect), which is the one table of client against connection path — read it first if you have not chosen a path yet. A temporary profile is a memory workspace with no email address, no password and nobody owning it. The only thing that identifies it is an opaque token starting `anona_tmp_`, which the CLI keeps in `~/.anona/credentials.json` (mode 600\). The server keeps only a hash of it. The token is never printed, so that file is its only copy. That token works wherever an API key does, and on the remote `/mcp` endpoint, so an agent can use it without a browser or a human in the loop. On a temporary profile the MCP tools do not need a `space_id`: the profile has exactly one space and omitting the argument uses it. That space is `default`, and it is the only one the profile can hold: a write naming any other space (a CLI `--space`, a REST `space_id`, an upload, an MCP `space_id`) and `POST /v1/spaces` are refused with `403 temp_profile_single_space`, so a typo cannot quietly create a second space. Claim the profile to create more. A signed-in account still has to name one, because an account may hold several and a memory written to the wrong space looks exactly like success. ## The limits | | Unclaimed profile | Claimed account | | - | - | - | | Credits | **1,000** | 5,000 | | Lifetime | **72 hours**, then deleted | No expiry | | Owner | Nobody | You | > [!WARNING] > > **Assume nothing will warn you before the 72 hours are up.** There is no email > address to send a warning to, so the CLI is the only channel: `anona status` > prints the deadline whenever you ask, and `anona mcp` prints it once at startup > inside the last 24 hours, on **stderr** — which many MCP clients never show a > human. When the deadline passes the profile's memories are deleted and the token > stops working. Check `anona status`, or claim the profile. ## Start to finish **1. Install the CLI** Either package works. They share one credential file, so you can start with one and claim with the other. ```bash theme={null} pip install -U anona # 0.19.0 starts a profile; 0.20.0 adds record/retrieve/reason npm install -g @anona-labs/memory # needs 0.16.0 or later; no shell verbs ``` **`-U` matters on pip.** Without it, an existing older copy is left in place and reported as a success, so `anona record` fails with `invalid choice`. `anona --help` lists what you actually have. If pip answers `externally-managed-environment` it is refusing a system Python; use `uvx` below or `pipx install anona`. With nothing installed: `uvx --from anona anona start`, or `npx @anona-labs/memory start`. Both always run the current version, which makes them the shortest path on a machine that already has an old copy. **2. Start a profile** ```bash theme={null} anona start ``` ``` Temporary profile created. It expires 2026-10-06 15:22 UTC (in 71 hours), after which its memories are deleted. The only reminder comes in the last 24 hours: run `anona status` to check, and `anona login` to claim it and keep what it holds. Its space, `default`, is normally created now; if a retrieve reports no space, the first memory you write creates it. ``` Note the expiry. If a profile is already stored, `anona start` prints its deadline and creates nothing. **3. Connect your client** The server is a stdio server named `anona`, launched as `anona mcp`. Register it the way your client does: | Client | Register it | | - | - | | Claude Code | `claude mcp add anona -- anona mcp` | | Codex | `codex mcp add anona -- anona mcp` | | Gemini CLI | `gemini mcp add -s user anona anona mcp` | | OpenCode 2.x | `opencode mcp add anona -- anona mcp` | | Goose, Cursor, Windsurf, Cline and others | see [the per-client table](/memory/agents/onboarding-prompt#2-start-a-temporary-profile) | Restart the client so it loads the tools. Then `record` something. The profile's one space, `default`, is created with the profile, so a `retrieve` before any `record` returns an empty result rather than an error — and no call needs to name a space. **4. Or skip the client** A temporary profile is also usable straight from the shell, which is the path to take when a client cannot do MCP, or before a restart has loaded the tools: ```bash theme={null} anona record "The user prefers pnpm." anona retrieve "package manager preference" anona reason "how does this team like to build?" ``` On a temporary profile no `--space` is needed: its one space is `default`. See [record, retrieve, reason](/memory/cli/memory). **5. Check the deadline** ```bash theme={null} anona status ``` ``` Temporary profile for https://memory.anonalabs.com: expires 2026-10-06 15:22 UTC (in 70 hours), after which its memories are deleted. Run `anona login` to claim it before then. ``` `status` reads only the local file. It cannot tell you the server still holds the profile. The CLI's default is `https://memory.anonalabs.com`, not the `api.` host the rest of these docs use. Both reach the same API. What matters is keeping to one: the CLI ties a stored credential to the host it was issued for, and refuses a `--base-url` naming a different one rather than send the credential there. `anona login --base-url ...` is how you switch. So leave the CLI on its default. For your own REST calls, such as the `curl` below, `api.anonalabs.com` is the default and `memory.anonalabs.com` works too, and the token is valid on either. **6. Claim it** ```bash theme={null} anona login ``` A browser opens on the sign-up page. Create an account there, or follow **Sign in** if you already have one; either way you come back to the approval screen. Read the client name, then choose **Allow**. That one approval claims the profile and signs in both the command line and `anona mcp`. Over SSH or in a container, `anona login` prints the URL instead; open it on the machine running the command, or add `--no-browser` to skip the attempt to open one. **7. Confirm** ```bash theme={null} anona status ``` The temporary-profile lines are gone, replaced by the stored sign-in credential. The dashboard now shows the account with its real balance, and the confirmation email names the space. ### Reminders on the MCP connection An agent is told about the deadline by the server, not only by its setup prompt, so a later session that never saw the prompt still knows: * The MCP `initialize` reply carries **instructions** naming the deadline and saying that `anona login` claims the profile, and that an agent with a shell tool can run it itself. * A tool result carries a second text item, `[Anona notice for the agent]`, about once an hour per profile, and every 15 minutes in the last 24 hours. Both stop once the profile is claimed. ## What claiming does What happens depends on whether the account you sign in with is new. **A new account takes over the profile's workspace.** `anona login` attaches it to the workspace that already holds the agent's memories, and renames that workspace to the organization name you chose at signup. Nothing is copied. Afterwards it is an ordinary account, indistinguishable from one that signed up first. * **The ceiling rises from 1,000 to 5,000 credits.** * **Credits already spent carry over.** A profile that used 800 credits has 4,200 left, not 5,000. The allowance cannot be collected twice by claiming. * **The memories are the same ones.** Nothing is copied, so nothing is lost or duplicated. * **The temporary token is destroyed.** Whatever the agent was holding stops working, and you use the account's own sign-in or an API key from here on. * **A confirmation email** names the space and your real balance. ### Claiming into an account you already have Sign in with your existing account and the profile's space is **copied into it** as a new space. Your account stays where it is. * **The copy is named after the profile's space.** That is usually `default`, and since your account most likely has its own `default`, the copy becomes `default-claimed` (then `default-claimed-2`, and so on). The confirmation email names it, and `anona spaces list` shows it. Pass that name as the space when you want those memories. * **Your other spaces and your credits do not change.** Copying calls no model, so it costs nothing, and the credits the profile spent are not charged to you. * **Your plan's space limit applies.** If your account already has as many spaces as its plan allows, the claim is refused with `claim_space_limit` (HTTP 409\) and nothing is copied. Delete a space or upgrade, then open the same sign-in link again before the 72 hours are up. * **Webhooks on the profile are not copied.** Set them up again on the new space if you want them. The browser approval says which of the two will happen before you choose Allow. An account that has already done things (created an API key, recorded usage, invited a colleague, or subscribed) counts as existing, even if it was created recently. ## Check the credit ceiling With the CLI, read it with `anona usage --json`, which uses the stored token without exposing it. The figures in these replies are illustrative: ```bash theme={null} anona usage --json ``` Without the CLI, the temporary token can read its own balance: ```bash theme={null} curl https://api.anonalabs.com/v1/usage/me \ -H "Authorization: Bearer anona_tmp_YOUR_TOKEN" ``` ```json theme={null} { "credits_remaining": 989, "credits_limit": 1000, "credits_used": 11, "rate_limit_per_min": 20 } ``` The token is in `~/.anona/credentials.json` under `temp_token`. Treat it like an API key: anyone holding it can read and write the profile. Past the ceiling, metered calls are refused in the standard error envelope with a lowercase `code`; see [error codes](/memory/api-reference/errors). Read it before and after claiming to see the effect. In the first production run, 11 credits used before the claim read `5000 limit / 11 used / 4989 remaining` after it. The temporary token stops working once claimed, so after the claim make the same call with an API key from the dashboard. ## Abandoning one `anona logout` refuses to delete a live unclaimed profile, because the token exists nowhere else and deleting the file strands the profile and everything in it until the server removes it: ```bash theme={null} anona logout # refused while a live unclaimed profile is stored anona logout --force # discards it. This cannot be undone ``` Signing in to a different server with `anona login --base-url ...` also replaces the stored profile. It prints a note first. ## Failure modes | You see | Meaning | | - | - | | `anona start: Too many temporary profiles from this address today.` | Profile creation is limited to 5 a day per address. Try again tomorrow. | | `anona start: The server refused to create a profile (404)` | The server has the feature switched off. Use `anona login` instead. | | `anona start: you are already signed in` | A signed-in credential is stored. Run `anona logout` first if you want a profile. | | `temp_profile_expired` (401) | The 72 hours have passed. The memories are deleted. | | `temp_profile_unclaimable` (409) | The profile is already claimed, expired or unknown. Run `anona start` for a new one. | | `claim_space_limit` (409) | Your account is at its plan's space limit, so the profile's space had nowhere to go. Nothing was claimed. Delete a space or upgrade, then approve again. | | `claim_unavailable` (503) | The memories could not be copied right now. Nothing was claimed; approve again. | | `claim_owner_only` (403) | Your organization is org-wide and only an owner or admin can add a space to it. Nothing was claimed; ask one of them, or claim into an account of your own. | ## Related * [The anona CLI](/memory/cli/overview) covers every command, and [Sign in & credentials](/memory/cli/sign-in) the order credentials are tried in. * [Self-onboarding prompt](/memory/agents/onboarding-prompt) is a prompt that does all of this for you, and reports back when it is done. # API Keys Source: https://docs.anonalabs.com/memory/api-reference/api-keys Create, list, and revoke API keys programmatically. API keys authenticate every data-plane call. Manage them from the dashboard, or with these endpoints. A key is minted by a member and carries the organization's plan and limits. > [!NOTE] > > These management endpoints are part of the control plane: they authenticate with a > **dashboard session**, not an API key. Calling them with `Bearer anona_live_...` > returns `401 session_required`. Creating and revoking keys additionally requires the > owner, admin, or developer role. | Method and path | What it does | | - | - | | `GET /v1/api-keys` | Lists the calling member's keys, with prefixes only. | | `POST /v1/api-keys` | Creates a key and returns the secret once. | | `DELETE /v1/api-keys/{id}` | Revokes a key immediately. | These are ordinary HTTPS calls in any language; the memory SDKs deliberately do not wrap them, because they authenticate with a session rather than the API key those clients hold. `$SESSION` below is the session token the dashboard sign-in returns. ## List keys ```http theme={null} GET /v1/api-keys ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/api-keys \ -H "Authorization: Bearer $SESSION" ``` ```python Python theme={null} import httpx keys = httpx.get( "https://api.anonalabs.com/v1/api-keys", headers={"Authorization": f"Bearer {SESSION}"}, ).json() ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.anonalabs.com/v1/api-keys", { headers: { Authorization: `Bearer ${SESSION}` }, }); const keys = await res.json(); ``` ```json theme={null} [ { "id": "key_...", "name": "prod-bot", "key_prefix": "anona_live_3", "env": "live", "created_at": "...", "last_used_at": "...", "expires_at": null, "status": "active" } ] ``` Returns the keys the calling member created. Only `key_prefix` — the first few characters of the secret — is returned; the full secret is shown once, at creation. `status` is `active` or `expired`; revoked keys are not listed. `expires_at` is `null` for a key that never expires. ## Create a key ```http theme={null} POST /v1/api-keys { "name": "prod-bot", "env": "live", "expires_in_days": 90 } ``` | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | A label to identify the key later. | | `env` | string | No | `"live"` (default) or `"test"`. | | `expires_in_days` | integer | No | Days until the key stops working, 1-3650. Omit for a key that never expires. | ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/api-keys \ -H "Authorization: Bearer $SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "prod-bot", "env": "live", "expires_in_days": 90 }' ``` ```python Python theme={null} created = httpx.post( "https://api.anonalabs.com/v1/api-keys", headers={"Authorization": f"Bearer {SESSION}"}, json={"name": "prod-bot", "env": "live", "expires_in_days": 90}, ).json() secret = created["api_key"] # returned once ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.anonalabs.com/v1/api-keys", { method: "POST", headers: { Authorization: `Bearer ${SESSION}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "prod-bot", env: "live", expires_in_days: 90 }), }); const created = await res.json(); const secret = created.api_key; // returned once ``` ```json theme={null} { "id": "key_...", "name": "prod-bot", "key_prefix": "anona_live_3", "env": "live", "created_at": "2026-08-15T09:00:00Z", "last_used_at": null, "expires_at": "2026-11-13T09:00:00Z", "status": "active", "api_key": "anona_live_XXXXXXXXXXXXXXXXXXXX" } ``` > [!WARNING] > > `api_key` — the full secret — appears in this response only. It is stored encrypted and > Anona cannot show it again. A lost key must be revoked and replaced. ## Revoke a key ```http theme={null} DELETE /v1/api-keys/{id} ``` ```bash cURL theme={null} curl -X DELETE https://api.anonalabs.com/v1/api-keys/key_123 \ -H "Authorization: Bearer $SESSION" ``` ```python Python theme={null} httpx.delete( "https://api.anonalabs.com/v1/api-keys/key_123", headers={"Authorization": f"Bearer {SESSION}"}, ) ``` ```typescript TypeScript theme={null} await fetch("https://api.anonalabs.com/v1/api-keys/key_123", { method: "DELETE", headers: { Authorization: `Bearer ${SESSION}` }, }); ``` Returns `204 No Content`. Revocation is immediate and permanent. > [!NOTE] > > To rotate a key: create the new one, cut traffic over, then revoke the old one. Plan and > limits come from the organization, so a rotated key keeps exactly the same access. ## Expiry A key created with `expires_in_days` stops working on its own. Until then nothing changes. Expiry is a deadline, not a reduced level of access. * **Omitting `expires_in_days` means the key never expires**, which is how every key behaved before this option existed. Existing keys are unaffected. * An expired key is rejected with `401` and the error code **`key_expired`**, distinct from `invalid_api_key`. Branch on the code: `key_expired` means rotate, `invalid_api_key` means the key was never valid. * **Expiry cannot be extended.** There is no endpoint to push the date back. Create a replacement key and cut traffic over, as with rotation. * Anona emails the key's owner about a week before the date, so the deadline does not arrive unannounced. > [!TIP] > > Use a short expiry for anything handed to a third party, a CI job, or a demo. A leaked > key with a deadline stops being a liability on a date you chose in advance. ## Next steps > [card] [Authentication](/memory/api-reference/authentication) > > How keys are sent, and how to handle them safely. > [card] [Usage](/memory/api-reference/usage) > > Credits and rate limits for the calling key. # Artifacts Source: https://docs.anonalabs.com/memory/api-reference/artifacts Build a shareable web page that gives an overview of a memory space, and control who can open it. An artifact is a single, self-contained web page built from what a space holds: an overview of a project, a summary of decisions, a status page. You ask for it in plain language, it is built in the background, and you share it through a stable link. Every artifact starts **private**. Its creator can share it with everyone who can read the space, and the space owner can make it **public** to anyone with the link. An artifact is a snapshot. Deleting a memory later does not change pages already built. ## Create an artifact ```http theme={null} POST /v1/spaces/{space_id}/artifacts Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "request": "An overview of the billing migration: what changed, what is left, who owns it", "conversation": [] } ``` | Field | Type | Required | Description | | - | - | - | - | | `request` | string | yes | What the page should cover, 1 to 2,000 characters. | | `conversation` | array | no | Earlier turns, oldest first, each `{"role": "user" or "assistant", "content": "..."}`. At most 40 turns and 40,000 characters in total. Used only to understand references such as "make that into a page"; the page is built from the space's memories. | **Response** `202 Accepted` ```json theme={null} { "id": "6f1c2a7e-0b7d-4a51-9a52-1d3f0e8c9b11", "space_id": "billing", "title": null, "request": "An overview of the billing migration: what changed, what is left, who owns it", "status": "pending", "sharing": "private", "error_code": null, "created_at": "2026-10-16T09:30:00Z", "completed_at": null, "link": "https://memory.anonalabs.com/a/6f1c2a7e-0b7d-4a51-9a52-1d3f0e8c9b11", "can_publish": true } ``` Building takes from several seconds to a few minutes. Poll [Get an artifact](#get-an-artifact) until `status` is `completed` or `failed`. Creating an artifact needs write access to the space; read-only members receive `403 space_read_only`. You can have one artifact building per space at a time. ### Cost An artifact is billed in credits for the tokens used to build it, plus a flat surcharge per page. Before anything is queued, the request is checked against the most it could cost; if your balance is below that, it is refused with `429 credits_exhausted` and a message naming the amount. A failed build is not charged. ## List artifacts ```http theme={null} GET /v1/spaces/{space_id}/artifacts ``` Returns `{"items": [...]}`, newest first, at most 50. You see your own artifacts and those shared with the space or made public. The space owner sees all of them, private ones included, so any link built from the space can be found and withdrawn. ## Get an artifact ```http theme={null} GET /v1/spaces/{space_id}/artifacts/{artifact_id} ``` Returns the artifact in the shape shown above. | Field | Description | | - | - | | `status` | `pending`, `running`, `completed` or `failed`. | | `sharing` | `private`, `space` or `public`. | | `error_code` | Why a build failed, when `status` is `failed`. | | `link` | The page's stable link. It never expires; whether it opens depends on `sharing`. | | `can_publish` | Whether you may make artifacts on this space public. | ## Change sharing ```http theme={null} PATCH /v1/spaces/{space_id}/artifacts/{artifact_id} Content-Type: application/json ``` ```json theme={null} { "sharing": "space" } ``` | Value | Who can open the link | | - | - | | `private` | The creator, while they can still read the space. | | `space` | Anyone who can read the space. | | `public` | Anyone with the link, signed in or not. | The creator and the space owner can change sharing. Sharing with the space needs write access to it. Making an artifact public needs the space owner's authority; anyone else receives `403 space_owner_only`. Returns the updated artifact. ## Delete an artifact ```http theme={null} DELETE /v1/spaces/{space_id}/artifacts/{artifact_id} ``` Deletes the artifact and its page; the link stops working at once. Allowed for the creator and the space owner. Returns `204 No Content`. Deleting a space deletes its artifacts too, public ones included: their links stop working at once, and any still building are cancelled without charge. Deleting an artifact that is still building cancels the build: it disappears at once, nothing is charged for it, and it still counts toward the hourly limit. ## Open an artifact ```http theme={null} GET /v1/artifacts/{artifact_id}/view ``` This is the access check behind the link. Authentication is optional: a public artifact opens for anyone. **Response** `200 OK` ```json theme={null} { "url": "https://...", "expires_in": 300, "title": "Billing migration overview", "shared_by": "Acme", "created_at": "2026-10-16T09:30:00Z", "can_manage": true, "csp": "default-src 'none'; ...", "space_id": "acme:billing", "sharing": "public", "can_publish": false } ``` | Field | Description | | - | - | | `url` | A short-lived URL of the page. Request a fresh one rather than storing it. | | `expires_in` | Seconds `url` stays valid. | | `shared_by` | The name of the organization that owns the space. | | `can_manage` | Whether you may change sharing or delete it. | | `csp` | Informational: the Content-Security-Policy the page already carries in its own `` tag. Do not set it as an iframe `csp` attribute — browsers then refuse frames whose server does not opt in, and storage cannot. Embed with `sandbox="allow-scripts"`. | | `space_id` | Only when `can_manage` is true: the space id to use with the routes above to change sharing or delete. For a space shared with you it is the qualified `{owner_slug}:{space}` form. `null` otherwise. | | `sharing` | Only when `can_manage` is true: `private`, `space` or `public`. `null` otherwise. | | `can_publish` | Only when `can_manage` is true: whether you may make the artifact public. `null` otherwise. | Anyone else who can open the page, including a signed-out visitor to a public link, gets `null` for these three fields: a link never reveals which space it came from or how it is shared. ## API keys Every route above accepts an API key as well as a dashboard session. An API key acts as the member it belongs to: it has that member's access to each space, their role, and their authority, and the artifacts it creates are theirs. A key can do exactly what its member can do in the dashboard, and no more. ## Errors | Status | Code | Meaning | | - | - | - | | 404 | `artifact_not_found` | No such artifact, or it is not shared with you. The two are deliberately indistinguishable. | | 404 | `space_not_found` | The space does not exist. Nothing is queued. | | 409 | `artifact_in_progress` | You already have an artifact building in this space. | | 503 | `artifacts_unavailable` | Artifacts are not available right now. | | 403 | `space_read_only` | You have read-only access to the space. | | 403 | `space_owner_only` | Only the space owner can make an artifact public. | | 429 | `credits_exhausted` | Your balance is below what the artifact could cost. | | 429 | `artifact_rate_limited` | Your organization has started 10 artifacts this hour, counting failed and deleted ones. The count resets each clock hour, so try again within the hour. | A failed build reports one of these in `error_code`: | Code | Meaning | | - | - | | `artifact_no_material` | The space had nothing to build a page from. | | `artifact_truncated` | The page was too long to finish. Ask for a narrower page. | | `artifact_too_large` | The finished page was over 2 MB. | | `artifact_rejected` | The page did not pass the safety checks applied to every artifact. | | `artifact_not_html` | The model did not produce a single HTML page. | | `artifact_timeout` | Building the page took longer than 10 minutes. | | `artifact_generation_failed` | The page could not be built after a retry. | | `credits_exhausted` | When the build started, your balance was below what it could cost. | | `space_unavailable` | The space could not be read when the build started: your access was removed, or its owner cannot be reached. | A failed build is not charged. # Authentication Source: https://docs.anonalabs.com/memory/api-reference/authentication Bearer API keys, key handling, and the errors you get when authentication fails. Every Anona Memory **data-plane** request — record, retrieve, reason, spaces, documents and the proxies — authenticates with a Bearer API key. A handful of control-plane endpoints do not take one at all: [API keys](/memory/api-reference/api-keys), [organization](/memory/api-reference/organization), [billing](/memory/api-reference/billing), space membership and `GET /v1/usage` authenticate with a **dashboard session** instead, and answer `401 session_required` to an API key. A few of them also accept the token `anona login --cli` stores; see [which credential each route accepts](#which-credential-each-route-accepts). ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"space_id": "customer-support-bot", "content": "User data"}' ``` ```python Python theme={null} from anona import AnonaClient client = AnonaClient(api_key="anona_live_YOUR_KEY") client.record(space_id="customer-support-bot", content="User data") ``` ```typescript TypeScript theme={null} import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); await anona.record({ spaceId: "customer-support-bot", content: "User data" }); ``` The SDKs set the header for you. Both take the key as a constructor argument and neither reads an environment variable on its own. ## Getting a key **1. Sign up and verify** Create an account at the [dashboard](https://memory.anonalabs.com) and verify your email. Sign-in is blocked until you do. **2. Create the key** Go to **API keys → New key**, choose an environment, and give the key a name. Signing up does not create one for you. **3. Copy it immediately** The full key is shown once. Afterwards only its prefix is visible, in the dashboard and in the API. | Environment | Format | Intended for | | - | - | - | | `live` | `anona_live_` | Production traffic | | `test` | `anona_test_` | Local development and CI | Keys can also be created and revoked programmatically. See [API Keys](/memory/api-reference/api-keys). ## Handling keys safely | Practice | Why | | - | - | | Treat the key like a password | It carries your organization's full data-plane access. | | Never commit it to version control | Load it from an environment variable or a secret manager instead. | | Never call the API from a browser | A client-side key is public. Proxy through your own backend. | | Use `test` keys outside production | Keeps development traffic off your production space and quota. | | Rotate on a schedule, or immediately on exposure | Create the new key, cut traffic over, then revoke the old one. | ```bash Shell theme={null} export ANONA_API_KEY="anona_live_YOUR_KEY" ``` ```python Python theme={null} import os from anona import AnonaClient client = AnonaClient(api_key=os.environ["ANONA_API_KEY"]) ``` ```typescript TypeScript theme={null} import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: process.env.ANONA_API_KEY! }); ``` ## Organization and space context Every request runs in the context of the organization behind the API key, plus the `space_id` you supply. The boundary is enforced at the gateway and again at the database layer, so another organization's space is not reachable by naming it. The one way across that boundary is a **share**: another organization can invite you to one of its spaces, and from then on it is addressable with your own key like any other. An API key acts as the member who created it, so it reaches, and lists, the spaces shared with that person. Removing a member from the organization revokes the keys they created. A shared space comes back from `GET /v1/spaces` carrying `shared_by` (the owning organization's name) and `qualified_id`. When a shared name collides with one of your own — a space's id is its name, so both can be `default` — the bare name is refused with `409 space_ambiguous` and you address it by the qualified `{owner_slug}:{space_name}` form instead. See [Space members](/memory/api-reference/spaces#space-members). ## Which credential each route accepts | Credential | Data plane | Control plane | | - | - | - | | API key (`anona_live_` / `anona_test_`) | Yes | No | | Temporary profile (`anona start`) | Yes | No | | `anona login --cli` | Yes | The routes listed below only | | Dashboard session | Yes | Yes | A token from `anona login --cli` is accepted on this fixed set of control-plane routes, and answers with the same body a dashboard session gets: | Method | Route | | - | - | | `GET` | `/v1/orgs/me`, `/v1/orgs` | | `GET` | `/v1/projects` | | `GET` | `/v1/api-keys` (lists keys; never returns a secret) | | `GET` | `/v1/usage`, `/v1/usage/breakdown` | | `GET` | `/v1/billing`, `/v1/billing/topups` | | `GET` | `/v1/members`, `/v1/members/me`, `/v1/members/invitations` | | `GET` | `/v1/invitations/pending` | | `GET` | `/v1/spaces/{space_id}/members` (owner or admin) | | `POST` | `/v1/invitations/accept`, `/v1/invitations/decline` | | `GET`, `POST` | `/v1/spaces/{space_id}/exports` | | `GET` | `/v1/spaces/{space_id}/exports/{job_id}/download` | Everything else on the control plane, including creating or revoking a key, changing plan, renaming or deleting an organization and inviting or removing members (of the organization or of a space), stays dashboard-only. A CLI token's role and organization are read from your membership on every request, not from the token, so a removal or a change of role applies to it within 30 seconds. `X-Anona-Org` selects one of your other organizations, as it does on the data plane. An API key or a temporary-profile token sent to one of the routes above gets `401 session_required` with the message: This needs a dashboard session or `anona login --cli`; an API key or temporary profile cannot use it. ## Rate limiting Requests to **metered endpoints** — the ones that spend credits — are rate-limited per organization according to your plan. Current limits are returned on every response from one of those endpoints: ``` X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Window X-Credits-Remaining ``` Exceeding the limit returns `429` with code `rate_limited`. Retry with exponential backoff. See [Usage](/memory/api-reference/usage) for reading your limits directly. ## Authentication errors ```json theme={null} { "error": { "code": "invalid_api_key", "message": "Invalid API key", "request_id": "req_..." } } ``` Each cause has its own `code`, and that is what to branch on — never the generic `unauthorized` / `forbidden`, which no endpoint returns. The [error reference](/memory/api-reference/errors) is the canonical list. | Cause | Code | Fix | | - | - | - | | Header missing | `401 missing_auth` | Send `Authorization: Bearer ` on every request. | | Wrong header format | `401 invalid_token` | The `Bearer` scheme is required: a bare `Authorization: ` is rejected. `x-api-key: ` **is** accepted on every data-plane endpoint, which is how the Anthropic SDK sends it. | | Key revoked or invalid | `401 invalid_api_key` | Create a new key in the dashboard. | | Key past its expiry date | `401 key_expired` | Rotate it. Expiry cannot be extended. | | Key belongs to another organization | `403 space_access_denied`, or `404 space_not_found` | Each organization's data is its own schema, so another organization's space is not merely forbidden, it is not there. Use a key issued by the organization that owns the space, or have them share it with you. | | A bare space name matches both your own space and a shared one | `409 space_ambiguous` | Address it by the `qualified_id` (`{owner_slug}:{space_name}`) returned by `GET /v1/spaces`. | | An API key or temporary-profile token sent to a dashboard-only endpoint | `401 session_required` | The key may be perfectly valid — it is the wrong kind of credential there. Sign in to the dashboard, or, on the [routes the CLI may use](#which-credential-each-route-accepts), run `anona login --cli`. | ## Next steps > [card] [Memories API](/memory/api-reference/memories) > > Store and manage memories. > [card] [Retrieve API](/memory/api-reference/retrieve) > > Query memories semantically. > [card] [API Keys](/memory/api-reference/api-keys) > > Create and revoke keys over the API. > [card] [Errors](/memory/api-reference/errors) > > The full error code reference. # Billing Source: https://docs.anonalabs.com/memory/api-reference/billing View your plan and credit usage, buy credit top-ups, and manage your subscription through Stripe. Anona holds the Stripe relationship, so you never touch Stripe keys directly. Paid-plan changes go through Stripe Checkout or the Customer Portal; free-plan changes happen in place. | Method and path | What it does | Role required | | - | - | - | | `GET /v1/billing` | Current plan, credit usage, and available plans. | Any member | | `GET /v1/billing/topups` | Credit-pack purchase history. | Any member | | `POST /v1/billing/upgrade` | Moves to a free plan, in place. | Owner or admin | | `POST /v1/billing/checkout` | Starts Stripe Checkout for a paid plan. | Owner or admin | | `POST /v1/billing/topup` | Starts Stripe Checkout for a one-time credit pack. | Owner or admin | | `POST /v1/billing/portal` | Opens the Stripe Customer Portal. | Owner or admin | | `POST /v1/billing/cancel` | Cancels at the end of the billing period. | Owner | | `POST /v1/billing/resume` | Undoes a scheduled cancellation before it lands. | Owner | These are control-plane calls, so they authenticate with a **dashboard session**, not an API key, and neither memory SDK wraps them. `$SESSION` is the session token sign-in returns. ```bash cURL theme={null} curl https://api.anonalabs.com/v1/billing \ -H "Authorization: Bearer $SESSION" ``` ```python Python theme={null} import httpx data = httpx.get( "https://api.anonalabs.com/v1/billing", headers={"Authorization": f"Bearer {SESSION}"}, ).json() ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.anonalabs.com/v1/billing", { headers: { Authorization: `Bearer ${SESSION}` }, }); const data = await res.json(); ``` ## View billing status `GET /v1/billing` returns the current plan, credit usage, and every plan you could switch to. `available_plans` lists only the plans on sale today. An organization on a plan we no longer offer keeps it: `plan_id`, its allowance and its billing are unchanged, and it simply does not see that plan listed back to itself. `credits_reset_at` is when the credit allowance refreshes: on a paid plan, the end of the Stripe billing period, so it falls on the day of the month you subscribed; on Developer, the signup anniversary. It is **not** the 1st of the calendar month. A date landing in a month too short for it (the 31st, in February) moves to the last day of that month. `current_month_ops`, the per-operation counts and `line_items` are counted over the same billing period as `credits_used`, from `period_start` to `credits_reset_at` (the field keeps its old name for compatibility). `period_start` is null only for a legacy account with no stored boundary, where the counts fall back to the calendar month. ```json theme={null} { "plan_id": "growth", "plan_name": "Growth", "price_per_month_usd": 249.0, "credits_limit": 200000, "credits_used": 1240, "credits_remaining": 203760, "credits_banked": 5000, "credits_reset_at": "2026-09-20T14:32:11Z", "period_start": "2026-08-20T14:32:11Z", "rate_limit_per_min": 30, "has_subscription": true, "cancel_at_period_end": false, "subscription_ends_at": null, "current_month_ops": 680, "memories_added": 400, "searches": 200, "insights": 20, "line_items": [ { "op_type": "Memories Added", "count": 400, "unit_price": 1.5, "subtotal": 600.0 }, { "op_type": "Searches", "count": 200, "unit_price": 1.0, "subtotal": 200.0 }, { "op_type": "Insights", "count": 20, "unit_price": 12.0, "subtotal": 240.0 }, { "op_type": "Chat Turns", "count": 60, "unit_price": 3.33, "subtotal": 200.0 } ], "available_plans": [ { "plan_id": "developer", "name": "Developer", "price_per_month_usd": 0.0, "credits": 5000, "rate_limit_per_min": 20, "max_spaces": 2 }, { "plan_id": "growth", "name": "Growth", "price_per_month_usd": 249.0, "credits": 200000, "rate_limit_per_min": 30, "max_spaces": 100 } ], "topup_packs": [ { "id": "small", "credits": 5000, "price_usd": 7.5 }, { "id": "medium", "credits": 25000, "price_usd": 37.5 }, { "id": "large", "credits": 100000, "price_usd": 150.0 } ], "can_buy_topups": true, "billed_to": null, "shared_orgs": [] } ``` | Field | Meaning | | - | - | | `credits_limit`, `credits_used` | The plan allowance for this period and how much of it is spent. | | `credits_banked` | Purchased top-up credits left. Never reset. | | `credits_remaining` | What you can still spend: the unspent allowance plus `credits_banked`. | | `credits_reset_at`, `period_start` | The current billing period. | | `has_subscription` | Whether a Stripe subscription is active. | | `cancel_at_period_end`, `subscription_ends_at` | Whether that subscription is set to stop, and when access ends. Read from Stripe. | | `topup_packs` | Packs this organization can buy now. Empty without a paid subscription, and inside a shared organization billed to another one. | | `can_buy_topups` | Whether the caller may buy a pack right now: an owner or admin of a personal organization with an active subscription. The same rule `POST /v1/billing/topup` enforces, so a `false` here means that call returns 403. Show your buy buttons only when it is `true`. | | `billed_to` | Set only inside a shared organization billed to another organization: the payer's name. Every credit figure is then the payer's pool, `price_per_month_usd` is null, the usage counts are zero, and `line_items`, `available_plans` and `topup_packs` are empty. | | `shared_orgs` | For a personal organization, the shared organizations it pays for and the credits each used this period. | Each line item's `subtotal` is the credits actually charged for that kind of operation this period, and `unit_price` is that subtotal divided by `count`: an average, since what one operation costs depends on the model and the tokens it used. A refunded charge reduces the subtotal and is not counted as an operation. `current_month_ops` counts every kind, chat turns included. > [!NOTE] > > `line_items` is credit-cost accounting, not a Stripe invoice. The amount you are actually > billed comes from Stripe. ## Change plan ### Free tiers ```http theme={null} POST /v1/billing/upgrade { "plan_id": "developer" } ``` Valid only for plans priced at zero. Any paid plan returns `400 payment_required` and directs you to `/v1/billing/checkout` instead. ### Paid plans ```http theme={null} POST /v1/billing/checkout { "plan_id": "growth" } ``` ```json theme={null} { "checkout_url": "https://checkout.stripe.com/c/pay/..." } ``` Redirect the user to `checkout_url`. Stripe's webhook flips the organization onto the new plan and syncs its quota once payment completes, so polling `/v1/billing` right after the redirect may still show the old plan for a few seconds. ## Manage an existing subscription `POST /v1/billing/portal` requires an existing Stripe customer, meaning you have completed checkout at least once. ```json theme={null} { "portal_url": "https://billing.stripe.com/p/session/..." } ``` Redirect the user there to update a payment method, view invoices, switch between paid plans, or cancel. Plan switches are prorated by Stripe, which is why they belong in the portal rather than in a second checkout. An **upgrade** applies immediately, and the organization's unused credits are added on top of the new plan's allowance for the current period. A **downgrade** is scheduled for the end of the period already paid for, exactly like a cancellation: the organization keeps the larger plan and its limits until then, and `GET /v1/billing` keeps reporting the larger plan until the change lands. ## Top-up packs An organization on a paid plan can buy extra credits at any time, at \$1.50 per 1,000. | `pack` | Credits | Price | | - | - | - | | `small` | 5,000 | \$7.50 | | `medium` | 25,000 | \$37.50 | | `large` | 100,000 | \$150.00 | ```http theme={null} POST /v1/billing/topup ``` ```json theme={null} { "pack": "small" } ``` ```json theme={null} { "checkout_url": "https://checkout.stripe.com/c/pay/..." } ``` Redirect the user to `checkout_url`; it is a one-time card payment, not a subscription. Credits are added once Stripe confirms payment, so `GET /v1/billing` may take a few seconds to show them. It reports them as `credits_banked`, and `credits_remaining` is your plan allowance still unspent plus `credits_banked`. `topup_packs` lists the packs on offer. Purchased credits are spent only after the plan allowance is used up, they are **not** reset at the end of the billing period, and they never expire. A free Developer organization gets `403 topup_requires_paid_plan` and has to upgrade first. If a pack is refunded, its credits are removed from `credits_banked` in proportion to the amount refunded; a chargeback that is lost removes the whole pack. Credits from the pack that were already spent are not reversed, and the balance never goes below zero. If a refund later fails or is canceled, the credits it removed are put back. Inside a shared organization billed to another organization, `/topup` answers `403 billing_personal_org_only`: buy from the owner's personal organization, whose pool the shared organization draws on. See [Credit top-ups](/memory/guides/plans-and-billing#credit-top-ups) for the full rules. ### Purchase history ```http theme={null} GET /v1/billing/topups ``` ```json theme={null} { "items": [ { "id": "9f2c41d07ab3", "purchased_at": "2026-10-12T09:41:27Z", "pack": "medium", "credits": 25000, "amount_usd": 37.5, "refunded_credits": 5000, "status": "partially_refunded" }, { "id": "51e0c8a2fd64", "purchased_at": "2026-09-30T16:02:10Z", "pack": "small", "credits": 5000, "amount_usd": 7.5, "refunded_credits": 0, "status": "active" } ] } ``` Newest first, at most 50. | Field | Meaning | | - | - | | `id` | Opaque, stable identifier for the purchase. | | `purchased_at` | When the credits were added. | | `pack`, `credits`, `amount_usd` | The pack bought, its credits and what it cost. | | `refunded_credits` | Credits taken back by a refund or a lost chargeback, including any already spent. | | `status` | `active`, `partially_refunded`, or `refunded` once `refunded_credits` reaches `credits`. | Inside a shared organization billed to another organization, `items` is always empty: the purchases are the payer's. ## Cancel ```http theme={null} POST /v1/billing/cancel ``` ```json theme={null} { "message": "Subscription will cancel at end of billing period." } ``` Cancellation takes effect at the end of the current billing period, the same date as `credits_reset_at`. The organization keeps its paid plan and credits until then, and purchased credits stay after it. `POST /v1/billing/resume` undoes it any time before that date: ```json theme={null} { "message": "Subscription resumed. Your plan will renew." } ``` ## Error responses | Status | Code | Cause | | - | - | - | | 400 | `payment_required` | `/upgrade` was called with a paid plan. Use `/checkout`. | | 400 | `invalid_plan` | `/checkout` was called with a `plan_id` that is free, unknown, or no longer on sale. | | 400 | `invalid_plan` | `/upgrade` was called with a `plan_id` that is unknown or no longer on sale. A free `plan_id` is the valid case here — a paid one is `payment_required` below. | | 400 | `no_subscription` | `/portal`, `/cancel` or `/resume` was called with no Stripe subscription on file. | | 403 | `insufficient_role` | The caller's role does not permit the billing action. | | 403 | `topup_requires_paid_plan` | `/topup` was called by an organization with no paid subscription. Upgrade first. | | 403 | `billing_personal_org_only` | A billing change was attempted from a shared organization billed to another one. Switch to the personal organization. | | 409 | `subscription_exists` | A subscription is already active. A second checkout would create a **second** subscription and bill twice, so plan changes go through `/portal`. Also returned by `/upgrade` when downgrading to Free while Stripe is still billing. Cancel in the portal instead. | | 409 | `checkout_in_progress` | A checkout for a *different* plan is already open for this organization. Finish or abandon it first; retrying the **same** plan is allowed and returns the original session. | | 503 | `billing_not_configured` | Stripe is not configured on this deployment. | | 503 | `price_not_configured` | No Stripe price is configured for that plan or top-up pack. | See the full [error reference](/memory/api-reference/errors). # Chat Source: https://docs.anonalabs.com/memory/api-reference/chat An OpenAI-compatible chat endpoint that automatically recalls and stores memory. `POST /v1/chat/completions` is an OpenAI-compatible chat endpoint with memory built in. On every request Anona retrieves the most relevant memories from the space, injects them into the prompt, and stores the exchange back, so the conversation stays grounded in what came before without any extra calls. Point an existing OpenAI-compatible client at the Anona base URL and pick a space. ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support-bot" \ -d '{"messages": [{"role": "user", "content": "What plan did I say I was on?"}]}' ``` ```python Python theme={null} from openai import OpenAI client = OpenAI( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com/v1", default_headers={"X-Anona-Space-Id": "support-bot"}, ) reply = client.chat.completions.create( model="gpt-oss", messages=[{"role": "user", "content": "What plan did I say I was on?"}], ) ``` ```typescript TypeScript theme={null} import OpenAI from "openai"; const client = new OpenAI({ apiKey: "anona_live_YOUR_KEY", baseURL: "https://api.anonalabs.com/v1", defaultHeaders: { "X-Anona-Space-Id": "support-bot" }, }); const reply = await client.chat.completions.create({ model: "gpt-oss", messages: [{ role: "user", content: "What plan did I say I was on?" }], }); ``` Your own OpenAI SDK, your own request shape — only the base URL and the key change. > [!NOTE] > > `model` is optional on the wire — the cURL call above omits it and gets > whatever this deployment serves. The OpenAI Python SDK requires it client-side, > though: omit it and `create()` raises `TypeError: Missing required arguments` > before any request leaves the process. So name a model when you call through an > SDK — `gpt-oss` is the short name of what this deployment currently serves, and > [`GET /v1/models`](/memory/api-reference/model-selection) marks it `default: true`. ## Request ```bash theme={null} POST https://api.anonalabs.com/v1/chat/completions Authorization: Bearer anona_live_... Content-Type: application/json ``` ```json theme={null} { "model": "gpt-oss", "space_id": "support-bot", "messages": [ { "role": "user", "content": "What plan did I say I was on?" } ], "memory_limit": 5, "temperature": 0.7, "max_tokens": 512 } ``` | Field | Type | Default | Description | | - | - | - | - | | `messages` | array | required | Standard chat messages, each with `role` and `content`. | | `model` | string | space default, then deployment default | Optional. Omit it to use whatever model this deployment serves; that is the recommended default. Name a tier (`fast`, `balanced`) or a model id to choose, and credits are charged at that model's rate. See [model selection](/memory/api-reference/model-selection). Every model is selectable on every plan. A model this deployment does not serve returns `400 unsupported_model`, listing what it does. | | `stream` | boolean | `false` | Stream the reply as server-sent events. See [Streaming](#streaming). | | `stream_options` | object | none | Streaming only. `{"include_usage": true}` appends the final usage chunk, exactly as on OpenAI. Omit it and no usage chunk is sent. | | `max_tokens` | integer | none | Optional. | | `temperature` | float | none | Optional. | Fields Anona does not model (`top_p`, `n`, `stop`, `presence_penalty` and the rest of the OpenAI request) are forwarded to the provider unchanged. ## Memory tunables Every knob can be set in the body **or** as a header. The header form exists so a stock client can set them once in `default_headers` and leave every call site alone; when both are present, the body wins. | Body field | Header | Type | Default | Description | | - | - | - | - | - | | `space_id` | `X-Anona-Space-Id` | string | `"default"` | Which space to recall from and store into. Omit it and the turn lands in a space literally named `default`, created on the first proxied turn if it does not exist yet, so a space you never created appears in your space list and in the dashboard. Set it once in `default_headers` to keep products apart. | | `user_id` | `X-Anona-User-Id` | string | none | Scope the turn to one end user: it recalls only that user's memories and stores the turn under them. One space, many users. See [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). | | `agent_id` | `X-Anona-Agent-Id` | string | none | Scope the turn to one agent. | | `session_id` | `X-Anona-Session-Id` | string | none | Scope the turn to one conversation or run. | | `memory_limit` | `X-Anona-Memory-Limit` | integer, 1 to 20 | `5` | How many memories to inject. | | `memory_token_budget` | `X-Anona-Memory-Tokens` | integer, 1 to 100,000 | none | Approximate token cap for the injected block. Whole memories are dropped, lowest-ranked first, until it fits; the text is never truncated mid-sentence. The count is an estimate, not a tokenizer. | | `auto_record` | `X-Anona-Auto-Record` | boolean | `true` | Store this turn back into the space. Set `false` for read-only turns, such as evaluating against a space without polluting it. | | `memory` | `X-Anona-Memory` | boolean | `true` | Set `false` to skip recall and injection entirely for one call. | | `block_order` | `X-Anona-Block-Order` | `"relevance"` or `"stable"` | `"relevance"` | How the injected memory block is ordered. `"stable"` keeps your provider's prompt cache warm. See [Prompt caching](#prompt-caching) below. | | `receipt` | `X-Anona-Receipt` | boolean | `false` | Build a fetchable [context receipt](/memory/api-reference/context-receipts) for this call. The response echoes the id back as `X-Anona-Receipt-Id`. | A header that cannot be parsed returns `400 invalid_tunable` naming the header. ## Prompt caching Every major provider discounts a prompt prefix it has already seen: Anthropic by around 90%, OpenAI by around 50%. That discount matches on an **unchanged prefix**, so it survives exactly as far into your prompt as the first byte that differs from last time. By default the memory block is ordered by relevance. Relevance is a property of *this turn's question*, so the same memories come back in a different order on the next turn, the block's first line changes, and the discount ends before your memories begin, and because the block is prepended, before your own system prompt too. `block_order: "stable"` changes three things together: * **Ordered by record time**, not by score. A property of the memory rather than of the question, so an unchanged set of memories renders identically two turns running, and a newly recorded memory appends at the tail. * **Bulleted, not numbered.** A numbered list renumbers every line below a memory that drops out of this turn's recall, so one dropout near the top rewrites the whole block. * **Placed after your system message** instead of before it, so your own instructions, usually the most static text in the prompt, are the prefix. ```python Python theme={null} client = OpenAI( base_url="https://api.anonalabs.com/v1", api_key="anona_live_YOUR_KEY", default_headers={ "X-Anona-Space-Id": "support-bot", "X-Anona-Block-Order": "stable", }, ) ``` ```typescript TypeScript theme={null} const client = new OpenAI({ baseURL: "https://api.anonalabs.com/v1", apiKey: "anona_live_YOUR_KEY", defaultHeaders: { "X-Anona-Space-Id": "support-bot", "X-Anona-Block-Order": "stable", }, }); ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [{"role": "user", "content": "What plan am I on?"}], "block_order": "stable" }' ``` > [!NOTE] > > Stable order protects the **prefix**, which means it protects memories older than any > that changed. If a memory drops out of recall, everything above it in record order still > matches; everything below it does not. Adding a memory is free; it lands at the tail. What it cannot do is make an unstable recall stable. If a turn recalls a completely different set of memories, there is no ordering that keeps the block the same. That is why we measure it rather than assert it: your dashboard's **Usage** page reports how many of the tokens we injected were actually unchanged since your previous turn. The measurement runs in both modes, so you can compare. Two honest limits on those numbers. They are `len/4` estimates, not tokenizer counts. And an unchanged prefix is what makes a cache hit *possible*. Whether your provider charged the discounted rate also depends on their minimum cacheable length and their own cache TTL, neither of which we can see. We report eligibility, never a saving. ### Space defaults The same knobs can be stored on the space, so they apply to every proxied call without touching a call site. Resolution is per field, in this order: ``` request body → request header → space default → platform default ``` | Method | Path | Description | | - | - | - | | `GET` | `/v1/spaces/{space_id}/chat-settings` | The space's stored defaults. An unconfigured space returns all nulls, not a 404. | | `PUT` | `/v1/spaces/{space_id}/chat-settings` | Replace them. A field left out of the body is **cleared**, not kept. | | `DELETE` | `/v1/spaces/{space_id}/chat-settings` | Remove every override. Idempotent. | ```bash cURL theme={null} curl -X PUT https://api.anonalabs.com/v1/spaces/support-bot/chat-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"memory_limit": 12, "auto_record": false}' ``` ```python Python theme={null} from anona import AnonaClient anona = AnonaClient(api_key="anona_live_YOUR_KEY") anona.set_chat_settings( space_id="support-bot", memory_limit=12, auto_record=False, ) ``` ```typescript TypeScript theme={null} import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); await anona.setChatSettings({ spaceId: "support-bot", memoryLimit: 12, autoRecord: false, }); ``` Remember the `PUT` is a full replace in every language: a field left out is cleared. ```json theme={null} { "space_id": "support-bot", "memory_limit": 12, "memory_token_budget": null, "auto_record": false, "memory": null, "chat_model": null } ``` `chat_model` sets which LLM this space's proxied calls run on: a tier name or a model id, stored resolved. A request that names its own `model` still wins. See [model selection](/memory/api-reference/model-selection). `space_id` is not a settable default: it is the key the defaults are stored under. A `null` means *unset*, not "off": that field falls through to the platform default, so a space can configure one knob and inherit the rest. `block_order` is not a space default either. It belongs to a client and its prompt rather than to a space, and the header form already sets it once per client. These endpoints are free, cost no credits, and remain available when an organization has run out of credits, because configuration should never be the thing you cannot change. ## Response OpenAI-shaped, plus two Anona fields. ```json theme={null} { "id": "chatcmpl-...", "object": "chat.completion", "model": "openai.gpt-oss-120b-1:0", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "..." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }, "memories_injected": 3, "space_id": "support-bot" } ``` | Field | Description | | - | - | | `memories_injected` | How many memories were pulled into this turn's prompt. | | `space_id` | The space the turn was recalled from and stored into. | The same two values are also returned as the `X-Anona-Memories-Injected` and `X-Anona-Space-Id` response headers, which is how you read them on a stream. `choices[].finish_reason` says why the reply stopped. It is normalised, not passed through: every provider's own stop reason is mapped onto OpenAI's four values before it reaches you, in streaming and non-streaming replies alike, so it is safe to branch on. | Value | Meaning | | - | - | | `stop` | The model finished on its own. Also what an unrecognised provider reason falls back to, since a value outside the enum breaks a typed client. | | `length` | Cut off at the token limit. This is the one case the field exists to warn you about — `end_turn` and `max_tokens` from the provider both arrive here. | | `tool_calls` | Stopped to call a tool. | | `content_filter` | Stopped by a safety or guardrail filter. | [`/v1/messages`](/memory/api-reference/messages) is the exception: it reports Anthropic's own `stop_reason` vocabulary (`end_turn`, `max_tokens`, `tool_use`) instead, because it is wire-compatible with the Anthropic SDK. The user turn and the assistant reply are both stored back into `space_id` automatically, unless `auto_record` is `false`. ## Streaming Send `"stream": true` and the reply arrives as server-sent events in the standard OpenAI chunk format, terminated by `data: [DONE]`. ```bash cURL theme={null} curl -N https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support-bot" \ -d '{ "messages": [{"role": "user", "content": "What plan am I on?"}], "stream": true, "stream_options": {"include_usage": true} }' ``` ```python Python theme={null} stream = client.chat.completions.create( model="gpt-oss", messages=[{"role": "user", "content": "What plan am I on?"}], stream=True, ) for chunk in stream: print(chunk.choices[0].delta.content or "", end="") ``` ```typescript TypeScript theme={null} const stream = await client.chat.completions.create({ model: "gpt-oss", messages: [{ role: "user", content: "What plan am I on?" }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` Memory recall happens before the model is called, so injection is unaffected by streaming. The first chunk carries the `memories_injected` and `space_id` fields alongside the usual chunk payload, and both are on the response headers from the start. The turn is recorded and billed after the last token, using the token counts from the final chunk. If the connection drops mid-stream, whatever the model already produced is still recorded and still billed, from an estimate of the text that was sent, since the counts travel in a chunk that never arrived. Token counts reach *you* only if you ask for them, as on OpenAI: set `"stream_options": {"include_usage": true}` and the stream ends with a usage chunk. Billing does not depend on it. > [!NOTE] > > Errors raised before the first token (an unavailable model, an unreachable provider) > come back as a normal JSON error with a real status code. Once the stream has started > the status is already sent, so a later failure ends the stream early instead. ## Other request shapes | Shape | Endpoint | | - | - | | OpenAI Chat Completions | `/v1/chat/completions` (this page) | | OpenAI Responses | [`/v1/responses`](/memory/api-reference/responses) | | Anthropic Messages | [`/v1/messages`](/memory/api-reference/messages) | All three run the identical memory wrapper and accept the same tunables. > [!NOTE] > > You never supply an LLM key. Anona runs the model for you, and billing is metered > in Anona credits rather than by the underlying provider. `model` chooses from what > this deployment serves: omit it for the default, or name one from > [model selection](/memory/api-reference/model-selection). A model this deployment does not > serve returns `400 unsupported_model`. # Context Receipts Source: https://docs.anonalabs.com/memory/api-reference/context-receipts A per-response manifest of what entered the prompt, what was cut, and why, retrievable after the fact. `retrieve` (and the drop-in proxy) return a ranked list, but a lot happens between "the engine found this" and "the model saw this": near-duplicates get collapsed, low-relevance hits get dropped, results past your `limit` get cut, and, if you rendered a prompt-ready block, whole memories can get dropped again to fit the token budget. A context receipt is that decision, written down: what survived, what didn't, and which reason removed it. A receipt covers two families of cut. By default it reports the ones made after retrieval returned, which is the fast path and costs nothing. Ask for `receipt_detail: "full"` and it also reports the ones the search pipeline made internally, so a memory that never made it out of ranking is accounted for instead of simply absent. > [!NOTE] > > **What a receipt does not prove.** A receipt reports what retrieval *returned* > and what assembly *cut*. It does not prove retrieval was correct. It cannot > tell you a relevant memory was missing from the candidates in the first place, > and it is not an isolation guarantee. Treat it as a debugging tool first, an > audit trail second, not a certificate that the right memories were found. > [!NOTE] > > **Best-effort, never load-bearing.** Asking for a receipt never changes what a > call returns or how it's billed, and a request succeeds whether or not its > receipt was actually stored. If you ask for one and it can't be built or saved > for any reason, the call still completes normally; you just won't have a > receipt to fetch afterward. ## How it works Every `retrieve` call already builds and stores a receipt, whether or not you ask for one. That's what makes a receipt fetchable later for a request nobody flagged in advance, which is the actual debugging case: grab the request id out of your own logs (`X-Request-ID`) after the fact and fetch it, even for a call that never set `receipt`. Passing `receipt: true` changes one thing only: that call's own response hands you the id back directly, so you don't have to go dig it out of your logs. ```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": "What plan is this customer on?", "receipt": true } ``` ```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": "What plan is this customer on?", "receipt": true }' ``` ```python Python theme={null} result = client.retrieve_receipt( space_id="customer-support-bot", query="What plan is this customer on?", ) print(result.receipt_id) for r in result: # iterates the results print(r["content"]) ``` ```typescript TypeScript theme={null} const result = await anona.retrieveReceipt({ spaceId: "customer-support-bot", query: "What plan is this customer on?", }); console.log(result.receipt_id, result.memories.length); ``` The SDKs set `receipt: true` for you — `retrieve` has nowhere to carry the id, so asking for it is a separate method rather than a flag. The response is unchanged except for one added field: ```json theme={null} { "results": [ "…unchanged…" ], "usage": null, "context": null, "token_estimate": null, "receipt_id": "3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e" } ``` `receipt_id` only appears when you set `receipt: true` and a receipt was actually built. Omitting `receipt` (the default) leaves the response exactly as it was before this existed, field for field. ### Asking for the search pipeline's own cuts `receipt_detail` controls how deep the receipt goes. It is independent of `receipt`: one decides whether this response hands you the id, the other decides what the receipt contains. | Value | What the receipt reports | | - | - | | `"basic"` (default) | Cuts made after search returned: `dedup`, `min_score`, `limit`, `budget`. Costs nothing extra. | | `"full"` | The above, plus the cuts search made internally: `candidate_cap`, `rerank_rank`, `engine_budget`. | ```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": "What plan is this customer on?", "receipt": true, "receipt_detail": "full" }' ``` ```python Python theme={null} result = client.retrieve_receipt( space_id="customer-support-bot", query="What plan is this customer on?", receipt_detail="full", ) ``` ```typescript TypeScript theme={null} const result = await anona.retrieveReceipt({ spaceId: "customer-support-bot", query: "What plan is this customer on?", receiptDetail: "full", }); ``` > [!NOTE] > > **`"full"` can cost you a little latency.** Repeated searches are served from a > cache, and the pipeline's internal decisions are not part of a cached answer. > So the first `"full"` call for a given query may re-run the search to collect > them, even when a `"basic"` call would have been served from cache. Later calls > reuse that richer entry. Results are identical either way; only the timing > differs. Leave it on `"basic"` for production traffic and reach for `"full"` > when you're actually investigating something. > [!NOTE] > > `receipt_id` is the same value as the `X-Request-ID` header every response > already carries. There's nothing extra to capture. If you log request ids > today, you already have what you need to fetch the receipt later. ## Fetch a receipt ```http theme={null} GET /v1/receipts/{request_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/receipts/3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} receipt = client.get_receipt("3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e") print(receipt["included"], receipt["excluded"]) ``` ```typescript TypeScript theme={null} const receipt = await anona.getReceipt("3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e"); console.log(receipt.included, receipt.excluded); ``` **Response** `200 OK` ```json theme={null} { "request_id": "3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e", "space_id": "customer-support-bot", "included": [ { "memory_id": "mem_abc123def456", "relevance_score": 1.0974, "token_estimate": 14 } ], "excluded": [ { "memory_id": "mem_9f21e2", "reason": "dedup", "detail": "covered by mem_abc123def456" }, { "memory_id": "mem_7a10c4", "reason": "min_score", "detail": "score=0.00012 floor=0.5" }, { "memory_id": "mem_5b88e0", "reason": "limit", "detail": "rank=11" } ], "excluded_truncated": {}, "token_accounting": {}, "engine_stages": false } ``` | Field | Type | Description | | - | - | - | | `request_id` | string | The id of the call this receipt was built for. Matches `receipt_id` and the `X-Request-ID` response header on that call. | | `space_id` | string | The space the call searched. | | `included` | array | What survived every cut, in result order. Each item is `{memory_id, relevance_score, token_estimate}`, with no memory content. | | `excluded` | array | What was cut, in the order it could have been cut: the search pipeline's own cuts first (only when `receipt_detail` was `"full"`), then `dedup`, `min_score`, `limit`, and `budget`. Each item is `{memory_id, reason, detail}`. Capped at 50 entries per reason. | | `excluded_truncated` | object | `{}` unless a reason above hit the 50-entry cap. When one did: `{reason: count}`, how many additional cuts for that reason didn't fit in `excluded`. Always accurate, never silently dropped: if the manifest was cut, this says so and by how much. | | `engine_stages` | boolean | Whether this call asked the search pipeline to account for its own cuts (`receipt_detail: "full"`). When `false`, `excluded` covers only what happened after search returned, and its silence about a memory says nothing about whether search considered it. | | `token_accounting` | object | `{}` unless the call rendered a prompt-ready block (`format: "block"` on retrieve, or a drop-in proxy call with memory injection on); there's no block to account for otherwise. When one was rendered: `{block_tokens, budget}`, where `block_tokens` is the rendered block's estimated size and `budget` is the ceiling it was rendered against (`null` if you didn't set one). | > [!NOTE] > > **Why cap it.** The engine's own fan-out is bounded by tokens, not result > count, so a space of short facts combined with a high `limit` and a > `min_score` floor can drop several hundred results in one call, far more > than a debugging manifest needs to be useful. `excluded` keeps the first 50 > per reason; `excluded_truncated` always reports how many more there were, so > a capped list never reads as a complete one. The cap never affects `results` > itself, only how much of the *cut* list gets written down. Receipts are kept for about an hour after the call that produced them, then expire. There is no way to list receipts; fetch each one by the id its call returned. ## The exclusion reasons ### After search returned (always available) | `reason` | What it means | | - | - | | `dedup` | A raw fact was dropped because a consolidated memory already returned covers it. `detail` names the covering memory's id. | | `min_score` | The result fell below the `min_score` floor you set. `detail` shows the score and the floor. Expect this reason to account for most of a filtered call: in the default `accurate` mode `relevance_score` is close to two-valued — 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. See [`relevance_score`](/memory/api-reference/retrieve#response). | | `limit` | The result ranked outside your requested `limit` (or `top_k`). `detail` shows its rank. | | `budget` | Rendering a prompt-ready block (`format: "block"`, or the drop-in proxy's memory injection) had to drop the whole memory to fit `context_max_tokens`. `detail` shows its rank. Only ever appears when a block was actually rendered. | `dedup`, `min_score`, and `limit` happen during retrieval itself, in that order, so each stage only ever sees what survived the one before it, so a given memory appears in `excluded` at most once. `budget` happens afterward, during rendering, and only applies to memories that made it past all three. ### Inside search (`receipt_detail: "full"` only) Search ranks far more candidates than it returns. These three reasons account for memories that were considered and dropped before the reasons above ever applied. | `reason` | What it means | | - | - | | `candidate_cap` | The memory was found, but ranked too low to enter the final scoring round. `detail` shows the rank it reached and the cap. Usually means the query didn't match it strongly enough, not that anything is wrong. | | `rerank_rank` | It reached final scoring and placed below your `limit`. `detail` shows its position. Raising `limit` would bring it back. | | `engine_budget` | It scored well enough to be returned, but was passed over to fit the response's own token budget: something ranked below it came back and this didn't. `detail` shows its position. | > [!NOTE] > > The two families never overlap. The reasons above describe memories search > never returned; the ones in the previous table describe memories it did return > and something afterward removed. A memory appears under exactly one reason, so > the counts in `excluded_truncated` stay accurate. ## Account for one specific memory The receipt tells you what was cut. It can't tell you about a memory that no stage mentions at all, and that's usually the memory you're asking about: you know it's in the space, and it isn't in your prompt. `explain` answers exactly that, for one memory id, against one earlier call. ```http theme={null} GET /v1/receipts/{request_id}/explain?memory_id=mem_abc123def456 Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/receipts/3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e/explain?memory_id=mem_abc123def456" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} print(client.explain( "3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e", "mem_abc123def456", )) ``` ```typescript TypeScript theme={null} console.log(await anona.explain( "3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e", "mem_abc123def456", )); ``` **Response** `200 OK` ```json theme={null} { "memory_id": "mem_abc123def456", "outcome": "excluded", "stage": "fusion", "detail": "fusion rank 812, cap 300", "arms": { "semantic": 812, "keyword": null, "related": null, "time": null }, "scores": { "fusion": 0.031 } } ``` | Field | Type | Description | | - | - | - | | `memory_id` | string | The memory you asked about. | | `outcome` | string | `included` (it came back on that call), `excluded` (search considered it and dropped it), or `not_retrieved` (no stage of search ever saw it). | | `stage` | string | Where it was dropped: `retrieval`, `fusion`, or `rerank`. `null` unless `outcome` is `excluded`. | | `detail` | string | A short plain-language account of that drop. | | `arms` | object | How each search method ranked it, or `null` for a method that didn't find it at all. Empty when nothing found it. | | `scores` | object | The scores it earned at the stage it reached. | ### Reading the result `not_retrieved` is the one worth acting on. It means no part of search matched the memory, so raising `limit` or lowering `min_score` will not help. Either the wording is too far from your query, or the memory isn't scoped where you're searching. Check `user_id`, `agent_id`, `session_id`, and any `tags` on the original call first. `arms` tells you which kind of matching worked. A memory found only by `keyword` and not by `semantic` usually means your query and the stored memory share words but not meaning, and vice versa. `related` means it was reached through another memory it's connected to, and `time` means it matched a date range in the query. > [!NOTE] > > **It replays the original search.** `explain` runs the same query again, pinned > to the moment the original call ran, so it reports the pipeline as it actually > behaved rather than as it would behave now. Memories written since then don't > change the answer. This works on any call, including ones that never set > `receipt` or `receipt_detail`, which is the point: the call you need to explain > is rarely the one you flagged in advance. > [!NOTE] > > **Free, but gated.** `explain` costs no credits. It does run a real search > under the hood, so it counts against your rate limit like any other call, and > an organization that has run out of credits can't call it, the same answer > `retrieve` would give. ## On the drop-in proxy The same tunable works on `/v1/chat/completions`, `/v1/responses`, and `/v1/messages`. Set `receipt: true` in the request body, or send it once as a header so a stock SDK never has to touch its request bodies: ``` X-Anona-Receipt: true ``` Body wins when both are set. A malformed header value comes back as `400 invalid_tunable`, never a server error. Instead of a body field, the id comes back as a response header: ``` X-Anona-Receipt-Id: 3f9a2b7c-1e4d-4a6b-9c3e-8d2f1a0b5c7e ``` Fetch it the same way, from the same `GET /v1/receipts/{request_id}` endpoint. On the proxy, `token_accounting` reflects what was actually injected into the model call, the same rendering the proxy uses to build its own prompt, not a separate estimate. > [!NOTE] > > `receipt_detail` is retrieve only. Proxy receipts are always `"basic"`, so they > report the cuts made after search returned and set `engine_stages` to `false`. > `explain` still works on them: it replays the memory search the proxy ran, so > you can account for a specific memory that never made it into a completion. ## Isolation A receipt can only be fetched by the organization whose API key made the original call. A request for another organization's receipt id gets the same `404` as an unknown or expired one; there's no way to distinguish "not yours" from "doesn't exist." The same rule holds one level down, for a space with per-space membership enforced: only a member of the space the receipt names can fetch it, even from inside the right organization. A colleague in the same org who isn't on that space gets the same `404`, not a `403`, which would itself confirm the receipt exists for a space they can't see. ## Pricing Free. `GET /v1/receipts/{request_id}` reads something already computed and already paid for at the time of the original call, so fetching it again costs nothing. ## Error responses | Status | Code | Cause | | - | - | - | | 401 | `missing_auth` / `invalid_api_key` | Missing or invalid API key. | | 404 | `receipt_not_found` | No receipt with that id for your organization: never built, expired, or belongs to someone else. On `explain`, also returned for a receipt old enough to predate the endpoint, which has nothing to replay. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Retrieve API](/memory/api-reference/retrieve) > > Rank a space's memories against a query. > [card] [Chat completions](/memory/api-reference/chat) > > The drop-in proxy that can build receipts for injected memory too. # Documents Source: https://docs.anonalabs.com/memory/api-reference/documents Upload files into a space so their content becomes searchable memory. A document is parsed, split into chunks, and stored as memory, so the same [Retrieve](/memory/api-reference/retrieve) and [Reason](/memory/api-reference/reason) calls surface file content alongside anything you recorded conversationally. | Category | Formats | How it is extracted | | - | - | - | | Documents | PDF, DOCX | Text extraction | | Office | PPTX, XLSX, XLS | Text and table extraction | | Web and plain text | HTML, HTM, TXT, Markdown, CSV | Direct parsing | | Images | JPG, JPEG, PNG | Text in the image | | Video | MP4, MOV, WEBM, MKV | On-screen text and a description of what happens | | Audio | MP3, WAV | Transcription | > [!WARNING] > > **Legacy `.doc` and `.ppt` are accepted and then fail.** The upload returns > `202`, and the job ends `failed` because there is no reader for the old binary > formats yet. Save as `.docx` / `.pptx` first. Audio from voice-memo apps is > usually `.m4a`, which is refused outright — convert it to MP3. ## Upload files ```http theme={null} POST /v1/spaces/{space_id}/documents Authorization: Bearer anona_live_YOUR_KEY Content-Type: multipart/form-data ``` | Form field | Type | Required | Description | | - | - | - | - | | `files` | file(s) | Yes | One or more files to upload. | | `tags` | string | No | Comma-separated tags applied to every file, so retrieval can scope to them later. Tags starting with `anona:` are reserved and rejected with `422`. | | `user_id` | string | No | End user these documents belong to. See [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). | | `agent_id` | string | No | Owning agent. | | `session_id` | string | No | Owning session. | | `strategy` | string | No | How the file is retained. Defaults to storing it as retrieval chunks; pass a named strategy to override. | > [!WARNING] > > If you use scoping, upload with the same scope you retrieve with. Scoped search > is strict, so a document uploaded **without** `user_id` is not returned to a > search **with** one: it is only reachable from an unscoped search. | Limit | Value | | - | - | | Files per request | 20 | | Size per file | 25 MB, or 64 MB for video | | Total per request | 50 MB, or 100 MB for a request containing video | Video is allowed more because 25 MB is a few seconds of it at any real bitrate. Ingestion is always asynchronous. The call returns immediately with a `job_ids` list; poll each with the [job status](/memory/api-reference/memories#job-status) endpoint. When a job reaches `completed`, that file's content is searchable. For an upload the job's own `memory_count` and `memory_ids` stay `null`; how many memories a file produced is on the document — `memory_count` from [List documents](#list-documents). > [!WARNING] > > **Known issue: files over about 1 MB currently fail.** Above roughly 1 MB per > file, the request is refused with `400 bad_request` ("There was an error > parsing the body") instead of being accepted up to the limits above. Several > smaller files in one request work. We are fixing this; until then, split or > compress large files. Uploading to a space that does not exist yet creates it, exactly as a first [record](/memory/api-reference/memories) does — an upload is a write, so there is no space to pre-create. **Response** `202 Accepted` ```json theme={null} { "job_ids": ["job_conv_7f1a", "job_conv_7f1b"] } ``` ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/customer-support-bot/documents \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -F "files=@handbook.pdf" \ -F "tags=hr,policy" \ -F "user_id=alice" ``` ```python Python theme={null} job = client.upload_file( space_id="customer-support-bot", file="handbook.pdf", tags=["hr", "policy"], ) print(job["job_ids"]) # poll each with client.get_job(...) ``` ```typescript TypeScript theme={null} import { readFileSync } from "node:fs"; const job = await anona.uploadFiles({ spaceId: "customer-support-bot", files: [{ data: readFileSync("handbook.pdf"), filename: "handbook.pdf" }], tags: ["hr", "policy"], }); console.log(job.job_ids); // poll each with anona.getJob(...) ``` The TypeScript client takes 1–20 files in one call; the Python client takes one per call. Both carry `user_id` / `agent_id` / `session_id` (`userId` / `agentId` / `sessionId` in TypeScript), and passing them matters whenever the space is scoped at all: a scoped `retrieve` is strict, so an upload written without scope is invisible to every scoped query — silently, since the files are stored and the job completes. > [!NOTE] > > Parsing and embedding a document is billed on completion, not at upload. Larger files > cost more, because more content is processed. Images and audio are read into text > first and then treated as text, so each costs what its reading costs. > > An image yields a verbatim transcription of any text in it, so a screenshot is > worth uploading; a photograph with no text in it currently yields little, since the > description of what it shows is not being produced yet. A video yields its on-screen > text and a description of what happens. **Speech in a video is not transcribed > yet** — for a recording where the words matter, upload the audio track as MP3. > > Very long recordings are rejected rather than truncated: a transcript that stopped > early would be stored as though it were the whole thing. Split a long recording and > upload the parts. ## List documents ```http theme={null} GET /v1/spaces/{space_id}/documents?limit=100&offset=0 Authorization: Bearer anona_live_YOUR_KEY ``` | Query parameter | Type | Description | | - | - | - | | `limit` | integer | Maximum results. Defaults to 100. | | `offset` | integer | Pagination offset. | | `q` | string | Optional substring filter on document id. | | `source` | string | Keep only documents of these kinds: `file`, `custom`, `memory`. Repeat the parameter or comma-separate. Omit for everything. | **Response** `200 OK` ```json theme={null} { "documents": [ { "document_id": "file_9c2f1a", "source": "file", "created_at": "2026-07-22T10:30:00Z", "updated_at": "2026-07-22T10:30:04Z", "text_length": 18240, "memory_count": 37, "tags": ["hr", "policy"] } ], "total": 1, "limit": 100, "offset": 0 } ``` `memory_count` is how many searchable memories were extracted from the document. ### What counts as a document This list contains everything ingested into the space, not only uploaded files. Storing a memory with `POST /v1/record` also creates a document row, so `source` tells you which is which. | `source` | Meaning | | - | - | | `file` | Uploaded through this endpoint. | | `custom` | Content grouped under a caller-chosen id. Reserved: the record endpoints do not currently accept a `document_id`, so new writes never produce this value. | | `memory` | Created implicitly by `POST /v1/record`. It holds a single stored memory rather than an uploaded document. | Newest rows come first, and a space with steady `record` traffic accumulates `memory` rows fast, so uploads are often not on the first page. Ask for the kind you want rather than paging until you find it: ```http theme={null} GET /v1/spaces/{space_id}/documents?source=file ``` `limit` and `offset` then apply to the filtered set, and `total` counts only the matching documents. Every memory that was *ingested* belongs to a document — an uploaded file, or the `memory` row `POST /v1/record` writes for it — and carries that `document_id` on retrieve and in the listings, so you can walk from a search hit back to the document that produced it, and deleting a document removes every memory extracted from it. `document_id` is nullable, and the common null is a `note`: a consolidation is *derived* from raw facts rather than ingested, so it belongs to no document. Walk its `source_ids` to the facts it was built from, and read their `document_id`. > [!NOTE] > > The dashboard's Documents tab hides `memory` rows by default for this reason, with a > toggle to show everything ingested. ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/documents?source=file" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} docs = client.list_documents(space_id="customer-support-bot") ``` ```typescript TypeScript theme={null} const docs = await anona.listDocuments({ spaceId: "customer-support-bot" }); ``` ## Get a document ```http theme={null} GET /v1/spaces/{space_id}/documents/{document_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot/documents/file_9c2f1a \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} doc = client.get_document(space_id="customer-support-bot", document_id="file_9c2f1a") ``` ```typescript TypeScript theme={null} const doc = await anona.getDocument({ spaceId: "customer-support-bot", documentId: "file_9c2f1a", }); ``` Returns the document's metadata and ingestion status, in the same shape as one item in the list above. ## Delete a document Removes the document and every memory extracted from it. On a space shared with you, only the owning organization can delete a document, whatever your role: deleting one removes memories other members wrote and rely on, the same reason memory delete is owner-only. ```http theme={null} DELETE /v1/spaces/{space_id}/documents/{document_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X DELETE \ https://api.anonalabs.com/v1/spaces/customer-support-bot/documents/file_9c2f1a \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} client.delete_document(space_id="customer-support-bot", document_id="file_9c2f1a") ``` ```typescript TypeScript theme={null} await anona.deleteDocument({ spaceId: "customer-support-bot", documentId: "file_9c2f1a", }); ``` **Response** `204 No Content`. ## Error responses | Status | Code | Cause | | - | - | - | | 400 | `too_many_files` | More than 20 files in one request. | | 400 | `bad_request` | The body could not be parsed. Today this is what a file over about 1 MB gets — see the known issue above. | | 401 | `missing_auth` / `invalid_api_key` | Missing or invalid API key. | | 403 | `space_access_denied` | You are not a member of this space. | | 403 | `space_owner_only` | Deleting a document from a space shared with you. Only the owning organization can. | | 404 | `space_not_found` | The space does not exist. A listing of a space that exists but holds nothing is an empty page, not this. | | 413 | `file_too_large` | A single file exceeds 25 MB, or 64 MB for video. | | 413 | `upload_too_large` | The total upload exceeds 50 MB, or 100 MB with video. | | 413 | `file_expands_too_much` | An Office file unpacks to far more than it weighs. Read from the archive's own index without decompressing, so it costs nothing to check. | | 415 | `unsupported_file_type` | A file's extension is outside the supported list above. One bad file rejects the whole request. | | 415 | `file_type_mismatch` | The file's contents do not match its extension — a zip named `.pdf`, or an archive named as text or audio. | | 400 | `invalid_source` | The `source` filter named a kind that does not exist. Valid values are `file`, `custom` and `memory`. | | 429 | `rate_limited` / `credits_exhausted` | Rate limit hit, or the organization is out of credits. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Retrieve API](/memory/api-reference/retrieve) > > Search across recorded memory and uploaded documents. > [card] [Memories API](/memory/api-reference/memories) > > Record memories and poll ingestion jobs. # Draw Source: https://docs.anonalabs.com/memory/api-reference/draw Ask for a chart or a diagram in plain language and get it drawn from a space's memories, with copyable Mermaid source. > [!NOTE] > > The route is `/graph` for historical reasons. It is not the [entity graph](/memory/api-reference/graph), which lists the entities a space has extracted. In the dashboard this is the `/draw` command; see [Charts, diagrams and artifacts](/memory/guides/charts-diagrams-artifacts). Ask for a chart in plain language and get back a validated chart spec, the same chart as [Mermaid](https://mermaid.js.org) source, and any short note the model added. Ask about a process or a structure instead ("how does signup work", "flow chart of our release process") and you get a Mermaid diagram rather than a chart. Either one is drawn from this space's memories and its curated summaries ([memory models](/memory/api-reference/models)) only: every number in it has to come from what the space holds. When the memories do not contain the numbers, you get no chart and a sentence saying so, rather than a plausible guess. ## Request ```http theme={null} POST /v1/spaces/{space_id}/graph Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "request": "Deploys per week this month" } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `request` | string | Yes | What to chart, 1 to 2,000 characters. | | `model` | string | No | Which LLM draws the chart: a tier name (`fast`, `balanced`) or a model id. Defaults to the space's chat default, then to the platform default. See [model selection](/memory/api-reference/model-selection). | | `conversation` | object\[] | No | Earlier turns, oldest first, so a follow-up such as "now by month" has context. Up to 40, each `{"role": "user" or "assistant", "content": string}`. | Unknown fields are rejected with `422 validation_error`. **Response** `200 OK` ```json theme={null} { "chart": { "type": "bar", "title": "Deploys per week", "x": "week", "y": ["deploys"], "data": [ { "week": "Oct 5", "deploys": 3 }, { "week": "Oct 12", "deploys": 5 } ] }, "mermaid": "xychart-beta\n title \"Deploys per week\"\n x-axis [\"Oct 5\", \"Oct 12\"]\n y-axis \"deploys\"\n bar [3, 5]", "text": "Deploys per week, from the release notes in this space.", "model": "us.amazon.nova-pro-v1:0" } ``` | Field | Type | Description | | - | - | - | | `chart` | object or null | The chart spec described below. Null when the answer is a diagram, when the memories hold no numbers for the request, when the model's chart did not validate, or when the chart was too large to finish (then `text` asks for fewer categories or a shorter time range). | | `diagram` | string or null | Mermaid source for a process or structure answer, validated as described under [Diagrams](#diagrams). Null for a chart, and when the model's diagram was refused. Never set together with `chart`. | | `mermaid` | string or null | Copyable Mermaid source. For a chart, its Mermaid export (null for scatter charts and for a pie chart with a negative value). For a diagram, the same text as `diagram`. Null when neither was drawn. | | `text` | string | The model's prose, without the chart or diagram block. When both are null this is the explanation. | | `model` | string | The model that answered. | ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/release-notes/graph \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"request": "Deploys per week this month"}' ``` ```python Python theme={null} import httpx resp = httpx.post( "https://api.anonalabs.com/v1/spaces/release-notes/graph", headers={"Authorization": "Bearer anona_live_YOUR_KEY"}, json={"request": "Deploys per week this month"}, timeout=120, ) body = resp.json() if body["chart"] is None and body["diagram"] is None: print(body["text"]) else: print(body["mermaid"]) ``` ```typescript TypeScript theme={null} const resp = await fetch( "https://api.anonalabs.com/v1/spaces/release-notes/graph", { method: "POST", headers: { Authorization: "Bearer anona_live_YOUR_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ request: "Deploys per week this month" }), }, ); const body = await resp.json(); console.log(body.chart || body.diagram ? body.mermaid : body.text); ``` ## The chart spec | Field | Type | Description | | - | - | - | | `type` | string | One of `bar`, `line`, `area`, `pie`, `scatter`. | | `title` | string | Chart title. May be empty. | | `x` | string | The key in each row that holds the category or x value. Its values are strings or numbers. | | `y` | string\[] | The keys in each row that hold the plotted values, one series each. Every value is a finite number. A pie chart uses only the first. | | `data` | object\[] | The rows, 1 to 500. A row may carry keys beyond `x` and `y`; they are returned as they came. | | `unit` | string | Optional label for the value axis. | The same spec is what the dashboard's Ask tab draws for `/draw`, so a chart looks the same wherever it was asked for. ## Mermaid mapping | Chart type | Mermaid | | - | - | | `bar` | `xychart-beta` with one `bar` series per `y` key | | `line` | `xychart-beta` with one `line` series per `y` key | | `area` | `xychart-beta`, drawn as `line` series (Mermaid has no area series) | | `pie` | `pie`, one slice per row from the first `y` key. Null if any value is negative. | | `scatter` | None. Mermaid has no scatter chart, so `mermaid` is null. | The value axis is labelled with `unit`, or with the first `y` key when `unit` is absent or empty. An empty title leaves the title line out. Labels are quoted; a double quote inside one becomes a single quote, and runs of whitespace become one space, because Mermaid has no escape inside a quoted label. ## Diagrams A diagram is Mermaid source written by the model from this space's memories. Every node, step and label has to come from what the space holds; when the memories do not describe what you asked for, you get no diagram and a sentence saying so. The first line must name one of these diagram types, spelled exactly as Mermaid spells it: `flowchart`, `graph`, `sequenceDiagram`, `stateDiagram`, `stateDiagram-v2`, `mindmap`, `timeline`. A diagram is refused whole, never edited, when it (node shapes in mind maps and label quoting in flowcharts are normalised, so labels are neither clipped nor rejected for containing parentheses; nothing else is ever edited): * uses any other diagram type * is longer than 8,000 characters or 200 lines * contains a `%%{` init or directive block * has a line starting with `click` * contains `href`, `javascript:`, ` B[Verify email]\n B --> C[Account usable]", "mermaid": "flowchart TD\n A[Sign up] --> B[Verify email]\n B --> C[Account usable]", "text": "How signup works, from the onboarding notes in this space.", "model": "us.amazon.nova-pro-v1:0" } ``` ## What it costs A chart or diagram is billed as one chat turn on the model that drew it, to the organization making the request (on a shared space, that is you, not the space's owner). Nothing is written back into the space, so a read-only member of a space can ask for charts and diagrams too. ## Errors | Status | Code | Cause | | - | - | - | | 400 | `unsupported_model` | `model` is not one this deployment serves. | | 400 | `invalid_model` | The provider rejected a model this deployment serves. | | 401 | `missing_auth` / `invalid_api_key` | Missing or invalid credentials. | | 403 | `space_access_denied` | You are not a member of this space. | | 404 | `space_not_found` | The space does not exist. It is not created, and you are not charged. | | 422 | `validation_error` | The body fails validation, including unknown fields. | | 429 | `rate_limited` / `credits_exhausted` | Rate limit reached, or the credit allowance is spent. | | 503 | `llm_error` / `llm_unreachable` | The model provider failed. Safe to retry. | ## Next steps > [card] [Charts, diagrams and artifacts](/memory/guides/charts-diagrams-artifacts) > > Use `/draw` and `/artifact` in the dashboard. > [card] [Artifacts API](/memory/api-reference/artifacts) > > Build a shareable page about a space. # Errors Source: https://docs.anonalabs.com/memory/api-reference/errors The error envelope, the error codes the API raises, and how to retry safely. ## Error envelope Every endpoint fails in the same shape: ```json theme={null} { "error": { "code": "invalid_api_key", "message": "Invalid API key", "request_id": "req_abc123" } } ``` | Field | Description | | - | - | | `code` | Stable machine-readable identifier. Branch on this, not on `message`. | | `message` | Human-readable explanation. Wording may change. | | `request_id` | Identifies the exact request server-side. Include it when contacting support. | ### Validation errors When a request body fails schema validation, the `422 validation_error` carries one more field inside `error`: `errors`, an array with one entry per failing field. The example is a `POST /v1/retrieve` with no `query` and a `limit` of 500. `message` still describes only the first failure, formatted `field: message`, so a form can use `errors` to mark every bad field in one round trip. ```json theme={null} { "error": { "code": "validation_error", "message": "query: Field required", "errors": [ { "field": "query", "message": "Field required" }, { "field": "limit", "message": "Input should be less than or equal to 100" } ], "request_id": "req_abc123" } } ``` | Field | Description | | - | - | | `errors[].field` | Dotted path to the failing field, such as `items.0.content`. `request` when the failure is not tied to one field. | | `errors[].message` | What is wrong with it. Wording may change. | The rejected value itself is never echoed back, so a 422 cannot leak what was submitted. `errors` is present only on schema-validation failures. Other codes, and the few `validation_error` responses raised by checks made after the body has parsed, carry `code` and `message` alone, so read `errors` when it is there and fall back to `message`. ## Status and error codes The codes you will actually see, by status: | Status | Code | Meaning | Retryable | | - | - | - | - | | 400 | `org_mismatch` | The request names an organization (`X-Anona-Org` or `org_id`) other than the one its API key or session belongs to. Refused rather than served from the credential's organization. | No | | 400 | `invalid_org_id` | `X-Anona-Org` was sent more than once or is not a UUID, or an MCP tool call's `org_id` argument is not a UUID. A malformed `org_id` in a request body is `422 validation_error`. | No | | 401 | `missing_auth` | No `Authorization` header (and no `x-api-key`). | No | | 401 | `invalid_api_key` | The key is not one Anona issued, or was revoked. | No | | 401 | `key_expired` | The key was valid but passed its expiry date. Rotate it. | No | | 401 | `invalid_token` | The dashboard session token is invalid or expired. | No | | 401 | `session_required` | An API key or temporary-profile token was sent to a control-plane endpoint. Some of them also accept the `anona login --cli` token; see [which credential each route accepts](/memory/api-reference/authentication#which-credential-each-route-accepts). | No | | 403 | `space_access_denied` | You are not a member of this space. | No | | 403 | `space_read_only` | A read-only member attempted a write. | No | | 403 | `space_owner_only` | A non-owner attempted export, memory delete/patch, document delete, or space config. | No | | 403 | `temp_profile_single_space` | A temporary profile wrote to, or tried to create, a space other than its one space, `default`. Use `default` (or omit the space), or claim the profile to create more. | No | | 403 | `space_unavailable` | The owner of a shared space can't be reached: the owning organization has revoked its API keys and holds no live one, or was deleted. A share is served while the owner holds a live key, and also when it has never created a key or holds only expired ones. | No | | 403 | `not_an_org_member` | A signed-in (OAuth or CLI) caller named an organization (`org_id` / `X-Anona-Org`) they are not a member of, or one that was deleted. | No | | 403 | `integration_owner_only` | Installing or disconnecting a Slack or GitHub integration is owner-only, and binding, unbinding and backfill are owner or admin (`slack_admin_only` / `github_admin_only`), once shared organizations are enabled. Until then owners and admins install and disconnect (others get `slack_admin_only` / `github_admin_only`), and any member can bind, unbind and backfill. | No | | 403 | `insufficient_role` | Your role cannot perform this control-plane action. | No | | 403 | `org_suspended` | The organization is suspended. Also returned when an MCP or CLI caller selects a suspended organization. | No | | 403 | `no_api_key` | Your organization holds no live API key, so the request cannot be served. Reached by a session, MCP OAuth or CLI caller on a route that forwards a key to the memory engine itself; the usual cause is that every key was revoked. Create one. | No | | 404 | `space_not_found` | The space does not exist, or the id is misspelled. | No | | 400 | `memory_not_editable` | The memory is a note, synthesised from other memories, and cannot be edited directly. Edit the facts it was built from (its `source_ids`); the note is rebuilt from them. | No | | 404 | `memory_not_found` | The space exists but holds no memory with that id — a misspelled id, or one already deleted. Returned by memory delete, edit and history alike. | No | | 404 | `job_not_found` | The space holds no job with that id, on reading or cancelling a job. The id belongs to the space it was queued in. | No | | 409 | `space_ambiguous` | A bare space name matches both your own space and a shared one — use the `qualified_id`. | No | | 409 | `job_not_cancellable` | `DELETE /v1/spaces/{space_id}/jobs/{job_id}` on a job that cannot be stopped: it has already finished, or its parts are already running. The message says which. | No | | 422 | `validation_error` | The body fails schema validation. Unknown fields are rejected too, so a misspelled field name lands here rather than being silently dropped. | No | | 422 | `reserved_tag` | A tag starting with the reserved `anona:` prefix. | No | | 422 | `invalid_scope` | A `user_id`, `agent_id` or `session_id` that is empty, or is not 1-128 characters of letters, digits or `._@=+-` (no spaces, no colons). | No | | 422 | `reserved_metadata_key` | A `metadata` key starting with the reserved `anona_` prefix. | No | | 422 | `route_conflict` | `space_id` and `route` were both supplied, or neither was — or, on `/v1/retrieve`, `max_targets` was sent without `route`. Applies to `/v1/record`, `/v1/record/batch` and `/v1/retrieve`. | No | | 422 | `no_route_target` | `route: "auto"` had nowhere to go. On a write: no eligible space and no usable default. On a read: no space could be selected for a reason that is not an answer about your memories, and no `fallback_space_id` was sent. A routed read that simply fits no space is not this error, it is an empty `200`. See [Automatic space routing](/memory/guides/automatic-routing#when-no-space-fits). | No | | 429 | `rate_limited` | The per-minute rate ceiling, on metered endpoints. Clears on its own. | Yes, with backoff | | 429 | `credits_exhausted` | The plan allowance and any purchased top-up credits are both spent. Backoff will not help — upgrade, buy a credit top-up, or wait for the reset. | No | | 500 | `internal_error` | Unexpected server error. | Yes | | 4xx or 5xx | `memory_error` | The memory engine refused or failed a call and no more specific code applies — the engine's own status is passed through, so this code appears on several. It is the default engine failure on `/v1/record`, `/v1/retrieve` and the memory routes; some surfaces replace it with their own (see below). | Only on a `5xx` | | 503 | `service_unavailable` | Temporary degradation. | Yes, after a short delay | A handful of framework-level failures fall back to generic codes — `bad_request`, `unauthorized`, `forbidden`, `not_found`, `method_not_allowed`, `conflict`, `rate_limited`, `internal_error`, `service_unavailable`. These come from the web framework rather than from a route, so in practice you see them only for a request that reached no endpoint: a misspelled path is `404 not_found`, the wrong verb on a real path is `405 method_not_allowed`. **No documented endpoint returns `unauthorized` or `forbidden`** — every authentication and authorization refusal carries one of the specific codes above (`missing_auth`, `invalid_token`, `invalid_api_key`, `key_expired`, `no_api_key`, `space_access_denied`, and so on). Branch on the specific codes and treat the generic ones as "my request did not reach the API I meant". This table is the canonical list. Where another page in these docs shows an error, it shows a code from here; if you find one that does not, the other page is wrong and we want to hear about it. ### Endpoint-specific codes | Status | Code | Where | Meaning | | - | - | - | - | | 400 | `unsupported_model` | `/v1/reason`, proxy endpoints, `POST /v1/spaces/{id}/graph`, model settings | `model` is not one this deployment serves. The message suggests up to three close matches and points to `GET /v1/models` for the full list; see [model selection](/memory/api-reference/model-selection). | | 429 | `model_throttled` | `/v1/reason`, proxy endpoints | The chosen model is at capacity. Request limits are per model, so another model may have headroom. Anona never silently substitutes one. | | 503 | `model_unavailable` | `/v1/reason`, proxy endpoints | The model is catalogued but this deployment cannot invoke it. Ours to fix, not yours. | | 400 | `invalid_model` | `/v1/chat/completions`, `POST /v1/spaces/{id}/graph` | A model this deployment serves was rejected by the provider itself. Distinct from `unsupported_model`, which is refused before the call is made. | | 400 | `invalid_tunable` | proxy endpoints | An `X-Anona-*` tunable header could not be parsed. The message names the header and what it expected. | | 401 | `key_expired` | any authenticated endpoint | The key was valid but has passed its expiry date. Distinct from `invalid_api_key`: this key worked yesterday, so the fix is to rotate rather than to go looking for the right key. Expiry cannot be extended. | | 401 | `session_required` | every control-plane endpoint — exports, space members, API keys, org, usage, billing, invitations | The endpoint authenticates with a signed-in dashboard session, not an API key or temporary profile. The reads listed under [which credential each route accepts](/memory/api-reference/authentication#which-credential-each-route-accepts), plus export start and invitation accept/decline, also accept the `anona login --cli` token. The key you sent may be perfectly valid — it is the wrong kind of credential here. | | 429 | `credits_exhausted` | metered endpoints | You have used all your credits: the monthly plan allowance and any purchased top-up credits are both spent. Upgrade your plan or buy a credit top-up to continue. Distinct from `rate_limited`, which is a per-minute ceiling and clears on its own. | | 403 | `topup_requires_paid_plan` | `POST /v1/billing/topup` | Credit packs are for organizations on a paid plan. A free Developer organization has to [upgrade](/memory/api-reference/billing#change-plan) first. | | 503 | `space_list_unavailable` | `GET /v1/spaces` | Space access could not be determined. Retry; the list is never returned unfiltered. | | 503 | `reason_timeout` | `/v1/reason`, the MCP `reason` tool | The question took longer to answer over this space than the deadline allows. Not a crash and not your request being rejected: ask something more specific, or — on `depth: "thorough"` — retry at `depth: "fast"`, which the message names. | | 4xx or 5xx | `graph_error` | `/v1/spaces/{id}/graph`, `/entities`, `/entities/{entity_id}` | The engine refused or failed a graph call, with its status passed through. A missing entity id is `404 graph_error`, **not** `space_not_found` — that code means the space itself does not exist. | | 4xx or 5xx | `model_error` | `/v1/spaces/{id}/models`, `/profile` and their sub-routes | The engine refused or failed a memory-model call, with its status passed through. A memory model that does not exist is `404 model_error`; so is a missing space on the memory-model routes, while `GET /v1/spaces/{id}/profile` on a missing space is `404 space_not_found`. Unrelated to `unsupported_model` / `invalid_model`, which are about the LLM. | | 4xx or 5xx | `document_error` | `POST /v1/spaces/{id}/documents` and the document routes | The engine refused or failed a document call, with its status passed through. | | 4xx or 5xx | `webhook_error` | the webhook routes | The engine refused or failed a webhook call, with its status passed through. | | 503 | `write_status_unknown` | the synchronous write endpoints — `/v1/record`, `/v1/record/batch`, document upload | The write timed out against the engine. Unlike every other failure here it does **not** mean the write was rejected — it may have landed. Read the space back before retrying, or a retry can double-write. | | 503 | `delete_unavailable` | `DELETE /v1/spaces/{space_id}/memories/{memory_id}` | Deletion is briefly unavailable. The memory has **not** been deleted — the only case where this is returned is one where we cannot confirm a delete, and we will not report one we did not make. Retry. | Some endpoints add further codes. See [Documents](/memory/api-reference/documents#error-responses), [Webhooks](/memory/api-reference/webhooks#errors), [Organization](/memory/api-reference/organization#error-responses), [Context Receipts](/memory/api-reference/context-receipts#error-responses), and [Billing](/memory/api-reference/billing#error-responses). ## Common mistakes ```bash theme={null} # Correct Authorization: Bearer anona_live_YOUR_KEY # Also accepted on every data-plane endpoint (how the Anthropic SDK sends keys) x-api-key: anona_live_YOUR_KEY # Rejected: the Bearer scheme is missing Authorization: anona_live_YOUR_KEY ``` ## Retrying Retry `429`, `500` and `503` with backoff. Never retry a `4xx` other than `429`: the request will fail identically every time. And never blindly retry a write on a `5xx` — a `5xx` means the request may well have executed, so a re-sent `POST /v1/record` stores the memory twice. ### What the SDKs already retry Both SDKs retry on your behalf, but **not on every call**, and the two draw the line differently. Check this table before adding a loop of your own. | Call | Retried on `429`? | Retried on `5xx`? | Retried on a dropped connection or timeout? | | - | - | - | - | | `retrieve`, `get_context`, `retrieve_receipt` | both SDKs | **Python no**, TypeScript yes | Python no, TypeScript yes | | `record`, `record_batch` | both SDKs | neither | neither | | `reason`, `reason_receipt`, `ask_about_user` | both SDKs | neither | neither | | File uploads | both SDKs | neither | neither | | `create_space`, `create_webhook`, `create_rule`, `create_memory_model`, `refresh_memory_model` | both SDKs | neither | neither | | Every `GET`, `PUT` and `DELETE` — reads, settings, deletes | both SDKs | both SDKs | Python no, TypeScript yes | Three things explain the whole table: * **A `429` is always retried, writes included.** The limiter refuses the request before the route runs, so nothing executed and a re-send cannot double-write. `credits_exhausted` is the one exception — it rides a `429` but is a balance, not a window, so no backoff can clear it and neither SDK retries it. * **A `5xx` is only retried where a replay is safe.** The Python client decides by HTTP method: `GET`, `HEAD`, `OPTIONS`, `PUT` and `DELETE` are retried, and every `POST` is not. `retrieve` and `reason` are `POST` requests, so **Python does not retry them on a `5xx` even though they are reads** — the TypeScript client marks each create explicitly instead and so does retry `retrieve`. If you need a `5xx` retried on `retrieve` in Python, wrap that one call. * **A multipart upload is never retried.** The body is built from your own file objects and they are not rewound between attempts, so a replay would upload a truncated file. Uploads surface the error instead. ### A loop that is safe to put above them Only the first table row leaves a gap worth closing. Retry `AnonaError` on the statuses the SDK skipped, honour the wait the server named, and let everything else through untouched. ```python Python theme={null} import random, time from anona import AnonaError _RETRYABLE = {429, 500, 503} def retry_read(call, max_retries=3): """Retry a read the Python SDK leaves alone because it is a POST.""" for attempt in range(max_retries + 1): try: return call() except AnonaError as e: last = attempt == max_retries if last or e.status_code not in _RETRYABLE: raise # A balance, not a window: no wait clears it. if e.code == "credits_exhausted": raise # The rate-limit window is a whole minute, so the server's own # number is the only backoff that can succeed. wait = e.retry_after or 2**attempt + random.uniform(0, 1) time.sleep(wait) results = retry_read( lambda: client.retrieve(space_id="customer-support-bot", query="Where does Alice work?") ) ``` ```typescript TypeScript theme={null} import { AnonaError } from "@anona-labs/memory"; const RETRYABLE = new Set([429, 500, 503]); async function retryRead(call: () => Promise, maxRetries = 3): Promise { for (let attempt = 0; ; attempt++) { try { return await call(); } catch (err) { const retryable = err instanceof AnonaError && RETRYABLE.has(err.statusCode) && err.code !== "credits_exhausted"; if (!retryable || attempt === maxRetries) throw err; const waitMs = err.retryAfter ? err.retryAfter * 1000 : 2 ** attempt * 1000 + Math.random() * 1000; await new Promise((r) => setTimeout(r, waitMs)); } } } ``` Note what both versions do **not** do: they never catch a bare `Exception`, and they never retry a `4xx`. A loop that catches everything retries `validation_error` and `space_not_found` three times before reporting the same failure three times more slowly. Tuning the SDK's own retrying is usually enough, and it is one constructor argument: `max_retries` / `maxRetries` (default `2`, `0` turns retrying off), and in Python `retry_max_wait` (default `60.0` seconds) caps how long a single hinted wait may park the call. See the [Python SDK](/memory/sdk/python#client-initialization) and [TypeScript SDK](/memory/sdk/javascript) pages. ## Handling errors in the SDKs ```python Python theme={null} from anona import AnonaClient, AnonaError client = AnonaClient(api_key="anona_live_YOUR_KEY") try: results = client.retrieve(space_id="customer-support-bot", query="Where does Alice work?") except AnonaError as e: print(e.status_code, e.code, e.request_id) ``` ```typescript TypeScript theme={null} import { Anona, AnonaError } from "@anona-labs/memory"; const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); try { const results = await anona.retrieve({ spaceId: "customer-support-bot", query: "Where does Alice work?", }); } catch (err) { if (err instanceof AnonaError) { console.log(err.statusCode, err.code, err.requestId); } else { throw err; } } ``` The TypeScript class carries the same information under camelCase names (`statusCode`, `requestId`), plus `retryAfter` on a `429`. | Attribute | Type | Contents | | - | - | - | | `status_code` | `int` | The HTTP status. | | `code` | `str` or `None` | The stable machine-readable code from the envelope. Branch on this. | | `detail` | `dict` or `str` | The parsed error body, or the raw text when the response is not JSON. | | `request_id` | `str` or `None` | The `X-Request-ID` of the failing call. Quote it to support. | | `retry_after` | `int` or `None` | Seconds to wait, on a `429` that named a number. | The TypeScript client raises the same error with `statusCode`, `code`, `requestId`, `detail` and `retryAfter`. Both SDKs already retry `429` on every call and a `5xx` on the calls where a replay is safe — see [What the SDKs already retry](#what-the-sdks-already-retry) before adding a second loop, because for most calls there is nothing left to add. ## Next steps > [card] [API overview](/memory/api-reference/overview) > > Every endpoint in one table. > [card] [Support](mailto:support@anonalabs.com) > > Contact us with a `request_id`. # Extraction settings Source: https://docs.anonalabs.com/memory/api-reference/extraction-settings Teach a space your domain: what a write should keep, in your vocabulary, before it ever becomes a memory. Recording a memory is not storage. Your text goes through one pass that decides which facts are worth keeping long-term and how they are phrased, and only what survives that pass is ever stored. It is the highest-leverage step in the whole system: a detail the extractor drops is not merely ranked low later, it is not there at all, and no amount of query tuning brings it back. By default every space runs the same general-purpose rules. **Extraction settings** point those rules at your domain: the terms your team uses, the fields that always matter, the noise worth skipping. > [!NOTE] > > Settings apply to writes made **after** you save them. Memories already stored are never > re-extracted, so changing these is safe and never rewrites history. > [!TIP] > > Everything on this page is also on the **Extraction** tab of a space in the > [dashboard](https://memory.anonalabs.com). ## The knobs `guidance` is **added** to the standard rules and applies in every mode. Reach for it first; it is the one most spaces ever need. `custom_prompt` **replaces** those rules, and only applies while `mode` is `custom`. The memory format is untouched either way: dates, entities and the fields of a memory are read the same, so custom rules change what gets kept, never the shape of what comes back. `labels` is different in kind. The two above steer what a write *keeps*; labels ask the same pass to *classify* what it kept, along dimensions you define. A labelled memory can then be filtered on directly. See [Label taxonomy](#label-taxonomy). ## Read the settings ```http theme={null} GET /v1/spaces/{space_id}/extraction-settings Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/engineering/extraction-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} settings = client.get_extraction_settings("engineering") ``` ```typescript TypeScript theme={null} const settings = await anona.getExtractionSettings("engineering"); ``` **Response** `200 OK` ```json theme={null} { "space_id": "engineering", "mode": "concise", "guidance": "Engineering log. Always capture service names, metric values with units, and the named owner.", "custom_prompt": null, "labels": null, "free_form_entities": null } ``` Every field is nullable, and `null` means **unset**: that field follows the platform default and keeps following it. That is a different thing from setting it to whatever the default happens to be today. ## Update the settings ```http theme={null} PUT /v1/spaces/{space_id}/extraction-settings Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "mode": "concise", "guidance": "Engineering log. Always capture service names, metric values with units, and the named owner. Treat incidents as dated events. Skip standup small talk." } ``` | Field | Type | Default | Description | | - | - | - | - | | `mode` | `concise` \| `verbose` \| `verbatim` \| `custom` | `concise` | How much detail a memory keeps. See [modes](#modes). | | `guidance` | string | unset | Added to the standard rules, in every mode. Max 4,000 characters. | | `custom_prompt` | string | unset | Replaces the standard rules. Only applies when `mode` is `custom`. Max 8,000 characters. | | `labels` | array | unset | Dimensions to classify every memory along. Max 12 groups. Each group's `description` is instruction text the extractor reads, so it must describe the label and not how to extract. See [Label taxonomy](#label-taxonomy). | | `free_form_entities` | boolean | `true` | When `false`, only entities belonging to a label group are kept. Requires `labels`. | ```bash cURL theme={null} curl -X PUT https://api.anonalabs.com/v1/spaces/engineering/extraction-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "concise", "guidance": "Engineering log. Always capture service names, metric values with units, and the named owner." }' ``` ```python Python theme={null} settings = client.set_extraction_settings( space_id="engineering", mode="concise", guidance=( "Engineering log. Always capture service names, metric values with " "units, and the named owner." ), ) ``` ```typescript TypeScript theme={null} const settings = await anona.setExtractionSettings({ spaceId: "engineering", mode: "concise", guidance: "Engineering log. Always capture service names, metric values with units, " + "and the named owner.", }); ``` `PUT` replaces the whole record, so a field you leave out is **cleared**, not kept. The body always describes the state you want, not a patch on top of what is there. The SDK methods send the whole record too, so an argument you omit clears that field. **Response** `200 OK`. The stored settings, in the same shape as the `GET`. ## Reset to the defaults ```http theme={null} DELETE /v1/spaces/{space_id}/extraction-settings Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X DELETE https://api.anonalabs.com/v1/spaces/engineering/extraction-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} client.reset_extraction_settings("engineering") ``` ```typescript TypeScript theme={null} await anona.resetExtractionSettings("engineering"); ``` **Response** `204 No Content` The space goes back to the standard rules. Stored memories are unaffected. ## Modes | Mode | What a write becomes | | - | - | | `concise` | One tight fact per idea. The default, and the right choice for conversational memory. | | `verbose` | Every specific the text contains. Larger memories and a higher cost per write; useful when detail matters more than volume. | | `verbatim` | Your text stored exactly as written, with only the surrounding metadata derived. For content whose wording is the point: policies, clauses, incident notes. | | `custom` | The rules in `custom_prompt` instead of the standard ones. | ## Writing good guidance Extraction responds to specifics, not adjectives. "Be thorough" changes little; naming a field changes a lot. ```text Support theme={null} Customer support transcripts. Always capture the product area, the plan the customer is on, and any promised follow-up with its date. "P1" means severity, not priority. Skip greetings and pleasantries. ``` ```text Clinical theme={null} Clinical notes. Always capture medication names with dosage and frequency, and appointment dates. "PT" means physical therapy, never patient. Never infer a diagnosis that was not stated outright. ``` ```text Sales theme={null} Sales calls. Always capture the accounts and people named, budget or timeline commitments, and objections raised. Record who said a thing, not only that it was said. ``` Three habits that pay off: * **Name your vocabulary.** Ambiguous abbreviations are the single most common source of wrong extractions, and one sentence fixes them. * **List the fields that must never be dropped.** That is what turns a good guess into a reliable one. * **Say what to skip.** Excluding noise is as valuable as including signal, and it keeps memories smaller and recall sharper. Guidance rides along on every write, so keep it to the rules that genuinely matter rather than a full manual. ## Label taxonomy Guidance changes what a write keeps. A **label taxonomy** changes what a write is *filed under*: you name the dimensions that matter in your domain, and extraction classifies every memory along them at write time. Because the classification happens once, on the way in, filtering on it later costs nothing and cannot drift. A group with `tag: true` is the useful case. Its classification is written onto the memory as the tag `":"`, which means [`tag_groups` on retrieve](/memory/api-reference/retrieve#compound-tag-filters) can filter on it like any other tag. ### Group types | `type` | Shape | Use it for | | - | - | - | | `value` | one of `values` | A dimension with a fixed, short vocabulary: status, severity, stage. | | `multi-values` | any number of `values` | The same, where more than one can apply at once. | | `text` | one free string | A dimension whose vocabulary you cannot list in advance, where one answer is right. | | `multi-text` | any number of free strings | The same, where a memory legitimately has several: names, aliases, identifiers. | `value` and `multi-values` need their `values` listed. `text` and `multi-text` must not list any: they take whatever the memory itself supplies. ### Identity: the case `multi-text` exists for The most useful taxonomy is usually the simplest one. A single `name` group teaches extraction to record every surface form a thing is known by, so a later query can match the word your user actually typed rather than the word your docs happen to use. ```json Body theme={null} { "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." } ] } ``` ```python Python theme={null} client.set_extraction_settings( space_id="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." ), } ], ) ``` ```typescript TypeScript theme={null} await anona.setExtractionSettings({ spaceId: "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 memory about Kubernetes then carries `name:kubernetes`, `name:k8s` and `name:kube`, and all three retrieve it: ```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": "engineering", "query": "what did we decide about k8s", "tag_groups": [{ "tags": ["name:k8s"], "match": "any_strict" }] }' ``` ```python Python theme={null} results = client.retrieve( space_id="engineering", query="what did we decide about k8s", tag_groups=[{"tags": ["name:k8s"], "match": "any_strict"}], ) ``` ```typescript TypeScript theme={null} const results = await anona.retrieve({ spaceId: "engineering", query: "what did we decide about k8s", tagGroups: [{ tags: ["name:k8s"], match: "any_strict" }], }); ``` Naming the format in the description is what makes that exact match reliable, because tags are matched as exact strings. Tell extraction how to write a name and normalise your query candidates the same way. ### A classification dimension The other common shape is a small fixed vocabulary, which lets a query exclude a whole class of memory: ```json theme={null} { "labels": [ { "key": "state", "type": "value", "tag": true, "description": "Whether this is current guidance. Use 'historical' when the text says it was replaced or superseded.", "values": [ { "value": "current", "description": "Active guidance that should shape answers now" }, { "value": "historical", "description": "Superseded or previously used; record only" } ] } ] } ``` Retrieved with a `not`, that is "everything except what we have moved on from": ```json theme={null} { "tag_groups": [{ "not": { "tags": ["state:historical"], "match": "any_strict" } }] } ``` ### Reserved keys A key becomes the part of the tag before the colon, so it must be lowercase letters, digits, `-` or `_`, and it may not be `anona`. That namespace carries [user, agent and session scoping](/memory/guides/scoping-multi-tenant), and a label group is written by extraction rather than by you, so a group allowed to claim it could file a memory under another end user's scope. ### What gets refused Every one of these is a setting that would look saved and do nothing, which is worse than an error: | Refused | Why | | - | - | | `value` or `multi-values` with no `values` | Extraction never asks for a dimension with no vocabulary, so the group classifies nothing. | | `text` or `multi-text` with `values` | These types have no fixed vocabulary, so the list would be ignored. | | Two groups with the same `key` | They would compile to one dimension, and claim one tag namespace. | | `key` of `anona`, or with uppercase, spaces or punctuation | See [Reserved keys](#reserved-keys). Tags match exactly, so `Name` and `name` would be two filters for one group. | | `free_form_entities: false` with no `labels` | It keeps only entities belonging to a label group, so with none defined it would keep none at all. | | A taxonomy over 24,000 bytes, or over 12 groups | It is sent to the extractor on every write, so its size is a cost on each one. | | `custom_prompt` with `mode` anything but `custom`, or unset | The extractor reads the prompt only in custom mode, so it would read back as saved and never be used. Set `mode` to `custom`, or leave the prompt out. | ### What a `description` is, and what it must not be A group's `description` is instruction text. It is rendered into the extraction prompt on every write and the model reads it, so it is the single most powerful field here and the easiest one to write badly. Describe **the label**: what the dimension means, what its values mean, and how to write a value. Do not describe **extraction**: what counts as a memory, when to split one fact into two, what to leave out. The model cannot tell whose job it is reading about, and a description phrased as extraction policy quietly changes what gets stored. This is not a theoretical risk. One `name` group whose description said "those are separate memories with their own names" and "a name must pick this memory out on its own" took the same 35 document corpus from 52 memories to 10, and every job still reported `completed` with no error. The same description, rewritten to talk only about names, stored 48. > [!WARNING] > > Extraction cannot fail loudly here. A description that steers the extractor produces fewer > memories, not an error, and the job that wrote them says `completed`. Compare > `items_submitted` against `memory_count` on the job (see > [Async jobs](/memory/api-reference/memories)) after a taxonomy change: a large gap between the two > is the signal, and a taxonomy is the first thing to suspect. Keep descriptions short for the same reason you keep `guidance` short: the whole taxonomy is sent on every write, so its length is a cost on each one. Keep them to what changes a decision. Labels apply to writes made after you save them. Memories already stored keep whatever they were written with, so switching a taxonomy on does not retag your history. ## Verifying a change There is no error when guidance is unhelpful; extraction simply keeps different things. The quickest way to see the effect: 1. Save the settings. 2. Record a representative piece of text with [`POST /v1/record`](/memory/api-reference/memories). 3. Read the memory back with [`GET /v1/spaces/{space_id}/memories`](/memory/api-reference/memories) and check the facts are the ones you wanted. With a label taxonomy there is something concrete to check: the memory you read back carries a `tags` entry of `":"` for every group you marked `tag: true`. If the tags are absent, extraction did not find the dimension in the text, and the group's `description` is what to change. Adjust and repeat. Because nothing is re-extracted, older memories keep whatever they were stored with. ## Permissions Extraction settings decide what every member's writes turn into, so reading and changing them is limited to the space's **owner**. A member of a shared space gets `403 space_owner_only`. These routes are not metered, so an org that has run out of credits can still change or turn off its settings. ## Errors | Code | Status | Meaning | | - | - | - | | `space_not_found` | 404 | No space with that id. | | `space_owner_only` | 403 | Only the owning org can read or change extraction settings. | | `validation_error` | 422 | An unknown `mode`, an unknown field name, text past a length limit, a `custom_prompt` outside custom mode, or a label group that would not do anything. See [what gets refused](#what-gets-refused). | # Feedback Source: https://docs.anonalabs.com/memory/api-reference/feedback Send us a bug report or an idea from your own code, without leaving it. `POST /v1/feedback` files a report straight into our inbox. It is the same endpoint behind the **Send feedback** button in the dashboard, and it accepts an API key as well as a dashboard session, so an agent or a script can report the thing it just hit. Feedback costs no credits. An organization that has run out is exactly the one most likely to have something to tell us. ## Send feedback ```http theme={null} POST /v1/feedback Authorization: Bearer anona_live_... ``` ```json Request theme={null} { "category": "bug", "message": "Retrieve returns nothing for a document I uploaded 10 minutes ago.", "page": "/dashboard/spaces/notes" } ``` ```json Response theme={null} { "status": "received" } ``` ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/feedback \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"category": "bug", "message": "Retrieve missed a fresh upload."}' ``` ```python Python theme={null} import httpx httpx.post( "https://api.anonalabs.com/v1/feedback", headers={"Authorization": f"Bearer {ANONA_API_KEY}"}, json={"category": "bug", "message": "Retrieve missed a fresh upload."}, ) ``` ```typescript TypeScript theme={null} await fetch("https://api.anonalabs.com/v1/feedback", { method: "POST", headers: { Authorization: `Bearer ${ANONA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ category: "bug", message: "Retrieve missed a fresh upload.", }), }); ``` Neither memory SDK wraps this endpoint — it is a plain HTTP call from any language. | Field | Type | Notes | | - | - | - | | `category` | `"bug"` · `"idea"` · `"other"` | Optional, defaults to `"other"`. | | `message` | string | Required. 1 to 4000 characters, and not only whitespace. | | `page` | string | Optional, up to 200 characters. Where you were when it happened. Send a path, not a full URL. | Any other field is rejected with `422`, including `email`. We never take the sender from the body: the address we reply to is read from your account, so a report always comes from someone we can actually answer. On an API key that is whoever created the key. The response is `202`, not `200`. The mail is dispatched in the background, so a `200` would be claiming a delivery we have not yet watched happen. ## Limits Ten reports an hour per member. Past that the endpoint answers `429` with `too_many_attempts`. A missing or invalid credential is `401`. ## What we do with it It goes to our support inbox and nowhere else. Nothing is echoed back to you, and nothing is stored as a memory in any of your spaces. # Graph Source: https://docs.anonalabs.com/memory/api-reference/graph Inspect the entity graph a space has built: the people, places, organizations and concepts in its memories, and which appear together. Nodes are entities: people, places, organizations, and concepts. An edge means two entities were mentioned together in the same memory, weighted by how often they co-occur. Use the graph to visualize what the memory layer knows, or to verify extraction accuracy. > [!TIP] > > Looking for charts and diagrams drawn from your memories? That is [Draw](/memory/api-reference/draw). > [!NOTE] > > Edges are co-occurrence relationships, not typed or labeled predicates. They tell you > that two entities are connected and how strongly, not the specific relationship between > them. ## Get the graph ```http theme={null} GET /v1/spaces/{space_id}/graph?limit=500&min_count=1 Authorization: Bearer anona_live_YOUR_KEY ``` | Query parameter | Type | Default | Description | | - | - | - | - | | `limit` | integer | 500 | Maximum entities (nodes), 1 to 2000. | | `min_count` | integer | 1 | Only include entities mentioned at least this many times. | **Response** `200 OK` ```json theme={null} { "nodes": [ { "id": "e_a1b2", "label": "Alice", "mention_count": 12 }, { "id": "e_c3d4", "label": "Google", "mention_count": 8 } ], "edges": [ { "source": "e_a1b2", "target": "e_c3d4", "source_label": "Alice", "target_label": "Google", "weight": 5, "last_co_occurred": "2026-07-14T12:34:56Z" } ], "total_entities": 2, "total_edges": 1 } ``` | Field | Type | Description | | - | - | - | | `nodes[].id` | string | Stable entity id. Pass it to the entity detail endpoint. | | `nodes[].label` | string | Entity name. | | `nodes[].mention_count` | integer | How many memories mention this entity. | | `edges[].source` / `edges[].target` | string | Entity ids at each end. | | `edges[].source_label` / `edges[].target_label` | string | Entity names at each end, so edges are readable without joining back to nodes. | | `edges[].weight` | integer | How many memories the pair co-occurs in. | | `edges[].last_co_occurred` | string or null | ISO timestamp of the most recent shared memory. | ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/graph?limit=500&min_count=1" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} graph = client.get_graph(space_id="customer-support-bot") for edge in graph["edges"]: print(edge["source_label"], "->", edge["target_label"], edge["weight"]) ``` ```typescript TypeScript theme={null} const graph = await anona.getGraph({ spaceId: "customer-support-bot" }); for (const edge of graph.edges) { console.log(edge.source_label, "->", edge.target_label, edge.weight); } ``` ## List entities ```http theme={null} GET /v1/spaces/{space_id}/entities?limit=100&offset=0 Authorization: Bearer anona_live_YOUR_KEY ``` **Response** `200 OK` ```json theme={null} { "items": [ { "id": "e_a1b2", "name": "Alice", "mention_count": 12, "first_seen": "2026-01-15T10:30:00Z", "last_seen": "2026-07-14T12:34:56Z" } ], "total": 42, "limit": 100, "offset": 0 } ``` ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/entities?limit=100&offset=0" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} entities = client.list_entities(space_id="customer-support-bot") ``` ```typescript TypeScript theme={null} const entities = await anona.listEntities({ spaceId: "customer-support-bot" }); ``` ## Get an entity Returns one entity plus its observations: the things the memory layer has learned about it. This is the accuracy-verification view, showing exactly what was extracted and attributed to each entity. ```http theme={null} GET /v1/spaces/{space_id}/entities/{entity_id} Authorization: Bearer anona_live_YOUR_KEY ``` **Response** `200 OK` ```json theme={null} { "id": "e_a1b2", "name": "Alice", "mention_count": 12, "first_seen": "2026-01-15T10:30:00Z", "last_seen": "2026-07-14T12:34:56Z", "observations": [ { "text": "Alice works at Google", "mentioned_at": "2026-01-15T10:30:00Z" }, { "text": "Alice leads the search team", "mentioned_at": "2026-06-02T09:00:00Z" } ] } ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot/entities/e_a1b2 \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} entity = client.get_entity(space_id="customer-support-bot", entity_id="e_a1b2") for obs in entity["observations"]: print(obs["text"]) ``` ```typescript TypeScript theme={null} const entity = await anona.getEntity({ spaceId: "customer-support-bot", entityId: "e_a1b2", }); for (const obs of entity.observations) { console.log(obs.text); } ``` ## How the graph is built Every time you record a memory, the entities it mentions are extracted. Two entities appearing in the same memory get an edge between them, and the more memories they share, the heavier that edge. The graph is computed across the entire space, not only recent memories. ## Error responses | Status | Code | Cause | | - | - | - | | 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, or the id is misspelled. | | 404 | `graph_error` | The space exists but holds no entity with that id. This surface reports every engine-side failure as `graph_error` with the engine's own status, so the same code also appears on other `4xx` and `5xx` statuses. | | 429 | `rate_limited` | Credit quota or rate limit exceeded. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Retrieve API](/memory/api-reference/retrieve) > > Query memories semantically. > [card] [Reason API](/memory/api-reference/reason) > > Synthesize an answer across memories. # Memories Source: https://docs.anonalabs.com/memory/api-reference/memories Store memories synchronously or in the background, bulk-ingest history, and list, inspect, or delete what a space holds. ## Store a memory ```http theme={null} POST /v1/record Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "space_id": "customer-support-bot", "content": "Alice works at Google and specializes in distributed systems", "context": "onboarding call", "user_id": "alice_123", "tags": ["work", "expertise"], "metadata": { "source": "crm" } } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `space_id` | string | Yes\* | Which space to store in. Required unless you send `route` instead. | | `content` | string | Yes | The memory text. | | `org_id` | string (UUID) | No | The organization this request is meant for. Optional: an API key belongs to one organization, and naming a different one is refused with `400 org_mismatch` rather than served from the key's. Same as the `X-Anona-Org` header; send one or the other, and see [Choosing an organization](/memory/api-reference/organization#choosing-an-organization-per-request). | | `route` | `"auto"` | Yes\* | Let Anona choose the space. Mutually exclusive with `space_id`; exactly one of the two is required. See [Let Anona choose the space](#let-anona-choose-the-space). | | `fallback_space_id` | string | No | With `route`, where the memory goes when nothing fits. Defaults to the organization's default space. | | `max_targets` | integer | No | With `route`, how many spaces the memory may be written to. Only `1` is accepted. Defaults to `1`. | | `context` | string | No | Freeform context for the memory. | | `timestamp` | string | No | ISO 8601 **event** time: when the thing happened, not when you are recording it. Set it when importing history. Returned as `timestamp` on retrieve, used in recency ranking, and what [`occurred_after` / `occurred_before`](/memory/api-reference/retrieve#filtering-on-event-time) match against. It is not copied into `occurred_start`, which is read from the memory's own text. Defaults to now. See [Searching over time](/memory/api-reference/retrieve#searching-over-time). | | `user_id` | string | No | End user this memory belongs to. Only a search carrying the same `user_id` returns it. See [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). | | `agent_id` | string | No | Agent that produced or owns this memory. | | `session_id` | string | No | Conversation or run this memory came from. | | `tags` | string\[] | No | Visibility-scope tags; `retrieve` can filter on them. Tags starting with `anona:` are reserved. | | `metadata` | object | No | Custom key-value data, returned on retrieval. Top-level keys starting with `anona_` are reserved and rejected with `422 reserved_metadata_key`. | | `async` | boolean | No | When `true`, queues the write and returns a `job_id` instead of waiting. Defaults to `false`. | ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "customer-support-bot", "content": "Alice works at Google and specializes in distributed systems", "context": "onboarding call", "user_id": "alice_123", "tags": ["work", "expertise"], "metadata": { "source": "crm" } }' ``` ```python Python theme={null} result = client.record( space_id="customer-support-bot", content="Alice works at Google and specializes in distributed systems", context="onboarding call", user_id="alice_123", tags=["work", "expertise"], metadata={"source": "crm"}, ) print(result["memory_id"]) ``` ```typescript TypeScript theme={null} const result = await anona.record({ spaceId: "customer-support-bot", content: "Alice works at Google and specializes in distributed systems", context: "onboarding call", userId: "alice_123", tags: ["work", "expertise"], metadata: { source: "crm" }, }); console.log(result.memory_id); ``` > [!NOTE] > > `user_id` is a real field, not metadata. Putting a user id in `metadata` stores > it but does **not** isolate anything: `metadata` is returned with results, never > filtered on. **Response** `201 Created` ```json theme={null} { "memory_id": "mem_abc123def456", "status": "stored", "usage": { "input_tokens": 42, "output_tokens": 12 } } ``` | `status` | Meaning | `memory_id` | | - | - | - | | `stored` | Facts were extracted and are searchable now. | The first memory created; `memory_ids` carries all of them. | | `nothing_extracted` | The write was accepted, the extraction ran, and it produced no facts. **Nothing is stored and there is nothing to retrieve.** `message` says what to do. | `null` | | `processing` | Async only. Queued; poll the job for the outcome. | `null` — not known yet. | > [!WARNING] > > **A `201` does not mean something was stored.** One item usually extracts to > several facts, but it can extract to none, and the write is still accepted > because the extraction itself did not fail — there was simply nothing durable in > the content. Measured against production, roughly **44% of short, context-free > statements** land there. > > Check `status`, not the HTTP code: > > ```json theme={null} > { > "memory_id": null, > "status": "nothing_extracted", > "message": "No facts could be extracted from that content, so nothing was stored in space 'customer-support-bot'. Short, context-free statements often extract to nothing — re-record it with a sentence of context (what the thing is, or why it matters), which usually fixes it.", > "usage": { "input_tokens": 9, "output_tokens": 4 } > } > ``` > > The fix is context, and the effect is large: `"Falcon retry count is 3."` > survived about 5 times in 9, while `"Dunlin, the webhook sender, has a > connection pool of 25."` survived 10 out of 10. Name what the thing is, or why > it matters, in the same sentence. > > This is charged either way — the extraction model ran. `message` is `null` on > every successful write, so a caller may surface it whenever it is present. > [!WARNING] > > **A synchronous write is slow, and async is several times faster.** A memory is > analyzed by a language model and its facts extracted *before* the call returns, so > `record` waits for that work. Measured against production with short memories: > > | Call | Time | > | - | - | > | `record` | **about 3-12 seconds** | > | `record` with `"async": true` | **1-3 seconds** | > > How long a synchronous write takes depends mostly on how much you send: longer content > means more for the model to extract, and very long content is extracted in several > passes, so expect the top of that range or beyond for anything past a few paragraphs. > > Anything a person is waiting on (a chat turn, a form submit, a page transition) > should use async mode below. Reach for a synchronous write only when the very next > thing you do is search for what you just stored. ## Store a memory asynchronously Set `"async": true` and the call returns in about a second with a `job_id` and `status: "processing"` in place of the stored `memory_id`. The memory is extracted and indexed in the background; poll the [job](#job-status) to learn when it is searchable. **This is the recommended way to write.** The extraction work is identical and costs the same; you simply stop waiting for it. The one thing that changes is timing: the memory is not searchable the instant the call returns, so a flow that writes and then immediately retrieves needs to wait for the job (or use a synchronous write). ```json Request theme={null} { "space_id": "customer-support-bot", "content": "Alice works at Google and specializes in distributed systems", "async": true } ``` ```json Response theme={null} { "memory_id": null, "job_id": "job_9f2a1c", "status": "processing" } ``` ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "customer-support-bot", "content": "Alice works at Google and specializes in distributed systems", "async": true }' ``` ```python Python theme={null} job = client.record( space_id="customer-support-bot", content="Alice works at Google and specializes in distributed systems", background=True, ) print(job["job_id"]) # poll this ``` ```typescript TypeScript theme={null} const job = await anona.record({ spaceId: "customer-support-bot", content: "Alice works at Google and specializes in distributed systems", background: true, }); console.log(job.job_id); // poll this ``` The SDKs spell the flag `background`; on the wire it is `async`. > [!TIP] > > Pair async writes with [webhooks](/memory/api-reference/webhooks) and you can drop polling > entirely: Anona posts to your endpoint when the memory is stored. ## Let Anona choose the space Send `route: "auto"` in place of `space_id` and Anona files the memory into the space it belongs in. Exactly one of the two must be present: sending both, or neither, is `422 route_conflict`. ```json Request theme={null} { "route": "auto", "content": "The Q3 launch slipped to October because of the billing migration." } ``` ```json Response theme={null} { "memory_id": "mem_abc123", "memory_ids": ["mem_abc123"], "status": "stored", "routed_to": [ { "space_id": "product", "confidence": 0.94, "stage": "model", "reason": null } ] } ``` `routed_to` is present only when `route` was used, and is `null` on a write that named its space. Each entry reports where the memory went and how that was decided. > [!NOTE] > > A `reason` of `router_disabled` means automatic routing is not enabled on the > deployment you are calling: `route: "auto"` is accepted and the write succeeds, > but the memory goes to your fallback space. The > [routing guide](/memory/guides/automatic-routing) lists every `reason` value and what > each one means. | Field | Type | Description | | - | - | - | | `space_id` | string | The space written to. A space shared by another organization appears in its qualified `owner-slug:space-name` form. | | `confidence` | number or null | The decision model's confidence. `null` on the `rule` and `fallback` stages, which do not produce one. | | `stage` | string | `rule`, `model` or `fallback`. There is no fourth value. | | `reason` | string or null | Why. A rule match names the rule; a fallback names why nothing claimed the memory. | | `stage` | Meaning | | - | - | | `rule` | A routing rule you declared matched. No model was called. | | `model` | The decision model picked this space, above the confidence threshold. | | `fallback` | Nothing claimed the memory, so it went to `fallback_space_id`, or to the organization's default space, or to a space named `default`. A memory filed this way is stored with `"anona_routed": "fallback"` in its `metadata`. | `max_targets` accepts only `1` today. `2` or `3` is refused with `422 validation_error` rather than accepted and quietly capped. > [!NOTE] > > **The same field means something wider on a read.** A memory is filed in one > space, so `max_targets` on a write is `1`. A *question* can legitimately be > answered out of several, so on > [`POST /v1/retrieve`](/memory/api-reference/retrieve#searching-more-than-one-space) the > same field accepts 1 to 20. | Status | Code | Meaning | | - | - | - | | 422 | `route_conflict` | Both `space_id` and `route` were sent, or neither was. | | 422 | `no_route_target` | There is nowhere to put the memory: no `fallback_space_id`, no organization default space and no space named `default`. | | 422 | `reserved_metadata_key` | A top-level `metadata` key begins with the reserved `anona_` prefix. | Error codes are lowercase snake\_case and arrive in the standard `{"error": {"code", "message"}}` envelope. Which spaces can be chosen, how to give a space a description, and the rules that answer before any model does are covered in [Automatic space routing](/memory/guides/automatic-routing). Content sent to the third-party decision model is disclosed in [Subprocessors](/memory/legal/subprocessors). A search can be routed the same way: [`POST /v1/retrieve`](/memory/api-reference/retrieve#let-anona-choose-the-space) takes `route: "auto"` in place of `space_id` and answers with `searched`. It behaves differently in one respect, so read that section rather than assuming symmetry: a routed read that fits no space returns no results rather than falling back to your default space, because on a read the default space is the least likely place to hold a specific answer. ## Bulk ingest Queue many memories in a single call, which is the efficient way to backfill history. Bulk ingest is always asynchronous: the batch is accepted and a `job_id` returned immediately. ```http theme={null} POST /v1/record/batch Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "space_id": "customer-support-bot", "items": [ { "content": "Alice works at Google." }, { "content": "Bob prefers async standups.", "context": "team sync" }, { "content": "The Q3 launch slipped to October.", "timestamp": "2026-06-01T00:00:00Z" } ] } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `space_id` | string | Yes\* | Which space to store in. Required unless you send `route` instead. | | `org_id` | string (UUID) | No | The organization this request is meant for. Optional: an API key belongs to one organization, and naming a different one is refused with `400 org_mismatch` rather than served from the key's. Same as the `X-Anona-Org` header; send one or the other, and see [Choosing an organization](/memory/api-reference/organization#choosing-an-organization-per-request). | | `route` | `"auto"` | Yes\* | Route the batch automatically. The whole batch is filed together as one unit; items cannot be routed individually. Mutually exclusive with `space_id`. | | `fallback_space_id` | string | No | With `route`, where the batch goes when nothing fits. | | `max_targets` | integer | No | With `route`, only `1` is accepted. Defaults to `1`. | | `items` | array | Yes | 1 to 100 memory objects. Each takes the same fields as `record`: `content` is required; `context`, `timestamp`, `metadata`, `tags`, and the scope keys `user_id` / `agent_id` / `session_id` are optional. | | `user_id` / `agent_id` / `session_id` | string | No | Scope applied to **every** item — a batch is usually one user's history. An item's own scope keys override these. | ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record/batch \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "customer-support-bot", "items": [ { "content": "Alice works at Google." }, { "content": "Bob prefers async standups.", "context": "team sync" } ] }' ``` ```python Python theme={null} job = client.record_batch( space_id="customer-support-bot", items=[ {"content": "Alice works at Google."}, {"content": "Bob prefers async standups.", "context": "team sync"}, ], ) print(job["job_id"], job["accepted"]) ``` ```typescript TypeScript theme={null} const job = await anona.recordBatch({ spaceId: "customer-support-bot", items: [ { content: "Alice works at Google." }, { content: "Bob prefers async standups.", context: "team sync" }, ], }); console.log(job.job_id, job.accepted); ``` **Response** `202 Accepted` ```json theme={null} { "job_id": "job_batch_71b3", "job_ids": ["job_batch_71b3"], "status": "processing", "accepted": 3 } ``` `accepted` is the number of items queued. Poll `job_id` for progress. A large batch may be split into several operations, all listed under `job_ids`. > [!NOTE] > > A routed batch returns these same four fields and nothing more: there is no > `routed_to` on a batch response. The routing decision is made from the content > of up to the first 20 items. If you need to know which space a batch landed in, > route a single `record` call instead, or set the space explicitly. > [!NOTE] > > To send more than 100 items, chunk them into multiple calls. Each item is charged a base > cost when queued and reconciled to its real cost once processing finishes, exactly as a > synchronous write is, so queuing does not make ingestion cheaper. ## Job status Checks a job returned by an async `record` or by `record/batch`. This read is free and consumes no credits. ```http theme={null} GET /v1/spaces/{space_id}/jobs/{job_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```json theme={null} { "job_id": "job_9f2a1c", "status": "completed", "created_at": "2026-07-20T10:00:00Z", "completed_at": "2026-07-20T10:00:03Z", "error": null, "memory_count": 2, "items_submitted": 2, "zero_yield_items": 0, "memory_ids": ["mem_...", "mem_..."], "progress": null, "retry_count": 0, "next_retry_at": null } ``` `memory_count` and `memory_ids` are set once the job completes — they are how you address the memories an async write produced. While a batch is running, `progress` carries `{stage, processed, total, parts_done, parts_total, memories_stored, updated_at}`; `processed`/`total` count chunks in the parts that have **started**, so `parts_done` / `parts_total` and `status` are the completion signals, not `processed == total`. `items_submitted` is how many content items the job was given, so `memory_count` can be read against something. One number alone says nothing: 15 memories is a good outcome for 12 items and a destroyed corpus for 35. It is `null` for a job that has no item count, such as a single `record` or a document upload. `zero_yield_items` is how many of those items produced no memory at all. Extraction retries a chunk once if it comes back with nothing before accepting the zero, so this is not ordinary model flakiness — it is content that came back empty twice. It is `0`, not `null`, once measured; distinguish the two before treating a missing field as "nothing was lost." Unlike the ratio above, a non-zero `zero_yield_items` is worth re-submitting that specific content, since nothing was written for it at all. `retry_count` and `next_retry_at` explain a job that is still `pending`. `progress` describes a job that is *running*; these describe one that has not started, which otherwise looks the same whether it is queued behind other work, parked until a later time, or has already failed and been requeued: | `retry_count` | `next_retry_at` | What it means | | - | - | - | | `0` | `null` | Queued, awaiting pickup — never attempted | | `0` | a future time | Parked on purpose until then — not stuck, and not a failure | | `> 0` | a future time | Failed at least once, backing off before the next attempt | | `> 0` | `null` or a past time | Failed before, due for another attempt now | Read `retry_count: 0` with `next_retry_at: null` against `created_at`: a job that has been waiting far longer than your other jobs take, with no attempts burned and no parked time set, is the one worth raising with support. A job with a future `next_retry_at` is working as intended and will resume on its own. > [!NOTE] > > A long-`pending` job is never auto-failed on a deadline. Ingestion is paced by the > extraction model and a large batch legitimately queues for tens of minutes, so a timeout > would report healthy work as data loss. These two fields are reported so you can tell > the cases apart; the verdict is yours. To stop a job you no longer want, `DELETE` it > (below) — that is free. > [!WARNING] > > A job that produced far fewer memories than it was given items still reports `completed` > with `error: null`, and that is deliberate: the memories it did write are real, and a > batch of near-identical items legitimately yields fewer memories than items. Nothing is > wrong with the write path. What changes this ratio is usually > [extraction settings](/memory/api-reference/extraction-settings): `guidance`, `custom_prompt`, > or a label `description` phrased as extraction policy. Compare the two numbers after any > change to them. | `status` | Meaning | Terminal | | - | - | - | | `pending` | Queued, not started yet. | No | | `processing` | Being extracted and stored. | No | | `completed` | Stored and searchable. | Yes | | `failed` | Processing failed. See `error`. | Yes | | `cancelled` | The job was cancelled. | Yes | | `not_found` | No such job in this space. | Yes | A `404 job_not_found` is returned when the `job_id` does not belong to the space. ### Poll until complete ```bash cURL theme={null} KEY="anona_live_YOUR_KEY" SPACE="customer-support-bot" # 1. Queue the write, capture the job_id JOB=$(curl -s https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"space_id":"'"$SPACE"'","content":"Alice works at Google.","async":true}' \ | jq -r .job_id) # 2. Poll every 2s until it stops processing while true; do STATUS=$(curl -s "https://api.anonalabs.com/v1/spaces/$SPACE/jobs/$JOB" \ -H "Authorization: Bearer $KEY" | jq -r .status) echo "status: $STATUS" case "$STATUS" in completed|failed|cancelled|not_found) break ;; esac sleep 2 done ``` ```python Python SDK theme={null} import time # 1. Queue the write (background mode returns a job, not a stored memory) job = client.record( space_id="customer-support-bot", content="Alice works at Google.", background=True, ) job_id = job["job_id"] # 2. Poll until the job reaches a terminal state while True: status = client.get_job(space_id="customer-support-bot", job_id=job_id) print("status:", status["status"]) if status["status"] in ("completed", "failed", "cancelled", "not_found"): break time.sleep(2) if status["status"] == "failed": raise RuntimeError(status["error"]) ``` ```typescript TypeScript theme={null} const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // 1. Queue the write (background mode returns a job, not a stored memory) const job = await anona.record({ spaceId: "customer-support-bot", content: "Alice works at Google.", background: true, }); // 2. Poll until the job reaches a terminal state let status; for (;;) { status = await anona.getJob({ spaceId: "customer-support-bot", jobId: job.job_id!, }); console.log("status:", status.status); if (["completed", "failed", "cancelled", "not_found"].includes(status.status)) break; await sleep(2000); } if (status.status === "failed") throw new Error(status.error ?? "job failed"); ``` > [!NOTE] > > A `record/batch` job is polled exactly the same way. `completed` means every item in the > batch has been stored and is searchable. ## Cancel a job Stops an ingestion job that has not finished. Cancelling is free, consumes no credits, and is never refused for an empty balance: a runaway import must always be stoppable. ```http theme={null} DELETE /v1/spaces/{space_id}/jobs/{job_id} Authorization: Bearer anona_live_YOUR_KEY ``` **Response** `200 OK` ```json theme={null} { "job_id": "job_batch_71b3", "status": "cancelled", "cancelled_parts": 4, "running_parts": 1 } ``` A job is split into parts that run independently, and only a part still queued can be stopped. A part already being processed runs to completion. `cancelled_parts` is what this call stopped; `running_parts` is what it could not, so poll the job afterwards to see those finish. | Response | When | | - | - | | `200` | At least one queued part was stopped, or the job was already cancelled. | | `403 space_read_only` | The caller has read-only access to the space. | | `404 job_not_found` | No job with that id in this space. | | `409 job_not_cancellable` | Every part had already started or finished. Nothing was stopped. | > [!NOTE] > > Cancelling does not undo work already done. Memories written by parts that ran stay > stored and searchable, and the base cost charged when the job was queued is not > refunded — but the parts that never run are never charged for the extraction they would > have done. ```bash cURL theme={null} curl -X DELETE "https://api.anonalabs.com/v1/spaces/$SPACE/jobs/$JOB" \ -H "Authorization: Bearer $KEY" ``` ```python Python theme={null} result = client.cancel_job(space_id="customer-support-bot", job_id="job_batch_71b3") print(result["cancelled_parts"], result["running_parts"]) ``` ```typescript TypeScript theme={null} const result = await anona.cancelJob({ spaceId: "customer-support-bot", jobId: "job_batch_71b3", }); console.log(result.cancelled_parts, result.running_parts); ``` ## List memories in a space ```http theme={null} GET /v1/spaces/{space_id}/memories?limit=10&offset=0&q=Alice&type=fact Authorization: Bearer anona_live_YOUR_KEY ``` | Query parameter | Type | Description | | - | - | - | | `limit` | integer | Maximum results to return. Default 50, maximum 200. | | `offset` | integer | Pagination offset. | | `q` | string | Optional text filter. | | `type` | string | Optional memory type filter: `fact`, `note`, `experience`, or `summary`, the same labels the response returns. Any other value is `422 validation_error`. | | `state` | string | `active` (default) lists live memories; `invalidated` lists retired ones kept for audit. | | `prefer_observations` | boolean | Defaults to `true`: list one row per piece of knowledge. Set `false` to also list the raw facts a `note` was synthesized from. Ignored, and the facts are listed, when the filter names an `agent_id`, `session_id` or `member_id`: a note is scoped to the user only, so it could not match that filter and hiding its facts would hide everything ([scoping](/memory/guides/scoping-multi-tenant#what-strict-means-in-practice)). | | `user_id` / `agent_id` / `session_id` | string | List only memories in that [scope](/memory/guides/scoping-multi-tenant). Strict: untagged memories never match a scoped listing. | | `member_id` | string | Only memories written by this member; pass `me` for your own. Attribution exists only in spaces shared across organizations, so in a personal space every memory is yours and `me` matches them all. | | `source` / `repo` / `pull_request` / `author` / `merged_by` | string / string / integer / string / string | Only memories a connector captured — the platform (`github`), the repository as that platform identifies it, the pull request or issue number, who wrote it and who merged it. Several are AND-ed, and a memory with no provenance never matches. Read the same values back in each row's `provenance`. | | `tag_groups` | string | A [compound tag filter](/memory/api-reference/retrieve#compound-tag-filters), JSON-encoded. This is a GET, so the expression travels as a string. See below. | ```bash cURL theme={null} curl -G "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories" \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ --data-urlencode "limit=10" \ --data-urlencode "q=Alice" \ --data-urlencode "type=fact" ``` ```python Python theme={null} page = client.list_memories( space_id="customer-support-bot", limit=10, q="Alice", memory_type="fact", ) for item in page["items"]: print(item["id"], item["content"]) ``` ```typescript TypeScript theme={null} const page = await anona.listMemories({ spaceId: "customer-support-bot", limit: 10, query: "Alice", memoryType: "fact", }); for (const item of page.items) { console.log(item.id, item.content); } ``` **Response** `200 OK` ```json theme={null} { "items": [ { "id": "mem_abc123def456", "content": "Alice works at Google and specializes in distributed systems", "text": "Alice works at Google and specializes in distributed systems", "context": "onboarding call", "date": "2026-07-14T12:34:56Z", "type": "fact", "entities": "Alice, Google", "tags": ["team:core", "name:alice"], "state": "active", "metadata": {"user_id": "alice_123"}, "source_ids": [], "user_id": "alice_123", "agent_id": null, "session_id": null, "member_id": null, "provenance": null } ], "total": 42, "limit": 10, "offset": 0 } ``` Every row also carries the scope it was written under and, when a connector wrote it, where it came from. These are fields rather than tags because the values are stored as reserved `anona:` tags, which never appear in `tags`: | Field | Type | Description | | - | - | - | | `user_id` / `agent_id` / `session_id` | string or null | The [scope](/memory/guides/scoping-multi-tenant) this memory was written under. `null` for an unscoped write, which is the common case. | | `member_id` | string or null | Which member of your team wrote it. Only ever set in a space shared with another organization — in a space of your own, every memory is yours. | | `provenance` | object or null | Where the memory came from, when a connector wrote it: `platform`, `repo`, `pull_request`, `author` and `merged_by`. `null` for anything written through the API. Read-only: it is stamped server-side, so unlike the scope fields it has no counterpart on `record`. Select on it with the `source` / `repo` / `pull_request` / `author` / `merged_by` query parameters above. | A row a connector captured carries the object instead of `null`: ```json theme={null} { "id": "mem_def789abc012", "content": "CD swaps only the image; Terraform ships the env.", "provenance": { "platform": "github", "repo": "1282949371", "pull_request": 658, "author": "7001", "merged_by": "9001" } } ``` `author` and `merged_by` are GitHub's numeric user ids, not logins, and they are usually different people. See [Where a memory came from](/memory/github#where-a-memory-came-from) for what each tag means and for the one caveat on captures written before October 2026. > [!NOTE] > > Anona stores knowledge in two layers: the raw facts you record, and the `note` it > synthesizes from them once they settle. Both describe the same thing, so listing > them side by side reads as duplicates, so by default this endpoint returns the > `note` and hides the facts folded into it. `source_ids` names those facts, and > `prefer_observations=false` lists them as rows of their own. `total` follows the > same rule, so paging stays consistent either way. ### Filtering the listing by tags `tag_groups` takes the same boolean expression [`/v1/retrieve`](/memory/api-reference/retrieve#compound-tag-filters) does. Because this is a GET there is no body to carry a nested structure, so it is JSON-encoded into the query string: ``` GET /v1/spaces/support/memories?tag_groups=[{"not":{"tags":["status:archived"]}}] ``` URL-encode it as your HTTP client requires. Groups are AND-ed, and each is a leaf `{"tags": [...], "match": ...}` or one of `{"and": [...]}`, `{"or": [...]}`, `{"not": {...}}`. There is no separate `tags` parameter on this endpoint, and there does not need to be: a flat tag list is a single leaf, `[{"tags": ["project:alpha"]}]`. `total` counts the filtered set, not the unfiltered one, so pagination over a filtered listing behaves the same as over an unfiltered one. > [!NOTE] > > Scope still applies on top. `user_id`, `agent_id`, `session_id` and `member_id` > are AND-ed onto your filter, so a scoped listing stays scoped. > [!WARNING] > > List results use a few different field names from the rest of the API. Map them before reusing > the objects elsewhere. > > | List field | Equivalent when writing or retrieving | > | - | - | > | `id` | `memory_id` | > | `date` | `created_at` | > > Each item carries the memory body twice, as `content` and as `text`, always with the > same value. **Use `content`**: it is the name `record` writes and `retrieve` returns. > `text` is deprecated and kept only so existing clients keep working. It will not be > removed without notice, but new code should not read it. > > `entities` also differs in shape: the list returns a single comma-separated > string, while retrieve returns an array of strings. `tags` does not: it is an > array of strings on both surfaces, carrying the tags written with the memory > plus any a [label taxonomy](/memory/api-reference/extraction-settings#label-taxonomy) > stamped. Scope stays out of it and arrives as `user_id` and friends instead. ## Edit or retire a memory Correct what was stored, or reversibly retire it. Every field is optional; only what you send changes. ```http theme={null} PATCH /v1/spaces/{space_id}/memories/{memory_id} Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "text": "Alice works at Google as a staff engineer.", "reason": "Title corrected after the onboarding call." } ``` | Field | Type | Description | | - | - | - | | `text` | string | Replacement memory text. The memory is re-embedded and re-consolidated. | | `context` | string | Replacement context. | | `occurred_start` / `occurred_end` | string | Corrected event time (ISO 8601). | | `memory_type` | string | Typically `fact` or `experience`. Validated by the engine, not at the gateway. | | `entities` | string\[] | Replaces the memory's entity set; `[]` detaches all. | | `state` | string | `invalidated` drops the memory out of retrieve, consolidation and reasoning while keeping it for audit; `active` restores it. | | `reason` | string | Free-text audit note, kept on the memory's [history](#memory-history). | ```bash cURL theme={null} curl -X PATCH \ "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories/mem_abc123def456" \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Alice works at Google as a staff engineer.", "reason": "Title corrected after the onboarding call." }' ``` ```python Python theme={null} memory = client.update_memory( space_id="customer-support-bot", memory_id="mem_abc123def456", text="Alice works at Google as a staff engineer.", reason="Title corrected after the onboarding call.", ) ``` ```typescript TypeScript theme={null} const memory = await anona.updateMemory({ spaceId: "customer-support-bot", memoryId: "mem_abc123def456", text: "Alice works at Google as a staff engineer.", reason: "Title corrected after the onboarding call.", }); ``` The response is the updated memory in the same shape the list returns, including its `state`, so an invalidate or restore can be confirmed from the reply. It is read back after the edit is applied, so every field in it is current. Editing `text` does not re-extract entities. The memory keeps the entity set it had, and the response shows that set, not one derived from the new text. Nothing recomputes it later either. If the edit changes who or what the memory is about, send `entities` in the same request to replace the set. Notes (`type: "note"`) cannot be edited — they are synthesized, and the engine rejects the attempt rather than letting a synthesis drift from its evidence. ## Memory history Memories are living records. As new information arrives, Anona updates what it knows and keeps the previous version. Fetch a memory's history to see what it said before and when it changed. ```http theme={null} GET /v1/spaces/{space_id}/memories/{memory_id}/history Authorization: Bearer anona_live_YOUR_KEY ``` ```json theme={null} { "memory_id": "mem_abc123def456", "history": [ { "previous_content": "Works at Google.", "changed_at": "2026-07-18T14:07:31Z", "previous_occurred_start": null, "previous_occurred_end": null } ] } ``` | Field | Description | | - | - | | `previous_content` | What the memory said before the change. | | `changed_at` | When that version was superseded. | | `previous_occurred_start` / `previous_occurred_end` | The event time that version referred to. | | `reason` | The audit note a [PATCH](#edit-or-retire-a-memory) supplied for the change. Null on versions rewritten by Anona's own consolidation. | ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories/mem_abc123def456/history" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} history = client.get_memory_history( space_id="customer-support-bot", memory_id="mem_abc123def456", ) ``` ```typescript TypeScript theme={null} const history = await anona.getMemoryHistory({ spaceId: "customer-support-bot", memoryId: "mem_abc123def456", }); ``` Entries are written both by Anona's own consolidation and by your [edits](#edit-or-retire-a-memory). An empty `history` array means the memory has never changed. ## Delete a memory ```http theme={null} DELETE /v1/spaces/{space_id}/memories/{memory_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X DELETE \ "https://api.anonalabs.com/v1/spaces/customer-support-bot/memories/mem_abc123def456" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} client.delete_memory(space_id="customer-support-bot", memory_id="mem_abc123def456") ``` ```typescript TypeScript theme={null} await anona.deleteMemory({ spaceId: "customer-support-bot", memoryId: "mem_abc123def456", }); ``` **Response** `204 No Content`. The body is empty. The deletion is permanent and it cascades: the memory, its links, its entity associations and every observation consolidated from it are removed, and the other memories that fed those observations are re-consolidated without it. There is no undo. To take a memory out of recall while keeping it for audit, [`PATCH`](#edit-or-retire-a-memory) it to `state: "invalidated"` instead — that is reversible. A memory id the space does not hold is `404 memory_not_found`, so deleting the same memory twice reports the second attempt honestly rather than as a success. There is no endpoint for fetching a single memory by id — read one through [listing](#list-memories-in-a-space) or [retrieve](/memory/api-reference/retrieve). Updating one is [`PATCH`](#edit-or-retire-a-memory), above. ## Error responses | Status | Code | Cause | | - | - | - | | 400 | `org_mismatch` | `org_id` or `X-Anona-Org` names an organization other than the key's. | | 400 | `invalid_org_id` | `X-Anona-Org` is not a UUID, or was sent more than once. A malformed body `org_id` is `422 validation_error`. | | 401 | `missing_auth` / `invalid_api_key` | Missing or invalid API key. | | 403 | `space_access_denied` | You are not a member of this space. | | 400 | `memory_not_editable` | Editing a note. Notes are synthesised from other memories; edit the facts in its `source_ids` instead. | | 404 | `space_not_found` | The space does not exist. | | 404 | `memory_not_found` | The space has no memory with that id. | | 422 | `validation_error` | The body fails schema validation. | | 429 | `rate_limited` | Credit quota or rate limit exceeded. | | 503 | `delete_unavailable` | Deletion is briefly unavailable. The memory has **not** been deleted — retry. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Retrieve API](/memory/api-reference/retrieve) > > Query memories semantically. > [card] [Reason API](/memory/api-reference/reason) > > Synthesize an answer across memories. > [card] [Documents](/memory/api-reference/documents) > > Ingest files into the same space. > [card] [Webhooks](/memory/api-reference/webhooks) > > Get told when ingestion finishes. # Messages Source: https://docs.anonalabs.com/memory/api-reference/messages The Anthropic Messages API shape, with the same automatic memory. `POST /v1/messages` serves the Anthropic Messages shape over the same memory wrapper as [Chat](/memory/api-reference/chat), so an application built on the Anthropic SDK gets memory by changing its base URL. ```bash cURL theme={null} curl https://api.anonalabs.com/v1/messages \ -H "x-api-key: anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support-bot" \ -d '{ "model": "claude-sonnet-4-6", "max_tokens": 512, "messages": [{"role": "user", "content": "What plan am I on?"}] }' ``` ```python Python theme={null} from anthropic import Anthropic client = Anthropic( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com", default_headers={"X-Anona-Space-Id": "support-bot"}, ) msg = client.messages.create( model="claude-sonnet-4-6", max_tokens=512, messages=[{"role": "user", "content": "What plan am I on?"}], ) print(msg.content[0].text) ``` ```typescript TypeScript theme={null} import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ apiKey: "anona_live_YOUR_KEY", baseURL: "https://api.anonalabs.com", defaultHeaders: { "X-Anona-Space-Id": "support-bot" }, }); const msg = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 512, messages: [{ role: "user", content: "What plan am I on?" }], }); console.log(msg.content[0].type === "text" ? msg.content[0].text : ""); ``` The key can be sent as `x-api-key` (what the Anthropic SDK does) or as `Authorization: Bearer`, the same Anona key either way. > [!NOTE] > > The shape is a wire format, not a provider: an Anthropic-shaped request does not mean > Anthropic inference, and you never supply a provider key. `model` chooses from what > this deployment serves. Omit it for the default, or name one from > [model selection](/memory/api-reference/model-selection); a model this deployment does not > serve returns `400 unsupported_model`. ## Request ```bash theme={null} POST https://api.anonalabs.com/v1/messages x-api-key: anona_live_... Content-Type: application/json ``` ```json theme={null} { "model": "claude-sonnet-4-6", "space_id": "support-bot", "max_tokens": 512, "system": "Answer in one sentence.", "messages": [ { "role": "user", "content": "What plan am I on?" } ] } ``` | Field | Type | Default | Description | | - | - | - | - | | `messages` | array | required | Each `content` is a string or a list of typed blocks; text blocks are used, others are skipped. | | `max_tokens` | integer | required | Required by this API shape, and forwarded to the model. | | `system` | string or array | none | Becomes the system prompt. The memory block is prepended to it. | | `model` | string | deployment default | Optional. | | `stream` | boolean | `false` | Emit the Anthropic event stream. | | `temperature` | float | none | Optional. | All [memory tunables](/memory/api-reference/chat#memory-tunables) apply here, in the body or as `X-Anona-*` headers. ## Response ```json theme={null} { "id": "msg_...", "type": "message", "role": "assistant", "content": [{ "type": "text", "text": "You're on the Scale plan." }], "model": "claude-sonnet-4-6", "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 42, "output_tokens": 9 }, "memories_injected": 3, "space_id": "support-bot" } ``` `memories_injected` and `space_id` are the Anona additions, and also arrive as the `X-Anona-Memories-Injected` and `X-Anona-Space-Id` response headers. ## Streaming `"stream": true` emits the Anthropic named-event sequence: ``` message_start content_block_start content_block_delta ← one per token, {"type": "text_delta", "text": "..."} content_block_stop message_delta ← stop_reason and output token count message_stop ``` ```bash cURL theme={null} curl -N https://api.anonalabs.com/v1/messages \ -H "x-api-key: anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support-bot" \ -d '{ "model": "claude-sonnet-4-6", "max_tokens": 512, "messages": [{"role": "user", "content": "What plan am I on?"}], "stream": true }' ``` ```python Python theme={null} with client.messages.stream( model="claude-sonnet-4-6", max_tokens=512, messages=[{"role": "user", "content": "What plan am I on?"}], ) as stream: for text in stream.text_stream: print(text, end="") ``` ```typescript TypeScript theme={null} const stream = client.messages.stream({ model: "claude-sonnet-4-6", max_tokens: 512, messages: [{ role: "user", content: "What plan am I on?" }], }); stream.on("text", (text) => process.stdout.write(text)); await stream.finalMessage(); ``` As on Chat, a failure before the first token is a normal JSON error with a real status code; the turn is recorded and billed after the stream ends. # Model selection Source: https://docs.anonalabs.com/memory/api-reference/model-selection Choose which LLM answers your reason and chat calls, and pay that model's rate. By default every call runs on the model Anona serves as standard. You can name a different one (per request, in the dashboard, or as a default for a space), and credits are charged at that model's own rate rather than a flat tier. > [!NOTE] > > Not to be confused with [memory models](/memory/api-reference/models), which are standing > answers a space keeps current. This page is about the **LLM** that does the work. ## Every model is available on every plan There is no plan gate. A Developer account can select the most expensive model in the catalog, and a Scale account pays the same rate for it. That works because credits are derived from each model's real per-token cost. A credit balance is therefore a **spend cap**, and the choice of model cannot move it. It only changes how fast the balance goes down. Spent entirely on input, the same 5,000 credits buy roughly 10.7M tokens of `nova-micro`, 2.5M of `gpt-oss`, or 68K of `claude-opus-4.6`, and cost us the same either way. The practical limit is throughput, not entitlement: request-per-minute quotas are per model, and the premium models carry the least headroom. See `requests_per_minute` in the table below. ## The default Omit `model` and you get **`openai.gpt-oss-120b-1:0`**, also reachable as the tier name `balanced`. It replaced Nova Pro as the default in September 2026: same extraction quality on our own benchmark, a fifth of the credits per operation, and twenty times the requests-per-minute headroom. Nova Pro is still selectable by name as `nova-pro`. ## Pick one, and copy the call Take a name out of [the rate table](#what-it-costs) below and drop it into the `model` field. Nothing else about the call changes: ```bash theme={null} curl https://api.anonalabs.com/v1/reason \ -H "Authorization: Bearer $ANONA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "support", "query": "what does this customer keep asking for?", "model": "gpt-oss" }' ``` `"model": "gpt-oss"` is the default, so that call is also what you get by leaving the field out: **2 credits per 1K input, 8 per 1K output, 1,000 requests a minute.** Swap the one string for any other name in the table — `nova-lite` for a fifth of the rate, `claude-opus-4.6` for about forty times it — and the credit rate moves with it. The same field works on the chat proxy, and on `PUT /v1/spaces/{id}/reason-settings` to make it the space default. ## Three ways to name a model **A short name**, like `nova-pro`, `deepseek` or `glm`, is what you will use most. One per model, and pinned to that model permanently: a short name is never re-pointed at something else. Most short names carry no version, because most families have one member here. Where a family has two, both names carry the version: `claude-sonnet-4.5` and `claude-sonnet-4.6`, `claude-opus-4.5` and `claude-opus-4.6`. That is the same promise, not an exception to it. A bare `claude-sonnet` would have to mean one of them today and could only mean the other later, which is exactly the substitution short names exist to prevent. **A tier name**, like `fast` or `balanced`, names an *intent* rather than a model, and Anona keeps it pointed at a sensible choice as the landscape changes. That re-pointing is the value: ask for `fast` and you keep getting whatever "fast" should mean. **A full model id**, like `us.amazon.nova-pro-v1:0`, also works, and always has. Nothing that already sends one needs to change. `GET /v1/models` returns the full id for every model, along with its exact credit rates. **A host-qualified name**, like `cerebras/gpt-oss-120b`, is accepted for any model served outside Bedrock. It is the `provider/model` form other tools use, and it is worth knowing because two entries here can be the same open weights at different hosts: `gpt-oss` and `gpt-oss-turbo` are both GPT-OSS 120B, and `gpt-oss-turbo` charges **2.33x on input and 1.25x on output**, which is 2.02x blended. If you are ever unsure which one a name refers to, spell the host out. > [!WARNING] > > The difference between the first two matters. A **short name is stable**; a **tier name > may move**. If you have measured a model and want that exact behaviour to stay put, name it: > `gpt-oss`, not `balanced`. ```json theme={null} { "space_id": "support", "query": "what does this customer keep asking for?", "model": "nova-lite" } ``` These all name the same model, and any of them is accepted anywhere `model` is: ```json theme={null} { "model": "gpt-oss" } { "model": "balanced" } { "model": "openai.gpt-oss-120b-1:0" } ``` And these three all name the Cerebras-hosted one, which is a different model and a different price: ```json theme={null} { "model": "gpt-oss-turbo" } { "model": "cerebras.gpt-oss-120b" } { "model": "cerebras/gpt-oss-120b" } ``` Models served through OpenRouter are named `openrouter//`. This one is GPT-OSS 120B again, at a third host and its own, lower, price: ```json theme={null} { "model": "gpt-oss-or" } { "model": "openrouter/openai/gpt-oss-120b" } ``` The vendor name on its own (`openai/gpt-oss-120b`) is not accepted, because more than one host serves those weights and the price depends on which. The first two agree **today**. `balanced` is an intent that may be re-pointed later, and in September 2026 it moved from Nova Pro to GPT-OSS; `gpt-oss` is that model permanently. If you had pinned `nova-pro`, you are still on Nova Pro. ## Using it from the API Resolution runs **request → space default → platform default**, so a request always wins over a stored setting. ```bash cURL theme={null} # Per request (reason) curl -X POST https://api.anonalabs.com/v1/reason \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"space_id": "support", "query": "...", "model": "balanced"}' # Space default (reason) curl -X PUT https://api.anonalabs.com/v1/spaces/support/reason-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"model": "fast", "depth": null}' # Space default (proxy) curl -X PUT https://api.anonalabs.com/v1/spaces/support/chat-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"chat_model": "fast"}' ``` ```python Python theme={null} from anona import AnonaClient anona = AnonaClient(api_key="anona_live_YOUR_KEY") # Per request (reason) answer = anona.reason(space_id="support", query="...", model="balanced") # Space default (reason) — both fields, always: the PUT is a full replace anona.set_reason_settings(space_id="support", model="fast", depth=None) # Space default (proxy) anona.set_chat_settings(space_id="support", chat_model="fast") ``` ```typescript TypeScript theme={null} import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); // Per request (reason) const answer = await anona.reason({ spaceId: "support", query: "...", model: "balanced", }); // Space default (reason) — both fields, always: the PUT is a full replace await anona.setReasonSettings({ spaceId: "support", model: "fast", depth: null }); // Space default (proxy) await anona.setChatSettings({ spaceId: "support", chatModel: "fast" }); ``` Both settings `PUT`s replace the whole record, which is why the SDK methods send every field: an argument you leave out is an argument that clears. Because the proxy is drop-in, a stock OpenAI SDK can set it once and forget it: ```python Python theme={null} from openai import OpenAI client = OpenAI( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com/v1", default_headers={"X-Anona-Space-Id": "support"}, ) resp = client.chat.completions.create( model="fast", # a tier name or a model id messages=[{"role": "user", "content": "What did we decide about pricing?"}], ) ``` ```typescript TypeScript theme={null} import OpenAI from "openai"; const client = new OpenAI({ apiKey: "anona_live_YOUR_KEY", baseURL: "https://api.anonalabs.com/v1", defaultHeaders: { "X-Anona-Space-Id": "support" }, }); const resp = await client.chat.completions.create({ model: "fast", // a tier name or a model id messages: [{ role: "user", content: "What did we decide about pricing?" }], }); ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support" \ -d '{ "model": "fast", "messages": [{"role": "user", "content": "What did we decide about pricing?"}] }' ``` A space default is stored as a **resolved model id**, even when you send a tier name. If `fast` is later re-pointed at a different model, spaces that already chose it stay where they are. You picked a behaviour, not a subscription to ours. `GET /v1/spaces/{space_id}/reason-settings` reads the current value; sending `{"model": null}`, or `DELETE`, returns the space to the platform default. That resource holds a second setting beside the model — [`depth`](/memory/api-reference/reason#depth), how hard `reason` looks before it answers — and **the `PUT` replaces the whole record**, so a body naming only `model` clears the depth. Send both fields, which is why the example above states `"depth": null` rather than leaving it out. The reason model also writes the space's **memory models**. A memory model is a `reason` answer the space keeps, so `POST /v1/spaces/{id}/models` and `POST /v1/spaces/{id}/models/{id}/refresh` run on the same model `/v1/reason` does, and so does a refresh that consolidation starts on its own. Change the reason model and the next refresh picks it up — existing content is not rewritten until then. > [!WARNING] > > `PUT /v1/spaces/{id}/chat-settings` replaces the whole record, so a field you omit is > cleared, not kept. The reason settings are a **separate** resource for exactly this > reason, so saving chat defaults never disturbs them — but that `PUT` replaces its > whole record too, and it holds both `model` and `depth`. ## Using it from the dashboard Open a space, then the **chat defaults** tab: | Row | What it changes | | - | - | | **Chat model** | The LLM that answers proxied chat calls (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`) for this space. | | **Reason model** | The LLM that answers `/v1/reason` for this space — and the one that writes its memory models. | | **Reason depth** | How hard `reason` looks before it answers here: *Fast* lets a current memory model answer on its own, *Thorough* always reads the notes and raw memories underneath. See [Depth](/memory/api-reference/reason#depth). | Each row has an **override** toggle. Left off, the row is inherited: the space follows the platform default, and it keeps following it if that default ever moves. Turn the toggle on and the picker appears, listing every model with its credit rate per 1K tokens so the cost of the choice is visible at the moment you make it. These write the same `reason-settings` and `chat-settings` resources the API uses, so a value set here is the value a request sees, and vice versa. ## List what you can use ```http theme={null} GET /v1/models Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/models \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} catalog = anona.list_catalog_models() for model in catalog["data"]: print(model["id"], model["credits_per_1k_input"]) ``` ```typescript TypeScript theme={null} const catalog = await anona.listCatalogModels(); for (const model of catalog.data) { console.log(model.id, model.credits_per_1k_input); } ``` ```json theme={null} { "object": "list", "data": [ { "id": "us.amazon.nova-lite-v1:0", "object": "model", "created": 0, "owned_by": "anona", "short_name": "nova-lite", "display_name": "Nova Lite", "alias": "fast", "default": false, "credits_per_1k_input": 0.8, "credits_per_1k_output": 3.2, "requests_per_minute": 400, "relative_cost": 0.4, "provider": "bedrock", "supports_memory_operations": true } ] } ``` The envelope is the standard OpenAI list shape, so `client.models.list()` works unchanged against the [proxy](/memory/api-reference/chat). Entries come back **cheapest first**, ordered by `relative_cost`. The names, `alias`, `default`, the credit rates, `requests_per_minute`, and `relative_cost` are Anona's additions; an SDK that does not know about them ignores them. `short_name` always names this exact model and never moves; `alias` names a tier and may be re-pointed. `default` marks the model you get by omitting the `model` field. At most one entry carries it, and sometimes none, when this deployment serves a model outside the catalog. ## Where each model works Most models answer everything. A few answer chat only, and `GET /v1/models` says which with **`supports_memory_operations`** — read it rather than assuming, and filter on it before offering a list as a reason model. The reason is the shape of the product rather than a restriction on you. Chat requests are proxied: Anona calls the model's host directly, so any host we hold a key for can answer one. Memory operations — `/v1/reason`, the memory models, the Slack and MCP asks — run inside the memory engine, which talks to one host. A model outside it is refused with `unsupported_model` and a message naming what is available for memory operations, rather than accepted and failed later. `provider` names the host. It matters because the same open weights can be served by more than one, at different prices and very different speeds: `gpt-oss` and `gpt-oss-turbo` are the same 120B model, the second on Cerebras at roughly 2x the credits and, measured over tool-calling calls of the shape reason sends, several times faster per step. Anona holds every one of these keys; there is no bring-your-own-key path, and a model you can name is one we pay for. ## What it costs Credit rates are derived from each model's real per-token price, so a model that costs five times more charges roughly five times the credits, with no rounding into tiers. **Cost vs default** compares against `gpt-oss`, blended 10:1 input:output. It is a shopping aid, not a billing figure: a model's in/out ratio is its own. Llama 70B charges the same for both, GPT-OSS charges 4x more for output, so cost a real workload from the exact credit rates on `GET /v1/models`, not from the multiplier. | Model | Use in `model` | Tier | Credits per 1K (in / out) | Cost vs default | | - | - | - | - | - | | GPT-OSS 20B (OpenRouter) | `gpt-oss-20b-or` | - | 0.25 / 1.27 | 0.14x | | DeepSeek V4 Flash 0423 (OpenRouter) | `deepseek-v4-flash-or` | - | 0.36 / 0.71 | 0.15x | | Nova Micro | `nova-micro` | - | 0.47 / 1.87 | 0.23x | | Nova Micro 1.0 (OpenRouter) | `nova-micro-v1-or` | - | 0.49 / 1.97 | 0.25x | | GPT-OSS 120B (OpenRouter) | `gpt-oss-or` | - | 0.52 / 2.63 | 0.28x | | Llama 3.1 8B Instruct (OpenRouter) | `llama-3.1-8b-instruct-or` | - | 0.7 / 1.13 | 0.29x | | Gemma 4 26B A4B (OpenRouter) | `gemma-4-26b-a4b-it-or` | - | 0.59 / 3.09 | 0.32x | | Nemotron Nano 3 30B | `nemotron-nano` | - | 0.8 / 3.2 | 0.40x | | Nova Lite | `nova-lite` | `fast` | 0.8 / 3.2 | 0.40x | | Nova Lite 1.0 (OpenRouter) | `nova-lite-v1-or` | - | 0.84 / 3.38 | 0.42x | | Nemotron 3 Nano 30B A3B (OpenRouter) | `nemotron-3-nano-30b-a3b-or` | - | 0.84 / 3.38 | 0.42x | | Nemotron 3.5 Lightning (OpenRouter) | `nemotron-3.5-lightning-or` | - | 0.98 / 2.81 | 0.45x | | GPT-OSS 20B | `gpt-oss-20b` | - | 0.93 / 4 | 0.48x | | GLM 4.7 Flash | `glm-flash` | - | 0.93 / 5.33 | 0.52x | | DeepSeek V4.1 Flash (OpenRouter) | `deepseek-v4.1-flash-or` | - | 0.63 / 8.44 | 0.53x | | GLM 4.7 Flash (OpenRouter) | `glm-4.7-flash-or` | - | 0.98 / 5.63 | 0.55x | | GLM 5.3 Flash (OpenRouter) | `glm-5.3-flash-or` | - | 1.27 / 4.22 | 0.60x | | Ministral 3 3B 2512 (OpenRouter) | `ministral-3b-2512-or` | - | 1.55 / 1.55 | 0.61x | | Nemotron 3 Super (OpenRouter) | `nemotron-3-super-120b-a12b-or` | - | 1.2 / 6.33 | 0.65x | | Llama 3.3 70B Instruct (OpenRouter) | `llama-3.3-70b-instruct-or` | - | 1.41 / 4.5 | 0.66x | | GPT-6 Luna Pro (OpenRouter) | `gpt-6-luna-pro-or` | - | 1.55 / 7.74 | 0.83x | | GPT-6 Luna (OpenRouter) | `gpt-6-luna-or` | - | 1.55 / 7.74 | 0.83x | | MiMo-V2.6-Flash (OpenRouter) | `mimo-v2.6-flash-or` | - | 1.97 / 3.94 | 0.84x | | Ministral 3 8B 2512 (OpenRouter) | `ministral-8b-2512-or` | - | 2.32 / 2.32 | 0.91x | | GLM 5.2 (OpenRouter) | `glm-5.2-or` | - | 1.99 / 6.25 | 0.93x | | Qwen3.8 Flash (OpenRouter) | `qwen3.8-flash-or` | - | 2.11 / 6.61 | 0.99x | | Hy3 (OpenRouter) | `hy3-or` | - | 1.97 / 8.16 | 0.99x | | **GPT-OSS 120B** | **`gpt-oss`** | **`balanced`** | **2 / 8** | **1x, the default** | | Nemotron 3 Super 120B | `nemotron-super` | - | 2 / 8.67 | 1.0x | | GLM 5.3 (OpenRouter) | `glm-5.3-or` | - | 2.19 / 6.87 | 1.0x | | Llama 4 Scout | `llama4-scout` | - | 2.27 / 8.8 | 1.1x | | Mistral Small 4 (OpenRouter) | `mistral-small-2603-or` | - | 2.32 / 9.28 | 1.2x | | Ministral 3 14B 2512 (OpenRouter) | `ministral-14b-2512-or` | - | 3.09 / 3.09 | 1.2x | | DeepSeek V4 Pro 0423 (OpenRouter) | `deepseek-v4-pro-or` | - | 2.94 / 5.87 | 1.3x | | Llama 4 Maverick (OpenRouter) | `llama-4-maverick-or` | - | 2.64 / 9.18 | 1.3x | | MiniMax M2.7 (OpenRouter) | `minimax-m2.7-or` | - | 2.95 / 11.82 | 1.5x | | DeepSeek V3.2 (OpenRouter) | `deepseek-v3.2-or` | - | 3.66 / 5.35 | 1.5x | | GPT-5.6 Luna Pro (OpenRouter) | `gpt-5.6-luna-pro-or` | - | 3.09 / 18.57 | 1.8x | | GPT-5.6 Luna (OpenRouter) | `gpt-5.6-luna-or` | - | 3.09 / 18.57 | 1.8x | | DeepSeek V3.1 (OpenRouter) | `deepseek-chat-v3.1-or` | - | 3.8 / 14.07 | 1.9x | | Qwen3.8 27B (OpenRouter) | `qwen3.8-27b-or` | - | 2.85 / 26.37 | 2.0x | | MiniMax M2.1 | `minimax` | - | 4 / 16 | 2.0x | | DeepSeek V3.1 Terminus (OpenRouter) | `deepseek-v3.1-terminus-or` | - | 4.22 / 14.07 | 2.0x | | GPT-OSS 120B (Cerebras) | `gpt-oss-turbo` | - | 4.67 / 10 | 2.0x | | MiniMax M2.5 (OpenRouter) | `minimax-m2.5-or` | - | 4.15 / 16.88 | 2.1x | | MiniMax M2.1 (OpenRouter) | `minimax-m2.1-or` | - | 4.22 / 16.88 | 2.1x | | Gemini 3.1 Flash Lite (OpenRouter) | `gemini-3.1-flash-lite-or` | - | 3.87 / 23.21 | 2.2x | | Command A+ (OpenRouter) | `command-a-plus-or` | - | 4.22 / 21.1 | 2.3x | | GLM 5.3 FlashX (OpenRouter) | `glm-5.3-flashx-or` | - | 5.2 / 17.58 | 2.5x | | MiMo-V2.6-Pro (OpenRouter) | `mimo-v2.6-pro-or` | - | 6.12 / 12.24 | 2.6x | | Nova 2 Lite (OpenRouter) | `nova-2-lite-v1-or` | - | 4.22 / 35.17 | 2.8x | | Gemini 3.5 Flash Lite (OpenRouter) | `gemini-3.5-flash-lite-or` | - | 4.64 / 38.68 | 3.0x | | Mistral Large 3 | `mistral-large` | - | 6.67 / 20 | 3.1x | | Mistral Medium 3.1 (OpenRouter) | `mistral-medium-3.1-or` | - | 6.19 / 30.95 | 3.3x | | Llama 70B | `llama-70b` | - | 9.6 / 9.6 | 3.8x | | DeepSeek | `deepseek` | - | 8.27 / 24.67 | 3.8x | | GLM | `glm` | - | 8 / 29.33 | 3.9x | | GLM 4.7 (OpenRouter) | `glm-4.7-or` | - | 8.44 / 30.95 | 4.1x | | Kimi K2.6 (OpenRouter) | `kimi-k2.6-or` | - | 8.42 / 35.45 | 4.3x | | Kimi K2.5 | `kimi` | - | 8 / 40 | 4.3x | | GLM 5 (OpenRouter) | `glm-5-or` | - | 9.85 / 31.51 | 4.6x | | Kimi K2.5 (OpenRouter) | `kimi-k2.5-or` | - | 8.44 / 46.77 | 4.7x | | Nemotron 3 Ultra (OpenRouter) | `nemotron-3-ultra-550b-a55b-or` | - | 8.79 / 43.96 | 4.7x | | Nova Pro | `nova-pro` | - | 10.67 / 42.67 | 5.3x | | Nova Pro 1.0 (OpenRouter) | `nova-pro-v1-or` | - | 11.25 / 45.01 | 5.6x | | Gemini 3.8 Flash (OpenRouter) | `gemini-3.8-flash-or` | - | 10.55 / 52.75 | 5.7x | | Gemini 3.7 Flash (OpenRouter) | `gemini-3.7-flash-or` | - | 10.55 / 52.75 | 5.7x | | Gemini 3.6 Flash (OpenRouter) | `gemini-3.6-flash-or` | - | 11.6 / 58.02 | 6.2x | | GLM 5 | `glm-5` | - | 13.33 / 42.67 | 6.3x | | Grok 4.3 (OpenRouter) | `grok-4.3-or` | - | 17.58 / 35.17 | 7.5x | | Claude Haiku 4.5 | `claude-haiku` | - | 14.67 / 73.33 | 7.9x | | Claude Haiku 4.5 (OpenRouter) | `claude-haiku-4.5-or` | - | 15.47 / 77.37 | 8.3x | | Mistral Medium 3.5 (OpenRouter) | `mistral-medium-3-5-or` | - | 23.21 / 116.05 | 12.4x | | Qwen3.8 2.4T A95B (OpenRouter) | `qwen3.8-2.4t-a95b-or` | - | 28.13 / 84.4 | 13.1x | | Grok 4.5 (OpenRouter) | `grok-4.5-or` | - | 28.13 / 84.4 | 13.1x | | Gemini 3.5 Flash (OpenRouter) | `gemini-3.5-flash-or` | - | 23.21 / 139.26 | 13.3x | | Grok 4.7 (OpenRouter) | `grok-4.7-or` | - | 30.95 / 92.84 | 14.4x | | Grok 4.6 (OpenRouter) | `grok-4.6-or` | - | 30.95 / 92.84 | 14.4x | | Claude Sonnet 5.5 (OpenRouter) | `claude-sonnet-5.5-or` | - | 28.13 / 140.67 | 15.1x | | Claude Sonnet 5 (OpenRouter) | `claude-sonnet-5-or` | - | 28.13 / 140.67 | 15.1x | | GPT-5.6 Sol Pro (OpenRouter) | `gpt-5.6-sol-pro-or` | - | 28.13 / 140.67 | 15.1x | | GPT-5.6 Sol (OpenRouter) | `gpt-5.6-sol-or` | - | 28.13 / 140.67 | 15.1x | | GPT-6.1 Sol Pro (OpenRouter) | `gpt-6.1-sol-pro-or` | - | 30.95 / 154.73 | 16.6x | | GPT-6.1 Sol (OpenRouter) | `gpt-6.1-sol-or` | - | 30.95 / 154.73 | 16.6x | | GPT-6 Sol Pro (OpenRouter) | `gpt-6-sol-pro-or` | - | 30.95 / 154.73 | 16.6x | | GPT-6 Sol (OpenRouter) | `gpt-6-sol-or` | - | 30.95 / 154.73 | 16.6x | | GPT-5.6 Terra Pro (OpenRouter) | `gpt-5.6-terra-pro-or` | - | 30.95 / 185.68 | 17.7x | | GPT-5.6 Terra (OpenRouter) | `gpt-5.6-terra-or` | - | 30.95 / 185.68 | 17.7x | | Kimi K3 (OpenRouter) | `kimi-k3-or` | - | 31.65 / 196.93 | 18.3x | | GLM 5.3 Prime (OpenRouter) | `glm-5.3-prime-or` | - | 39.39 / 123.79 | 18.5x | | Claude Sonnet 4.5 (OpenRouter) | `claude-sonnet-4.5-or` | - | 42.2 / 211 | 22.6x | | Claude Sonnet 4.6 (OpenRouter) | `claude-sonnet-4.6-or` | - | 42.2 / 211 | 22.6x | | Claude Sonnet 4.5 | `claude-sonnet-4.5` | - | 44 / 220 | 23.6x | | Claude Sonnet 4.6 | `claude-sonnet-4.6` | - | 44 / 220 | 23.6x | | Qwen3.8 Max Prime (OpenRouter) | `qwen3.8-max-prime-or` | - | 56.27 / 168.8 | 26.1x | | Claude Opus 5.5 (OpenRouter) | `claude-opus-5.5-or` | - | 56.27 / 281.33 | 30.1x | | Claude Opus 4.5 (OpenRouter) | `claude-opus-4.5-or` | - | 70.33 / 351.67 | 37.7x | | Claude Opus 4.6 (OpenRouter) | `claude-opus-4.6-or` | - | 70.33 / 351.67 | 37.7x | | Claude Opus 5 (OpenRouter) | `claude-opus-5-or` | - | 70.33 / 351.67 | 37.7x | | Claude Opus 4.8 (OpenRouter) | `claude-opus-4.8-or` | - | 70.33 / 351.67 | 37.7x | | Claude Opus 4.7 (OpenRouter) | `claude-opus-4.7-or` | - | 70.33 / 351.67 | 37.7x | | Claude Opus 4.5 | `claude-opus-4.5` | - | 73.33 / 366.67 | 39.3x | | Claude Opus 4.6 | `claude-opus-4.6` | - | 73.33 / 366.67 | 39.3x | | GPT-5.5 (OpenRouter) | `gpt-5.5-or` | - | 77.37 / 464.2 | 44.2x | | Claude Fable 5.1 (OpenRouter) | `claude-fable-5.1-or` | - | 140.67 / 703.33 | 75.4x | | Claude Fable 5 (OpenRouter) | `claude-fable-5-or` | - | 140.67 / 703.33 | 75.4x | | GPT-6 Astra (OpenRouter) | `gpt-6-astra-or` | - | 154.73 / 773.67 | 82.9x | | GPT-6 Astra Pro (OpenRouter) | `gpt-6-astra-pro-or` | - | 154.73 / 773.67 | 82.9x | `GET /v1/models` is the authoritative list; this table is a snapshot. The two can differ in length as well as in content: a model whose host this deployment holds no key for is **not listed**, though it stays resolvable by id so naming it gets you a precise `model_unavailable` rather than a flat `unsupported_model`. If a name in this table is missing from your `GET /v1/models`, that is why. Reason also carries a flat base of 5 credits, so a short answer on a cheap model costs 5 rather than fractions of one. > [!NOTE] > > A worked example. A reason call reading 4,000 tokens of context and writing 400 on > the default costs `5 + (4 × 2.0) + (0.4 × 8.0)` ≈ **16 credits**. The same call on > `nova-micro` costs `5 + (4 × 0.47) + (0.4 × 1.87)` ≈ **8 credits**, and at that end of > the catalog the flat base is most of the bill, not the tokens. ## Which model answered Reason echoes it back, so you can reconcile an answer and its cost against the model that actually produced them rather than the one you asked for: ```json theme={null} { "insights": "...", "model": "us.amazon.nova-lite-v1:0", "usage": { "input_tokens": 4120, "output_tokens": 380 } } ``` ## Errors Anona never quietly substitutes a different model. If the one you named cannot serve the request, the request fails and says so. | Code | Status | Meaning | | - | - | - | | `unsupported_model` | 400 | Not a model this deployment serves — or, on a memory operation, a chat-only one (see `supports_memory_operations`). For an unknown name the message suggests up to three close matches, for example `Did you mean 'claude-sonnet-4.6', 'claude-sonnet-4.5'?`, and points to `GET /v1/models`; for a chat-only model it lists the models memory operations accept. | | `model_throttled` | 429 | That model is at capacity right now. Retry, or use one with more headroom (see `requests_per_minute`). | | `model_unavailable` | 503 | The model is listed but this deployment cannot reach it. Ours to fix. | Request limits are **per model**, which is also a reason to choose one: the high-throughput models carry forty times the headroom of the premium ones (1,000 rpm against Opus's 25). ## Choosing well * **Leave it alone** unless you have a reason. The default is the balanced option. * **`fast`** for high-volume, low-stakes work: classification, short recall summaries, anything where you would rather have ten answers than one better one. * **A premium model** for reason over a large space, where the synthesis is the product and a better one is worth several times the credits. `mistral-large` is 3.1x the default and `kimi` 4.3x; the Claude family runs from `claude-haiku` at 7.9x to `claude-opus-4.6` at 39.3x. Price is identical within each Claude family, so prefer the higher version: it carries a larger context window for the same rate. * **Watch the credit burn, not the plan.** Nothing stops a Developer account running any model in the catalog; it simply exhausts the month's credits sooner. `claude-opus-4.6` burns about **170 times** faster than `nova-micro`, and the dearest entry in the catalog about **355 times** faster. Both figures are the ratio of the **Cost vs default** column below, so they carry its 10:1 blend. * **`gpt-oss-turbo` is cheapest exactly where it is fastest.** It is the same model as the default at a faster host. It charges 2.33x on input and only 1.25x on output, so what you pay depends on the shape of the call — and so does what you gain. Measured against prod with alternating calls: | call | credits | speed | | - | - | - | | short reply (\~50 tokens out) | **the same 3** | 1.24x faster | | long answer (\~700 tokens out) | 6 → 8, **1.33x** | **3.64x faster** | | large context in, short answer out | up to **2.2x** | barely faster | So: worth it for drafting, summarising and anything that writes at length, where it is over three times faster for a third more credits. Not worth it for stuffing a large context in to get a sentence back — that is where it costs most and helps least. A short interactive reply is free either way, because both land on the 3-credit chat floor. Choose it per call rather than as a space default. * **Measure before pinning an id.** Reason is a multi-step agent loop, so a slower model costs more wall time than its per-call latency suggests. # Memory models Source: https://docs.anonalabs.com/memory/api-reference/models Standing questions a space keeps a current answer to: a running model of the user, their preferences, and how they work. Retrieve answers a question from individual memories. A **memory model** answers a standing question from *all* of them, and keeps that answer current as new memories arrive. Give a model a question ("how does this user like to be communicated with?", "what is this customer's account history?") and the memory layer synthesizes an answer across everything the space knows, then rewrites it as the space learns more. Read it back as markdown and drop it straight into a system prompt. > [!NOTE] > > A model's content is generated in the background. Creating or refreshing one returns a > `job_id`; poll `GET /v1/spaces/{space_id}/jobs/{job_id}`, then read the model to get its > content. > [!NOTE] > > A memory model is a `reason` answer the space keeps, so it is written by the > space's **reason model** — see > [choosing a model](/memory/api-reference/model-selection). A stronger model is > noticeably better at the thing these are for: holding the *current* value of a > fact that has changed. Change the setting and the next refresh picks it up. > [!TIP] > > Everything on this page is also on the **Models** tab of a space in the > [dashboard](https://memory.anonalabs.com): the profile, each model's rendered content, > and create, edit, refresh, clear and delete. Useful for reading what a space has > concluded without writing a line of code. ## Create a model ```http theme={null} POST /v1/spaces/{space_id}/models Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "name": "Communication style", "query": "How does this user prefer to be communicated with?", "model_id": "comms", "tags": ["prefs"], "max_tokens": 2048, "trigger": { "mode": "delta", "refresh_on_new_memories": true } } ``` | Field | Type | Default | Description | | - | - | - | - | | `name` | string | required | Human-readable name. | | `query` | string | required | The question this model answers from the space's memories. | | `model_id` | string | generated | Optional stable id. Lowercase alphanumeric and hyphens. | | `tags` | string\[] | none | Only build the model from memories carrying these tags. | | `max_tokens` | integer | unset (engine default, currently 2048) | Ceiling on generated content length, 256 to 8192. | | `trigger.mode` | `full` \| `delta` | `full` | How a refresh rewrites the content. See [refresh modes](#refresh-modes). | | `trigger.refresh_on_new_memories` | boolean | `false` | Refresh automatically once newly recorded memories are consolidated. | | `trigger.memory_type` | string\[] | all | Restrict to `fact`, `experience` and/or `note`. | ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/customer-support-bot/models \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Communication style", "query": "How does this user prefer to be communicated with?", "model_id": "comms", "tags": ["prefs"], "trigger": { "mode": "delta", "refresh_on_new_memories": true } }' ``` ```python Python theme={null} job = client.create_memory_model( space_id="customer-support-bot", name="Communication style", query="How does this user prefer to be communicated with?", model_id="comms", tags=["prefs"], trigger={"mode": "delta", "refresh_on_new_memories": True}, ) ``` ```typescript TypeScript theme={null} const job = await anona.createMemoryModel({ spaceId: "customer-support-bot", name: "Communication style", query: "How does this user prefer to be communicated with?", modelId: "comms", tags: ["prefs"], trigger: { mode: "delta", refresh_on_new_memories: true }, }); ``` **Response** `202 Accepted` ```json theme={null} { "model_id": "comms", "job_id": "job_9f2a", "status": "processing" } ``` ## List models ```http theme={null} GET /v1/spaces/{space_id}/models?limit=50&offset=0&tags=prefs Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/models?limit=50&tags=prefs" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} models = client.list_memory_models(space_id="customer-support-bot", tags=["prefs"]) ``` ```typescript TypeScript theme={null} const models = await anona.listMemoryModels({ spaceId: "customer-support-bot", tags: ["prefs"], }); ``` **Response** `200 OK` ```json theme={null} { "items": [ { "id": "comms", "space_id": "customer-support-bot", "name": "Communication style", "query": "How does this user prefer to be communicated with?", "content": "Prefers short, direct answers. Has asked for brevity in several sessions and disengages past about three paragraphs.", "tags": ["prefs"], "max_tokens": 2048, "trigger": { "mode": "delta", "refresh_on_new_memories": true, "memory_type": null }, "last_refreshed_at": "2026-08-01T10:00:00Z", "created_at": "2026-07-01T10:00:00Z", "is_stale": false } ], "total": 1, "limit": 50, "offset": 0 } ``` | Field | Type | Description | | - | - | - | | `content` | string or null | The current answer, as markdown. Null until the first refresh finishes, or after a clear. A model that has never been refreshed reports `null`, never a partial or placeholder answer. | | `is_stale` | boolean or null | True when memories have been recorded since the last refresh, so the content is behind. | | `last_refreshed_at` | string or null | ISO timestamp of the last rewrite. | ## Get a model ```http theme={null} GET /v1/spaces/{space_id}/models/{model_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot/models/comms \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} model = client.get_memory_model(space_id="customer-support-bot", model_id="comms") ``` ```typescript TypeScript theme={null} const model = await anona.getMemoryModel({ spaceId: "customer-support-bot", modelId: "comms", }); ``` Returns the same object as a list item, **plus `sources`** — the memories, notes and other models the current content was written from. ```json theme={null} { "id": "comms", "space_id": "support", "content": "Prefers short, direct answers.", "sources": [ { "id": "3f2a…", "type": "fact", "text": "Alice asked for shorter replies" }, { "id": "9c81…", "type": "note", "text": "Alice: communication preferences" } ] } ``` | Field | Type | Description | | - | - | - | | `sources[].id` | string | The memory, note or model id. | | `sources[].type` | string | `fact` or `experience` for a raw memory, `note` for a consolidated one, `summary` for another memory model. | | `sources[].text` | string or null | What that source says, as stored. | These are the memories the refresh **declared it used**, not everything it retrieved. That is what makes a wrong answer diagnosable: if the memory you expected is absent from the list, it was never reached — record it, or check the model's `tags` and `query`. If it is present and the answer still contradicts it, the memory was read and mishandled, which is a different problem and worth reporting. An empty list means the content rests on nothing in this space. For a model that has never refreshed that is expected; for one with content, it is a finding. > [!NOTE] > > `sources` is served on this route only. The [list](#list-models) omits it > — a page of models each carrying the full text of everything behind it is a large > response for a view that only names them. ## Update a model Changes the definition. Existing content is left alone. Change the query and the answer only catches up on the next refresh. ```http theme={null} PATCH /v1/spaces/{space_id}/models/{model_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X PATCH https://api.anonalabs.com/v1/spaces/customer-support-bot/models/comms \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Communication preferences", "max_tokens": 4096 }' ``` ```python Python theme={null} model = client.update_memory_model( space_id="customer-support-bot", model_id="comms", name="Communication preferences", max_tokens=4096, ) ``` ```typescript TypeScript theme={null} const model = await anona.updateMemoryModel({ spaceId: "customer-support-bot", modelId: "comms", name: "Communication preferences", maxTokens: 4096, }); ``` **Response** `200 OK`, the updated model. ## Refresh a model Re-answers the model's question from everything the space knows now. ```http theme={null} POST /v1/spaces/{space_id}/models/{model_id}/refresh Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X POST \ https://api.anonalabs.com/v1/spaces/customer-support-bot/models/comms/refresh \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} job = client.refresh_memory_model(space_id="customer-support-bot", model_id="comms") ``` ```typescript TypeScript theme={null} const job = await anona.refreshMemoryModel({ spaceId: "customer-support-bot", modelId: "comms", }); ``` **Response** `202 Accepted` ```json theme={null} { "model_id": "comms", "job_id": "job_be71", "status": "queued" } ``` ### Refresh modes | Mode | Behaviour | | - | - | | `full` | Regenerates the content from scratch every time. Predictable, and the right default. | | `delta` | Edits in place: untouched sections are preserved verbatim, stale parts are dropped, new material is added. Cheaper on long documents. A model with no content yet, or whose `query` changed, falls back to a full regeneration automatically. | A `delta` model that has drifted over many incremental edits can be reset: clear it, then refresh for a clean rebuild. ## Clear a model's content Drops the content, keeps the definition. ```http theme={null} POST /v1/spaces/{space_id}/models/{model_id}/clear Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X POST \ https://api.anonalabs.com/v1/spaces/customer-support-bot/models/comms/clear \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} model = client.clear_memory_model(space_id="customer-support-bot", model_id="comms") ``` ```typescript TypeScript theme={null} const model = await anona.clearMemoryModel({ spaceId: "customer-support-bot", modelId: "comms", }); ``` **Response** `200 OK`, the model, with `content` now null. ## Delete a model Removes the model. The memories it was built from are untouched. ```http theme={null} DELETE /v1/spaces/{space_id}/models/{model_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X DELETE https://api.anonalabs.com/v1/spaces/customer-support-bot/models/comms \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} client.delete_memory_model(space_id="customer-support-bot", model_id="comms") ``` ```typescript TypeScript theme={null} await anona.deleteMemoryModel({ spaceId: "customer-support-bot", modelId: "comms" }); ``` **Response** `204 No Content` ## Model history Every refresh archives the content it replaced, most recent first, showing how the space's view of the question changed as it learned more. ```http theme={null} GET /v1/spaces/{space_id}/models/{model_id}/history Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot/models/comms/history \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} history = client.get_memory_model_history( space_id="customer-support-bot", model_id="comms", ) ``` ```typescript TypeScript theme={null} const history = await anona.getMemoryModelHistory({ spaceId: "customer-support-bot", modelId: "comms", }); ``` **Response** `200 OK` ```json theme={null} { "entries": [ { "previous_content": "Prefers detailed explanations.", "changed_at": "2026-07-15T09:00:00Z" } ], "total": 1 } ``` ## Space profile The space's standing description: what it is for, and how it weighs what it is told. ```http theme={null} GET /v1/spaces/{space_id}/profile Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot/profile \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} profile = client.get_space_profile("customer-support-bot") ``` ```typescript TypeScript theme={null} const profile = await anona.getSpaceProfile("customer-support-bot"); ``` **Response** `200 OK` ```json theme={null} { "space_id": "customer-support-bot", "name": "Alice", "mission": "Keep Alice's team organized and shipping.", "disposition": { "skepticism": 3, "literalism": 4, "empathy": 5 } } ``` | Field | Type | Description | | - | - | - | | `mission` | string | What this space is for. Empty if never set. | | `disposition.skepticism` | integer | 1 takes statements at face value, 5 questions them. | | `disposition.literalism` | integer | 1 reads intent loosely, 5 reads literally. | | `disposition.empathy` | integer | 1 ignores emotional context, 5 weighs it heavily. | Disposition shapes how the space interprets what it is told when it synthesizes. It does not affect plain retrieval. ### Setting the profile ```http theme={null} PUT /v1/spaces/{space_id}/profile Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json { "mission": "This space tracks the Halcyon account for the customer-success team; answers are read by non-technical account managers.", "disposition": { "skepticism": 2, "literalism": 2, "empathy": 5 } } ``` ```bash cURL theme={null} curl -X PUT https://api.anonalabs.com/v1/spaces/customer-support-bot/profile \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "mission": "This space tracks the Halcyon account for the customer-success team.", "disposition": { "skepticism": 2, "literalism": 2, "empathy": 5 } }' ``` ```python Python theme={null} client.set_space_profile( space_id="customer-support-bot", mission="This space tracks the Halcyon account for the customer-success team.", disposition={"skepticism": 2, "literalism": 2, "empathy": 5}, ) ``` ```typescript TypeScript theme={null} await anona.setSpaceProfile({ spaceId: "customer-support-bot", mission: "This space tracks the Halcyon account for the customer-success team.", disposition: { skepticism: 2, literalism: 2, empathy: 5 }, }); ``` Both SDK methods take `mission` and `disposition` as **required** arguments, for the same reason the endpoint replaces rather than patches: an argument you leave out is an argument that clears. Read the current pair back with `get_space_profile` / `getSpaceProfile` when you only mean to change one of them. Responds `200 OK` with the profile as it now stands. | Field | Type | Description | | - | - | - | | `mission` | string \| null | Up to 4,000 characters. Null or blank clears it. | | `disposition.skepticism` | integer \| null | 1-5. Null restores the default of 3. | | `disposition.literalism` | integer \| null | 1-5. Null restores the default of 3. | | `disposition.empathy` | integer \| null | 1-5. Null restores the default of 3. | Three things worth knowing before you call it. **It is a full replace.** A body naming only `mission` clears the dials back to the default, exactly as [chat defaults](/memory/api-reference/chat#space-defaults) and [extraction settings](/memory/api-reference/extraction-settings) do. Send both halves every time, or send only what you want the space to end up with. **The response is what the space now has, not what you sent.** A dial cleared to `null` comes back as `3`, because that is the value the space will actually reason with. **Owner-only.** A space shared with you can be read and written to, but not reconfigured — the mission reframes every answer the space gives to everyone who can read it. A non-owner gets `403 space_owner_only`. `DELETE /v1/spaces/{space_id}/profile` returns `204` and puts the space back to no mission and 3/3/3. It is idempotent, and it is `reset_space_profile(space_id)` / `deleteSpaceProfile(spaceId)` in the SDKs. The mission is prepended to the reasoning prompt on every [reason](/memory/api-reference/reason) call and every model refresh, so it costs a few tokens per call rather than once — worth keeping to a paragraph rather than a page. Setting the profile is itself free. ## Cost Creating and refreshing a model each run a synthesis pass over the space's memories and are billed like [reason](/memory/api-reference/reason). Reading models, history and the profile is free. ## Error responses | Status | Code | Cause | | - | - | - | | 401 | `missing_auth` / `invalid_api_key` / `key_expired` | No `Authorization` header, a key Anona did not issue or has revoked, or a key past its expiry date. | | 403 | `space_access_denied` | You are not a member of this space. | | 404 | `model_error` | No memory model with that id, or no such space. This surface reports every engine-side failure as `model_error` with the engine's own status, so the same code also appears on `4xx` and `5xx`. Nothing to do with `unsupported_model` or `invalid_model`, which are about the LLM a space thinks with. | | 422 | `validation_error` | Unknown or malformed field in the request body. | | 429 | `rate_limited` | Credit quota or rate limit exceeded. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Reason API](/memory/api-reference/reason) > > Answer a one-off question across memories. > [card] [MCP integration](/memory/mcp-integration) > > Let an agent read a space's profile and models directly. # Organization Source: https://docs.anonalabs.com/memory/api-reference/organization View your organization, manage projects, and manage organization members. These endpoints back the dashboard's organization settings pages. All require a **dashboard session token** — an API key is a data-plane credential and gets `401 session_required` here. | Method and path | What it does | Role required | | - | - | - | | `GET /v1/orgs/me` | Your organization. | Any member | | `PATCH /v1/orgs/me` | Renames your organization. | Owner or admin | | `GET /v1/projects` · `POST /v1/projects` | Lists and creates projects. | Any member | | `GET /v1/members/me` | The currently signed-in member. | Any member | | `GET /v1/members` | Every member in your organization. | Any member | | `POST /v1/members/invite` | Invites a new member. | Owner | | `DELETE /v1/members/{member_id}` | Removes a member. | Owner | ## Get your organization ```http theme={null} GET /v1/orgs/me ``` These are control-plane calls, so they authenticate with a **dashboard session**, not an API key, and neither memory SDK wraps them. `$SESSION` is the session token sign-in returns. ```bash cURL theme={null} curl https://api.anonalabs.com/v1/orgs/me \ -H "Authorization: Bearer $SESSION" ``` ```python Python theme={null} import httpx data = httpx.get( "https://api.anonalabs.com/v1/orgs/me", headers={"Authorization": f"Bearer {SESSION}"}, ).json() ``` ```typescript TypeScript theme={null} const res = await fetch("https://api.anonalabs.com/v1/orgs/me", { headers: { Authorization: `Bearer ${SESSION}` }, }); const data = await res.json(); ``` ```json theme={null} { "org_id": "org_...", "name": "Acme Inc", "slug": "acme-inc", "created_at": "2026-01-10T09:00:00Z" } ``` ## Rename your organization ```http theme={null} PATCH /v1/orgs/me ``` Changes the display name only. Your organization's slug never changes: it is the owner half of a qualified shared-space id (`{owner_slug}:{space_name}`, for example `acme:demo`), which is an address customers already write down and share, so changing it would silently break every one of those addresses. ```json Request theme={null} { "name": "Acme Robotics" } ``` ```json Response theme={null} { "org_id": "org_...", "name": "Acme Robotics", "slug": "acme-inc", "created_at": "2026-01-10T09:00:00Z" } ``` Restricted to owners and admins. ## Projects Projects group API keys under an organization. Every organization needs at least one project before it can create an API key. ```json GET /v1/projects theme={null} [ { "project_id": "proj_...", "org_id": "org_...", "name": "default", "description": null, "created_at": "..." } ] ``` ```json POST /v1/projects theme={null} { "name": "customer-support-bot", "description": "Support agent memories" } ``` `POST` returns the created project in the same shape as the list items above. ## Members ```http theme={null} GET /v1/members/me ``` ```json theme={null} { "member_id": "mem_...", "email": "you@acme.com", "role": "owner", "email_verified": true, "created_at": "..." } ``` `GET /v1/members` returns every member in your organization, as a list of the same shape. ### Invite a member `POST /v1/members/invite` is restricted to the owner. ```json Request theme={null} { "email": "new-teammate@acme.com", "role": "developer" } ``` ```json Response theme={null} { "invitation_id": "inv_...", "email": "new-teammate@acme.com", "role": "developer", "expires_at": "2026-07-22T09:00:00Z" } ``` `role` must be one of the roles in your organization's role set, excluding `owner`. Nobody can be invited directly as an owner. ### Remove a member `DELETE /v1/members/{member_id}` returns `204 No Content`. Owner only. ## Space invitations When someone shares a space with you (from the dashboard's space members page), the invitation is addressed to your email and waits for you to act on it. These endpoints back that flow; any signed-in member can call them. | Method and path | What it does | | - | - | | `GET /v1/invitations/pending` | Space invitations waiting on the signed-in person, matched on their email. | | `POST /v1/invitations/accept` | Accepts one. Body: `{ "token": "..." }`. Returns the granted `space_id`. | | `POST /v1/invitations/decline` | Declines one. Body: `{ "token": "..." }`. Returns `204 No Content`. | ```json GET /v1/invitations/pending theme={null} { "invitations": [ { "token": "...", "org_name": "Acme Inc", "space_id": "support-bot", "role": "viewer", "expires_at": "2026-09-06T09:00:00Z" } ] } ``` Accepting grants access to that one space in the inviting organization — you stay in your own organization. The session's email must match the invited address (`403 invite_email_mismatch` otherwise, so a forwarded invitation email does not hand over the seat), and an org-level invitation token is rejected with `400 not_a_space_invite` — joining an organization goes through the signup flow the invitation email links to. ## Choosing an organization per request A person can belong to several organizations: their personal one, plus any shared organization they have joined. Most requests need no extra input. A request runs in your **personal organization** unless it names another, and it names another in one of three ways: the `X-Anona-Org` header (or an `org_id` field on record, batch record, retrieve and reason), an API key minted in that organization, or a dashboard session switched into it. What naming an organization does depends on the credential: | Credential | Names its own organization | Names a different organization | | - | - | - | | API key | Accepted. | `400 org_mismatch`. A key belongs to one organization, so the name is a check, not a switch. | | Dashboard session token | Accepted. | `400 org_mismatch`. Switch with `POST /auth/switch-org`; a header never switches a session. | | Temporary profile token | Accepted. | `400 org_mismatch`. | | MCP OAuth or CLI token | Accepted. | Selects your membership there: `403 not_an_org_member` if you hold none, `403 org_suspended` if it is suspended. | | Nothing named | Your personal organization (an API key or switched session uses its own). | | Send `X-Anona-Org` once, as a UUID. A repeated header, or a value that is not a UUID, is `400 invalid_org_id`, as is an MCP tool call's `org_id` argument that is not a UUID. A malformed `org_id` in a request body is `422 validation_error`. When both the header and `org_id` are sent and differ, the request is `400 org_mismatch`. ```bash theme={null} curl https://api.anonalabs.com/v1/retrieve \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "X-Anona-Org: 3f0c9a52-7d1e-4b6a-9c41-2e8d5a7b1f00" \ -H "Content-Type: application/json" \ -d '{"space_id": "support", "query": "refund policy"}' ``` Consenting to an MCP client binds it to your **primary** (personal) membership; use `org_id` or the header to work in a shared organization afterwards. ### Slack and GitHub integrations | Action | Role required | | - | - | | Install or disconnect (including the OAuth callback, which re-checks the role) | Owner | | Bind, unbind, backfill | Owner or admin | A non-owner who installs or disconnects gets `403 integration_owner_only`; a member who is neither owner nor admin who binds, unbinds or backfills gets `403 slack_admin_only` or `403 github_admin_only`. These rules take effect once shared organizations are enabled. Until then, owners and admins install and disconnect (anyone else gets `403 slack_admin_only` or `403 github_admin_only`), and any member can bind, unbind and backfill. Organizations that have never created an API key can still share spaces out and run connectors. A connector resolves the person who spoke to their membership in the organization it is installed in, and falls back to the membership their linked account holds when that one has no grant. ## Error responses | Status | Code | Cause | | - | - | - | | 400 | `invalid_role` | The requested invite role is not valid for this organization. | | 400 | `no_project` | An API key was created before any project existed. | | 400 | `cannot_remove_self` | You cannot remove your own membership. | | 400 | `cannot_remove_owner` | The organization owner cannot be removed. | | 401 | `session_required` | An API key was sent; these endpoints need a signed-in session. | | 403 | `insufficient_role` | The caller's role cannot perform this action. | | 404 | `member_not_found` | The member does not exist, or is not in your organization. | | 409 | `already_member` | The invited email already has an Anona account. | | 422 | `invalid_org_name` | The new name is empty or shorter than 2 characters after trimming whitespace. | See the full [error reference](/memory/api-reference/errors). # API Overview Source: https://docs.anonalabs.com/memory/api-reference/overview The full Anona Memory API surface, grouped by data plane and control plane. The API splits into two planes. | Plane | What it covers | Authentication | | - | - | - | | **Data plane** | The memory operations your agent calls at runtime. Metered in credits. | API key: `Authorization: Bearer anona_live_...` | | **Control plane** | Account, workspace, key, and billing management. Backs the dashboard, and is callable programmatically. | Session token, or an API key where noted | **Base URL:** `https://api.anonalabs.com` > [!NOTE] > > `https://memory.anonalabs.com` also works and always will — including the remote > MCP endpoint and its OAuth flow, which are served on both hostnames. If that is > what your code already uses, nothing needs to change. `api.anonalabs.com` is > simply the recommended host for new integrations. Every endpoint returns errors in a single envelope. See [Errors](/memory/api-reference/errors). ## Data plane Runtime memory operations. See [Authentication](/memory/api-reference/authentication) for how to pass your API key. | Method and path | What it does | Reference | | - | - | - | | `POST /v1/record` | Stores a memory. Anona extracts the durable facts and files them in the space. Pass `"async": true` to queue the write and get a `job_id` back immediately. | [Memories](/memory/api-reference/memories) | | `POST /v1/record/batch` | Bulk-ingests up to 100 memories in one call. Always async, returns a `job_id`. | [Memories](/memory/api-reference/memories) | | `GET /v1/spaces/{space_id}/jobs/{job_id}` | Status of an async ingestion job. Free, consumes no credits. | [Memories](/memory/api-reference/memories) | | `DELETE /v1/spaces/{space_id}/jobs/{job_id}` | Cancels the queued parts of an async ingestion job. Free, consumes no credits. | [Memories](/memory/api-reference/memories) | | `POST /v1/retrieve` | Semantic and keyword search over a space. Returns ranked memories with entities and event time. | [Retrieve](/memory/api-reference/retrieve) | | `POST /v1/reason` | Cross-memory synthesis: asks a question and reasons over everything in the space. | [Reason](/memory/api-reference/reason) | | `GET /v1/spaces/{space_id}/memories` | Lists stored memories, filtered by `type`, `state`, scope, or text `q`, with pagination. | [Memories](/memory/api-reference/memories) | | `GET /v1/spaces/{space_id}/memories/{memory_id}/history` | Change history for a memory: what it said before, and when it changed. | [Memories](/memory/api-reference/memories) | | `PATCH /v1/spaces/{space_id}/memories/{memory_id}` | Edits a memory, or reversibly retires and restores it (`state`). | [Memories](/memory/api-reference/memories#edit-or-retire-a-memory) | | `DELETE /v1/spaces/{space_id}/memories/{memory_id}` | Deletes a memory. | [Memories](/memory/api-reference/memories) | | `POST /v1/spaces/{space_id}/documents` | Uploads files. Their content is parsed, chunked, and made searchable. | [Documents](/memory/api-reference/documents) | | `GET /v1/spaces/{space_id}/documents` · `GET` `DELETE .../documents/{document_id}` | Lists, inspects, and deletes ingested documents. | [Documents](/memory/api-reference/documents) | | `GET /v1/spaces/{space_id}/graph` | The entity graph built from the space, as nodes and co-occurrence edges. | [Graph](/memory/api-reference/graph) | | `GET /v1/spaces/{space_id}/entities` · `GET .../entities/{entity_id}` | Lists entities, and one entity with its observations. | [Graph](/memory/api-reference/graph) | | `GET /v1/receipts/{request_id}` | The context receipt a retrieve or proxy call built, for audit. | [Context receipts](/memory/api-reference/context-receipts) | | `GET /v1/models` | The LLM catalog: every selectable model with its credit rates. | [Model selection](/memory/api-reference/model-selection) | | `GET` `POST /v1/spaces/{space_id}/models` and `GET` `PATCH` `DELETE .../models/{model_id}`, plus `/refresh`, `/clear`, `/history` | Memory models: standing syntheses the space keeps up to date. | [Memory models](/memory/api-reference/models) | | `GET` `PUT` `DELETE /v1/spaces/{space_id}/profile` | The space's mission and disposition dials: what it is for, and how it weighs what it is told. Write is owner-only. Free. | [Memory models](/memory/api-reference/models#space-profile) | | `GET /v1/spaces/{space_id}/users/{user_id}/profile` · `POST .../users/{user_id}/ask` | A per-user profile, and questions about one user. | [User profiles](/memory/api-reference/user-profiles) | | `POST /v1/chat/completions` | OpenAI-compatible chat that auto-retrieves relevant memories into the prompt, and stores the turn back. Supports streaming. | [Chat](/memory/api-reference/chat) | | `POST /v1/responses` | The same memory wrapper in the OpenAI Responses API shape. | [Responses](/memory/api-reference/responses) | | `POST /v1/messages` | The same memory wrapper in the Anthropic Messages shape. | [Messages](/memory/api-reference/messages) | | `GET` `PUT` `DELETE /v1/spaces/{space_id}/chat-settings` | Per-space defaults for the proxy: how many memories to inject, the injected token budget, whether to auto-record. Free. | [Chat](/memory/api-reference/chat#space-defaults) | | `GET` `PUT` `DELETE /v1/spaces/{space_id}/extraction-settings` | Per-space extraction overrides: how much detail a write keeps (`mode`), plus `guidance` added to the standard rules and a `custom_prompt` that replaces them. Free. | [Extraction settings](/memory/api-reference/extraction-settings) | | `GET` `PUT` `DELETE /v1/spaces/{space_id}/reason-settings` | Per-space defaults for `reason`: which model answers, and how deeply it looks. Free. | [Model selection](/memory/api-reference/model-selection) | | `GET` `POST /v1/spaces/{space_id}/rules` · `PATCH` `DELETE .../rules/{rule_id}` | Rules every answer from the space must follow: standing instructions that override what the memories say. Owner-only. Free. | [Spaces](/memory/api-reference/spaces#rules) | | `POST /mcp` | Remote MCP endpoint. Tools: `record`, `retrieve`, `list_spaces`, `reason`, `get_profile`, `get_model`, `get_user_profile`. | [MCP](/memory/mcp-integration) | | `GET /v1/usage/me` | Credits and rate limit remaining for the calling API key. | [Usage](/memory/api-reference/usage) | | `POST` `GET` `PATCH` `DELETE /v1/spaces/{space_id}/webhooks` | Registers an HTTPS endpoint Anona calls when memories are stored or consolidated, instead of polling. | [Webhooks](/memory/api-reference/webhooks) | > [accordion] Try it out: record and retrieve a memory > > ```bash cURL theme={null} > curl -X POST https://api.anonalabs.com/v1/record \ > -H "Authorization: Bearer anona_live_YOUR_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "space_id": "demo-space", > "content": "The user prefers dark mode and replies in Spanish." > }' > > curl -X POST https://api.anonalabs.com/v1/retrieve \ > -H "Authorization: Bearer anona_live_YOUR_KEY" \ > -H "Content-Type: application/json" \ > -d '{ > "space_id": "demo-space", > "query": "What language does the user prefer?" > }' > ``` > > ```python Python theme={null} > from anona import AnonaClient > > client = AnonaClient(api_key="anona_live_YOUR_KEY") > > client.record( > space_id="demo-space", > content="The user prefers dark mode and replies in Spanish.", > ) > > results = client.retrieve( > space_id="demo-space", > query="What language does the user prefer?", > ) > ``` > > ```typescript TypeScript theme={null} > import { Anona } from "@anona-labs/memory"; > > const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); > > await anona.record({ > spaceId: "demo-space", > content: "The user prefers dark mode and replies in Spanish.", > }); > > const results = await anona.retrieve({ > spaceId: "demo-space", > query: "What language does the user prefer?", > }); > ``` > > See [Memories](/memory/api-reference/memories) and [Retrieve](/memory/api-reference/retrieve) for the full request and response shapes. ## Control plane Account and workspace management. Most of these back the dashboard; the workspace, key, and usage endpoints are also useful programmatically. | Method and path | What it does | Reference | | - | - | - | | `GET /v1/orgs/me` | Your organization: plan, status, limits. | [Organization](/memory/api-reference/organization) | | `GET /v1/spaces` · `POST /v1/spaces` | Lists and creates memory spaces. `POST` takes an optional `space_type` that starts the space off with a mission, extraction guidance and two memory models. | [Spaces](/memory/api-reference/spaces) | | `GET /v1/space-types` | The space types `POST /v1/spaces` accepts, and what each one sets up. Needs no API key, and is free. | [Spaces](/memory/api-reference/spaces#space-types) | | `GET /v1/spaces/{id}` · `DELETE /v1/spaces/{id}` | Fetches and deletes a space. | [Spaces](/memory/api-reference/spaces) | | `GET /v1/spaces/{id}/members` · `POST .../members/invite` · `DELETE .../members/{member_id}` · `GET .../slack-invites` | Space membership. | [Spaces](/memory/api-reference/spaces) | | `GET /v1/api-keys` · `POST /v1/api-keys` · `DELETE /v1/api-keys/{id}` | Manages API keys. | [API Keys](/memory/api-reference/api-keys) | | `GET /v1/projects` · `POST /v1/projects` | Manages projects. | [Organization](/memory/api-reference/organization) | | `GET /v1/members/me` · `GET /v1/members` · `POST /v1/members/invite` · `DELETE /v1/members/{id}` | The signed-in member, and organization member management. | [Organization](/memory/api-reference/organization) | | `GET /v1/invitations/pending` · `POST /v1/invitations/accept` · `POST /v1/invitations/decline` | Space invitations waiting on the signed-in person. | [Organization](/memory/api-reference/organization#space-invitations) | | `POST /v1/spaces/{id}/exports` · `GET .../exports` · `GET .../exports/{job_id}/download` | Exports everything a space holds as a downloadable bundle. Session-only, owner-gated. | [Export](/memory/guides/export) | | `GET /v1/usage` · `GET /v1/usage/breakdown` | Organization usage, and a per-operation breakdown. | [Usage](/memory/api-reference/usage) | | `GET /v1/billing` · `POST /v1/billing/upgrade` | Views the plan, and changes to a free tier. | [Billing](/memory/api-reference/billing) | | `POST /v1/billing/checkout` · `POST /v1/billing/portal` · `POST /v1/billing/cancel` | Stripe checkout, billing portal, and cancellation. | [Billing](/memory/api-reference/billing) | > [accordion] Try it out: list spaces and check usage > > ```bash cURL theme={null} > curl https://api.anonalabs.com/v1/spaces \ > -H "Authorization: Bearer anona_live_YOUR_KEY" > > curl https://api.anonalabs.com/v1/usage/me \ > -H "Authorization: Bearer anona_live_YOUR_KEY" > ``` > > ```python Python theme={null} > from anona import AnonaClient > > client = AnonaClient(api_key="anona_live_YOUR_KEY") > > spaces = client.list_spaces() > usage = client.get_usage() > ``` > > ```typescript TypeScript theme={null} > import { Anona } from "@anona-labs/memory"; > > const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); > > const spaces = await anona.listSpaces(); > const usage = await anona.getUsage(); > ``` > > See [Spaces](/memory/api-reference/spaces) and [Usage](/memory/api-reference/usage) for the full request and response shapes. > [!NOTE] > > Account sign-in (`/auth/...`) and MCP OAuth (`/oauth/...`) are browser flows used by the > dashboard and by MCP clients. See [Authentication](/memory/api-reference/authentication) and > [MCP](/memory/mcp-integration) rather than calling them directly. # Reason Source: https://docs.anonalabs.com/memory/api-reference/reason Synthesize a single answer across everything a space has accumulated, instead of a ranked list. Most memory APIs stop at retrieval: they hand back a ranked list and leave the reasoning to you. Reason goes a step further. It runs an agentic pass across the memories in a space, connecting related facts, weighing recency, and resolving what matters, then returns one synthesized answer. | | [Retrieve](/memory/api-reference/retrieve) | Reason | | - | - | - | | **Returns** | A ranked list of memories | A single synthesized answer | | **Latency** | Low | Higher, it reasons across the space | | **Cost** | Lower | Higher | | **Use when** | You want context to inject into a prompt | You want a conclusion, not the evidence | ## Reason over a space ```http theme={null} POST /v1/reason Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "space_id": "customer-support-bot", "query": "What have we learned about Alice's career?" } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `space_id` | string | Yes | Which space to synthesize from. | | `query` | string | Yes | What you want to learn. | | `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](/memory/api-reference/organization#choosing-an-organization-per-request). | | `user_id` | string | No | Reason only over this end user's memories. | | `agent_id` | string | No | Reason only over this agent's memories. | | `session_id` | string | No | Reason only over this conversation's memories. | | `model` | string | No | Which LLM answers: a tier name (`fast`, `balanced`) or a model id. Defaults to the space's setting, then to the platform default. See [model selection](/memory/api-reference/model-selection). | | `depth` | string | No | `fast` (default) or `thorough`. How hard to look before answering — see [Depth](#depth). Defaults to the space's setting, then to `fast`. | | `tag_groups` | object\[] | No | Boolean tag filter, narrowing what the reasoning agent may look at. Same expression `/v1/retrieve` takes. See [Compound tag filters](/memory/api-reference/retrieve#compound-tag-filters). | ## Depth Reason keeps a layer of standing answers — [memory models](/memory/api-reference/models) — above the notes and the raw memories. On the default depth, a memory model that is current can answer on its own, which is what makes a typical call fast. When that model is out of date, so is the answer, and asking the same question again does not help. `depth: "thorough"` is how you say *look harder*. It makes the pass read the notes and the raw memories underneath before it answers, whatever the models say. ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/reason \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "accounts", "query": "Is the two-year renewal a done deal?", "depth": "thorough" }' ``` ```python Python theme={null} answer = client.reason( space_id="accounts", query="Is the two-year renewal a done deal?", depth="thorough", ) ``` ```typescript TypeScript theme={null} const answer = await anona.reason({ spaceId: "accounts", query: "Is the two-year renewal a done deal?", depth: "thorough", }); ``` | | `fast` (default) | `thorough` | | - | - | - | | **Reads** | Memory models first; stops there if they are current | Always the notes and the raw memories too | | **Latency** | Lower | Higher — several seconds more on a typical space | | **Cost** | Lower | Higher: more retrieval, more tokens | | **Use when** | The question is routine and the space is settled | The answer looks stale, or the stakes are worth the wait | Credits are charged on the tokens the call actually uses, so `thorough` costs more because it does more, not because it is priced differently. A very deep question over a very large space can still run out of time; that is `503 reason_timeout`, and dropping back to `fast` is the first thing to try. ### A default for the space A space that is habitually asked hard questions can make `thorough` its default, so callers do not have to remember the field: ```http theme={null} PUT /v1/spaces/{space_id}/reason-settings ``` ```bash cURL theme={null} curl -X PUT https://api.anonalabs.com/v1/spaces/accounts/reason-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": null, "depth": "thorough" }' ``` ```python Python theme={null} client.set_reason_settings(space_id="accounts", model=None, depth="thorough") ``` ```typescript TypeScript theme={null} await anona.setReasonSettings({ spaceId: "accounts", model: null, depth: "thorough" }); ``` Both clients take `model` and `depth` together for the reason below: they are one resource, and an argument you leave out is an argument that clears the other field. A `depth` on the request still wins, so one caller can drop back to `fast` for a simple question without changing the space. > [!WARNING] > > This `PUT` replaces the whole resource. A body naming only `model` clears `depth`, and > the other way round — send both fields, or send `DELETE` to clear everything. ## Reason about one user Pass any of the [scope keys](/memory/api-reference/retrieve#scoping-within-a-space) and the answer is synthesized from that scope alone, the same way [Retrieve](/memory/api-reference/retrieve) narrows a search. Without them, reason covers every memory in the space. ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/reason \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "customer-support-bot", "query": "What is this customer waiting on?", "user_id": "acme_corp" }' ``` ```python Python theme={null} answer = client.reason( space_id="customer-support-bot", query="What is this customer waiting on?", user_id="acme_corp", ) ``` ```typescript TypeScript theme={null} const answer = await anona.reason({ spaceId: "customer-support-bot", query: "What is this customer waiting on?", userId: "acme_corp", }); ``` A scoped question never falls back to the wider space: memories written without a scope are not visible to a scoped call, so a space that adopts scoping part-way through will not see its earlier history here until those memories are rewritten with a scope. **Response** `200 OK` ```json theme={null} { "status": "complete", "insights": "Alice has progressed from junior to senior engineer over 6 years, specializing in distributed systems. She has taken on mentorship responsibilities recently.", "sources": { "models": [], "memories": 7, "notes": 2 }, "rules_applied": [], "usage": { "input_tokens": 340, "output_tokens": 58 } } ``` ## What the answer was built from `sources` says which layer the answer came from, so a result that looks wrong can be diagnosed instead of guessed at. | Field | Meaning | | - | - | | `models` | Ids of the [memory models](/memory/api-reference/models) that grounded the answer. | | `memories` | How many raw memories were used. | | `notes` | How many consolidated notes were used. | These are the memories the reasoning pass **declared it used**, not everything it looked at — which is what makes "the memory is missing" separable from "the memory is there and was not used". Those need opposite fixes. A non-empty `models` list is the first thing to check when an answer looks out of date: it means a standing answer spoke. Refresh that model, or ask again with [`depth: "thorough"`](#depth) to read past it. `"memories": 0, "notes": 0` with no models is not an error — it means the pass found nothing to build on, which is usually an empty space or a scope that matches no memories. > [!NOTE] > > `sources` carries counts, not the memories themselves, because it rides every answer. > To see *which* memories are behind a named model, read > `GET /v1/spaces/{space_id}/models/{model_id}` — that returns the list with their text. ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/reason \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "customer-support-bot", "query": "What have we learned about Alice'"'"'s career?" }' ``` ```python Python theme={null} answer = client.reason( space_id="customer-support-bot", query="What have we learned about Alice's career?", ) print(answer) ``` ```typescript TypeScript theme={null} const answer = await anona.reason({ spaceId: "customer-support-bot", query: "What have we learned about Alice's career?", }); console.log(answer.insights); ``` `client.reason()` returns the synthesized string directly, or `None` when nothing was found. The TypeScript client returns the whole `InsightsResult`, so read `.insights`. A bare string has nowhere to carry `sources`, so Python also offers `client.reason_receipt()` (and `async_reason_receipt`): same question, same answer, same arguments, returning a `ReasonWithReceipt` with `insights`, `sources`, `rules_applied`, `model` and `usage`. `str()` of it is the answer, so it can stand in for `reason` anywhere. ```python Python theme={null} receipt = client.reason_receipt( space_id="customer-support-bot", query="What have we learned about Alice's career?", ) print(receipt.insights, receipt.sources, receipt.model) ``` ## Which rules shaped the answer A space can be given [rules](/memory/api-reference/spaces#rules) — standing instructions it must follow whatever the memories say. `rules_applied` names the ones that actually applied to this answer: ```json theme={null} { "status": "complete", "insights": "The two-year renewal is agreed in principle but not committed: no signed document is recorded.", "rules_applied": [ { "id": "rl_7c1f0a2b", "name": "Never call revenue committed" } ] } ``` | Field | Meaning | | - | - | | `id` | The rule's id, as `GET /v1/spaces/{space_id}/rules` returns it. | | `name` | The name you gave the rule. | **The rule's text is deliberately not echoed.** It is your own configuration, readable once from `GET /v1/spaces/{space_id}/rules`, while this rides every answer — and a space may hold 25 active rules of 2,000 characters each. The name is what identifies which rule spoke. `rules_applied` is always present and never null. An empty list is the finding that no rule applied, which is worth being able to establish: a rule overrides what the memories say, so *the memories are wrong* and *a rule said otherwise* look identical in the prose and need opposite fixes. When an answer is surprising, this is the first field to read after [`sources`](#what-the-answer-was-built-from). > [!NOTE] > > The TypeScript client returns the whole result, so `rules_applied` is on it. The > Python `client.reason()` returns the synthesized text alone — read the field from > the API response if you need it. ## How it works Reason is read-only and never writes new memories. Internally it runs a multi-step pass: retrieving relevant facts, expanding context through linked entities, and cross-referencing related memories. Answer quality improves as more relevant memories accumulate in the space. > [!NOTE] > > `usage` covers that whole internal pass, not just the answer you see. Reason may make > several LLM calls before it settles on a synthesis, so `output_tokens` is routinely > several times larger than the visible answer — a one-sentence conclusion can honestly > report a few hundred output tokens. Credits are charged on these totals. The same > applies to [asking about one user](/memory/api-reference/user-profiles#ask-about-a-user). ## Choose the model that answers Reason is the call where the model matters most, because it is an agentic pass, not a single completion, so a stronger model changes the quality of the synthesis rather than just its wording. Name one per request, or set a default for the space: ```json theme={null} { "space_id": "customer-support-bot", "query": "What is this customer waiting on?", "model": "balanced" } ``` The response echoes the model that actually answered, and credits are charged at that model's rate. Full detail in [model selection](/memory/api-reference/model-selection). ## Error responses | Status | Code | Cause | | - | - | - | | 400 | `bad_request` | Missing `query` or `space_id`. | | 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. | | 429 | `rate_limited` | Credit quota or rate limit exceeded. | | 400 | `unsupported_model` | `model` names something this deployment does not serve. | | 429 | `model_throttled` | The chosen model is at capacity. Anona does not silently substitute another. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Retrieve API](/memory/api-reference/retrieve) > > Query memories for specific information. > [card] [Chat](/memory/api-reference/chat) > > OpenAI-compatible chat with memory built in. # Responses Source: https://docs.anonalabs.com/memory/api-reference/responses The OpenAI Responses API shape, with the same automatic memory. `POST /v1/responses` serves the OpenAI Responses API shape over the same memory wrapper as [Chat](/memory/api-reference/chat): relevant memories are recalled and injected before the model runs, and the turn is stored back afterwards. ```bash cURL theme={null} curl https://api.anonalabs.com/v1/responses \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support-bot" \ -d '{ "model": "gpt-oss", "instructions": "Answer in one sentence.", "input": "What plan am I on?" }' ``` ```python Python theme={null} from openai import OpenAI client = OpenAI( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com/v1", default_headers={"X-Anona-Space-Id": "support-bot"}, ) resp = client.responses.create( model="gpt-oss", instructions="Answer in one sentence.", input="What plan am I on?", ) print(resp.output_text) ``` ```typescript TypeScript theme={null} import OpenAI from "openai"; const client = new OpenAI({ apiKey: "anona_live_YOUR_KEY", baseURL: "https://api.anonalabs.com/v1", defaultHeaders: { "X-Anona-Space-Id": "support-bot" }, }); const resp = await client.responses.create({ model: "gpt-oss", instructions: "Answer in one sentence.", input: "What plan am I on?", }); console.log(resp.output_text); ``` ## Request ```bash theme={null} POST https://api.anonalabs.com/v1/responses Authorization: Bearer anona_live_... Content-Type: application/json ``` ```json theme={null} { "model": "gpt-oss", "space_id": "support-bot", "instructions": "Answer in one sentence.", "input": "What plan am I on?", "max_output_tokens": 512 } ``` | Field | Type | Default | Description | | - | - | - | - | | `input` | string or array | required | A string, or the array form `[{ "role": ..., "content": ... }]` where `content` is a string or a list of typed parts. | | `instructions` | string | none | Becomes the system prompt. The memory block is prepended to it. | | `model` | string | deployment default | Optional. Omit to use whatever model this deployment serves. | | `stream` | boolean | `false` | Emit the typed Responses event stream. | | `max_output_tokens` | integer | none | Optional. | | `temperature` | float | none | Optional. | All [memory tunables](/memory/api-reference/chat#memory-tunables) apply here, in the body or as `X-Anona-*` headers. Content parts without text (images, tool results) are skipped when building the memory query. ## Response ```json theme={null} { "id": "resp_...", "object": "response", "status": "completed", "model": "openai.gpt-oss-120b-1:0", "output": [ { "id": "msg_...", "type": "message", "status": "completed", "role": "assistant", "content": [{ "type": "output_text", "text": "You're on the Scale plan.", "annotations": [] }] } ], "output_text": "You're on the Scale plan.", "usage": { "input_tokens": 42, "output_tokens": 9, "total_tokens": 51 }, "memories_injected": 3, "space_id": "support-bot" } ``` `memories_injected` and `space_id` are the Anona additions, and also arrive as the `X-Anona-Memories-Injected` and `X-Anona-Space-Id` response headers. > [!NOTE] > > `status` is always `completed` and `incomplete_details` always `null` on this > endpoint: there is no `finish_reason` here, and a reply cut off at the token limit is > not distinguishable from one that finished. Use the > [chat proxy](/memory/api-reference/chat#response), whose `finish_reason` reads `length`, if > your client needs to detect truncation. ## Streaming `"stream": true` emits the typed Responses event sequence, each event numbered with `sequence_number`: ``` response.created response.in_progress response.output_item.added response.content_part.added response.output_text.delta ← one per token response.output_text.done response.content_part.done response.output_item.done response.completed ``` ```bash cURL theme={null} curl -N https://api.anonalabs.com/v1/responses \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: support-bot" \ -d '{"model": "gpt-oss", "input": "What plan am I on?", "stream": true}' ``` ```python Python theme={null} with client.responses.stream( model="gpt-oss", input="What plan am I on?", ) as stream: for event in stream: if event.type == "response.output_text.delta": print(event.delta, end="") ``` ```typescript TypeScript theme={null} const stream = await client.responses.create({ model: "gpt-oss", input: "What plan am I on?", stream: true, }); for await (const event of stream) { if (event.type === "response.output_text.delta") process.stdout.write(event.delta); } ``` The final `response.completed` event carries the full response object, including token usage. As on Chat, a failure before the first token is a normal JSON error with a real status code; the turn is recorded and billed after the stream ends. # Retrieve Source: https://docs.anonalabs.com/memory/api-reference/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](/memory/api-reference/organization#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](/memory/api-reference/context-receipts) 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](/memory/api-reference/context-receipts). | ## 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](/memory/api-reference/memories). | | `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](/memory/api-reference/extraction-settings#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](/memory/api-reference/memories), 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](/memory/api-reference/documents) 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](/memory/api-reference/memories) 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`](/memory/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](/memory/guides/automatic-routing). 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](/memory/api-reference/graph). ## 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](/memory/api-reference/memories) | `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](/memory/api-reference/chat#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: > [accordion] 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. > [accordion] 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. > [accordion] 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`. > [accordion] 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. > [accordion] 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](/memory/api-reference/extraction-settings#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`](/memory/api-reference/reason), where it narrows what the reasoning agent may look at, and by the `retrieve` tool on the [MCP server](/memory/mcp-integration). 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](/memory/api-reference/extraction-settings#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](/memory/api-reference/context-receipts). ## 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](/memory/api-reference/errors). ## Next steps > [card] [Reason API](/memory/api-reference/reason) > > Get a synthesized answer instead of a ranked list. > [card] [Graph API](/memory/api-reference/graph) > > Inspect the entities behind your results. > [card] [Context Receipts](/memory/api-reference/context-receipts) > > See exactly what was cut from a call, and why. # Sales enquiry Source: https://docs.anonalabs.com/memory/api-reference/sales-enquiry Ask about an Enterprise plan from your own code, without an account. `POST /v1/sales/enquiry` is the endpoint behind the [contact sales](https://memory.anonalabs.com/contact-sales) form. Enterprise has no self-serve path, so this is how an Enterprise conversation starts if you would rather not use the form or email [support@anonalabs.com](mailto:support@anonalabs.com). It needs **no authentication**: the person asking usually does not have an account yet. It costs no credits. ## Send an enquiry ```http theme={null} POST /v1/sales/enquiry Content-Type: application/json ``` ```json Request theme={null} { "name": "Ada Lovelace", "email": "ada@example.com", "company": "Example Corp", "message": "We need a committed annual pool for three teams.", "estimated_credits": "about 4M a month" } ``` ```json Response theme={null} { "status": "received" } ``` ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/sales/enquiry \ -H "Content-Type: application/json" \ -d '{"name": "Ada Lovelace", "email": "ada@example.com", "company": "Example Corp", "message": "We need a committed annual pool."}' ``` | Field | Type | Notes | | - | - | - | | `name` | string | Required, 1 to 120 characters. | | `email` | string | Required, a valid email address. This is where we reply. | | `company` | string | Required, 1 to 160 characters. | | `message` | string | Required, 1 to 4000 characters. | | `estimated_credits` | string | Optional, up to 80 characters. A rough figure in your own words; "not sure yet" is a fine answer. | Any other field is rejected with `422`, as is a field outside its length or an invalid email address. ## Responses | Status | Meaning | | - | - | | `202` | The enquiry was recorded and handed to our sales team. | | `422` | The body failed validation (see the table above). | | `429` | `too_many_attempts`: more than 5 enquiries an hour from one IP address, or more than 3 an hour for one email address. | The response is `202`, not `200`: the enquiry is stored before anything is sent, and the email to our team is dispatched in the background, so the response cannot confirm delivery. Nothing is sent back to the address in the body; we reply from the sales inbox. # Spaces Source: https://docs.anonalabs.com/memory/api-reference/spaces Create and manage memory spaces, the isolation boundary for memories within your organization. ## Create a space ```http theme={null} POST /v1/spaces/ Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "name": "customer-support-bot", "description": "Memories for the customer support chatbot", "space_type": "customer_support" } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | The name of the space. It also becomes the `space_id`. | | `description` | string | No | What this space is for. | | `space_type` | string | No | Sets the new space up for a job: a mission, extraction guidance, disposition dials and two starting memory models. One of `customer_support`, `coding_agent`, `personal_assistant`, `sales_account`, `blank`. Defaults to `blank`, which configures nothing. See [Space types](#space-types). | `space_id` is not a settable field. Sending one is rejected with `422`. So is a `space_type` that is not on the list above — a request asking for a type Anona does not have is refused rather than quietly creating a plain space. **Response** `201 Created` ```json theme={null} { "space_type": "customer_support", "space_id": "customer-support-bot", "name": "customer-support-bot", "description": "Memories for the customer support chatbot", "created_at": "2026-07-14T12:34:56Z" } ``` **`space_type` in the response is what was applied, not what you asked for.** It is `null` when nothing was applied: when you sent nothing, when you sent `blank`, and also when a type was requested and could not be applied. The space is created and usable either way — applying a type never fails the create — so this field is how you tell a seeded space from a plain one without a second call. If it comes back `null` after you asked for a type, either set the pieces yourself or delete the space and create it again. ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/ \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "customer-support-bot", "description": "Memories for the customer support chatbot" }' ``` ```python Python theme={null} space = client.create_space( "customer-support-bot", description="Memories for the customer support chatbot", ) ``` ```typescript TypeScript theme={null} const space = await anona.createSpace({ name: "customer-support-bot", description: "Memories for the customer support chatbot", }); ``` **The `space_id` is the name you chose.** There is no separate generated id. The name you pass on create is the identifier every other endpoint takes, so pick it deliberately. Every memory operation requires it. > [!WARNING] > > A name containing characters that are not URL-safe produces a `space_id` containing > them too. That is legal, but the id then has to be percent-encoded everywhere it > appears in a path: `my space` becomes `/v1/spaces/my%20space`. Sticking to letters, > digits, `-` and `_` avoids the problem entirely. ## Space types A new space is an empty box. A **space type** fills some of it in at creation: what the space is for, what its extraction pass should keep, how it weighs what it is told, and two standing questions worth keeping an answer to. Pass one as `space_type` on create. That is the only moment a type can be applied — there is no endpoint that converts an existing space, because everything a type sets is already settable on its own. | `space_type` | What it is for | Dials it moves | | - | - | - | | `customer_support` | Conversations with customers. Keeps who they are and what was promised to them. | skepticism 2, empathy 5 | | `coding_agent` | An agent working in a codebase. Keeps decisions, constraints and the conventions it must not break. | literalism 4, empathy 2 | | `personal_assistant` | One person's assistant. Keeps their preferences, their people and what they are waiting on. | empathy 4 | | `sales_account` | One account through a deal. Keeps the people, the objections and where it stands. | skepticism 4, empathy 4 | | `blank` | An empty space. Nothing is configured for you. This is the default. | none | A dial a type does not move stays at its default of 3. The dials are moved only where the use case really differs, and they change how a space weighs what it is told when it synthesizes — never what a plain retrieve returns. See [space profile](/memory/api-reference/models#space-profile). ### What a type sets, and how to change it Three things, all of which stay editable afterwards. | What | Where it lands | How to change it later | | - | - | - | | Mission and disposition | The space profile | `PUT /v1/spaces/{space_id}/profile` — see [space profile](/memory/api-reference/models#space-profile) | | Extraction guidance | The space's `guidance`, added to the standard extraction rules | `PUT /v1/spaces/{space_id}/extraction-settings` — see [extraction settings](/memory/api-reference/extraction-settings) | | Two memory models | Standing questions the space keeps a current answer to | `POST`, `PATCH` and `DELETE /v1/spaces/{space_id}/models` — see [memory models](/memory/api-reference/models) | A space type is a starting point, not a mode. Nothing is locked, nothing behaves differently later because of the type you chose, and a space you seeded is the same kind of space as one you configured by hand. It writes configuration only: no memories are created, and your own writes are unaffected. `space_type` is also not stored as a property of the space, and it is not a field of the read responses at all: `GET /v1/spaces/{space_id}` and `GET /v1/spaces` omit it rather than reporting it null, because there would be nothing to report — what a type did is the mission, the guidance and the models it wrote, and each of those reads back on its own endpoint. > [!WARNING] > > Both of those `PUT`s are full replaces. Read the current settings and send them back > with your edit to keep what the type wrote; sending only the one field you care about > clears the rest. ### The starting models are empty on purpose > [!NOTE] > > A seeded space's two models have **no content** until the space has memories worth > reading. This is expected, not a failed apply. Each one is created with `mode: "delta"` and `refresh_on_new_memories: true`, so it writes itself the first time newly recorded memories are consolidated, and rewrites itself as the space learns more. Until then `GET /v1/spaces/{space_id}/models` lists them with `content: null`. Generating an answer at creation would have been worse than empty. The space is seconds old and holds nothing, so the pass would have spent your credits to store "I have no information about this" as the model's content — and that sentence then reads as the answer, to you and to any agent reading the model, until something replaces it. An empty model is honest about what the space knows; a confident empty one is not. Record something and let it consolidate, then read the models back. To fill one sooner, `POST /v1/spaces/{space_id}/models/{model_id}/refresh` writes it now; that call is billed like [reason](/memory/api-reference/reason). ### What each type starts with Model ids are stable, so a seeded model can be refreshed, edited or deleted by id. | Type | Model | The question it answers | | - | - | - | | `customer_support` | Customer profile and preferences (`customer-profile`) | Who is this customer, what do they use us for, and how do they prefer to be dealt with? | | `customer_support` | Open issues and commitments (`open-commitments`) | What is still unresolved for this customer, and what have we promised them that has not happened yet? | | `coding_agent` | Project context (`project-context`) | What is this project, how is it structured, and what constraints govern changes to it? | | `coding_agent` | Decisions and what was rejected (`decisions-and-rejections`) | What has been decided about how this system works, and what approaches were tried and rejected, and why? | | `personal_assistant` | Preferences and working style (`how-they-work`) | How does this person prefer to work and be communicated with? | | `personal_assistant` | Open threads (`open-threads`) | What is this person waiting on, and what have they committed to that is not done yet? | | `sales_account` | Account and stakeholders (`account-map`) | Who is involved in this account, what does each of them care about, and who decides? | | `sales_account` | Deal state and objections (`deal-state`) | Where does this deal stand, what objections have been raised, and what is the agreed next step? | ### List the space types ```http theme={null} GET /v1/space-types ``` No API key and no credits: it is a static catalogue and it names no customer data. Read it rather than hardcoding the list, which goes stale the first time a type is added or reworded. ```bash theme={null} curl https://api.anonalabs.com/v1/space-types ``` **Response** `200 OK` ```json theme={null} { "items": [ { "space_type": "customer_support", "name": "Customer support", "summary": "For conversations with customers. Keeps who they are and what was promised to them.", "mission": "This space supports customer conversations. Answers are read by support agents who need to pick up a thread without reading its history, so say who the customer is, what they asked for, and what was promised to them.", "disposition": { "skepticism": 2, "empathy": 5 }, "models": [ { "name": "Customer profile and preferences", "question": "Who is this customer, what do they use us for, and how do they prefer to be dealt with?" }, { "name": "Open issues and commitments", "question": "What is still unresolved for this customer, and what have we promised them that has not happened yet?" } ] } ], "total": 5 } ``` One entry per type, in the order a picker should show them, with `blank` last. `mission` is the exact text the type writes to the space profile — worth reading before you choose, and worth editing afterwards if it is nearly right. `disposition` carries only the dials that type moves, and `models` only their names and questions; the answers are written in the space itself. ## List spaces ```http theme={null} GET /v1/spaces/ Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/ \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} for space in client.list_spaces(): print(space["space_id"], space["name"]) ``` ```typescript TypeScript theme={null} for (const space of await anona.listSpaces()) { console.log(space.space_id, space.name); } ``` **Response** `200 OK` ```json theme={null} { "spaces": [ { "space_id": "customer-support-bot", "name": "customer-support-bot", "created_at": "2026-07-14T12:34:56Z" } ], "total": 1 } ``` The list holds your organization's spaces you can open, and every space another organization has shared with you. A shared one carries `shared_by` (the owning organization's name) and `qualified_id` (`{owner_slug}:{space_name}`); both are `null` for a space you own. With an API key, "you" is the member who created the key: a key lists, and reaches, what that person can. A key created before keys recorded their creator lists every space in the organization and no shares. ## Get space details ```http theme={null} GET /v1/spaces/{space_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} space = client.get_space("customer-support-bot") ``` ```typescript TypeScript theme={null} const space = await anona.getSpace("customer-support-bot"); ``` **Response** `200 OK` ```json theme={null} { "space_id": "customer-support-bot", "name": "customer-support-bot", "description": "Memories for the customer support chatbot", "created_at": "2026-07-14T12:34:56Z" } ``` `space_id` may be a shared space's `qualified_id` as well as a bare name. A share is read from the owning organization and comes back with `shared_by` and `qualified_id` set; a bare name that matches both your own space and a share is `409 space_ambiguous`. ## Delete a space ```http theme={null} DELETE /v1/spaces/{space_id} Authorization: Bearer anona_live_YOUR_KEY ``` ```bash cURL theme={null} curl -X DELETE https://api.anonalabs.com/v1/spaces/customer-support-bot \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} client.delete_space("customer-support-bot") ``` ```typescript TypeScript theme={null} await anona.deleteSpace("customer-support-bot"); ``` **Response** `204 No Content`. A space that does not exist is `404 space_not_found`, so a mistyped id is reported rather than read as a successful delete. > [!WARNING] > > Deleting a space permanently destroys the space and every memory in it. This cannot be > undone. ## Space members Spaces can optionally have named members within your organization. > [!NOTE] > > The membership endpoints are control-plane calls: they need a **dashboard session** > with the owner or admin role, not an API key. An API key gets `401 session_required`; > a developer or viewer session gets `403 insufficient_role`. The `GET` listing also > accepts the token `anona login --cli` stores, with the same role check. | Method and path | What it does | | - | - | | `GET /v1/spaces/{space_id}/members` | Lists the members of a space. | | `POST /v1/spaces/{space_id}/members/invite` | Invites a member to the space. | | `DELETE /v1/spaces/{space_id}/members/{member_id}` | Removes a member from the space. | | `GET /v1/spaces/{space_id}/slack-invites` | Lists everyone a bound Slack channel offered this space to who has not accepted. | | State | Who can reach the space | | - | - | | No member rows | Anyone in the organization. The space is treated as org-wide. | | One or more member rows | Only the listed members. Access is enforced. | The org-wide state exists for spaces that pre-date membership: creating a space through the API or dashboard automatically writes a member row for the creator, so a new space is member-only from the moment it exists. ### Invited over Slack A space bound to a [Slack channel](/memory/slack) also has people who were offered it over Slack and have not taken it. They have no Anona account yet, so they are listed separately rather than as half-built members: ```http theme={null} GET /v1/spaces/{space_id}/slack-invites ``` **Response** `200 OK` ```json theme={null} { "items": [ { "id": "7c1f0a2b-1d4e-4a5f-9b31-0c2d8e6f1a44", "slack_user_id": "U086JQ2AB", "display_name": "Alice Ng", "channel_id": "C07ABCDEF", "channel_name": "general", "role": "viewer", "state": "pending", "invited_at": "2026-10-01T09:00:00Z", "expires_at": "2026-10-15T09:00:00Z" } ] } ``` `state` is `pending` (asked, no answer yet), `declined` (said no) or `expired` (fourteen days passed). Anyone who accepted is absent, because they hold a real membership row and the member listing above already names them. There is no email field and there will not be one: the Slack roster is read through `users:read`, never `users:read.email`. `display_name` is null for a roster synced before names were stored. ## Rules A **rule** is a standing instruction the space must follow whenever it answers a question. It is not a preference and it is not a tone setting — those are the disposition dials on the [space profile](/memory/api-reference/models#space-profile), which only shift how an answer is worded. A rule applies to **every** answer the space gives, and it overrides what the memories say: ```text A rule theme={null} Never describe revenue as committed unless a memory records a signed document. Name the stage the deal is actually at instead. ``` That holds even when a memory says the deal is done. Rules shape every answer Anona synthesizes for you: [reason](/memory/api-reference/reason), [asking about one end user](/memory/api-reference/user-profiles#ask-about-a-user), and the answers [memory models](/memory/api-reference/models) keep. They do not touch [retrieve](/memory/api-reference/retrieve) or the [drop-in proxy](/memory/api-reference/chat): a search returns the memories it matched, and the proxy hands your own prompt to your own model with memories added to it. Whatever shapes those answers is yours. > [!NOTE] > > Rules are **owner-only** and **free**. They consume no credits, deliberately — an > organization out of credits must still be able to switch off a rule that is > producing bad answers. ### List the rules on a space ```http theme={null} GET /v1/spaces/{space_id}/rules Authorization: Bearer anona_live_YOUR_KEY ``` **Response** `200 OK` ```json theme={null} { "items": [ { "id": "rl_7c1f0a2b", "space_id": "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, "is_active": true, "tags": [], "created_at": "2026-09-18T09:12:00Z", "updated_at": "2026-09-18T09:12:00Z" } ], "total": 1 } ``` Highest priority first. Inactive rules are listed too: they are stored and still editable, they are simply not applied. ### Add a rule ```http theme={null} POST /v1/spaces/{space_id}/rules Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "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 } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Up to 120 characters. The label an answer reports back in `rules_applied`, so write it for whoever reads that answer. | | `content` | string | Yes | Up to 2,000 characters. The rule itself, in plain language. | | `priority` | integer | No | 0–1000, default 0. Rules apply highest first; equal priorities break on recency, newest first. | | `is_active` | boolean | No | Default `true`. A rule that is off is stored and editable but never applied. | | `tags` | string\[] | No | Up to 20. Narrows when the rule applies — see [Scoping a rule](#scoping-a-rule). Most rules want none. | **Response** `201 Created`, carrying the stored rule in the shape above. ### How many rules a space can have A space may have **25 active** rules at once. A create or an activation that would make it 26 is refused with `422 too_many_active_rules`. The cap counts *active* rules only, so switching one off always frees its slot without deleting anything: ```http theme={null} PATCH /v1/spaces/{space_id}/rules/{rule_id} ``` ```json theme={null} { "is_active": false } ``` Every active rule is written into every answer the space composes, so the cap is what stops a space from spending more of an answer on its own rules than on its memories. Well under it is the better place to be: a rule that is not worth the room is worth switching off. ### Edit or remove a rule `PATCH` is a partial edit — only the fields you send change, and the rest are left exactly as they are. That is deliberate: switching a rule off or lifting its priority should never mean resending the text of something enforced on every answer, where a typo is expensive. A `PATCH` that names no field is refused with `422 empty_update` rather than treated as a no-op. ```http theme={null} DELETE /v1/spaces/{space_id}/rules/{rule_id} ``` **Response** `204 No Content`. Answers already given are unchanged; later ones stop obeying it. ### Which rules shaped an answer [`POST /v1/reason`](/memory/api-reference/reason#which-rules-shaped-the-answer) returns `rules_applied`, naming the rules that actually applied to that answer: ```json theme={null} "rules_applied": [ { "id": "rl_7c1f0a2b", "name": "Never call revenue committed" } ] ``` It carries the id and the name, and **deliberately not the rule's text**. The text is your own configuration — you can read it once from `GET /v1/spaces/{space_id}/rules` — while this rides every answer, and a space may hold 25 active rules of 2,000 characters each. An empty list is a real finding, not a missing field: it says no rule applied. That is what separates *the memories are wrong* from *a rule said otherwise*, which need opposite fixes. ### Scoping a rule `tags` narrow when a rule applies. Most rules want none. * A rule with **no tags** applies to every question the space is asked. This is the usual case, and the one to reach for. * A **tagged** rule applies only to a question filtered to matching tags with [`tag_groups`](/memory/api-reference/retrieve#compound-tag-filters). A question that names no filter is answered under the untagged rules alone, so a tagged rule can never reach a question that did not ask for it. * A question scoped to one end user (`user_id`, `agent_id`, `session_id`) is likewise answered under the untagged rules alone. A tag starting with `anona:` is refused with `422 reserved_tag`. That namespace carries scope, and a rule able to claim it could pin itself to one end user's questions. ### From the SDKs ```python Python theme={null} 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, ) 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_7c1f0a2b", is_active=False) client.delete_rule("accounts", "rl_7c1f0a2b") ``` ```typescript TypeScript theme={null} await anona.createRule({ spaceId: "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, }); for (const rule of await anona.listRules("accounts")) { console.log(rule.priority, rule.name, rule.is_active ? "active" : "off"); } await anona.updateRule({ spaceId: "accounts", ruleId: "rl_7c1f0a2b", isActive: false, }); await anona.deleteRule({ spaceId: "accounts", ruleId: "rl_7c1f0a2b" }); ``` Both clients have `async_` twins in Python and are promise-based in TypeScript. `update_rule` / `updateRule` send only the fields you pass. ## In the SDKs ```python Python theme={null} spaces = client.list_spaces() for s in spaces: print(s["space_id"], s["name"]) for t in client.list_space_types(): print(t["space_type"], "-", t["summary"]) space = client.create_space( "support-bot", description="Customer support memory", space_type="customer_support", ) # None means nothing was applied, whatever you asked for. print(space["space_type"]) client.delete_space("support-bot") ``` ```typescript TypeScript theme={null} const spaces = await anona.listSpaces(); for (const s of spaces) { console.log(s.space_id, s.name); } for (const t of await anona.listSpaceTypes()) { console.log(t.space_type, "-", t.summary); } const space = await anona.createSpace({ name: "support-bot", description: "Customer support memory", spaceType: "customer_support", }); // null means nothing was applied, whatever you asked for. console.log(space.space_type); await anona.deleteSpace("support-bot"); ``` Listing, listing types, creating, fetching and deleting a space are wrapped in both clients (Python also ships `async_` variants). Membership management is dashboard/session-only, so it is deliberately in neither API-key SDK. ## Error responses | Status | Code | Cause | | - | - | - | | 401 | `missing_auth` / `invalid_api_key` | Missing or invalid API key. | | 403 | `space_access_denied` | You are not a member of this space. | | 403 | `space_limit_reached` | Your plan's space limit is used up. | | 403 | `space_read_only` | A read-only member attempted a write. | | 403 | `space_owner_only` | Only the owning organization can read or change a space's rules. | | 403 | `space_unavailable` | The owner of a shared space can't be reached: the owning organization has revoked its API keys and holds no live one, or was deleted. A share is served while the owner holds a live key, and also when it has never created a key or holds only expired ones. | | 404 | `space_not_found` | The space does not exist. | | 404 | `rule_error` | No rule with that id on this space. | | 409 | `space_ambiguous` | A bare name matches both your own space and one shared with you — use the `qualified_id`. | | 409 | `space_exists` | Create was called with a name you already hold. A space's id is its name, so re-creating is never a fresh space — fetch or delete the existing one instead. | | 422 | `validation_error` | Missing or invalid `name`, a `space_id` field, or a `space_type` that is not one of the five. | | 422 | `too_many_active_rules` | A space may have at most 25 active rules. Switch one off or remove it first. | | 422 | `empty_update` | A rule `PATCH` that names no field to change. | | 422 | `reserved_tag` | A rule tag starting with `anona:`, which is reserved for scope. | There is no `429` here. The space endpoints are control-plane calls: they spend no credits and are not rate-limited, so they carry none of the `X-RateLimit-*` headers either. See [Usage](/memory/api-reference/usage#response-headers). See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Memories API](/memory/api-reference/memories) > > Store and manage memories. > [card] [Errors](/memory/api-reference/errors) > > The complete error code reference. # Usage Source: https://docs.anonalabs.com/memory/api-reference/usage Check credits and rate limits for a single key or for the whole organization. Anona meters every operation in credits and enforces a per-minute rate limit. These endpoints report where you stand. > [!NOTE] > > Token counts on [Reason](/memory/api-reference/reason) and ask-about-user responses cover the > whole internal reasoning pass — several LLM calls of retrieval and synthesis — not just > the answer text you receive. A short answer with a few hundred `output_tokens` is > normal, and those totals are what credits are charged on. | Method and path | Auth | Returns | | - | - | - | | `GET /v1/usage/me` | API key | Current credit balance and rate limit for the calling key's organization. | | `GET /v1/usage` | Dashboard session | Credit usage over a period, with a time series. | | `GET /v1/usage/breakdown` | Dashboard session | Per-operation breakdown across record, retrieve, reason, and chat. | The organization endpoints back the dashboard and need a signed-in session — an API key gets `401 session_required` there. `GET /v1/usage/me` is the one to call from inside your application. ## Key usage A lightweight balance check to run from inside your application. ```http theme={null} GET https://api.anonalabs.com/v1/usage/me Authorization: Bearer anona_live_... ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/usage/me \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} usage = client.get_usage() print(usage["credits_remaining"], usage["rate_limit_per_min"]) ``` ```typescript TypeScript theme={null} const usage = await anona.getUsage(); console.log(usage.credits_remaining, usage.rate_limit_per_min); ``` ```json theme={null} { "credits_remaining": 1487233, "credits_limit": 1500000, "credits_used": 12767, "rate_limit_per_min": 2000 } ``` ## Response headers Live limits are also returned on every response from a **metered endpoint** — the ones that spend credits — so you can track them without an extra call. Control-plane endpoints (spaces, keys, members, usage, billing) are not rate-limited and carry none of these headers. | Header | Meaning | | - | - | | `X-RateLimit-Limit` | Requests allowed in the current window. | | `X-RateLimit-Remaining` | Requests left in the current window. | | `X-RateLimit-Window` | Length of the window. | | `X-Credits-Remaining` | Credits left on the organization's quota. | ## Organization usage `GET /v1/usage` and `GET /v1/usage/breakdown` both back the usage views in the dashboard. The period window is the `days` query parameter: how many days back from now to count, 1 to 365, defaulting to 30. It is echoed as `period_days` in the response. ```http theme={null} GET https://api.anonalabs.com/v1/usage?days=7 Authorization: Bearer $SESSION ``` ### Memory context Both also report what memory injection cost your prompts on the [drop-in proxy](/memory/api-reference/chat), and how much of it your provider had already seen. ```json theme={null} { "total_memories_added": 412, "total_searches": 1980, "total_insights": 37, "period_days": 30, "series": [ "…" ], "memory_context": { "calls": 1204, "context_tokens": 386400, "cache_prefix_tokens": 271900 } } ``` | Field | Meaning | | - | - | | `calls` | Proxied chat turns in the period. Direct `retrieve` and `record` calls build no prompt, so they are not counted here. | | `context_tokens` | What the injected memory block cost those prompts. | | `cache_prefix_tokens` | How much of it was byte-identical to the previous turn's block for the same space and scope, the part a provider's prompt cache can match on. | `GET /v1/usage/breakdown` carries the same two totals per space. Both figures are `len/4` estimates, not tokenizer counts. And `cache_prefix_tokens` is eligibility rather than a saving: an unchanged prefix is what makes a cache hit possible, but whether your provider charged the discounted rate also depends on their minimum cacheable length and their own cache TTL. Raise it with [`block_order: "stable"`](/memory/api-reference/chat#prompt-caching). ## Next steps > [card] [Billing](/memory/api-reference/billing) > > Plans, credit costs, and subscription management. > [card] [Errors](/memory/api-reference/errors) > > What happens when a quota runs out. # User Profiles Source: https://docs.anonalabs.com/memory/api-reference/user-profiles Everything a space has learned about one end user, and a scoped question-and-answer over just their memories. If your app [scopes writes and reads by `user_id`](/memory/guides/scoping-multi-tenant), a space accumulates a per-user history without anywhere to address it directly; you'd otherwise reconstruct it yourself with `retrieve` and a wide-open query. These two endpoints are that address: one reads back what a space knows about a user, the other asks a question that is answered from that user's memories only. > [!NOTE] > > Both endpoints require `user_id` to already be a scope your app uses. There is nothing to > create or register up front. See [An unknown user is not a 404](#an-unknown-user-is-not-a-404) > below. ## Get a user's profile ```http theme={null} GET /v1/spaces/{space_id}/users/{user_id}/profile Authorization: Bearer anona_live_YOUR_KEY ``` | Query parameter | Type | Default | Description | | - | - | - | - | | `limit` | integer | `50` | Maximum memories to return, 1 to 200. | | `offset` | integer | `0` | Pagination offset. | | `memory_type` | string | none | Restrict to one layer, e.g. `note` for only the synthesized view. Omit for everything known about this user, collapsed so each piece of knowledge appears once. See [below](#the-default-view-is-one-layer). | | `format` | string | `results` | `results` returns only `memories`. `block` also renders a prompt-ready `context` string, the same renderer [Retrieve](/memory/api-reference/retrieve#prompt-ready-context) uses. | | `context_max_tokens` | integer | none | Approximate token ceiling for that block. Only applies when `format` is `block`. | **Response** `200 OK` ```json theme={null} { "space_id": "customer-support-bot", "user_id": "alice_123", "memory_count": 14, "first_seen": "2026-05-02T09:12:00Z", "last_active": "2026-08-15T18:40:11Z", "memories": [ { "id": "mem_abc123def456", "content": "Alice prefers email over phone for support follow-ups.", "text": "Alice prefers email over phone for support follow-ups.", "context": "support call", "date": "2026-08-15T18:40:11Z", "type": "note", "entities": "Alice", "metadata": { "source": "crm" }, "source_ids": ["mem_9f21e2", "mem_9f2c04"], "user_id": "alice_123", "agent_id": null, "session_id": null, "member_id": null } ] } ``` | Field | Type | Description | | - | - | - | | `space_id` | string | The space this profile was read from. | | `user_id` | string | The end user, as passed in the path. | | `memory_count` | integer | How many memories carry this user's scope, under whatever `memory_type` filter was sent. `0` for a user who has never been written to. See below. **This number can go down as well as up**. See [The count is not monotonic](#the-count-is-not-monotonic). | | `first_seen` | string or null | When this user's earliest memory was learned, taken from record time, not the event it describes. `null` if `memory_count` is `0`, or if the oldest memory has no recorded timestamp. | | `last_active` | string or null | When this user's most recent memory was learned. `null` if `memory_count` is `0`. | | `memories` | array | The same item shape [`GET /v1/spaces/{space_id}/memories`](/memory/api-reference/memories#list-memories-in-a-space) returns. | | `context` | string or null | Present only when `format` is `block`. | | `token_estimate` | integer or null | Present only when `format` is `block`. Derived from a characters-per-token approximation, not a tokenizer, so treat it as a guide, not an exact count. | ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/users/alice_123/profile?limit=20" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} profile = client.get_user_profile( space_id="customer-support-bot", user_id="alice_123", limit=20, ) print(profile["memory_count"], profile["last_active"]) ``` ```typescript TypeScript theme={null} const profile = await anona.getUserProfile({ spaceId: "customer-support-bot", userId: "alice_123", limit: 20, }); console.log(profile.memory_count, profile.last_active); ``` ### Prompt-ready profile Pass `format=block` to also get a joined, token-budgeted string, ready to drop into a system prompt, the same rendering `retrieve` uses. ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/users/alice_123/profile?format=block&context_max_tokens=500" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} data = client.get_user_profile( space_id="customer-support-bot", user_id="alice_123", format="block", context_max_tokens=500, ) messages = [ {"role": "system", "content": f"What we know about this user:\n{data['context']}"}, {"role": "user", "content": user_message}, ] ``` ```typescript TypeScript theme={null} const data = await anona.getUserProfile({ spaceId: "customer-support-bot", userId: "alice_123", format: "block", contextMaxTokens: 500, }); const messages = [ { role: "system", content: `What we know about this user:\n${data.context}` }, { role: "user", content: userMessage }, ]; ``` ```json theme={null} { "context": "1. Alice prefers email over phone for support follow-ups.\n2. Alice is on the Scale plan.", "token_estimate": 24, "memory_count": 14, "memories": [ "…unchanged…" ] } ``` ## Ask about a user Runs a synthesis pass over one user's memories only, and returns a single answer instead of a list. Scope is not a filter you can relax here: it is forced to `all_strict`, so the answer only ever comes from memories written under this `user_id`. ```http theme={null} POST /v1/spaces/{space_id}/users/{user_id}/ask Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json ``` ```json theme={null} { "query": "What does Alice care about most in support interactions?", "model": "balanced" } ``` | Parameter | Type | Required | Description | | - | - | - | - | | `query` | string | Yes | What you want to learn about this user. | | `model` | string | No | Which LLM answers. A tier name (`fast`, `balanced`) or a model id, from the same catalog [Reason](/memory/api-reference/reason) uses, and credits are charged at that model's rate. See [model selection](/memory/api-reference/model-selection). Omit it to use the space's reason default, then the platform default. | | `depth` | string | No | `fast` (default) or `thorough`. How hard to look before answering, the same field [Reason](/memory/api-reference/reason#depth) takes. Omit it to use the space's reason default, then `fast`. | **Response** `200 OK` ```json theme={null} { "space_id": "customer-support-bot", "user_id": "alice_123", "insights": "Alice values fast, low-friction resolutions and prefers email confirmation over phone calls.", "usage": { "input_tokens": 210, "output_tokens": 42 }, "model": "openai.gpt-oss-120b-1:0" } ``` | Field | Type | Description | | - | - | - | | `space_id` | string | The space this answer was synthesized from. | | `user_id` | string | The end user this answer is about. | | `insights` | string or null | The synthesized answer, or `null` when nothing was found for this user. | | `usage` | object or null | Token usage for the synthesis call. | | `model` | string or null | The model that actually answered, not necessarily the one requested, since omitting `model` resolves a default. Reconcile the credits on this call against this field, not against what you asked for. | > [!NOTE] > > `model` and `depth` both resolve **request → the space's reason default → the platform > default**, the same ladder [`/v1/reason`](/memory/api-reference/reason) uses and from the same > stored settings. So configuring a space once covers both surfaces, and a per-question > value here overrides it without changing what is stored. > > `depth: "thorough"` is worth more on this endpoint than on the space-wide one: a single > user's [memory models](/memory/api-reference/models) are refreshed less often than the space's, > so a standing answer about one person is the more likely one to have gone stale. ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/customer-support-bot/users/alice_123/ask \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "What does Alice care about most in support interactions?"}' ``` ```python Python theme={null} answer = client.ask_about_user( space_id="customer-support-bot", user_id="alice_123", query="What does Alice care about most in support interactions?", ) print(answer["insights"]) ``` ```typescript TypeScript theme={null} const answer = await anona.askAboutUser({ spaceId: "customer-support-bot", userId: "alice_123", query: "What does Alice care about most in support interactions?", }); console.log(answer.insights); ``` ## Two things worth knowing ### An unknown user is not a 404 A `user_id` is not a resource you create; it's a scope tag stamped onto memories the first time you record with that `user_id`. There's no user registry, so there's no valid set for a typo to fall outside of. A `user_id` nobody has ever recorded under still returns `200`: ```json theme={null} { "space_id": "customer-support-bot", "user_id": "does-not-exist-yet", "memory_count": 0, "first_seen": null, "last_active": null, "memories": [] } ``` A `404 space_not_found` is still returned for an unknown **space**, because a space is a real object your account owns, so that stays a real error. ### The default view is one layer Omitting `memory_type` returns each piece of knowledge about the user **once**: the consolidated `note` where one has been synthesized, the raw `fact` where it hasn't yet. Consolidation runs on a background loop, so a user active in the last few minutes may show up as raw facts with no note above them yet. That's expected, not a gap. Pass `memory_type=note` to see only the synthesized layer. `memory_type=fact` is **not** the full evidence set: this endpoint always collapses a fact into its consolidation once one exists, so `memory_type=fact` here returns only facts that have not been consolidated yet, a set that shrinks toward empty as consolidation catches up. To read every raw fact regardless of consolidation state, use [`GET /v1/spaces/{space_id}/memories`](/memory/api-reference/memories#list-memories-in-a-space) with `type=fact&prefer_observations=false`. See [Memory types](/memory/api-reference/retrieve#memory-types) for the full set of layers. ### The count is not monotonic Because the default view collapses layers, **`memory_count` can decrease between two reads even though nothing was deleted and more was written in between.** Consolidation folds several raw facts into a single note. Before it runs, those facts are counted individually; afterwards they are hidden behind the one note that replaced them, and the count drops. A profile observed during a bulk import can genuinely read 115, then 67 a few minutes later, while the underlying corpus grew the whole time. Nothing is lost. To see the full corpus including every raw fact regardless of consolidation state, use [`GET /v1/spaces/{space_id}/memories`](/memory/api-reference/memories#list-memories-in-a-space) with `prefer_observations=false`, and that total only ever grows. Treat `memory_count` as "how many distinct things we currently know about this user", not as an ingestion counter. If you need a stable number to show users or alert on, take it from the uncollapsed list instead. ## Scope must match exactly `user_id` here has to be the **same value** your app passes when recording. See [Scoping](/memory/guides/scoping-multi-tenant). A profile only sees memories written **with** that scope, so: * A typo'd `user_id` on either side silently looks like an empty profile, not an error. * If your space has history that predates adopting scoping, that older, unscoped history will not appear here. Backfill the scope onto it if these endpoints need to see it too. ## Pricing | Operation | Credits | Notes | | - | - | - | | Get profile | 1 | Same base cost as [Search](/memory/api-reference/retrieve). | | Ask about a user | 5, plus token usage | Priced the same as [Reason](/memory/api-reference/reason), since a synthesis pass runs behind it. | ## Error responses | Status | Code | Cause | | - | - | - | | 401 | `missing_auth` / `invalid_api_key` / `key_expired` | No `Authorization` header, a key Anona did not issue or has revoked, or a key past its expiry date. | | 403 | `space_access_denied` | You are not a member of this space. | | 403 | `space_unavailable` | The space is shared, but the owner can't be reached: the owning organization has revoked its API keys and holds no live one, or was deleted. A share is served while the owner holds a live key, and also when it has never created a key or holds only expired ones. | | 404 | `space_not_found` | No space with that id. | | 409 | `space_ambiguous` | This name exists both as a space you own and as one shared with you. Address it with the qualified form, `{owner}:{space_name}`. | | 422 | `invalid_scope` | `user_id` is empty, or is not 1-128 characters of letters, digits, or `._@=+-` (no spaces or colons). | | 429 | `rate_limited` / `credits_exhausted` | Rate limit or credit quota exceeded. | See the full [error reference](/memory/api-reference/errors). ## Next steps > [card] [Scoping guide](/memory/guides/scoping-multi-tenant) > > How `user_id` isolates memories inside one space. > [card] [Reason API](/memory/api-reference/reason) > > Synthesize an answer across an entire space, not just one user. > [card] [Retrieve API](/memory/api-reference/retrieve) > > Rank a space's memories against a query. > [card] [MCP Integration](/memory/mcp-integration) > > Read a user's profile from an MCP-connected assistant. # Webhooks Source: https://docs.anonalabs.com/memory/api-reference/webhooks Register an HTTPS endpoint and Anona posts to it when something happens in a space, so you never have to poll. Every delivery is signed with HMAC-SHA256 and retried if your endpoint is down. ## Events | Event | Fires when | | - | - | | `memory.created` | An item finished being stored and indexed. | | `memory.consolidated` | Related memories were merged into a consolidated note. | | `security.policy_triggered` | Content matched a security policy and was redacted or blocked. | ## From the dashboard Everything below is also available without writing code. Open a space in the dashboard and pick the **Webhooks** tab, where you can add and edit endpoints, choose which events they receive, pause one without deleting it, and read the delivery log for a receiver that is not responding. > [!WARNING] > > The signing secret is shown once, immediately after you add the webhook. It is never > displayed again, in the dashboard or in the API. ## Register a webhook ```http theme={null} POST /v1/spaces/{space_id}/webhooks Authorization: Bearer anona_live_YOUR_KEY Content-Type: application/json { "url": "https://example.com/hooks/anona", "event_types": ["memory.created", "memory.consolidated"], "enabled": true } ``` | Field | Type | Default | Description | | - | - | - | - | | `url` | string | required | HTTPS endpoint. Must be publicly reachable. | | `event_types` | string\[] | `["memory.created"]` | Events to deliver. | | `enabled` | boolean | `true` | Deliveries pause while this is `false`. | ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/anona", "event_types": ["memory.created", "memory.consolidated"] }' ``` ```python Python theme={null} hook = client.create_webhook( space_id="customer-support-bot", url="https://example.com/hooks/anona", event_types=["memory.created", "memory.consolidated"], ) store_secret(hook["secret"]) # shown once ``` ```typescript TypeScript theme={null} const hook = await anona.createWebhook({ spaceId: "customer-support-bot", url: "https://example.com/hooks/anona", eventTypes: ["memory.created", "memory.consolidated"], }); storeSecret(hook.secret); // shown once ``` **Response** `201 Created` ```json theme={null} { "id": "wh_a1b2c3d4", "space_id": "customer-support-bot", "url": "https://example.com/hooks/anona", "event_types": ["memory.created", "memory.consolidated"], "enabled": true, "secret": "whsec_KJ8s...", "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z" } ``` > [!WARNING] > > `secret` is returned only here, only once. Store it now: you need it to verify > signatures, and it is `null` on every other response. | Constraint | Value | | - | - | | Webhooks per space | 5 | | Scheme | HTTPS only. Plain `http://` is rejected. | | Address | Must resolve publicly. `localhost` and private ranges are rejected with `invalid_webhook_url`. | ## Delivery format ```http theme={null} POST /hooks/anona HTTP/1.1 Content-Type: application/json X-Anona-Event: memory.created X-Anona-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015... { "event": "memory.created", "space_id": "customer-support-bot", "operation_id": "op_7f3e2a91", "status": "completed", "timestamp": "2026-07-30T12:00:03Z", "data": { "document_id": "doc_5b1c", "tags": ["support"] } } ``` The `data` object varies by event: | Event | `data` fields | | - | - | | `memory.created` | `document_id`, `tags` | | `memory.consolidated` | `observations_created`, `observations_updated`, `observations_deleted`, `error_message` | | `security.policy_triggered` | `action` (`redact` or `block`), `detector`, `document_id`, `matched_types`, `message` | Fields with no value are omitted rather than sent as `null`. Respond with any `2xx` status to acknowledge. Anything else counts as a failure and is retried. ## Verifying signatures `X-Anona-Signature` is `sha256=` followed by the HMAC-SHA256 of the raw request body, keyed with your webhook secret. Compare it in constant time, never with `==` on the raw strings. ```python Python theme={null} import hmac, hashlib def verify(raw_body: bytes, signature_header: str, secret: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header) # FastAPI example. Note the RAW body, not the parsed JSON. @app.post("/hooks/anona") async def receive(request: Request): raw = await request.body() if not verify(raw, request.headers.get("X-Anona-Signature", ""), WEBHOOK_SECRET): raise HTTPException(status_code=401, detail="bad signature") event = json.loads(raw) ... return {"ok": True} ``` ```typescript TypeScript theme={null} import crypto from "node:crypto"; function verify(rawBody, signatureHeader, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: express.raw() keeps the body bytes intact for signing app.post("/hooks/anona", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req.body, req.get("X-Anona-Signature"), WEBHOOK_SECRET)) { return res.status(401).send("bad signature"); } const event = JSON.parse(req.body); res.json({ ok: true }); }); ``` > [!NOTE] > > Sign the raw bytes. Parsing the JSON and re-serializing it changes key order and > whitespace, which changes the hash. This is the most common reason verification fails. ## Retries A delivery is attempted up to 6 times over roughly 7 hours, then marked failed. | Attempt | Sent after | | - | - | | 1 | Immediately | | 2 | 5 seconds | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 5 hours | Deliveries are at-least-once: a receiver that times out after doing its work still gets retried. Make your handler idempotent on `operation_id`. ## Timing and ordering | Behavior | What it means for you | | - | - | | **Webhooks never slow down your writes.** | The delivery is queued as part of the write and sent by a background worker, so a slow, failing, or retrying endpoint has no effect on `POST /v1/record` latency. | | **Acknowledge fast, work afterwards.** | Deliveries time out after 10 seconds. Return a `2xx` as soon as you have accepted the event, then do the real work asynchronously. A receiver that holds the connection open is treated as failed and retried, so slow handlers turn into duplicate deliveries. | | **A delivery means the write is committed.** | The delivery is queued in the same database transaction as the memory itself. You never get an event for a write that was rolled back, and a write is never committed while silently dropping its notification. | | **The event arrives after the API response.** | Typically within a second or two of the write completing. | | **A new webhook can take up to 15 seconds to start receiving events.** | Registration is immediate, but a brief internal cache means the first event or two after adding a webhook may not be delivered. If you are testing an integration and the first event does not arrive, wait a moment and write again before digging further. Edits to an existing webhook take effect immediately. | > [!TIP] > > Pair webhooks with `"async": true` on `POST /v1/record`. Async ingestion returns a > `job_id` straight away instead of holding the request open while memories are extracted > and indexed, and the webhook is then how you learn it finished. No polling at all. ## List webhooks ```http theme={null} GET /v1/spaces/{space_id}/webhooks ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} hooks = client.list_webhooks("customer-support-bot") ``` ```typescript TypeScript theme={null} const hooks = await anona.listWebhooks("customer-support-bot"); ``` ```json theme={null} { "items": [ { "id": "wh_a1b2c3d4", "space_id": "customer-support-bot", "url": "https://example.com/hooks/anona", "event_types": ["memory.created"], "enabled": true, "secret": null, "created_at": "2026-07-30T12:00:00Z", "updated_at": "2026-07-30T12:00:00Z" } ] } ``` ## Update a webhook Only the fields you send are changed. Returns the updated webhook. ```bash cURL theme={null} curl -X PATCH \ https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks/wh_a1b2c3d4 \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' ``` ```python Python theme={null} hook = client.update_webhook( space_id="customer-support-bot", webhook_id="wh_a1b2c3d4", enabled=False, ) ``` ```typescript TypeScript theme={null} const hook = await anona.updateWebhook({ spaceId: "customer-support-bot", webhookId: "wh_a1b2c3d4", enabled: false, }); ``` ## Delete a webhook ```bash cURL theme={null} curl -X DELETE \ https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks/wh_a1b2c3d4 \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} client.delete_webhook(space_id="customer-support-bot", webhook_id="wh_a1b2c3d4") ``` ```typescript TypeScript theme={null} await anona.deleteWebhook({ spaceId: "customer-support-bot", webhookId: "wh_a1b2c3d4", }); ``` Returns `204 No Content`. Queued deliveries for that webhook stop. ## Debugging deliveries When a receiver is not working, inspect the attempts: ```bash cURL theme={null} curl "https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks/wh_a1b2c3d4/deliveries?limit=50" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` ```python Python theme={null} page = client.list_webhook_deliveries( space_id="customer-support-bot", webhook_id="wh_a1b2c3d4", limit=50, ) ``` ```typescript TypeScript theme={null} const page = await anona.listWebhookDeliveries({ spaceId: "customer-support-bot", webhookId: "wh_a1b2c3d4", limit: 50, }); ``` ```json theme={null} { "items": [ { "id": "dlv_1f2e", "event_type": "memory.created", "url": "https://example.com/hooks/anona", "status": "pending", "attempts": 3, "response_status": 500, "error": "Server error '500 Internal Server Error'", "next_retry_at": "2026-07-30T12:35:00Z", "last_attempt_at": "2026-07-30T12:05:00Z", "created_at": "2026-07-30T12:00:03Z" } ], "next_cursor": null } ``` | Field | Description | | - | - | | `status` | `pending`, `processing`, `completed`, or `failed`. A delivery waiting on its next attempt is `pending` with `next_retry_at` set; `failed` is terminal, reached only after all 6 attempts, and leaves `next_retry_at` null. | | `attempts` | How many times delivery has been tried. | | `response_status` | The HTTP status your endpoint last returned. | | `error` | Why the last attempt failed. | | `next_retry_at` | When the next attempt is scheduled, or `null` if none remain. | Pass `next_cursor` back as `cursor` to page through older deliveries. ## Errors | Code | Meaning | | - | - | | `invalid_webhook_url` | Not HTTPS, embeds credentials, or does not resolve to a public address. | | `unknown_event_type` | The event name is not in the table above. | | `webhook_limit_reached` | The space already has 5 webhooks. | | `no_fields` | The `PATCH` body was empty. | See [Errors](/memory/api-reference/errors) for the general error shape. # Changelog Source: https://docs.anonalabs.com/memory/changelog What shipped in Anona Memory, newest first. Anona Memory ships continuously, so there are no version numbers to follow. This page groups each month's customer-facing changes. Internal work, infrastructure and tests are left out. Subscribe to the feed at [docs.anonalabs.com/memory/changelog/rss.xml](https://docs.anonalabs.com/memory/changelog/rss.xml). **October 2026** ### One sign-in for the CLI and MCP `anona login` now asks for one approval instead of two. pip `anona` 0.26.1 and npm `@anona-labs/memory` 0.20.1 store a single login that the data commands and `anona mcp` both use, so a person signs in once. `anona login --mcp` still asks for a separate MCP-only login, which `anona mcp` uses only when there is no command-line login. An existing MCP-only login keeps working. See [Signing in](/memory/cli/sign-in#with-anona-login). ### The CLI now covers the rest of the API pip `anona` 0.26.0 and npm `@anona-labs/memory` 0.20.0 add a command for nearly every remaining API route: * **Per-space config:** `models` (list, create, edit, refresh, clear, history, delete), `profile set` and `profile reset`, `rules`, `webhooks` (including `deliveries`), `routing-settings`, and `spaces members list` (needs `anona login --cli`). * **Explore:** `graph` (and `graph draw`), `entities`, `receipts get`, `ask-user`, `chat` and `space-types`. * **Account:** `org`, `orgs`, `projects`, `keys`, `members`, `invitations`, `billing`, `exports` and `usage --history` / `--breakdown`. These read the control plane, which a CLI login from `anona login --cli` can now call on a fixed set of read routes. An API key or temporary profile gets `session_required` (exit `4`). * Creating or revoking a key, changing plan, renaming or deleting an organization and inviting or removing members, of the organization or of a space, stay in the dashboard. Those commands print the dashboard link and exit `4` without sending anything. See [the CLI reference](/memory/cli/account) and [Authentication](/memory/api-reference/authentication#which-credential-each-route-accepts). ### Save a session on demand: `/anona-memory:save` Ask the agent to save or export the session, or run `/anona-memory:save` in Claude Code (plugin `0.4.0`), and it records every durable fact the session settled, one memory each, then tells you how many it wrote. Add a topic to save only part of it: `/anona-memory:save the deploy decisions`. An exported conversation works the same way: hand it over and ask the agent to save what is in it. See [Saving a session](/memory/integrations/skills#saving-a-session). ### Claude Code hooks keep Anona in sync, no API key needed The Claude Code plugin (`0.3.0`) now runs three hooks on its own, covering the moments the agent gets no turn: * **Session start:** recalls what your space knows about the repository, branch and recent commits, and hands it to the agent as untrusted background data. It runs in the background, so startup never waits. * **Before compaction:** sends a redacted excerpt of the conversation about to be summarised away (your prompts and the agent's replies, never tool output), so its decisions and fixes are kept. * **Session end:** records a short work log: the task, branch, files changed and commits. They use `ANONA_API_KEY` when it is set, and otherwise the login the `anona` CLI already holds, through `anona mcp`. Reads route automatically when you have more than one space. Writes go only to a space that is your own. Update with `/plugin marketplace update anona`, then restart Claude Code. Installs from the installer or `npx skills` can add the hooks by hand. See [Hooks](/memory/integrations/skills#hooks). ### The onboarding prompt no longer plants an example memory The onboarding prompt's setup check used to record "The user prefers pnpm." into the new profile's real memory. An agent following it literally stored a preference you never stated. The check now records that the setup check passed, and the page says its other example sentences only show the shape of a call. `anona start` also no longer says "Nothing warns you before then": the CLI does remind you in the last 24 hours. In pip `anona` 0.25.4 and npm `@anona-labs/memory` 0.19.4. ### Clearer CLI messages, and an unknown model suggests the right one * **Unknown model names:** a misspelled model name in `chat-settings`, `reason-settings` or any API field now suggests up to three close matches and points to `GET /v1/models`, instead of listing every model. * **Unknown commands:** an unknown `anona` command now says your copy may be older than the docs and gives the upgrade command. * **Claiming:** `anona login --help` explains that approving claims a stored temporary profile. * **`anona status`:** * It says the MCP token renews itself, and that you only need `anona login` again after a revocation or 30 unused days. * With an API key, it says the key is not checked here (`anona doctor` checks it). * **Onboarding prompt:** agents now read credits with `anona usage --json` instead of taking the token out of `~/.anona/credentials.json`. In pip `anona` 0.25.3 and npm `@anona-labs/memory` 0.19.3. ### `member_id=me` in a personal space matches every memory `member_id=me`, on the memory list and on retrieve, used to return nothing in a personal space. That is because only spaces shared across organizations record who wrote each memory. In a personal space every memory is yours, so `me` there now adds no filter. A named member id is still applied as given. The CLI's `--member-id me` follows, and `anona guide` now lists the exit codes for `anona status`, a second `anona start`, `anona logout`, `job_not_cancellable` and `unsupported_model`. In pip `anona` 0.25.2 and npm `@anona-labs/memory` 0.19.2. ### Clearer errors for tag filters and missing spaces A malformed `tag_groups` filter now gets one error per mistake, with a field path made of the fields you sent (`tag_groups.0.and.1.match`). Before, one bad group could come back as eight errors whose path included internal validator names. A group that isn't an object gets a message listing the four valid shapes. `GET /v1/spaces/{id}/profile` on a space that doesn't exist now returns `404 space_not_found`. It used to return `model_error` with a garbled message. ### The agent skills use the `anona` CLI When an agent has no MCP tools, the `anona-memory` skill now reaches memory through the `anona` CLI before falling back to `curl`: it runs `anona status` once, then `retrieve`, `record` and `reason` with `--json`, and acts on the exit code (3 means nothing was stored, 4 means no usable credential). The CLI uses an API key, the command-line login from `anona login`, or a temporary profile, so a machine signed in with `anona login` gets memory without a key in `~/.anona/config.env`. The `anona-memory-sdk` skill now says which credentials work in code and which only work in the CLI. Needs pip `anona` 0.24.0 or later. See [Agent Skills](/memory/integrations/skills). ### `anona login` signs the command line in too `anona login` now asks for two approvals in the browser, one after the other: one for the command line and one for MCP, each stored in its own file and renewed on its own. `record`, `retrieve`, `reason` and the other data commands use the command-line one when no API key is set, so a signed-in machine no longer needs a key for them; they still need a space named, as with a key. `anona mcp` keeps using the MCP credential, unchanged. `anona login --cli` and `anona login --mcp` ask for one only. `anona logout` now also revokes what it deletes, and takes `--cli` or `--mcp`; `anona status` reports both. If one approval fails the other still runs. In pip `anona` 0.24.0 and npm `@anona-labs/memory` 0.18.0 (login, logout and status only). See [the CLI](/memory/cli/sign-in#with-anona-login). ### The `anona` CLI reads and manages spaces, memories and jobs The pip CLI now covers more of the API, with the same API key or temporary profile as `record`, `retrieve` and `reason`: `anona spaces list|create|get`, `anona memories list|edit|delete|history`, `anona jobs get|cancel`, `anona profile`, `anona model`, `anona user-profile` and `anona usage`. Every one takes `--json`, and the plain output shows full ids so the next command can use them. `memories delete` and `jobs cancel` do nothing without `--yes`: they exit 2 and say what would have been deleted. In `anona` 0.23.0. See [the CLI](/memory/cli/overview#what-each-package-has). ### `anona record`, `retrieve` and `reason` take `--json` and the API's request fields All three CLI verbs now take `--json`, which prints the API response as one JSON document on stdout and nothing else; on a failure stdout stays empty and the error goes to stderr. `reason --json` includes `sources`, what the answer was built from. The exit codes are unchanged, so a `record` that stored nothing still exits 3. The verbs also take the API's request fields as flags: `record` reads content from stdin (`-`) or `--file`, and takes `--tags`, `--timestamp`, `--metadata` and the scope ids; `retrieve` takes `--tags`, `--tags-match`, `--as-of`, `--occurred-after` and `--occurred-before`; `reason` takes `--depth` and the scope ids. A flag you leave out is left out of the request, so existing commands send exactly what they did. In `anona` 0.22.0. See [the CLI](/memory/cli/memory). ### `anona record`, `retrieve` and `reason` take an API key The three CLI verbs that call the memory API directly used to work only on a temporary profile. They now take an API key, from `--api-key`, `ANONA_API_KEY` or `~/.anona/config.env`, in that order, so an account can use them without MCP. With a key, name the space with `--space` or `ANONA_SPACE_ID`; nothing is picked for you. A key from the environment or `config.env` goes only to Anona's own servers unless the server is named in the same place, so a stray `--base-url` cannot carry it off. `anona login` stays the credential for MCP. A missing or refused credential now exits 4. In `anona` 0.21.0. See [the CLI](/memory/cli/sign-in#with-an-api-key). ### A temporary profile can no longer gain a second space by typo A temporary profile holds exactly one space, `default`, but a write naming any other space used to create it: `anona record "..." --space cedar-typo` answered `Stored in space 'cedar-typo'.` and left the profile with two. Writes to any other space, and `POST /v1/spaces`, are now refused with `403 temp_profile_single_space`, on every surface: the CLI, REST, uploads and MCP. Writes to `default` are unchanged. See [temporary profiles](/memory/agents/temp-profiles). ### An agent- or session-scoped memory list no longer comes back empty A `note` is scoped to the user its facts are about, not to the agent or session that wrote them. The memory list hides a fact once a note covers it, so `GET /v1/spaces/{id}/memories?agent_id=...` used to return neither the fact, because it was covered, nor the note, because the note has no agent scope. A list filtered by `agent_id`, `session_id` or `member_id` now returns the facts. [Scoping](/memory/guides/scoping-multi-tenant#what-strict-means-in-practice) now explains which scope a note carries. ### `anona retrieve` speaks JSON and takes the API's filters `anona retrieve --json` prints the retrieve API's response as one JSON document (ids, scores, dates and scope on every row), so a script or an agent can cite, dedupe or delete what it found. `--limit`, `--min-score`, `--user-id`, `--agent-id` and `--session-id` pass straight to the API, and plain output now shows each memory's score and short id, so a weak match no longer looks like a strong one. In `anona` 0.20.5. See [the CLI](/memory/cli/memory). ### The CLI no longer sends your credential to a server you name `anona mcp`, `record`, `retrieve` and `reason` used to follow `--base-url` or `ANONA_BASE_URL` to any server, plain HTTP included, and send it the stored credential. They now talk only to the server that issued the credential, and refuse a different one with a message naming `anona login --base-url`, which is how you switch. Every command also requires `https://`, except for `localhost` and `127.0.0.1`. Fixed in `anona` 0.20.4 and `@anona-labs/memory` 0.17.1: upgrade with `pip install -U anona` or `npm install -g @anona-labs/memory@latest`. ### Top-up purchase history, and a billing guide for top-ups The billing page now shows your plan allowance and purchased credits separately, and lists past top-ups under the packs, marked when a pack was refunded. The same list is available from `GET /v1/billing/topups`. [Plans and billing](/memory/guides/plans-and-billing#credit-top-ups) has a new section on who can buy, spend order, refunds and low-balance emails. ### Buy extra credits on a paid plan Paid plans can buy credit packs of 5,000, 25,000 or 100,000 credits at \$1.50 per 1,000, from the billing page or with `POST /v1/billing/topup`. Purchased credits are used after the plan allowance and never expire. See [Billing](/memory/api-reference/billing#top-up-packs). ### Agent Skills for OpenClaw The [skills installer](/memory/integrations/skills) now installs into OpenClaw, into `~/.openclaw/skills` or `$OPENCLAW_STATE_DIR/skills`. It is picked up automatically when OpenClaw is present, or with `--app openclaw`. The [MCP page](/memory/mcp-integration#openclaw) shows how to register the server with an OpenClaw node. ### GitHub captures now say who wrote it, who merged it and who commented A memory captured from a merged pull request names the people in it. The text Anona stores opens with the author and the merger: ``` Author: bob Merged by: alice ``` and the discussion that happened in plain pull request comments is captured alongside the review conclusions, each comment attributed to whoever wrote it. Before this, a capture carried the title, the body and the review verdicts, so "who decided this?" had no answer in the memory at all. The same trust rule the connector already applied to a pull request body applies to each comment: it is captured only when GitHub says the person who wrote it is the repository's owner, a member of the organization or a collaborator. An outside contributor's prose is left out, as are bots. A long thread is trimmed to the newest comments that fit, whole comments only, and the capture says how many were left out. Provenance gained a second field to match. `anona:author:` now means the person who **wrote** the pull request, and the new `anona:merged_by:` means the person who merged it — usually two different people. Both come back in `provenance` on a retrieve result and on a memory list row, and both are selectable: ```bash theme={null} curl "https://api.anonalabs.com/v1/spaces/my-space/memories?repo=1282949371&merged_by=9001" \ -H "Authorization: Bearer anona_live_YOUR_KEY" ``` One caveat on history: until now `author` was stamped from whoever clicked merge, so memories captured before this change carry the **merger's** id under `author` and no `merged_by`. Those rows are not rewritten, so a filter on `author` over a repository's older captures is matching mergers. Captures from here on carry both fields, each meaning what it says. Imported history carries `author` but no `merged_by` — GitHub's pull request list does not say who merged each one. See [Where a memory came from](/memory/github#where-a-memory-came-from). ### A queued job now says why it is waiting A job that had not started yet reported `status: "pending"` with `progress: null` and `error: null`, and nothing else. That one state covered three different situations — queued behind other work, parked until a later time, or already failed and requeued — so a job waiting on a transient problem was indistinguishable from one simply taking its turn, and the only way to find out was to keep polling. [Job status](/memory/api-reference/memories#job-status) now also returns `retry_count` (how many attempts the job has burned) and `next_retry_at` (when the worker will try it next). `0` with `null` means queued and awaiting pickup; a future `next_retry_at` means the job is parked on purpose and will resume on its own. A long-`pending` job is still never auto-failed on a deadline — a large batch legitimately queues for tens of minutes, and a timeout would report healthy work as data loss. Both fields are additive, so nothing else about the response changes. ### Shareable artifacts `POST /v1/spaces/{space_id}/artifacts` builds a self-contained web page that gives an overview of a space, in the background, and returns a stable link. Artifacts start private; the creator can share one with everyone who can read the space, and the space owner can make it public. Links open at `/a/{id}` on the dashboard, in a sandboxed frame, and public ones need no sign-in. A build is billed for the tokens it uses plus a flat amount, and a failed build costs nothing. See [Artifacts](/memory/api-reference/artifacts). In the dashboard, type `/artifact` in a space's **Ask** tab. Everything you have built is listed in the space's **Artifacts** tab, where you can change sharing, copy the link or delete it. See [Charts, diagrams and artifacts](/memory/guides/charts-diagrams-artifacts). ### Charts and diagrams from your memories `POST /v1/spaces/{space_id}/graph` draws from what a space holds. Ask in plain language and get back either a chart (bar, line, area, pie or scatter) or a Mermaid diagram (flowchart, sequence, state, mind map or timeline), plus copyable Mermaid source and a short note. Every number, step and label comes from the space's memories; when they don't describe what you asked for, you get a sentence saying so and nothing is drawn. Billed as one chat turn, and open to read-only members, since nothing is written back. See [Draw](/memory/api-reference/draw). In the dashboard, type `/draw` in a space's **Ask** tab (or `/` to see the commands), or just ask for "a pie chart of…" or "a flowchart of…": the answer is drawn in the conversation with a **Copy Mermaid** button. ### Who manages Slack and GitHub in a shared organization Once shared organizations are enabled, installing or disconnecting the Slack or GitHub integration is the organization owner's alone (`403 integration_owner_only` for anyone else), and binding a channel or repository, unbinding it and starting a GitHub backfill need an owner or admin (`403 slack_admin_only` / `403 github_admin_only`). See [Slack and GitHub integrations](/memory/api-reference/organization#slack-and-github-integrations). Nothing changes before then: owners and admins install and disconnect, and any member can bind, unbind and backfill, exactly as today. ### A truncated reply now says so `finish_reason` on the [chat](/memory/api-reference/chat) proxy carried the provider's own stop reason on several models instead of the documented value. A reply cut off at the token limit came back as `end_turn` or `max_tokens` rather than `length`, so the one case the field exists to warn you about read as a reply that ended normally. Guardrail and safety stops arrived unmapped the same way. `finish_reason` now carries the documented values on every model and in both streaming and non-streaming replies: `stop`, `length`, `tool_calls` and `content_filter`. A reply that hit the token limit reads `length`. The [responses](/memory/api-reference/responses) proxy has no `finish_reason` field — it reports `status` instead, which is always `completed`. If you match on the raw provider spellings — `end_turn`, `max_tokens`, `guardrail_intervened` — match on the documented values instead. Code that already reads the documented values is unaffected, and nothing else about the response changes. [`/v1/messages`](/memory/api-reference/messages) is unchanged: it reports Anthropic's own `stop_reason` vocabulary (`end_turn`, `max_tokens`, `tool_use`) and always did. ### Long writes are stored in full A single write longer than about 40,000 characters, such as a long transcript or a pasted document sent to [record](/memory/api-reference/memories), was stored only up to roughly its first 30,000 characters. The rest was never read, and the write still reported success. Every part of a long write is now stored and searchable. Writes made before this fix keep what was stored at the time. If you sent long single writes, re-sending them restores the missing part. ### Retrieve can return the passage behind each memory A memory is a summary of what was said. Set `include_source: true` on [retrieve](/memory/api-reference/retrieve#source-passages) and each result also carries `source`: the passage of the original write it was extracted from. Use it to quote or cite a memory, or when an exact number or phrase matters more than the summary. It is off by default, so existing calls are unchanged. Passages add context, often doubling a response's size, so `source_max_tokens` caps the total. A synthesized `note` carries the passage of the facts it was built from. ### 86 more models, served through OpenRouter The model catalog grows from 22 to 108. The new models are named `openrouter//` - for example `openrouter/anthropic/claude-sonnet-5.5`, `openrouter/openai/gpt-6-sol` or `openrouter/google/gemini-3.8-flash` - and each also has a short name ending in `-or`. They work anywhere `model` is accepted: the chat, responses and messages proxies, `/v1/reason`, and the model picker on the **ask** tab. Every model on the list was invoked before it was added, and had to hold a tool call, return valid JSON and stream cleanly. Credits are derived from each model's real per-token price, the same way as every other model, so `GET /v1/models` and the [model selection](/memory/api-reference/model-selection) table carry exact rates. Nothing changes for existing callers. The default model is still GPT-OSS 120B on the same host, the existing models keep their names and prices, and a model is only used when you name it. Responses keep the OpenAI shape: no provider-specific fields are added. ### The agent skill keeps what compaction would drop Compacting a conversation replaces it with a summary, and whatever the summary leaves out is gone. The `anona-memory` [agent skill](/memory/integrations/skills#compaction) now treats that as a deadline. Tell the agent you are about to compact and it records what the session settled first; when a conversation resumes from a summary, it records the durable facts the summary kept before going back to the task. It records facts, one memory each, into the session's usual space - never the transcript or the summary itself. The usual rules hold: no secrets, nothing already in the repository, nothing still a hypothesis. Claude Code gives an agent no turn before it compacts, so the Claude Code plugin (`0.2.0`) adds a `SessionStart` hook that fires after every compaction and prompts the second half automatically. Installs from the installer or `npx skills` can add the same hook to `settings.json` by hand; the [skills page](/memory/integrations/skills#hooks) has the snippet. **September 2026** ### A routed search can look in 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 `POST /v1/retrieve` now searches the spaces that look likely to hold the answer rather than only the single best one, and returns one list ranked across all of them. `searched` carries an entry per space, best first. `max_targets` caps how many spaces a routed read may search - on the request, or as an organization default under **Profile -> Automatic search** in the dashboard. It is a **ceiling, not a count**: only spaces that look likely are searched at all, so a question that plainly belongs to one space still searches one, and raising the cap does not widen every search. Each space searched is charged, which is what makes it a cost control rather than a relevance setting. Set it to `1` to keep a routed read to a single space. Results are ranked across the spaces rather than concatenated, so the leading memory need not come from the leading space. If one space fails while others succeed the read still returns what they found, and the failed space keeps its `searched` entry with `reason: "search_failed"`. `max_targets` is on the MCP `retrieve` tool and in both SDKs. On MCP, when more than one space was searched each memory is labelled with the space it came from, because a merged answer without provenance gives a model no way to weigh it. Sending `max_targets` on a retrieve that names its `space_id` is `422 route_conflict`; both SDKs refuse it locally, without a round trip. ### Search without naming a space `POST /v1/retrieve` now takes `route: "auto"` in place of `space_id`, so a caller that knows the question but not which space holds the answer can have the space picked for it. The MCP `retrieve` tool takes it too, and both SDKs now expose routing on `record`, `record_batch` and `retrieve`. The answer comes back as `searched`, naming the space that was searched, the confidence, and which stage decided: `rule` when one of your own rules matched, `model` when the decision model chose, `fallback` when a `fallback_space_id` you sent was used. Read it - a query answered from the wrong space looks exactly like a space that holds nothing, and this is the only field that distinguishes them. On a retrieve that names its `space_id`, `searched` is absent from the body entirely, so nothing changes for anyone not using routing. One behaviour differs from routed writes on purpose. When no space fits, a routed read returns an empty `results` array with the reason, and does **not** fall back to your default space. On a write the default space is the sink and filing there is right; on a read it is the bag of everything that fitted nowhere, so it is the worst place to look for a specific answer, and irrelevant memories are worse than none. `fallback_space_id` therefore has no default on a read: set it when you would rather search something than nothing. An unroutable query is still charged, like any retrieve that matches nothing. Routing is still opt in per request, and `POST /v1/reason` is unchanged: it requires a `space_id`. See [Searching without naming a space](/memory/guides/automatic-routing#searching-without-naming-a-space). ### Automatic routing is now switched on `route: "auto"` has been accepted for a few days but was not yet enabled, so every routed write fell back to the default space and reported `reason: "router_disabled"`. It now routes for real: a routed write is matched against your spaces and filed into the one it belongs in. Two consequences worth knowing. A routed write now sends the memory's text, and the descriptions of the spaces it could go to, to the third-party decision model named in [Subprocessors](/memory/legal/subprocessors) - read that page before using it if your organization cares where content goes. And nothing else changed: a write that names its `space_id` still sends nothing anywhere new, routing is still opt in on every individual request, and a space can be excluded from consideration entirely with `auto_route_enabled: false`. ### Write a memory without naming a space `POST /v1/record` and `POST /v1/record/batch` now take `route: "auto"` in place of `space_id`, and Anona files the memory into the space it belongs in. The MCP `record` tool takes it too, which matters most there: an agent sees a list of space ids and none of the reasoning behind them. The answer comes back as `routed_to`, naming the space, the confidence and which stage decided: `rule` when one of your own declared rules matched, `model` when the decision model chose, `fallback` when nothing claimed the memory and it went to your default space. A fallback write is marked `"anona_routed": "fallback"` in its metadata, so what needs filing by hand is something you can list. Each space gets an optional routing profile at `/v1/spaces/{space_id}/routing-settings`: a charter, topics, and rules that answer before any model is called. The description a space is judged on is built primarily from terms derived from the space's own contents, and a charter adds to those terms rather than replacing them. Routing is opt in on every request. A write that names its space is unchanged, and sends nothing anywhere new. See [Automatic space routing](/memory/guides/automatic-routing) and [Subprocessors](/memory/legal/subprocessors). ### GitHub can correct the sentences your knowledge file gets wrong The knowledge pull request used to only write Anona's own section, between the `anona:begin` and `anona:end` markers, and never touch a byte outside them. **Corrections** extends it: where your memory space contradicts a sentence *you* wrote, Anona proposes a rewrite of that sentence. It is off by default, and turning it on needs a knowledge file chosen first. A pass proposes at most three rewrites, each carrying the memories that contradict it — quoted, with their source — so a correction you cannot check is one you never have to merge. Everything else in the file is left byte for byte as it was. Each edit lands wrapped in an `` tag, and that tag is the opt-out: delete it and that sentence is never proposed again. Closing the pull request says no to everything it proposed, permanently. Corrections share the existing cadence and the one-pull-request-at-a-time rule, and a long file is walked six sections at a time across successive passes. ### A job now says whether any of its content was silently lost Extraction can come back with no facts at all for a chunk it was given, and the job used to answer `completed`, `error: null` regardless — the content simply gone, with nothing on the response saying so. A retry now runs once before that zero is accepted, which recovers most of them, and `GET /v1/spaces/{space_id}/jobs/{job_id}` gains `zero_yield_items`: how many of the job's `items_submitted` produced nothing on both attempts. It is `0`, not `null`, once a job completes — that distinction is the point, since a missing field used to be indistinguishable from a measured zero. Non-zero is worth re-submitting that content; it is not treated as a failed job, for the same reason a low `memory_count` against `items_submitted` isn't — everything else in the batch is real and searchable. ### Both SDKs can set a space's reason depth, and can no longer clear it by accident `depth` reached the API in the release that added it but never reached the SDKs. Two consequences, and the second was losing data. `reason` now takes it per call, in both clients — `depth="thorough"` checks the notes and the raw memories underneath instead of letting a current memory model answer on its own. It is the second look to ask for when an answer reads stale, and until now it was reachable only by hand-rolling the HTTP call. The setter is the part that was doing harm. `PUT /v1/spaces/{space_id}/reason-settings` is a full replace, and both SDKs sent only `model` — so a space whose owner had chosen `"thorough"` in the dashboard silently fell back to `"fast"` the next time anything pinned its model through an SDK. Cheaper, shallower answers, with nothing to say why. `set_reason_settings` / `setReasonSettings` now require **both** `model` and `depth`. A call that names one and not the other is an error at the call site rather than a setting the customer loses quietly. If you are changing one, read the current pair back with `get_reason_settings` / `getReasonSettings` first. The Python client also gained `reason_receipt` (and `async_reason_receipt`), which returns the same answer plus what it was built from: `sources` says which layer spoke — memory models, raw memories or consolidated notes — alongside `rules_applied` and the model that actually ran. `reason` itself still returns the answer string, so nothing that reads it needs to change. In TypeScript, `sources` and `model` are now declared on `InsightsResult`; they were already on the wire. ### A space can be given rules it must follow `POST /v1/spaces/{space_id}/rules` adds a standing instruction the space obeys whenever it answers: ```json theme={null} { "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 } ``` A rule is not a preference and not a tone setting — those are the disposition dials on the space profile, which shift how an answer is *worded*. A rule applies to **every** answer the space gives and it **overrides what the memories say**: the one above holds even when a memory records the deal as done. Until now the only way to get that was to repeat the instruction in every prompt, and any caller who forgot it got a different answer from the same space. `GET` lists them, highest priority first, with ties broken by recency. `PATCH` is a partial edit, so switching one off or lifting its priority never means resending the text of something enforced on every answer. `DELETE` removes it. All of it is owner-only and free — an organization out of credits must still be able to switch off a rule that is producing bad answers. A space may have **25 active** rules at once. Every active rule is written into every answer the space composes, so the cap is what stops a space from spending more of an answer on its own rules than on its memories. It counts *active* rules only, so `{"is_active": false}` always frees a slot without deleting anything. **And an answer now 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 half of that: it says no rule applied, which is what separates "the memories are wrong" from "a rule said otherwise". Those need opposite fixes and read identically in the prose. Both SDKs wrap it: `list_rules` / `create_rule` / `update_rule` / `delete_rule` in Python, `listRules` / `createRule` / `updateRule` / `deleteRule` in TypeScript. Full detail in [Rules](/memory/api-reference/spaces#rules). ### A space can be created knowing what it is for `POST /v1/spaces` takes **`space_type`**, and sets the new space up for that job: ```json theme={null} { "name": "halcyon-account", "space_type": "sales_account" } ``` Five to choose from — `customer_support`, `coding_agent`, `personal_assistant`, `sales_account` and `blank`. `GET /v1/space-types` lists them with what each one sets up, and needs no API key. A type writes three things: the space's mission, the guidance its extraction pass follows, and two memory models — the standing questions that kind of space usually wants an answer to. All three have been settable one endpoint at a time for a while, and a space that never had them set ran general-purpose defaults forever — which is what most spaces did. **The two models start empty, and that is deliberate.** Each carries `refresh_on_new_memories`, so it writes itself once the space has memories worth reading, and rewrites itself as the space learns more. Generating at creation would have spent your credits to store "I have no information about this" as the model's content — and that sentence then reads as the answer until something replaces it. Record something, let it consolidate, and read the models back; or refresh one by hand if you would rather not wait. Everything a type sets stays editable: `PUT /v1/spaces/{space_id}/profile` for the mission and dials, `PUT /v1/spaces/{space_id}/extraction-settings` for the guidance, and the models API for the models. A space type is a starting point, not a mode. Omitting `space_type` creates exactly the space this endpoint created before, and an unrecognised value is a `422` rather than a plain space you did not ask for. The response echoes `space_type` back, and it is `null` whenever nothing was applied — including when a type was asked for and applying it failed. Creating the space never fails over a preset, so read that field rather than assuming the type you sent is the type you got. ### Answers say what they were built from `POST /v1/reason` now returns **`sources`** — which memory models, how many raw memories and how many notes the answer was built on. `GET /v1/spaces/{space_id}/models/{model_id}` returns the list itself, with each source's id, kind and text. Until now a wrong answer gave you prose and nothing else, and there was no way to tell *the memory is missing* from *the memory is there and was not used* — which need opposite fixes. These are the memories the pass declared it used, so that distinction is now readable: if what you expected is absent it was never reached; if it is present and the answer contradicts it, that is worth reporting to us. A non-empty `sources.models` means a standing answer spoke, which is the first thing to check when a result looks out of date — refresh that model, or ask again with `depth: "thorough"`. The MCP `reason` and `get_model` tools state the same thing in a closing line, and the dashboard shows "Built from …" under a model's answer. ### A space's mission and disposition can be set The Models tab has always opened with a **Space profile** card — a mission line and three dials, skepticism, literalism and empathy — and there was no way to change any of it. Every space showed the same card with the same numbers. `PUT /v1/spaces/{space_id}/profile` now sets both, and the card has an edit control: ```json theme={null} { "mission": "This space tracks the Halcyon account for the customer-success team; answers are read by non-technical account managers.", "disposition": { "skepticism": 2, "literalism": 2, "empathy": 5 } } ``` The mission is the more useful half: it is written into every Ask answer and every model refresh, so the framing does not have to be repeated in each question. The dials (1-5, default 3) change how the space weighs what it is told when it synthesises; they do not affect plain retrieval. Setting a profile is free and owner-only — a space shared with you can be read and written to, but not reconfigured. The `PUT` replaces the whole profile, so send both halves, and `DELETE` puts it back to no mission and 3/3/3. ### Reason can be told to look past its own summary `POST /v1/reason` takes **`depth`**: `fast`, which is what every call has done until now, or `thorough`. Reason keeps a layer of standing answers — memory models — above your notes and raw memories, and on `fast` a model that is current answers on its own. That is what makes a typical call quick, and it is also why a stale model made every answer built on it stale, with no way to ask for a second look. `depth: "thorough"` reads the notes and the raw memories underneath whatever the models say. It takes longer and uses more tokens, and it is charged on the tokens it actually uses rather than at a different rate. A space can make it the default with `PUT /v1/spaces/{space_id}/reason-settings` (`{"model": null, "depth": "thorough"}`) — a `depth` on the request still wins. Note that this `PUT` replaces the whole resource, so send both fields. The MCP `reason` tool and `POST /v1/spaces/{space_id}/users/{user_id}/ask` take `depth` too, and all three read the same space default. ### Deleting a memory now deletes it `DELETE /v1/spaces/{space_id}/memories/{memory_id}` answered `204` without removing the memory. It cleared the observations consolidated *from* that memory and reset the memory for re-consolidation — so the memory stayed in the space, and was synthesised into fresh observations on the next consolidation run. It deletes now: the memory, its links, its entity associations, and every observation built from it, with the other memories that fed those observations re-consolidated without it. There is no undo. **If you used this endpoint, re-run those deletions.** Anything deleted before this shipped is still in your space and will still be returned by `GET /v1/spaces/{space_id}/memories` and by `retrieve`. Two smaller changes ride with it. A memory id the space does not hold is now `404 memory_not_found` instead of a success, so deleting the same memory twice reports the second attempt honestly. And where a delete cannot be confirmed, the endpoint returns `503 delete_unavailable` and states that the memory was **not** deleted, rather than reporting one it did not make. To take a memory out of recall while keeping it for audit, `PATCH` it to `state: "invalidated"` instead — that is reversible, and it is unchanged. ### A faster host for a model you already have `gpt-oss-turbo` joins the catalog. It is the **same 120B model** as the default `gpt-oss`, served by Cerebras instead of Bedrock — measured several times faster per call on the tool-calling shape reason and agents send, at **2.02x the credits**. Both stay: if latency is not your problem, you should not pay for it. Choose it per request with `"model": "gpt-oss-turbo"`, or as a space default on the chat defaults tab. Credit rates are derived from the host's real per-token price like every other model, so nothing about how you are billed changes — the rate is on `GET /v1/models` and in the pricing table. Two new fields ride on each `GET /v1/models` entry. `provider` names the host, because the same weights can now appear twice. `supports_memory_operations` is false for a model that answers chat but not `/v1/reason` — `gpt-oss-turbo` is chat-only today, and the dashboard's reason picker hides it rather than offering a choice that would be refused. Naming it on a memory call returns `400 unsupported_model` listing what can be used instead; nothing is ever silently substituted. ### Labels are stamped, and you can see them Two fixes that belong together, because the second is why the first took so long to find. **A label group is now always filled in.** A group the extraction model was free to skip was a group it skipped: measured across a corpus, a `multi-text` group stamped nothing on any memory while a `value` group marked required stamped on every one. Adding the first alongside the second stopped both. Every group is now answered, with an empty value saying "none" rather than the answer being left out. `optional` keeps its meaning and does the job it should always have done: an optional group may answer `null` or `[]`, and `optional: false` on a list now requires at least one value, where before it was read and ignored. If you configured a taxonomy and saw no tags on your memories, that is this, and it applies to writes made from now on. Existing memories are never re-extracted, so a space that ingested under the old behaviour needs re-ingesting to carry the tags it should have. **A memory now returns its own tags.** `tags` is on every [retrieve](/memory/api-reference/retrieve) result and every row of the [memory list](/memory/api-reference/memories), in the same shape on both: ```json theme={null} { "memory_id": "mem_abc123", "content": "The ingestion workers run on Kubernetes.", "tags": ["team:core", "name:kubernetes", "state:current"] } ``` These are exactly what `tags` and `tag_groups` match on, so this is how you check what a filter will select before you rely on it. It is also the only way to see whether a label taxonomy is doing anything: until now a group that stamped nothing looked identical to one that worked. Scope is not included, and that is deliberate: `user_id`, `agent_id`, `session_id` and `member_id` carry it as fields, as they always have. ### A tag filter can match a name you half remember `tag_groups` matched tag strings exactly, which is the right behaviour when your own code wrote the tags and the wrong one when it did not. An agent building a filter out of something a user typed had to know the exact string a memory was stamped with, and one transposed letter meant the memory could not be reached at all. A leaf can now carry `"resolve": "fuzzy"`. Its tags become candidates rather than requirements: each is compared against the tags your space already holds, and the close ones are added to the filter. ```json theme={null} { "tag_groups": [ { "tags": ["name:typsecript"], "resolve": "fuzzy" } ] } ``` `name:typsecript` matches nothing on its own. Resolved, it also matches `name:typescript`. It only ever widens: the tags you named are always kept, so a fuzzy leaf matches at least everything the exact one would have. Resolution stays inside the tag's namespace, so `name:chekcout` can reach `name:checkout` and never `status:checkout`. Candidates shorter than four characters are matched literally, because similarity on two or three characters is noise rather than signal. It fixes misspellings, not abbreviations: `kubernetes` and `k8s` have almost no letters in common, so nothing resolves one to the other. That is what the `multi-text` label group above is for, and the two work together, the taxonomy recording every name on the way in and the filter forgiving how it is typed on the way out. Unset by default, so nothing changes for a filter that does not ask for it. [Details](/memory/api-reference/retrieve#matching-a-name-you-are-not-sure-of). ### Extraction can tag your memories for you Filtering recall is only as good as the tags a memory carries, and until now every one of them had to be supplied by hand on the write that created it. Anything your own code did not think to tag was unfilterable forever, because nothing re-reads a stored memory. A space can now declare a **label taxonomy** on [extraction settings](/memory/api-reference/extraction-settings#label-taxonomy): the dimensions that matter in your domain, described once. Extraction classifies every memory along them as it writes, and a group marked `tag: true` becomes a real tag, so [`tag_groups`](/memory/api-reference/retrieve#compound-tag-filters) filters on it like any other. ```json theme={null} { "labels": [ { "key": "name", "type": "multi-text", "tag": true, "description": "Every name the subject of this memory is known by, including abbreviations and short forms." } ] } ``` A memory about Kubernetes then carries `name:kubernetes`, `name:k8s` and `name:kube`, and a query for any of the three finds it. `multi-text` is the type that makes that work: identity rarely has a single surface form, and a taxonomy whose vocabulary you cannot list in advance now has a shape that fits it. Four types in total, two with a fixed vocabulary you supply and two without. The settings are unset by default and nothing changes for a space that does not use them. Labels apply to writes made after you save them, so turning a taxonomy on never retags your history, and turning one off never loses it. One thing to know when you write a taxonomy: a group's `description` is instruction text the extractor reads, so it should describe the label and not how to extract. A description that says what counts as a separate memory, or when to split one fact into two, is read as extraction policy and obeyed as one, and the result is fewer memories rather than an error. A multi-line description no longer escapes into the surrounding prompt, which is worth some of that effect but not most of it: the wording is. The [extraction settings page](/memory/api-reference/extraction-settings) has the worked example and the numbers. ### Every ingest job says how many items it was given A job reported how many memories it created and nothing to read that against. Fifteen memories is a good outcome for twelve items and a destroyed corpus for thirty-five, so `items_submitted` now sits beside `memory_count` on [job status](/memory/api-reference/memories). It is the quickest check after changing extraction settings, which is the setting most able to change the ratio. A job whose two numbers are far apart still reports `completed`: the memories it wrote are real, and a batch of near-identical items legitimately yields fewer memories than items. The pair is the answer; what it means is yours to read. Progress counters on a running batch also now cover the whole job rather than whichever part reported last. ### Filter recall with boolean tag expressions `tags` is a flat list under one match mode, so it can say "any of these" or "all of these" and nothing else. There was no way to write an OR of groups, and no way to exclude anything at all. [`tag_groups`](/memory/api-reference/retrieve#compound-tag-filters) takes an expression instead. Groups are AND-ed; each is a leaf or one of `and`, `or`, `not`, nested as deep as you need: ```json theme={null} { "space_id": "support", "query": "what is left to do", "tag_groups": [ { "or": [{ "tags": ["project:alpha"] }, { "tags": ["project:beta"] }] }, { "not": { "tags": ["status:archived"] } } ] } ``` The filter is compiled into the search itself rather than applied to its output, so an excluded memory never competes for a slot and you get the best *n* matches that passed the filter instead of whatever survived it. A leaf may name up to 500 tags, and one expression up to 1,000 in total, well above the 50 that `tags` itself allows. That is deliberate: naming every candidate you might mean and letting the filter decide which of them your space actually has is the normal way to use this, and it is the cheap shape too, since one leaf is a single set-containment test however many tags it lists. It works on [retrieve](/memory/api-reference/retrieve#compound-tag-filters), on [reason](/memory/api-reference/reason), on the `retrieve` tool of the [MCP server](/memory/mcp-integration), and on the [memory listing](/memory/api-reference/memories), where it is JSON-encoded into the query string because that endpoint is a GET. It is in both SDKs too: `tag_groups` in Python, `tagGroups` in TypeScript, where it is typed so a malformed filter is a compile error. Scoping still holds. `tags`, `user_id`, `agent_id` and `session_id` are all AND-ed onto whatever you write, so a scoped query stays scoped when you add a filter, and a filter can never reach outside the scope it was asked in. ### Every SDK method is in the reference Both SDK pages had fallen behind the clients they document. The [TypeScript reference](/memory/sdk/javascript) was missing 25 of the client's 52 methods, including extraction settings, chat defaults, memory models, the model catalog and webhooks; the [Python reference](/memory/sdk/python) was missing 20 of 54. Both now list every method the installed package exposes, so a capability you have been paying for is no longer invisible because only the other language's page mentioned it. The published SDK README gained the context receipt methods (`get_context`, `retrieve_receipt`, `get_receipt`, `explain`) and `create_space` / `delete_space`, none of which it had ever listed. The [endpoint index](/memory/api-reference/overview) also described extraction settings with field names the API does not accept. The fields are `mode`, `guidance` and `custom_prompt`, as the [extraction settings](/memory/api-reference/extraction-settings) page has always said. ### Fourteen more models, and a new default The model catalog goes from seven to twenty one. New this month: Claude Haiku 4.5, Claude Sonnet 4.5 and 4.6, Claude Opus 4.5 and 4.6, GLM 4.7 Flash, GLM 5, Kimi K2.5, MiniMax M2.1, Mistral Large 3, Nemotron Nano 3 30B, Nemotron 3 Super 120B, GPT-OSS 20B and Llama 4 Scout. Every one of them was checked by running the real work against it, not by reading a spec sheet: a model is only listed here if it answered a live tool-calling request from this deployment and held the output contract while doing it. Several models that look fine on paper are absent for exactly that reason. **GPT-OSS 120B is now the default**, replacing Nova Pro. It matched Nova Pro on extraction quality in our own testing, and it carries twenty times the requests-per-minute headroom, so heavy periods are far less likely to queue. It is also more reliable on the write path: the occasional failed extraction that could surface as a `503` on a synchronous `record` came from the old default and is gone. Nova Pro has not gone anywhere. It stays fully selectable as `nova-pro`, and if you pinned it by name you are still on it. **If you never set `model`, nothing changes for you.** A record or a reason costs the same credits it did before, because the credit scale was recalibrated around the new default at the same time. **If you did pin a model by name, its credit rate has changed.** Costs are derived from each model's real per-token price, and those are now expressed against GPT-OSS rather than Nova Pro. Nova Pro reads as 5.3x the default where it used to read as 1x. The dollars behind a credit did not move; what changed is which model sits at the baseline. Check `GET /v1/models` for the exact rate of whatever you pin, and prefer it to the multiplier when costing a real workload. [Model selection](/memory/api-reference/model-selection) ### Agent Skills Install once, and your coding agent uses memory without being told to. It recalls what is already known before a task and records what the task settled after it, in Claude Code, Codex, Cursor, OpenCode and Hermes. ```bash theme={null} curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/skills/install.sh | bash ``` A second skill covers building on Anona: the SDK and REST surface, spaces, per-user scoping and error codes, so code written against it matches what the API actually does. Claude Desktop and claude.ai take a ZIP upload rather than a directory. Pass `--zip` to build one, then add Anona as a connector so the skill has tools to call. [Agent Skills](/memory/integrations/skills) ### Context receipts A receipt can now account for the cuts search makes internally, not only the ones made after it returned. Pass `receipt_detail: "full"` on retrieve and the manifest gains three reasons for memories that were ranked and dropped before your `limit` or `min_score` ever applied. There is also a way to ask about one specific memory: `GET /v1/receipts/{request_id}/explain?memory_id=...` replays that search, pinned to the moment it originally ran, and reports where the memory left the pipeline, which search methods found it, and what it scored. Its `not_retrieved` answer is the one that was previously impossible to get: no part of search matched the memory at all, so raising `limit` will not help and the scope or the wording is what to check. It works on any earlier call, not just ones that asked for a receipt in advance, and costs no credits. [Context receipts](/memory/api-reference/context-receipts) ### Slack Anona answers in Slack from a bound space. It reads the thread it was mentioned in, not only the message, so a question that depends on what was said three replies ago still gets an answer. A conversation can also be saved into memory, either by asking the bot in the thread or from the message menu. The channel has to be set to **Read & write** first: a channel is read only by default, because saving changes what the space recalls afterwards. Saves are capped per workspace. Connecting your Slack identity to your Anona account attributes the memories you save to you. [Slack integration](/memory/slack) ### Uploads Video is read into memory. MP4, MOV, WEBM and MKV, covering speech, on-screen text and a description of what is happening. Video gets a larger per-file ceiling than other uploads, since a few seconds of it outweighs a long document. [Documents and uploads](/memory/api-reference/documents) ### SDKs Both the Python and the TypeScript client can now filter on when something happened, not only on when it was recorded. [Temporal search](/memory/guides/temporal-search) ### API An API key can file a feedback report. Before this it took a dashboard session, which an agent running on its own does not have. [Feedback](/memory/api-reference/feedback) ### Fixes * An ingestion that fails gives back the credit it charged to start. * A memory model keeps its answer when the model that writes it wraps that answer in an envelope of its own. The wrapper is unwrapped rather than stored. * `amazon.nova-2-lite-v1:0` is billed at the cheap rate. It was falling through to the mid tier and collecting about 2.5 times what it was worth. * Creating a space that already exists answers `409` instead of failing with a server error, and uploading to a space that does not exist creates it. **August 2026** ### Memory * Scope every write and read by user, agent and session, so one space can serve many end users without them seeing each other. [Multi-tenant scoping](/memory/guides/scoping-multi-tenant) * Ask what memory looked like at a past moment with `as_of`, and filter recall on when an event happened rather than when it was written. [Temporal search](/memory/guides/temporal-search) * Edit or invalidate a single memory with `PATCH`. [Memories](/memory/api-reference/memories) * A write returns the ids of the memories it created, on both the synchronous and the asynchronous path. * A running ingestion job can be cancelled. Parts already in flight finish; the rest are dropped. * Per-user profiles, and a way to ask a question about one user. [User profiles](/memory/api-reference/user-profiles) * Per-space extraction settings, so a space can be told what is worth remembering. [Extraction settings](/memory/api-reference/extraction-settings) * Export a space and download everything in it. [Export](/memory/guides/export) ### Uploads Files are read into memory: documents, images (JPG, PNG) and audio (MP3, WAV). Each file is routed to a parser by its extension, per file rather than per request, so a mixed batch is handled correctly. Two limits arrived with them. A file whose bytes contradict its extension is rejected, and an archive that unpacks to far more than it weighs is refused before anything is unpacked. [Documents and uploads](/memory/api-reference/documents) ### Retrieve and the drop-in proxy * `format="block"` returns the assembled context as a string, ready to paste into a prompt. [Retrieve](/memory/api-reference/retrieve) * A context receipt says what entered the prompt, what was cut and why. [Context receipts](/memory/api-reference/context-receipts) * `block_order="stable"` holds the memory block in a stable order across turns so a provider's prompt cache can hit it, with a meter reporting how much of the prefix was eligible. * The proxy covers three wire shapes: OpenAI Chat Completions, OpenAI Responses and Anthropic Messages. [Migrating to the proxy](/memory/guides/migrating-to-proxy) * Choose the model that answers `reason` and chat, from a published catalog. Every model is available on every plan. [Model selection](/memory/api-reference/model-selection) ### Spaces and access * A space can be shared, including with someone in another organization, who joins by invitation as a read only or a read and write member. * API keys can be created with an expiry (`expires_in_days`). An expired key is rejected with `key_expired`, which is distinct from an invalid one. [API keys](/memory/api-reference/api-keys) * Signup no longer mints an API key. Create one in the dashboard, or through the API, when you need it. * You can sign up with a social identity rather than a password. ### SDKs and integrations * A TypeScript SDK. [JavaScript and TypeScript](/memory/sdk/javascript) * Memory adapters for six Python agent frameworks: LangChain, CrewAI, LlamaIndex, Google ADK, Microsoft Agent Framework and Strands. [Integrations](/memory/integrations/langchain) * Both SDKs reach per-space configuration and the per-user profile endpoints. ### Dashboard * A new visual system across the whole dashboard and the transactional email. * The Ask tab is a multi-turn chat over a space's memory, with a model picker. * A Users tab, a memory models tab, and a Profile page that owns the organization name. * Send us feedback without leaving the page you are on. ### Billing * Stripe checkout, the customer portal and webhook settlement. * Plans repriced. Developer is the free tier at 5,000 credits a month. [Plans and billing](/memory/guides/plans-and-billing) * Starter and Scale are no longer sold. Existing subscriptions keep their allowance and their price. # Account: org, keys, members, billing, exports Source: https://docs.anonalabs.com/memory/cli/account Read your organization, API keys, members, invitations, billing, usage history and exports from the anona CLI, and which of those need anona login --cli. pip 0.26.0; npm 0.20.0 These commands read the account around your spaces rather than the memories in them. They call the control plane, which an API key does not reach. ## Which credential they need **Every command on this page that sends a request needs the command-line login from [`anona login --cli`](/memory/cli/sign-in#with-anona-login)**, except plain `anona usage` (your credit balance), which an API key can read too. Only the `exports` commands take `--space`. An API key or a temporary profile gets exit `4` with `session_required`: ```text theme={null} anona: session_required: This needs a dashboard session or `anona login --cli`; an API key or temporary profile cannot use it. ``` If you are signed in with `anona login --cli` and still get `session_required`, an API key is set: `--api-key`, `ANONA_API_KEY` or `~/.anona/config.env` outranks the login. Unset the key, or run the command without it, to use the login. The credential matrix, and the fixed list of routes a CLI login may call, is in [Authentication](/memory/api-reference/authentication#which-credential-each-route-accepts). Your role and organization are read from your membership on every request, so being removed or demoted applies to the CLI within 30 seconds. ## Reading the account ```bash theme={null} anona org anona orgs list anona projects list anona keys list anona members list anona members me anona members invitations anona billing anona billing topups anona usage --history --days 30 anona usage --breakdown --days 7 ``` | Command | Prints | `--json` | | - | - | - | | `org` | ` ` | `{"org_id", "name", "slug", ...}` | | `orgs list` | ` ` for each organization you belong to | `{"orgs": [...], "max_orgs"}` | | `projects list` | ` ` | `[{"project_id", "name", "description"}]` | | `keys list` | `… `. **Never the secret** | `[{"id", "name", "key_prefix", "env", "created_at", "last_used_at", "status"}]` | | `members list` | ` ` | `[{"member_id", "email", "role", ...}]` | | `members me` | who you are signed in as | `{"member_id", "email", "role", ...}` | | `members invitations` | invitations your organization has sent | `{"invitations": [...]}` | | `billing` | your plan, credits and the period end | `{"plan_id", "plan_name", "credits_limit", "credits_used", "credits_remaining", ...}` | | `billing topups` | your top-up purchases | `{"items": [...]}` | `usage` with no flag is unchanged: your credit balance, which an API key can also read ([profile, user-profile, usage](/memory/cli/profiles-and-usage#usage)). Two flags turn it into a history, and these need `anona login --cli`: | Flag | | | - | - | | `--history` | Daily operation counts instead of the credit balance | | `--breakdown` | Operation counts per space instead of the credit balance | | `--days N` | With either: how many days back, 1-365, default 30 | ## Invitations ```bash theme={null} anona invitations pending anona invitations accept TOKEN anona invitations decline TOKEN ``` `pending` lists what is waiting on you, with each invitation's `token`. `accept` prints `Joined .` (or the space you were given access to); `decline` prints `Declined.`. Accepting checks that your signed-in email is the invited address, so a forwarded token does not hand over the seat. ## Exports ```bash theme={null} anona exports create --space notes anona exports list --space notes anona exports download JOB_ID --out notes.zip --space notes ``` An export is a downloadable bundle of one space. Only the space's owner can start one. `exports create` prints `Export started.`; `exports list` prints ` `; `exports download` saves the file and prints `Saved bytes to .`. The file comes from a short-lived download link. If the link has expired or its storage refuses it, the command exits `7` and prints `anona: download link expired or refused (HTTP ); run download again.` Running `exports download` again asks for a fresh link. | Flag | | | - | - | | `--out FILE` | Where to save it. Default: `anona-export-JOB_ID.zip` | | `--yes` | Overwrite `FILE` if it already exists. Without it, an existing file makes the command exit `2` with `Nothing was sent.` | **`--json`:** `create` prints `{"job_id", "space_id", "status", "created_at"}`, `list` prints `{"items": [...], "total"}`, and `download` prints `{"job_id", "path", "bytes"}`, written by the CLI after saving the file. ## Done in the dashboard Anything that creates, changes or spends is not a CLI command. These print one line to stderr, send **nothing**, and exit `4`: | Command | Prints | | - | - | | `anona keys create`, `anona keys revoke` | `anona: creating an API key is done in the dashboard: https://memory.anonalabs.com/dashboard/keys` | | `anona billing upgrade`, `checkout`, `topup`, `cancel`, `portal` | `anona: upgrading your plan is done in the dashboard: https://memory.anonalabs.com/dashboard/billing` | | `anona org rename`, `delete`, `transfer`, `leave` | `anona: renaming the organization is done in the dashboard: https://memory.anonalabs.com/dashboard/profile` | | `anona members invite`, `anona members remove` | `anona: inviting a member is done in the dashboard: https://memory.anonalabs.com/dashboard/members` | | `anona spaces members remove SPACE MEMBER_ID` | `anona: removing a space member is done in the dashboard: https://memory.anonalabs.com/dashboard/spaces/SPACE` | The verb in the sentence (`upgrading your plan`, `renaming the organization`, ...) follows the command you typed. Any further arguments, and `--yes`, are accepted and ignored: the command still sends nothing and exits `4`. Listing a **space's** members is a CLI command: see [`spaces members list`](/memory/cli/space-config#space-members). # The CLI for AI agents Source: https://docs.anonalabs.com/memory/cli/agents How an agent learns the anona CLI: anona guide, the anona-cli skill, and when to use MCP or the CLI. An agent with a shell can use Anona through the CLI without being taught every flag in advance. Three things make that work: a reference the CLI prints about itself, a skill that teaches the habits, and output built for a program to read. ## MCP first, the CLI as the fallback | The agent has | Use | | - | - | | Anona's MCP tools (`record`, `retrieve`, `reason`, `list_spaces`) in its own tool list | The MCP tools. Nothing to set up, no shell needed. See [MCP](/memory/mcp-integration). | | A shell, and no MCP tools | The CLI, always with `--json`. | | A shell, and work MCP has no tool for: uploading files, ingesting a JSON Lines export, changing a space's settings, cancelling a job | The CLI, even alongside MCP. | | No shell (Claude Desktop chat, claude.ai) | A [custom connector](/memory/integrations/skills#claude-desktop-and-claudeai) to `https://memory.anonalabs.com/mcp`. | ## Where to start: `anona` pip 0.25.0; npm 0.19.0 An agent that has just found the command can run `anona` with no arguments. It prints a short orientation and exits `0`: how to get a credential, the three memory commands, and that `anona guide` is the reference to read before anything unfamiliar. It touches no credential and sends nothing, so it is safe to run first. (`anona --quiet` with no command still exits `2`.) ## anona guide pip 0.25.0; npm 0.19.0 ```bash theme={null} anona guide # the whole CLI, compact, for a model to read anona guide --json # the same, as a command tree anona help --llm # the same as `anona guide` ``` `anona guide` prints a compact reference generated from the CLI's own parser: what the CLI is for, the credential order, the global options, every command with every flag and its help, an example and the shape of its `--json` output, the exit codes, and the common errors with their fixes. Because it is generated from the parser, it describes **the copy that is installed**, and cannot list a flag that copy lacks. `anona guide --json` is the same as one document: ```json theme={null} {"purpose": "...", "credentials": ["..."], "credential_notes": ["..."], "global_options": [], "commands": [], "exit_codes": [], "common_errors": []} ``` Each entry in `commands` has `name`, `path`, `help`, `positionals`, `options` (each with `flags`, `help`, `takes_value`, `metavar`, `choices`, `required`) and `subcommands`; a runnable command also has `example` and `json_output`. A group such as `spaces` lists its verbs under `subcommands`, named in full (`"spaces list"`, path `["spaces", "list"]`). `exit_codes` entries are `{"code", "name", "meaning"}` and `common_errors` entries are `{"error", "fix"}`. **Tell an agent to read it before composing an unfamiliar command**, rather than guessing a flag. A guessed flag exits `2` with nothing sent, which is cheap, but a guessed *meaning* (a filter that silently matches nothing) is not. `anona --help` answers for one command. ```bash theme={null} anona guide --json | jq '.. | objects | select(.name? == "documents upload") | .options[].flags' ``` ## The anona-cli skill The [Agent Skills](/memory/integrations/skills) installer ships an `anona-cli` skill that teaches an agent the CLI's habits: check `anona guide --json` before an unfamiliar command, which credential applies and how to set one up, how to read each exit code (`3` is not a failure to retry, `7` after a write means check before re-sending, `8` means poll), that a destructive command needs `--yes` and the user's say-so, and how to scope a write to one end user. The `anona-memory` skill uses the CLI as its fallback when MCP tools are absent. ```bash theme={null} curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/skills/install.sh | bash -s -- --skill anona-cli ``` ## Rules an agent should follow * **Always `--json`**, and branch on the exit code, never on the text. * **Name the space**: `--space` or `ANONA_SPACE_ID`, except on a temporary profile, whose only space is `default`. * **`3` from `record` is not a failure**: the server accepted the write and extracted nothing. Re-record once, with a sentence of context. * **Never re-run a write after `7` without checking**: it may have been stored. * **`8` means still running**: poll with `anona jobs get JOB_ID`. * **Never add `--yes` on your own**: `spaces delete`, `memories delete`, `documents delete`, `jobs cancel` and the settings `reset` commands refuse without it, and the user should confirm first. * **Signing in is the person's step**: `anona login` needs someone at a browser. An agent may run `anona start` for a temporary profile and say so. ## Give an MCP client memory with the CLI `anona mcp` is a stdio MCP server that uses the login from `anona login`, the same one the data commands use, so a client needs no URL, no header, no API key and no second sign-in: ```bash theme={null} anona login claude mcp add anona -- anona mcp ``` It presents, in order: the command-line login for its server, then an MCP-only login from `anona login --mcp`, then a temporary profile from `anona start`. The tools are available the next time the client starts. For a client that runs its own OAuth, skip the CLI and point it at the remote server directly: ```bash theme={null} claude mcp add --transport http anona-oauth https://memory.anonalabs.com/mcp ``` **`anona mcp` handles one message at a time.** It reads a request, waits for the answer, then reads the next. A slow `reason` can take up to 120 seconds, and for that long every other request from the same client waits behind it. If your agent issues parallel memory calls, expect them to queue. ## Related * [Self-onboarding](/memory/agents/self-onboarding): the four ways an agent can connect. * [Onboarding prompt](/memory/agents/onboarding-prompt): a prompt that walks an agent through setup. # documents Source: https://docs.anonalabs.com/memory/cli/documents Upload files into a space and manage them from the anona CLI. pip 0.25.0; npm 0.19.0 Upload files to be read into memories, then list, show or delete them. Every command addresses one space: `--space`, or `ANONA_SPACE_ID`. ## documents upload ```bash theme={null} anona documents upload report.pdf notes.md --space notes anona documents upload handbook.pdf --tags hr,policy --user-id alice --space support --wait anona documents upload docs/*.md --space notes --json ``` | Flag | | | - | - | | `PATH...` | The files to upload, at most 20 per call | | `--tags a,b` | Tags applied to every uploaded file | | `--strategy NAME` | A named ingestion strategy (default: the space's) | | `--user-id`, `--agent-id`, `--session-id` | The scope to upload under | | `--wait` | Poll the jobs until they finish; the exit code reports the outcome | | `--wait-timeout SECONDS` | With `--wait`: give up after this long and exit `8` (default 300) | Ingestion is always asynchronous. Without `--wait` the command prints the job ids and exits `0` as soon as the server has accepted the files; check them with [`anona jobs get`](/memory/cli/jobs). With `--wait` it polls until every job finishes: `0` all completed, `1` one failed or was cancelled, `8` still running when the wait ran out. An upload does not report "nothing stored" (exit `3`): its job carries no item count to judge that by. A path that is not a readable file exits `2` before anything is sent. The server's limits are 20 files, 25 MB per file (64 MB for video), and 50 MB per request (100 MB with video); past them, or for a type it cannot read, the command exits `1` with the server's code (`unsupported_file_type`, `file_too_large`, `upload_too_large`, `file_type_mismatch`, `file_expands_too_much`) and a message naming the limit. Uploading to a space that does not exist creates it. > [!WARNING] > > If the space uses scoping, upload with the same `--user-id` you retrieve with. > Scoped search is strict, so a file uploaded without a scope is invisible to > every scoped `retrieve`, silently: the job still completes. **`--json`:** `{"job_ids": [...]}`. With `--wait`: `{"submitted": {"job_ids": [...]}, "jobs": []}`. ## documents list ```bash theme={null} anona documents list --space notes --source file ``` A space's documents, newest first. | Flag | | | - | - | | `--limit N` | At most this many, 1-500 (default 100) | | `--offset N` | Skip this many first | | `--source KIND` | `file` (uploaded), `custom` (a `document_id` you set on a write) or `memory` (implicit) | **`--json`:** `{"documents": [{"document_id", "source", "created_at", "memory_count", "tags"}], "total", "limit", "offset"}`. ## documents get ```bash theme={null} anona documents get DOCUMENT_ID --space notes ``` **`--json`:** the document's metadata object, as the API returns it. ## documents delete ```bash theme={null} anona documents delete DOCUMENT_ID --yes --space notes ``` Deletes a document **and the memories extracted from it**. Without `--yes` it exits `2` and sends nothing. **`--json`:** `{"deleted": true, "document_id", "space_id"}`, written by the CLI (the API answers with no body). ## Related * API: [Documents](/memory/api-reference/documents). * [Chat with your documents](/memory/use-cases/chat-with-your-documents). # Explore: graph, entities, receipts, chat Source: https://docs.anonalabs.com/memory/cli/explore Look at a space's entity graph, entities and context receipts, ask about one end user, chat with memory and list space types from the anona CLI. pip 0.26.0; npm 0.20.0 Read-side commands for understanding what a space holds and why a request returned what it did. Each takes `--space` (or `ANONA_SPACE_ID`), `--api-key` and `--json`, except `receipts get` and `space-types`, which take no `--space`. ## Graph and entities ```bash theme={null} anona graph --limit 20 --space notes anona graph draw "memories added per week" --space notes anona entities list --limit 20 --space notes anona entities get ENTITY_ID --space notes ``` `graph` prints ` entities, links` and then `name (count)` for each node, at most 50 lines. Edges are co-occurrence, not typed relationships. See [Graph](/memory/api-reference/graph). | Flag | On | | | - | - | - | | `--limit N` | `graph` | At most this many entities, 1-2000 (default 500) | | `--min-count N` | `graph` | Only entities mentioned at least this many times | | `--model M` | `graph draw` | The LLM that draws the chart (see `anona llm-models`) | | `--limit N`, `--offset N` | `entities list` | Up to 500 (default 100); skip | `graph draw PROMPT` asks for a chart or diagram drawn from the space's memories ([Draw](/memory/api-reference/draw)); `--json` gives `{"chart", "diagram", "mermaid", "text", "model"}`. `entities list` prints ` ` for each entity, most mentioned first; `entities get` shows one and the observations behind it. ## Receipts ```bash theme={null} anona receipts get REQUEST_ID anona receipts get REQUEST_ID --explain --memory-id MEMORY_ID ``` The context receipt of a request that asked for one: what was included, what was left out, and the token accounting. `REQUEST_ID` is the `X-Request-ID` of a retrieve or proxied call. `--explain` explains why one memory was or was not included, and needs `--memory-id`. See [Context receipts](/memory/api-reference/context-receipts). ## Ask about one user ```bash theme={null} anona ask-user alice "What does she prefer?" --space notes anona ask-user alice "What does she prefer?" --depth thorough --model MODEL --space notes ``` Answers a question about one end user, from what the space knows of them. `USER` is the `user_id` your writes were [scoped](/memory/guides/scoping-multi-tenant) with. `--model M` picks the LLM (see `anona llm-models`; default: the space's) and `--depth fast|thorough` how deep it reads. See [User profiles](/memory/api-reference/user-profiles). ## Chat ```bash theme={null} anona chat "What did we decide about releases?" --space notes anona chat "Hello" --space notes --no-memory ``` One message through the memory-backed [chat proxy](/memory/api-reference/chat). It prints the reply. `--no-memory` sends `"memory": false` and `"auto_record": false`: nothing is recalled into the turn and the turn is not recorded. `--model M` picks the LLM. ## Space types ```bash theme={null} anona space-types ``` The templates a new space can start from: ` `. # jobs Source: https://docs.anonalabs.com/memory/cli/jobs Check or cancel the background jobs that queued writes and document uploads return. `record --async`, `record --batch` and `documents upload` return job ids instead of waiting. These two commands follow them up. Both address one space: `--space`, or `ANONA_SPACE_ID`. | Command | pip | npm | | - | - | - | | `jobs get`, `jobs cancel` | yes | 0.19.0 | ## jobs get ```bash theme={null} anona jobs get JOB_ID --space notes anona jobs get JOB_ID --space notes --json | jq -r .status ``` The job's status, how many memories it made from how many items, and their ids. `GET /v1/spaces/{id}/jobs/{job_id}`. A poll is a read: it costs nothing and is safe to repeat. A job is still running while its status is `pending`, `processing`, `running` or `queued`; anything else is final (`completed`, `failed`, `cancelled`). `memory_count` far below `items_submitted` is not a failure: near-identical items legitimately extract to fewer memories. `zero_yield_items` is how many items extraction produced no memory from. It can be non-zero even when `memory_count` is at or above `items_submitted`, because one item can produce several memories and another none. An id the space does not hold (a typo, or a job from another space) exits `5` with `job_not_found` and prints nothing on stdout, `--json` or not. The API itself answers that case with `200` and `"status": "not_found"`. To wait for a job instead of polling it yourself, pass `--wait` to the command that queued it ([`record`](/memory/cli/memory#background-writes), [`documents upload`](/memory/cli/documents#documents-upload)). **`--json`:** `{"job_id", "status", "created_at", "completed_at", "error", "memory_count", "memory_ids", "items_submitted", "zero_yield_items", "progress"}`. ## jobs cancel ```bash theme={null} anona jobs cancel JOB_ID --yes --space notes ``` Cancels the parts of a job still queued. The parts already running finish, and the output says how many. Without `--yes` it exits `2` and sends nothing. A job that has already finished cannot be cancelled (`job_not_cancellable`). **`--json`:** `{"job_id", "status", "cancelled_parts", "running_parts"}`. ## Related * API: [Job status](/memory/api-reference/memories#job-status), [Cancel a job](/memory/api-reference/memories#cancel-a-job). * [Stop a runaway import](/memory/use-cases/stop-a-runaway-import). # memories Source: https://docs.anonalabs.com/memory/cli/memories Page through, correct, delete and trace stored memories from the anona CLI. Every command here addresses one space: pass `--space`, or set `ANONA_SPACE_ID` (a temporary profile uses `default`). To *search* memories use [`retrieve`](/memory/cli/memory#retrieve); these commands browse and change them. | Command | pip | npm | | - | - | - | | `memories list`, `edit`, `delete`, `history` | yes | 0.19.0 | | `memories list --member-id`, `--tag-groups` | 0.25.0 | 0.19.0 | ## memories list ```bash theme={null} anona memories list --space notes anona memories list --space notes --q pnpm --limit 20 anona memories list --space notes --member-id me --json ``` One memory per line: full id, type, content; then `Showing 1-50 of N`. `GET /v1/spaces/{id}/memories`. | Flag | | | - | - | | `--limit N` | At most this many, 1-200 (default 50) | | `--offset N` | Skip this many first (default 0) | | `--q TEXT` | Only memories whose text or context contains this (a case-insensitive substring match) | | `--type TYPE` | Only memories of this type: `fact`, `experience`, `note` or `summary`. By default a fact a note already covers is hidden behind that note, so `--type fact` can list nothing while facts exist; add `--no-prefer-observations` to see them. Only facts and experiences can be edited; a note is rebuilt from them. | | `--no-prefer-observations` | List the raw facts beside the synthesized notes built from them. The default lists only the note | | `--member-id ID` | Only memories this member wrote; `me` for your own. In a personal space every memory is yours, so `me` matches them all. *pip 0.25.0* | | `--tag-groups JSON` | A [compound tag filter](/memory/api-reference/retrieve#compound-tag-filters); `@FILE` reads it. *pip 0.25.0* | | `--user-id`, `--agent-id`, `--session-id` | Only memories recorded under that scope | An empty list is a document with an empty list, and exit `0`. **`--json`:** `{"items": [{"id", "content", "type", "state", "tags", "source_ids", ...}], "total", "limit", "offset"}`. ## memories edit ```bash theme={null} anona memories edit MEMORY_ID --text "The team ships on Wednesdays." --reason "moved in March" --space notes ``` Corrects one memory's text in place. `--text` is required; `--reason` is kept on the memory's history. `PATCH /v1/spaces/{id}/memories/{memory_id}`. Owner only on a shared space (`space_owner_only`). **`--json`:** the edited memory, `{"id", "content", "type", "state", ...}`. ## memories delete ```bash theme={null} anona memories delete MEMORY_ID --yes --space notes ``` Deletes one memory. `DELETE /v1/spaces/{id}/memories/{memory_id}`. Without `--yes` it exits `2`, sends nothing, and says what would have been deleted: ```text theme={null} anona: this would delete memory 0f3c9a2e-… in space 'notes'. Pass --yes to do it. Nothing was sent. ``` It never prompts. Owner only on a shared space. **`--json`:** `{"deleted": true, "memory_id", "space_id"}`, written by the CLI (the API answers with no body). ## memories history ```bash theme={null} anona memories history MEMORY_ID --space notes ``` Each earlier version of one memory, when it changed and why. `GET /v1/spaces/{id}/memories/{memory_id}/history`. **`--json`:** `{"memory_id", "history": [{"previous_content", "changed_at", "reason"}]}`. There is no command to read one memory by id, because the API has no such call; `memories list --q` or `retrieve` finds it. ## Related * API: [Memories](/memory/api-reference/memories#list-memories-in-a-space). * [Correct a memory](/memory/use-cases/correct-a-memory). # record, retrieve, reason Source: https://docs.anonalabs.com/memory/cli/memory Store memories, search them and ask questions over them from the shell, with every flag the three commands take. The three memory calls. Each takes `--space`, `--api-key`, `--base-url` and `--json`, plus the [global options](/memory/cli/scripting#global-options). A flag you do not give is left out of the request, so the server's own defaults apply. A malformed value (a date that does not parse, a `--metadata` that is not a JSON object, an empty tag) exits `2` and sends nothing. Dates are ISO 8601: `2026-06-01`, `2026-06-01T09:00:00Z` or `2026-06-01T09:00:00+02:00`, sent as you typed them. | Command | pip | npm | | - | - | - | | `record`, `retrieve`, `reason` with the flags marked *0.24.0* below | yes | 0.19.0 | | the flags marked *0.25.0* | 0.25.0 | 0.19.0 | See [What each package has](/memory/cli/overview#what-each-package-has). ## record Store a memory. A write is extracted into the facts it contains rather than stored verbatim, so one sentence can become two memories. ```bash theme={null} anona record "The team ships on Tuesdays." --space notes git log -1 --format=%B | anona record - --tags git --session-id "$BRANCH" --space notes anona record --file notes/decision.md --timestamp 2025-06-01 --metadata '{"source": "adr"}' --space notes ``` | Flag | What it sends | Since | | - | - | - | | `CONTENT` | The memory, as a complete sentence. `-` reads it from stdin | 0.24.0 | | `--file PATH` | Read the content from a file (UTF-8). Not together with `CONTENT` | 0.24.0 | | `--tags a,b` | `tags`, comma-separated. Repeating the flag adds more | 0.24.0 | | `--timestamp DATE` | `timestamp`: when the event happened, not when you record it. Use it when importing history | 0.24.0 | | `--metadata JSON` | `metadata`: a JSON object, e.g. `'{"source": "cli"}'` | 0.24.0 | | `--user-id`, `--agent-id`, `--session-id` | The [scope](/memory/guides/scoping-multi-tenant) to file the memory under | 0.24.0 | | `--async` | Queue the write and return a job id at once instead of waiting for extraction | 0.25.0 | | `--batch FILE` | Record up to 100 memories from a JSON Lines file (`-` for stdin). Always queued. See [Batch ingest](#batch-ingest) | 0.25.0 | | `--wait` | With `--async` or `--batch`: poll the job until it finishes, and exit with its outcome | 0.25.0 | | `--wait-timeout SECONDS` | With `--wait`: give up after this long and exit `8` (default 300) | 0.25.0 | `record` prints what was stored, and the memory ids: ```text theme={null} Stored 2 memories in space 'default': 0f3c…, 7ab1… ``` **A write that extracts to nothing is not a success.** Short, context-free content often produces no fact at all. The API accepts the request with `status: "nothing_extracted"` and no ids; `record` prints the API's advice on stderr and exits **`3`**. The usual fix is to record again with a sentence of context: what the thing is, or why it matters. Empty content, from any of the three sources, exits `2`. **`--json`:** `{"memory_id", "memory_ids": [...], "status", "message"?, "usage", "routed_to"}`. `routed_to` is null on a write that named its space. With `--async`: `{"job_id", "status"}`. ### Background writes pip 0.25.0; npm 0.19.0 A plain `record` waits for extraction, which is one model call. `--async` queues the write and returns as soon as the server has accepted it: ```bash theme={null} anona record "Quarterly planning moved to the first Monday." --async --space notes # Queued in space 'notes'. Job: job_… # Check it with `anona jobs get job_… --space notes`. ``` Add `--wait` to queue it and then poll until it finishes. Polling a job is a read: it costs nothing and is safe to repeat. The exit code then reports what the job did: `0` stored, `3` completed but stored nothing, `1` failed or was cancelled, `8` still running when `--wait-timeout` ran out (check later with `anona jobs get`). `--wait` on a plain `record` exits `2`, since a plain record already waits. With `--wait --json`, the document is `{"submitted": , "jobs": []}`. ### Batch ingest pip 0.25.0; npm 0.19.0 `--batch FILE` records up to 100 memories in one queued call. The file is [JSON Lines](https://jsonlines.org): one item per line, either a JSON string (the content alone) or an object with `content` and, optionally, any of `context`, `timestamp`, `metadata`, `tags`, `user_id`, `agent_id`, `session_id`. Blank lines are skipped. ```json theme={null} {"content": "Acme renewed on the annual plan.", "timestamp": "2026-03-02", "user_id": "acme"} ``` ```bash theme={null} cat > history.jsonl <<'EOF' "The release branch is cut on Mondays." {"content": "Acme renewed on the annual plan.", "timestamp": "2026-03-02", "tags": ["billing"], "user_id": "acme"} {"content": "Staging has no seed data.", "metadata": {"source": "wiki"}} EOF anona record --batch history.jsonl --space notes --wait jq -c '{content: .text, timestamp: .date}' export.json | anona record --batch - --space notes --json ``` The whole file is checked before anything is sent: a line that is not valid JSON, an unknown field, an empty `content`, an empty file or more than 100 items exits `2`, names the line, and sends nothing. Split a larger export into files of 100. `--tags`, `--timestamp` and `--metadata` cannot be combined with `--batch`; put them on each line. `--user-id`, `--agent-id` and `--session-id` apply to every item. **`--json`:** `{"job_id", "job_ids", "status", "accepted"}` (`job_ids` is null unless the batch was split into several jobs), or with `--wait` the `submitted` / `jobs` document above. For very large imports, see [Bulk import with webhooks](/memory/guides/bulk-import-webhooks). ## retrieve Search your memories by meaning. ```bash theme={null} anona retrieve "when do we ship" --space notes anona retrieve "deploy schedule" --space ops --json --limit 3 | jq -r '.results[].content' anona retrieve "what changed" --space ops --occurred-after 2026-06-01 ``` | Flag | What it sends | Since | | - | - | - | | `QUERY` | What to search for | 0.24.0 | | `--limit N` | `limit`: 1-100, default 10 | 0.24.0 | | `--min-score X` | `min_score`: drop results below this. Read the note below first | 0.24.0 | | `--tags a,b` | `tags`: only memories carrying these tags. Repeatable | 0.24.0 | | `--tags-match MODE` | `tags_match`: `any` (default), `all`, `any_strict`, `all_strict` or `exact`. `any` and `all` also return memories with **no tags at all**, which match every non-strict filter; `any_strict`, `all_strict` and `exact` exclude untagged memories | 0.24.0 | | `--as-of DATE` | `as_of`: only memories **recorded** at or before this instant | 0.24.0 | | `--occurred-after DATE` | `occurred_after`: only memories about events at or after this | 0.24.0 | | `--occurred-before DATE` | `occurred_before`: only memories about events at or before this | 0.24.0 | | `--user-id`, `--agent-id`, `--session-id` | Only memories recorded under that scope. Strict: an unscoped memory is not returned | 0.24.0 | | `--tag-groups JSON` | `tag_groups`: a [compound tag filter](/memory/api-reference/retrieve#compound-tag-filters), AND-ed with any scope. `@FILE` reads it from a file | 0.25.0 | | `--member-id ID` | Only memories this member wrote; `me` for your own. In a personal space every memory is yours, so `me` matches them all | 0.25.0 | | `--no-prefer-observations` | Return the raw facts beside the synthesized note built from them. The default (`--prefer-observations`) returns only the note | 0.25.0 | | `--format MODE` | `results`: one line per memory (default). `block`: the [prompt-ready context string](/memory/api-reference/retrieve#prompt-ready-context) | 0.25.0 | | `--max-tokens N` | With `--format block`: cap the block at about this many tokens | 0.25.0 | Plain output is one memory per line, with its relevance score and the first eight characters of its id: ```text theme={null} 1. 1.1 3f2a9c1e The user prefers pnpm. 2. 6e-05 9b7d0e44 Builds run on Node 22. ``` A search that matches nothing prints `No memories matched.` and exits `0`. **Scores span orders of magnitude**: about 1.1 for a near-verbatim match, and anywhere from about 0.1 down to 0.00001 for a correct match worded differently. Most `--min-score` floors therefore keep only the near-verbatim matches. See the [retrieve API](/memory/api-reference/retrieve) before setting one. A search carrying a scope id always matches tags strictly, whatever `--tags-match` says. `--as-of` is about when a memory was recorded; `--occurred-after` / `--occurred-before` about when the thing it describes happened. History imported today has today's record time and its own event time. **`--tag-groups`** takes a JSON array of `{"tags": [...], "match": ...}`, `{"and": [...]}`, `{"or": [...]}` and `{"not": {...}}` expressions; a single object is taken as a one-element array. Only the shape is checked locally, the grammar and limits are the server's: ```bash theme={null} anona retrieve "open work" --space notes \ --tag-groups '[{"or": [{"tags": ["project:alpha"]}, {"tags": ["project:beta"]}]}, {"not": {"tags": ["status:archived"]}}]' anona retrieve "open work" --space notes --tag-groups @filter.json ``` **`--format block`** prints the context string itself, ready to paste into a prompt: ```bash theme={null} anona retrieve "how does this user like answers" --space support --user-id alice \ --format block --max-tokens 800 > context.txt ``` `--max-tokens` without `--format block` exits `2`. **`--json`:** `{"results": [{"memory_id", "content", "relevance_score", "memory_type", "tags", ...}]}`, every row as the [retrieve API](/memory/api-reference/retrieve) returns it. With `--format block`: `{"results": [...], "context", "token_estimate", "usage"}`. An empty search is `{"results": []}` with exit `0`. ## reason Ask a question and get an answer synthesized from your memories. Use it when no single memory holds the answer. ```bash theme={null} anona reason "What is our release process?" --space notes anona reason "how does this team ship?" --space notes --depth thorough --json | jq .sources ``` | Flag | What it sends | Since | | - | - | - | | `QUERY` | The question | 0.24.0 | | `--depth MODE` | `depth`: `fast` or `thorough`. `fast` lets a current [memory model](/memory/api-reference/models) answer on its own; `thorough` always reads the notes and raw memories under it, slower and costlier. Omitted, the space's [reason settings](/memory/cli/settings#reason-settings) apply, else `fast` | 0.24.0 | | `--user-id`, `--agent-id`, `--session-id` | Reason only over that scope | 0.24.0 | `reason` is an agent loop rather than a lookup, so it takes tens of seconds and sometimes longer. It says so on stderr while it works, so `anona reason "..." > answer.md` captures only the answer. `--depth thorough` is the second look to ask for when an answer reads stale. **`--json`:** `{"insights", "sources": {"models", "memories", "notes"}, "rules_applied": [...], "model", "usage"}`. `sources` says what the answer was built from, which the plain output does not show. ## Related * [Scripting & automation](/memory/cli/scripting): exit codes, retries, `--json`. * [Jobs](/memory/cli/jobs): check or cancel what `--async` and `--batch` queued. * API: [Memories](/memory/api-reference/memories), [Retrieve](/memory/api-reference/retrieve), [Reason](/memory/api-reference/reason). # llm-models and model Source: https://docs.anonalabs.com/memory/cli/models Two different things called models: the LLMs you can choose (llm-models) and a space's memory models (model). "Model" means two unrelated things in Anona, and the CLI keeps them apart: | Command | Is about | Example | | - | - | - | | `anona llm-models` | the **LLMs** you can choose to answer `reason` or proxied chat, and what each costs | `openai.gpt-oss-120b-1:0` | | `anona model MODEL_ID` | one **memory model**: a standing question a space keeps an up-to-date answer to | "What does this team's release process look like?" | | Command | pip | npm | | - | - | - | | `model` | yes | 0.19.0 | | `llm-models` | 0.25.0 | 0.19.0 | ## llm-models pip 0.25.0; npm 0.19.0 ```bash theme={null} anona llm-models anona llm-models --json | jq -r '.data[] | select(.supports_memory_operations) | .id' ``` The LLMs this deployment serves, with their credit price per 1,000 input and output tokens. Takes no `--space`. `GET /v1/models`. Use an id (or a tier alias) from here in [`reason-settings set --model`](/memory/cli/settings#reason-settings) and [`chat-settings set --chat-model`](/memory/cli/settings#chat-settings). A model whose `supports_memory_operations` is `false` works in proxied chat only, so it is refused as a reason model. See [Model selection](/memory/api-reference/model-selection). **`--json`:** `{"object": "list", "data": [{"id", "display_name", "alias", "default", "credits_per_1k_input", "credits_per_1k_output", "provider", "supports_memory_operations", ...}]}`. ## model ```bash theme={null} anona model MODEL_ID --space notes anona model MODEL_ID --space notes --json | jq -r .content ``` One memory model: its name, its question and its current content (markdown). `GET /v1/spaces/{id}/models/{model_id}`. Addresses one space: `--space`, or `ANONA_SPACE_ID`. Creating, refreshing and listing memory models is not a CLI command yet: use the dashboard's **models** tab or the [API](/memory/api-reference/models). `anona profile` shows the space's profile, which the memory models sit beside; see [profile](/memory/cli/profiles-and-usage#profile). **`--json`:** `{"id", "space_id", "name", "query", "content", "tags", ...}`. # Anona CLI Source: https://docs.anonalabs.com/memory/cli/overview The anona command line: record, retrieve and reason over memories, manage spaces, documents, jobs and settings, and connect MCP clients, from a terminal, a script or an agent harness. `anona` is the command line for Anona Memory. It does three jobs: * **Use memory from a shell.** `anona record`, `anona retrieve` and `anona reason` are the same three calls as the API, with `--json` output and stable exit codes, so a script, a CI job or an agent without MCP can use memory directly. * **Manage what is stored.** Spaces, memories, uploaded documents, background jobs and per-space settings, each one command. * **Connect an MCP client with no API key.** `anona login` signs you in once in a browser, and `anona mcp` is the stdio server your client launches. ```bash theme={null} pip install -U anona anona login anona record "Deploys run Tuesdays at 10:00 UTC." --space ops anona retrieve "when do we deploy" --space ops ``` ## CLI, MCP or SDK | You are | Use | | - | - | | An agent in a client that supports MCP (Claude Code, Cursor, Codex, Hermes, OpenClaw) | [MCP](/memory/mcp-integration). The tools are already authenticated and need no shell. | | An agent with a shell and no MCP, or a harness where MCP is not configured | The CLI, with `--json`. See [For AI agents](/memory/cli/agents). | | A script, a cron job or a CI step | The CLI, with `--json` and the [exit codes](/memory/cli/scripting#exit-codes). | | Bulk work: uploading a folder of files, ingesting a JSON Lines export | The CLI: [`documents upload`](/memory/cli/documents) and [`record --batch`](/memory/cli/memory#batch-ingest). | | Application code | The [Python](/memory/sdk/python) or [JavaScript](/memory/sdk/javascript) SDK, with an API key. | The CLI is a thin wrapper over the [REST API](/memory/api-reference/overview): every data command is one API call (or a short sequence, such as polling a job), and a flag you do not give is left out of the request rather than sent as a default. ## Install ```bash theme={null} pip install -U anona ``` The `-U` matters. `pip install anona` over an existing copy reports success and **leaves the old version in place**, so a machine that installed the CLI before a command existed keeps a binary without it, and the command then fails with `invalid choice: ''`, which reads like it was never real. From pip 0.25.3 / npm 0.19.3 that error adds the installed version and the upgrade command. `anona --help` lists what your copy actually has, and `anona --version` prints its version. The two packages are numbered separately: pip 0.26.x and npm 0.20.x are the same release, with the same commands and output. If pip answers `error: externally-managed-environment`, it is refusing to write to a system Python, which Debian, Ubuntu and Homebrew all do by default. Use either of these instead: ```bash theme={null} uvx --from anona anona start # nothing installed, always the current version pipx install anona # or an isolated install on your PATH ``` pip also suggests `--break-system-packages`, and it does work (`pip install -U --break-system-packages anona`). It writes into the Python your OS maintains, so prefer either command above when you have the choice. If you have Node and no Python, the JavaScript package also installs a binary called `anona`, with no runtime dependencies: ```bash theme={null} npm install -g @anona-labs/memory # then: anona login npx @anona-labs/memory login # or, with nothing installed ``` > [!NOTE] > > **Both packages install a binary called `anona`.** With both installed you get > whichever comes first on your `PATH`. That is harmless: they share > `~/.anona/credentials.json` (MCP) and `~/.anona/cli-credentials.json` (the > command line), and the same locks, so a profile started with one can be > claimed with the other, and a Python and a Node `anona mcp` refreshing at the > same instant take turns rather than both spending the refresh token. A client > configured with `anona mcp` keeps working if you uninstall one package only if > the other is still on `PATH`. ## First run pip 0.25.0; npm 0.19.0 `anona` on its own, with no arguments, prints where to start and exits `0`: how to get a credential (`anona start`, `anona login`, `anona status`), the three memory commands, and `anona guide` as the full reference. It reads no credential and sends nothing. `anona --help` ends with the same pointer to `anona guide`. ```bash theme={null} anona # where to start anona guide # every command, flag, exit code and recipe ``` Only the empty command line does this. A global option with no command after it, such as `anona --quiet`, is still a usage error (exit `2`), since a script wrote it. Older copies answer a bare `anona` with `the following arguments are required: command` and exit `2`. ## What each package has The pip package is the reference implementation. The npm package is being brought to parity with it; until then, check this table before you rely on a command from `npx @anona-labs/memory`. | Command | pip `anona` | npm `@anona-labs/memory` | | - | - | - | | `start`, `login`, `logout`, `status`, `mcp` | yes | yes | | `record`, `retrieve`, `reason` (the flags in pip 0.24.0) | yes | 0.19.0 | | `spaces list` / `create` / `get`, `memories`, `jobs`, `profile`, `model`, `user-profile`, `usage` | yes | 0.19.0 | | `record --async` / `--batch` / `--wait` | 0.25.0 | 0.19.0 | | `retrieve --tag-groups`, `--member-id`, `--no-prefer-observations`, `--format block` | 0.25.0 | 0.19.0 | | `spaces delete`, `documents`, `chat-settings`, `reason-settings`, `extraction-settings`, `llm-models` | 0.25.0 | 0.19.0 | | `doctor`, `guide`, `help`, `completion` | 0.25.0 | 0.19.0 | | `--profile`, `--timeout`, `--retries`, `--quiet` | 0.25.0 | 0.19.0 | | Exit codes `5`-`8` | 0.25.0 | 0.19.0 (`1` before) | | `models` (list, create, edit, ...), `profile set`, `rules`, `webhooks`, `routing-settings`, `spaces members`: [space config](/memory/cli/space-config) | 0.26.0 | 0.20.0 | | `graph`, `entities`, `receipts`, `ask-user`, `chat`, `space-types`: [explore](/memory/cli/explore) | 0.26.0 | 0.20.0 | | `org`, `orgs`, `projects`, `keys`, `members`, `invitations`, `billing`, `exports`, `usage --history`: [account](/memory/cli/account) (need `anona login --cli`) | 0.26.0 | 0.20.0 | A version in the table is the first release of that package with the command. The command pages mark each command the same way. `anona --help` on your own copy is the final word. ## Where things are stored Everything the CLI keeps is under `~/.anona/` (or `~/.anona/profiles/NAME/` for a [named profile](/memory/cli/sign-in#named-profiles)): | File | Holds | Written by | | - | - | - | | `cli-credentials.json` | the login the data commands and `anona mcp` use | `anona login` (or `login --cli`) | | `credentials.json` | an MCP-only login, and a temporary profile | `anona login --mcp`, `anona start` | | `config.env` | `ANONA_API_KEY`, `ANONA_SPACE_ID`, `ANONA_BASE_URL` | you, or the [agent skills installer](/memory/integrations/skills) | All three are mode 600. No command prints a whole key or token. ## Next > [card] [Sign in & credentials](/memory/cli/sign-in) > > API key, `anona login`, temporary profiles, and which one wins. > [card] [Record, retrieve, reason](/memory/cli/memory) > > The three memory calls and every flag they take. > [card] [Scripting & automation](/memory/cli/scripting) > > `--json`, exit codes, retries, waiting on jobs, batch files. > [card] [For AI agents](/memory/cli/agents) > > `anona guide`, the `anona-cli` skill, and when to use MCP instead. # profile, user-profile, usage Source: https://docs.anonalabs.com/memory/cli/profiles-and-usage Read a space's profile, what a space knows about one end user, and your credit balance from the anona CLI. | Command | pip | npm | | - | - | - | | `profile`, `user-profile`, `usage` | yes | 0.19.0 | ## profile ```bash theme={null} anona profile --space notes ``` The space's profile: its mission and disposition, the standing instructions `reason` reads. `GET /v1/spaces/{id}/profile`. Addresses one space: `--space`, or `ANONA_SPACE_ID`. To change it, use `anona profile set` and `anona profile reset` ([space config](/memory/cli/space-config#profile), pip 0.26.0; npm 0.20.0), or see [Setting the profile](/memory/api-reference/models#setting-the-profile). **`--json`:** `{"space_id", "name", "mission", "disposition": {...}}`. ## user-profile ```bash theme={null} anona user-profile alice --space support anona user-profile alice --space support --limit 20 --json ``` What a space knows about one end user, one memory per line. `GET /v1/spaces/{id}/users/{user_id}/profile`. `USER_ID` is the `user_id` your writes were [scoped](/memory/guides/scoping-multi-tenant) with. | Flag | | | - | - | | `--limit N` | At most this many memories, 1-200 | | `--offset N` | Skip this many memories first | A user the space has never heard of is an empty profile, not an error. See [User profiles](/memory/api-reference/user-profiles). **`--json`:** `{"space_id", "user_id", "memory_count", "first_seen", "last_active", "memories": [...]}`. ## usage ```bash theme={null} anona usage anona usage --json | jq .credits_remaining ``` Credits remaining, your limit, what you have used, and your per-minute rate limit. Takes no `--space`. `GET /v1/usage/me`. Check it when a command exits `6` with `credits_exhausted`. Reads spend credits as well as writes: `retrieve`, `reason`, `memories list`, `documents list` and `profile` are all refused at zero. `anona doctor` reports the same balance. `usage --history` and `usage --breakdown` show operation counts over time and need `anona login --cli`: see [account](/memory/cli/account#reading-the-account). **`--json`:** `{"credits_remaining", "credits_limit", "credits_used", "credits_banked", "rate_limit_per_min", ...}`. # Scripting & automation Source: https://docs.anonalabs.com/memory/cli/scripting JSON output, exit codes, retries and timeouts, waiting on background jobs, batch files and shell completion for the anona CLI. Everything a script needs to use `anona` without parsing prose: one JSON document on stdout, a stable exit code, and errors on stderr. ## JSON output Every data command takes `--json`, and then prints **one JSON document on stdout and nothing else there**. Each command page shows its document; the short form is also printed by [`anona guide`](/memory/cli/agents#anona-guide). * On success, stdout is the document, usually the API's response as it came. Where the API answers with no body (a delete, a settings reset), the CLI writes a small document of its own, such as `{"deleted": true, "memory_id": ..., "space_id": ...}`. * An empty result is still a document with exit `0`: `{"results": []}`, `{"items": [], ...}`. * On failure, stdout is **empty** and a one-line error is on stderr, so `anona retrieve ... --json | jq` never receives half a document. * The one exception is `record` that stored nothing: exit `3`, and its document (with `status: "nothing_extracted"`) is still on stdout. * Non-ASCII and control characters are escaped (`é`, `\u001b`), so the output is safe to print to a terminal and decodes to exactly what was stored. Progress and retry notices go to stderr, so they never mix into the document. `--quiet` turns them off. ```bash theme={null} anona record "Deploys run Tuesdays at 10:00 UTC." --tags ops,schedule --space ops --json anona retrieve "deploy schedule" --space ops --json --limit 3 | jq -r '.results[].content' anona reason "how does this team ship?" --space ops --json | jq -r .insights ``` ## Exit codes A script branches on the exit code rather than on the message. Each code means the same thing on every command. | Code | Name | Means | | - | - | - | | `0` | `ok` | It did what it was asked. A search that matched nothing is `0` | | `1` | `failed` | The server refused the request for a reason no code below names (a `409` or `422`, say), or a job `--wait` was watching failed or was cancelled | | `2` | `usage` | The command line was wrong. **Nothing was sent, and nothing was billed** | | `3` | `nothing_stored` | `record` only: accepted, but no memory was extracted. Not an error to retry blindly: re-record with more context | | `4` | `auth` | No usable credential, a credential that may not go to that server, or a `401` / `403` | | `5` | `not_found` | `404`: the space, memory, job or setting does not exist | | `6` | `rate_limited` | `429` after the retries ran out, or out of credits | | `7` | `unavailable` | A `5xx`, a timeout, or the server could not be reached. **A write may still have been stored** | | `8` | `still_running` | `--wait` gave up while the job was still running | > [!NOTE] > > **Codes `5` to `8` are new.** pip `anona` 0.24.0 and the npm CLI report every > refused or failed request as `1`, and a `403` as `1` rather than `4`. The table > above is the next pip release; the npm CLI follows when its port lands. A script > that must run on both can treat `1` and `5`-`7` alike. The account commands (`start`, `login`, `logout`, `status`) use only `0` and `1`, plus `2` for a wrong command line. ```bash theme={null} anona record "$FACT" --space notes --json > out.json case $? in 0) echo "stored" ;; 3) echo "nothing extracted; add context" ;; 7) echo "unknown: check before retrying" ;; *) echo "failed" ;; esac ``` ## Errors Errors go to stderr, one line, in the API's own terms: ```text theme={null} anona: validation_error: Request validation failed -- content: String should have at least 1 character (request_id req_…) anona: could not reach api.anonalabs.com: Connection refused ``` The first word after `anona:` is the [error code](/memory/api-reference/errors), which is the stable thing to match on. A failure to reach the server is reported as exactly that, never as an HTTP status, because no server answered. Quote the `request_id` when you contact support. ## Retries and timeouts `--timeout`, `--retries`: pip 0.25.0; npm 0.19.0 | Option | Default | | | - | - | - | | `--timeout SECONDS` | 120 | Give up on one HTTP request after this long | | `--retries N` | 2 | Retry a `429`, or a `5xx` on a request that is safe to repeat, up to N times, honouring `Retry-After` | | `--quiet`, `-q` | off | No progress or retry notices on stderr; results and errors still print | **A write is never re-sent after a `5xx` or a timeout.** The server may already have stored it, and a second send would store it twice. So after exit `7` from `record`, `documents upload` or any other write, check before you run it again: `anona retrieve` or `anona memories list --q` for a record, `anona documents list` for an upload. Reads are retried, and so is a `429` on anything, because a rate-limited request was not processed. Each retry says so on stderr while it waits (`anona: rate limited, retrying in 3 s (1/2)`), so a slow command can be told from a hung one. An expired `anona login` is renewed and the request sent once more; that is separate from `--retries`. ## Waiting on background work pip 0.25.0; npm 0.19.0 `record --async`, `record --batch` and `documents upload` queue work and return job ids. Add `--wait` to poll until the jobs finish, and the exit code reports the outcome instead of the submission: | Exit | After `--wait` | | - | - | | `0` | every job completed | | `3` | `record` only: completed, but no memory was stored | | `1` | a job failed or was cancelled | | `5` | the server no longer knows a job | | `8` | still running when `--wait-timeout` ran out (default 300 s); check later with `anona jobs get JOB_ID` | Polling is free and backs off from 1 s to 5 s. `--wait-timeout 0` is a single status check. With `--json` the document is `{"submitted": , "jobs": []}`. ```bash theme={null} anona record --batch history.jsonl --space notes --wait --wait-timeout 600 --json \ | jq '.jobs[] | {job_id, status, memory_count}' ``` ## Batch files `record --batch FILE` takes [JSON Lines](https://jsonlines.org), at most 100 items, one per line: a JSON string, or an object with `content` and any of `context`, `timestamp`, `metadata`, `tags`, `user_id`, `agent_id`, `session_id`. The file is validated in full before anything is sent; a bad line exits `2` and names the line number. Details and examples: [Batch ingest](/memory/cli/memory#batch-ingest). ```bash theme={null} split -l 100 big.jsonl part- for f in part-*; do anona record --batch "$f" --space notes --wait || break; done ``` ## Global options These go before or after the command. | Option | | | - | - | | `--base-url URL` | The Anona server. See [Where a key may be sent](/memory/cli/sign-in#where-a-key-may-be-sent) | | `--profile NAME` | A [named profile](/memory/cli/sign-in#named-profiles) (or `ANONA_PROFILE`) | | `--timeout SECONDS`, `--retries N`, `--quiet` | As above | | `--no-browser` | `login` only: print the URL instead of opening it | | `--version` | Print the installed version | Environment: `ANONA_API_KEY`, `ANONA_SPACE_ID`, `ANONA_BASE_URL`, `ANONA_PROFILE`. ## In CI Use an API key from your CI's secret store, never `anona login`, which needs a person at a browser: ```bash theme={null} pip install -U anona export ANONA_API_KEY="$ANONA_KEY_FROM_SECRETS" export ANONA_SPACE_ID=release-notes git log -1 --format=%B | anona record - --tags ci --metadata "{\"sha\": \"$GITHUB_SHA\"}" --json ``` `anona doctor --json` is a cheap first step that fails the job early, with exit `4` or `7`, when the key or the network is wrong. ## Shell completion pip 0.25.0; npm 0.19.0 ```bash theme={null} anona completion bash > ~/.local/share/bash-completion/completions/anona anona completion zsh > "${fpath[1]}/_anona" anona completion fish > ~/.config/fish/completions/anona.fish ``` The script completes every command, subcommand and flag, generated from the installed CLI, so regenerate it after an upgrade. # Space settings Source: https://docs.anonalabs.com/memory/cli/settings Show and change a space's chat, reason and extraction settings from the anona CLI. pip 0.25.0; npm 0.19.0 Three groups of per-space settings, each with the same three verbs: | Command | Settings for | | - | - | | `chat-settings` | the [drop-in LLM proxy](/memory/api-reference/chat#space-defaults): how many memories it injects, and whether it records each turn | | `reason-settings` | [`reason`](/memory/api-reference/reason#a-default-for-the-space): which model answers and how deep it reads | | `extraction-settings` | [extraction](/memory/api-reference/extraction-settings): how text written to the space becomes memories | ```bash theme={null} anona chat-settings get --space notes anona chat-settings set --memory-limit 5 --space notes anona chat-settings reset --yes --space notes anona reason-settings get --space notes anona reason-settings set --depth thorough --space notes anona reason-settings reset --yes --space notes anona extraction-settings get --space notes anona extraction-settings set --mode verbose --space notes anona extraction-settings reset --yes --space notes ``` Every command addresses one space: `--space`, or `ANONA_SPACE_ID`. In every `get`, a `null` field means "not set here: the platform default applies". ## How `set` works `set` changes the fields you name **and keeps the rest**. The API's `PUT` is a full replace, so sending only the changed field would clear every other one; `set` reads the stored settings, applies your changes, and writes the whole record back. Two consequences: * `--clear FIELD` unsets one field so it follows the platform default again. It is repeatable. Setting and clearing the same field in one command exits `2`. * `set` with no field named exits `2` and sends nothing. Because it is a read followed by a write, two `set` commands racing on the same space can each overwrite the other's change. Run them one after the other. `reset --yes` clears every override in the group. Without `--yes` it exits `2` and sends nothing. **`--json`:** `get` and `set` print the stored settings (after the write, for `set`). `reset` prints `{"reset": true, "space_id", "settings"}`, written by the CLI, since the API answers with no body. ## chat-settings | Flag | Field | | | - | - | - | | `--memory-limit N` | `memory_limit` | Memories injected per turn, 1-20 | | `--memory-token-budget N` | `memory_token_budget` | Token cap on the injected block, 1-100000 | | `--auto-record` / `--no-auto-record` | `auto_record` | Record each proxied turn | | `--memory` / `--no-memory` | `memory` | Inject memories into proxied calls | | `--chat-model ID` | `chat_model` | The LLM proxied calls use (see [`llm-models`](/memory/cli/models#llm-models)) | | `--clear FIELD` | | Any of the five field names | ```bash theme={null} anona chat-settings set --memory-limit 5 --no-auto-record --space support anona chat-settings set --clear chat_model --space support ``` **`--json`:** `{"space_id", "memory_limit", "memory_token_budget", "auto_record", "memory", "chat_model"}`. ## reason-settings | Flag | Field | | | - | - | - | | `--model ID` | `model` | A model id or tier name from [`llm-models`](/memory/cli/models#llm-models) | | `--depth MODE` | `depth` | `fast` or `thorough`: the default for [`reason --depth`](/memory/cli/memory#reason) | | `--clear FIELD` | | `model` or `depth` | ```bash theme={null} anona reason-settings set --depth thorough --space notes anona reason-settings set --clear model --space notes ``` A `--depth` on the `reason` command itself still wins over the space's setting. **`--json`:** `{"space_id", "model", "depth"}`. ## extraction-settings | Flag | Field | | | - | - | - | | `--mode MODE` | `mode` | `concise`, `verbose`, `verbatim` or `custom`: how much of the text becomes memories | | `--guidance TEXT` | `guidance` | Added to the extraction rules. `@FILE` reads it from a file | | `--custom-prompt TEXT` | `custom_prompt` | Replaces the rules; needs `--mode custom`. `@FILE` reads it | | `--labels-file PATH` | `labels` | A JSON array of [label groups](/memory/api-reference/extraction-settings#label-taxonomy) to classify memories by | | `--free-form-entities` / `--no-free-form-entities` | `free_form_entities` | Keep entities outside the label groups. `--no-...` needs labels | | `--clear FIELD` | | Any of the five field names | ```bash theme={null} anona extraction-settings set --mode verbose --space research anona extraction-settings set --guidance @guidance.md --space research anona extraction-settings set --labels-file labels.json --no-free-form-entities --space research ``` `labels.json` is a JSON array of groups. Each entry in `values` is an object with `value` (and an optional `description`), not a plain string: ```json labels.json theme={null} [{"key": "team", "description": "Which team owns it", "type": "value", "values": [{"value": "billing", "description": "Billing team"}]}] ``` `type` is `value` (one of the listed values), `multi-values` (any number of them), `text` (one free-form value) or `multi-text` (any number of free-form values). The two enumerated types need `values`; the free-form ones take none. `key` is lowercase letters, digits, `-` or `_`, with no colon or spaces, and not `anona`. Add `"tag": true` to stamp `key:value` onto each memory as a tag, so retrieve can filter on it. A `--labels-file` that cannot be read or is not a JSON array exits `2`. The server checks the rest (a label group's shape, a reserved key) and answers `validation_error` (exit `1`). Settings change what future writes extract; memories already stored are not re-extracted. See [Verifying a change](/memory/api-reference/extraction-settings#verifying-a-change). **`--json`:** `{"space_id", "mode", "guidance", "custom_prompt", "labels", "free_form_entities"}`. ## Permissions On a space shared with you, changing settings is owner only: a member gets `space_owner_only` (exit `4`). # Sign in & credentials Source: https://docs.anonalabs.com/memory/cli/sign-in The three credentials the anona CLI uses, the order it picks them in, named profiles, and how to check which one a command will use. The data commands (`record`, `retrieve`, `reason` and everything else that reads or writes memory) work with any of three credentials: | Credential | Get it with | Reaches | Space | | - | - | - | - | | **API key** | the dashboard, under **API keys** | your organization | required: `--space` or `ANONA_SPACE_ID` | | **Command-line login** | `anona login` (or `anona login --cli`) | your account | required: `--space` or `ANONA_SPACE_ID` | | **Temporary profile** | `anona start` | one throwaway workspace, no account | always `default`; omit `--space` | A fourth credential, the **MCP login**, is stored by `anona login` too, but only `anona mcp` uses it. The REST API refuses it, so the data commands never fall back to it. ## Which credential wins The first one found is used: 1. `--api-key` on the command line 2. `ANONA_API_KEY` in the environment 3. `ANONA_API_KEY` in `~/.anona/config.env` (or the active [profile's](#named-profiles) `config.env`) 4. the command-line login from `anona login` 5. a temporary profile from `anona start` An API key, wherever it came from, outranks every stored login. With only the MCP login stored, the data commands exit `4` and tell you to run `anona login --cli`. `--api-key` is visible to other users of the machine in the process list, so prefer the variable or the file. **A space is required with an API key or the command-line login.** Both reach an account that can hold many spaces, so the CLI never picks one for you: pass `--space`, or set `ANONA_SPACE_ID` in the environment or in `config.env` (in that order). Without one the command exits `2` and sends nothing. `anona spaces list` shows the spaces you can use. ## Check what a command will use ```bash theme={null} anona status # which credentials are stored, where they came from anona doctor # which one wins and why, and whether it actually works ``` `anona status` reads only the local files. It names the API key the data commands would use and where it came from, the command-line login, and the MCP login, each with the server it is for and when its access token expires, and for a temporary profile when the profile expires. It shows at most a key's first characters (`anona_live_abc1…`) and never a token. Because it does not call the server, a revoked credential still shows as found, and an API key is reported as "not checked here". An access token's expiry is not a deadline to act on: it renews itself, and you need `anona login` again only after a revocation or 30 unused days. A temporary profile stored beside an API key is introduced as what `anona mcp` uses. `anona status` with nothing stored prints `Not logged in` and exits `1`. `anona doctor` (pip 0.25.0; npm 0.19.0) goes further. It reports which Python and which `anona` are running, which profile and directory are active, which credential a data command would use **and why the others lost**, which server it talks to, whether that server answers, and whether the space a data command would use is reachable with that credential, and how many credits are left (the same balance `anona usage` shows). It never prints a secret, and its health check carries no credential at all. ```bash theme={null} anona doctor --space notes anona doctor --json | jq .credential ``` It exits `0` when everything checks out (it prints `All good.`), and otherwise with the code a data command would fail with: `4` for a credential problem, `7` for a server it cannot reach, `5` for a space that does not exist, `2` when no space is named at all, `6` when no credits are left (reads are refused at zero too). A balance it cannot read is reported as unknown and does not fail the check. With `--json` it prints one document: `{"anona_version", "python", "profile", "profile_dir", "credential": {"kind", "display", "source", "why", "base_url", ...}, "health": {"ok", "status"}, "space": {"id", "source", "accessible", "error"}, "credits": {"remaining", "limit", "used", "exhausted", "error"}, "ok"}`. ## With an API key Create a key in the dashboard under **API keys**, then: ```bash theme={null} export ANONA_API_KEY=anona_live_YOUR_KEY export ANONA_SPACE_ID=notes anona record "The user prefers pnpm." anona retrieve "package manager preference" ``` Or keep both in `~/.anona/config.env`: ```bash theme={null} ANONA_API_KEY=anona_live_YOUR_KEY ANONA_SPACE_ID=notes ``` The CLI reads `config.env` as data and never runs it, takes only `ANONA_API_KEY`, `ANONA_SPACE_ID` and `ANONA_BASE_URL` from it, and warns if the file is readable by anyone but you (`chmod 600 ~/.anona/config.env`). The [agent skills installer](/memory/integrations/skills) writes this file for you. ### Where a key may be sent A key only goes to Anona's own servers unless you say otherwise in the same place it came from. The default is `https://api.anonalabs.com`, and a key from the environment or `config.env` is sent only to `api.anonalabs.com` or `memory.anonalabs.com`. It may go to another server only when that server is named beside it: `--base-url` beside `--api-key` on the same command line, `ANONA_BASE_URL` beside `ANONA_API_KEY` in the environment, or both in `config.env`. A `--base-url` on its own never redirects a key you set elsewhere, so a URL an agent was talked into typing cannot carry it off. Anything else is refused with exit `4` before a request is made: ```text theme={null} anona: refusing to send the API key from ANONA_API_KEY to https://other-host (named by --base-url). ... ``` The server must be `https://`, except `localhost` and `127.0.0.1`. ## With `anona login` ```bash theme={null} anona login ``` `anona login` asks for one approval in the browser, stored in `~/.anona/cli-credentials.json`. The data commands use it, and so does [`anona mcp`](/memory/cli/agents#give-an-mcp-client-memory-with-the-cli), so one sign-in covers both (pip `anona` 0.26.1 or later, npm `@anona-labs/memory` 0.20.1 or later; older versions asked for a second, MCP-only approval). If a temporary profile is stored, the approval claims it. `anona login --mcp` asks instead for a separate MCP-only credential, stored in `~/.anona/credentials.json`. It can call only the MCP tools, and `anona mcp` uses it only when there is no command-line login for the same server. Each credential is its own app on the approval screen and under **Connected apps**, with its own refresh token, so revoking one never touches the other. `anona login --cli` is the same as `anona login`, except that a server which does not issue command-line credentials is a failure rather than a fallback to the MCP-only approval. A browser opens on the approval screen. If you have no account, you create one there. Read the client name before you approve it: that screen is the whole security model. Over SSH or in a container the browser cannot open on its own, so `anona login` prints the URL and you open it yourself; `--no-browser` skips the attempt. The approval redirects back to a local port on the machine running `anona login`, so open the URL in a browser on that machine, or forward that port. **Renewal is automatic.** The access token lasts an hour and the refresh token 30 days, rolling: each refresh issues a new 30 days, so a credential you use does not expire. When a data command finds the access token expired it refreshes first, and if the server refuses the token anyway it refreshes once and retries once. You are asked to approve again only after 30 days of total silence, or after you revoke the app under **Connected apps** in the dashboard; the command then exits `4` and says to run `anona login --cli`. **A stored login only goes to the server that issued it.** A `--base-url` or `ANONA_BASE_URL` naming a different one is refused with exit `4`, not followed: ```text theme={null} anona: the stored credential is for https://memory.anonalabs.com, not https://other-host. Run `anona login --base-url https://other-host` to switch. ``` Switching servers is always an explicit `anona login --base-url`. For a self-hosted deployment pass `--base-url https://your-host` or set `ANONA_BASE_URL`; the flag works before or after the command and wins over the variable. ### Signing out ```bash theme={null} anona logout # both logins anona logout --cli # only the command line anona logout --mcp # only MCP ``` `logout` deletes the local file and then asks the server to revoke the credential. The file is deleted even when the server cannot be reached, and the CLI says so; a credential it could not revoke dies after 30 days unused, or revoke it under **Connected apps**. `logout` refuses to discard a live, unclaimed temporary profile unless you pass `--force`, because its memories cannot be recovered. With nothing stored, `logout` says there was nothing to remove and still exits `0`. Neither `login` nor `logout` touches `config.env`. ## With a temporary profile ```bash theme={null} anona start ``` `anona start` creates a **temporary profile**: a memory workspace with no account, no email address and nobody owning it. An agent can use it immediately, and a person claims it later with `anona login`, which keeps everything it holds. It prints the deadline: ```text theme={null} Temporary profile created. It expires 2026-10-06 15:22 UTC (in 71 hours), after which its memories are deleted. The only reminder comes in the last 24 hours: run `anona status` to check, and `anona login` to claim it and keep what it holds. Its space, `default`, is normally created now; if a retrieve reports no space, the first memory you write creates it. ``` * **72 hours, then deletion**, memories included. `anona status` prints the deadline whenever you ask. * **One space, `default`.** Omit `--space`. Creating another is refused with `temp_profile_single_space` until the profile is claimed. * **1,000 credits while unclaimed**, rising to the normal allowance on claim. * **Claiming works with a new or an existing account.** A new account takes over the profile's workspace; an existing one gets the profile's space copied in as a new space (see [What claiming does](/memory/agents/temp-profiles#what-claiming-does)). * **One live profile at a time.** `anona start` refuses to create a second. **There is no separate claim command.** Run `anona login` on the machine (and `--profile`) that holds the temporary profile: approving in the browser claims it, and `anona login --help` says so. `anona usage --json` reads its credit balance without exposing the token. The full walkthrough is on [Temporary profiles](/memory/agents/temp-profiles). ## Named profiles pip 0.25.0; npm 0.19.0 `--profile NAME` (or `ANONA_PROFILE=NAME`) switches the whole credential set to `~/.anona/profiles/NAME/`: its own command-line login, its own MCP login and temporary profile, its own `config.env`, and its own locks. Nothing is shared, so a refresh in one profile can never rotate a token in another. Unset, or `default`, is `~/.anona` itself. ```bash theme={null} anona --profile work login --cli anona --profile work spaces list ANONA_PROFILE=personal anona retrieve "reading list" --space books ``` A profile name is letters, digits, `-` and `_`, at most 64 characters; anything else exits `2`. Use one profile per account or per deployment, for example to keep a staging server's login apart from production's. > [!WARNING] > > **The `anona login` credentials are for the CLI, not for your code.** The MCP > one works only on `/mcp`; the command-line one works only for the `anona` > commands on this machine. The Python and JavaScript SDKs and raw REST calls > need an API key, minted in the dashboard under **API keys**. A temporary > profile's `anona_tmp_` token is the exception: it also authenticates the REST > API until it is claimed or expires. # Memory models, profile, rules, webhooks, routing Source: https://docs.anonalabs.com/memory/cli/space-config Create and manage a space's memory models, profile, rules, webhooks, routing settings and members from the anona CLI. pip 0.26.0; npm 0.20.0 What shapes how a space behaves, one command per API call. Every command here addresses one space: `--space`, or `ANONA_SPACE_ID`. Each takes `--api-key` and `--json`. A flag you do not give is left out of the request. Rules and webhooks also work from a temporary profile ([`anona start`](/memory/cli/sign-in)). A command that deletes or empties something (`delete`, `clear`, `reset`) refuses without `--yes`: it exits `2`, prints `Nothing was sent.`, and sends nothing. ## Memory models A memory model is a question a space keeps answered. See [Memory models](/memory/api-reference/models) for the concept; `anona model ID` is the older spelling of `models get`. ```bash theme={null} anona models list --space notes anona models create "Build habits" --query "How does this team build?" --space notes anona models get MODEL_ID --space notes anona models edit MODEL_ID --query "How does the team ship?" --space notes anona models refresh MODEL_ID --space notes anona models history MODEL_ID --space notes anona models clear MODEL_ID --yes --space notes anona models delete MODEL_ID --yes --space notes ``` | Flag | On | | | - | - | - | | `--limit N`, `--offset N`, `--tags T` | `list` | Page size 1-200 (default 50); skip; only models scoped to these comma-separated tags | | `--query Q` | `create`, `edit` | The question the model answers (required on `create`) | | `--name NAME` | `edit` | The new name (`create` takes it as its argument) | | `--tags T` | `create`, `edit` | Comma-separated tags that scope the model to matching memories | | `--mode full\|delta` | `create`, `edit` | `full` regenerates on refresh, `delta` edits in place | | `--refresh-on-new-memories`, `--no-refresh-on-new-memories` | `create`, `edit` | Refresh when new memories are consolidated | | `--memory-type TYPE` | `create`, `edit` | Build from this memory type only; repeatable | | `--max-tokens N` | `create`, `edit` | Ceiling on the content length, 256-8192 | | `--model-id ID` | `create` | A stable id: lowercase letters, digits and hyphens | `create` and `refresh` run in the background: they print `Creating memory model (job ).` and `Refreshing (job ).`, and the job is pollable with [`anona jobs get`](/memory/cli/jobs). They are billed at submit time. `edit` merges what you pass into the stored trigger rather than replacing it, and does not refresh. `clear` empties the content and keeps the definition; `delete` removes the model. ## Profile ```bash theme={null} anona profile --space notes anona profile set --mission "Support notes for the billing team" --skepticism 4 --space notes anona profile reset --yes --space notes ``` `profile` alone is unchanged. `set` changes the fields you name **and keeps the rest** (the API replaces the whole profile, so the CLI reads, merges and writes back); `reset` returns it to the defaults. | Flag | | | - | - | | `--mission TEXT` | What the space is for; `@FILE` reads it from a file | | `--skepticism 1-5` | 1 takes statements at face value, 5 the opposite | | `--literalism 1-5` | 1 reads intent loosely, 5 the opposite | | `--empathy 1-5` | 1 ignores emotional context, 5 the opposite | | `--yes` | With `reset`: confirm | ## Rules Standing instructions `reason` applies in this space. See [Rules](/memory/api-reference/spaces#rules). ```bash theme={null} anona rules list --space notes anona rules add "Never state revenue as committed without a signed document." --space notes anona rules edit RULE_ID --no-active --space notes anona rules delete RULE_ID --yes --space notes ``` | Flag | On | | | - | - | - | | `--name NAME` | `add`, `edit` | A short label (`add` default: the first 60 characters of the text) | | `--text TEXT` | `edit` | The new rule text; `@FILE` reads it | | `--priority N` | `add`, `edit` | 0-1000; higher comes first in the prompt | | `--active`, `--no-active` | `add`, `edit` | Switch the rule on or off | | `--tags T` | `add`, `edit` | Comma-separated tags (`edit` replaces them) | Output is ` `, then `Added rule .`, `Updated rule .` and `Deleted rule .`. ## Webhooks ```bash theme={null} anona webhooks list --space notes anona webhooks create https://example.com/hooks/anona --events memory.created,memory.consolidated --space notes anona webhooks edit WEBHOOK_ID --no-enabled --space notes anona webhooks deliveries WEBHOOK_ID --limit 20 --space notes anona webhooks delete WEBHOOK_ID --yes --space notes ``` | Flag | On | | | - | - | - | | `--events E,E` | `create`, `edit` | `memory.created`, `memory.consolidated`, `security.policy_triggered` (`create` default: `memory.created`) | | `--url URL` | `edit` | The new HTTPS endpoint | | `--enabled`, `--no-enabled` | `create`, `edit` | Deliver events, or pause | | `--limit N`, `--cursor C` | `deliveries` | Up to 200 (default 50); continue from a previous page | `create` shows the signing secret **once** and says `Save this secret now; it is not shown again.`. No other command prints it. See [Webhooks](/memory/api-reference/webhooks) for the payloads and signature. ## Routing settings Where a write with no space lands. Same `get` / `set` / `reset` shape as the [other settings](/memory/cli/settings#how-set-works): `set` keeps the fields you do not name, `--clear FIELD` unsets one (repeatable; one of `charter`, `topics`, `rules`), and `reset --yes` clears them all. ```bash theme={null} anona routing-settings get --space notes anona routing-settings set --charter "Billing questions" --topics billing,invoices --space notes anona routing-settings reset --yes --space notes ``` | Flag | | | - | - | | `--charter TEXT` | What belongs in this space; `@FILE` reads it | | `--topics A,B` | Comma-separated topics the space covers | | `--rules-file PATH` | A JSON array of routing rules, e.g. `[{"field":"agent_id","op":"equals","value":"billing-bot","space_id":"billing"}]` | | `--auto-route`, `--no-auto-route` | Let writes with no space land by topic | | `--shared-auto-route`, `--no-shared-auto-route` | Let writes route into spaces shared with you | ## Space members ```bash theme={null} anona spaces members list notes ``` `list` prints ` ` for everyone a space is shared with. It reads the space's member directory, which needs the command-line login from [`anona login --cli`](/memory/cli/sign-in#with-anona-login) and an owner or admin role; an API key or a temporary profile gets exit `4` with `session_required`. Inviting or removing someone is done in the dashboard. `anona spaces members remove SPACE MEMBER_ID` sends nothing, exits `4`, and prints: ```text theme={null} anona: removing a space member is done in the dashboard: https://memory.anonalabs.com/dashboard/spaces/SPACE ``` `SPACE` is the space you named, escaped as one URL path segment. # spaces Source: https://docs.anonalabs.com/memory/cli/spaces List, create, show and delete spaces from the anona CLI. A space is one memory store. Its id **is** its name. These commands take no `--space`: the space is the argument, or there is none. | Command | pip | npm | | - | - | - | | `spaces list`, `spaces create`, `spaces get` | yes | 0.19.0 | | `spaces delete` | 0.25.0 | 0.19.0 | ## spaces list ```bash theme={null} anona spaces list anona spaces list --json | jq -r '.spaces[].space_id' ``` One space per line: its id (`owner:name` for one shared with you), who shared it, and its description. `GET /v1/spaces/`. **`--json`:** `{"spaces": [{"space_id", "name", "description", "qualified_id", "shared_by", ...}], "total"}`. ## spaces create ```bash theme={null} anona spaces create notes --description "Team notes" ``` | Argument | | | - | - | | `NAME` | The new space's name, which is also its id | | `--description TEXT` | What the space is for | Prints the new space's id. `POST /v1/spaces/`. You rarely need this: the first `record` or `documents upload` to a space that does not exist creates it. A temporary profile holds exactly one space, so `spaces create` on one is refused with `temp_profile_single_space` (exit `4`). **`--json`:** `{"space_id", "name", "description", "created_at", "space_type", ...}`. ## spaces get ```bash theme={null} anona spaces get notes anona spaces get acme:notes # a space another organization shared with you ``` The space's fields. `GET /v1/spaces/{id}`. A shared space is addressed by its bare name until that name collides with one of your own; then the API answers `space_ambiguous` and you use the `owner:name` form that `spaces list` shows. **`--json`:** `{"space_id", "name", "description", "created_at", "qualified_id", "shared_by"}`. ## spaces delete pip 0.25.0; npm 0.19.0 ```bash theme={null} anona spaces delete old-notes --yes ``` Deletes the space **and every memory in it**, permanently. `DELETE /v1/spaces/{id}`. Without `--yes` it exits `2`, sends nothing, and says what would have been deleted: ```text theme={null} anona: this would delete space 'old-notes' and every memory in it, permanently. Pass --yes to do it. Nothing was sent. ``` It never asks interactively, because the caller is often an agent with no terminal to answer on. Only the space's owner can delete it; a member of a shared space gets `space_owner_only` (exit `4`). **`--json`:** `{"deleted": true, "space_id"}`, written by the CLI (the API answers with no body). ## Related * API: [Spaces](/memory/api-reference/spaces). # Troubleshooting the CLI Source: https://docs.anonalabs.com/memory/cli/troubleshooting What the anona CLI's common errors mean and how to fix each one. Start with these two. Neither prints a secret. ```bash theme={null} anona doctor --space YOUR_SPACE # which credential wins, does the server answer, is the space reachable, credits left anona --version # is this the copy you think it is ``` The error line names the [error code](/memory/api-reference/errors) right after `anona:`. The exit codes are on [Scripting & automation](/memory/cli/scripting#exit-codes). ## Install and version | Symptom | Cause and fix | | - | - | | `invalid choice: 'guide'` (or any command) | The `anona` on your `PATH` is older than the command. From pip 0.25.3 / npm 0.19.3 the error says so itself, with the installed version and the upgrade command. `pip install -U anona` or `npm install -g @anona-labs/memory`, or run `uvx --from anona anona `. `anona --version` shows what you have; [What each package has](/memory/cli/overview#what-each-package-has) lists when each command arrived. The pip and npm packages have had the same commands since pip 0.25.0 / npm 0.19.0 | | `error: externally-managed-environment` | pip refusing a system Python. Use `uvx --from anona anona ...` or `pipx install anona` | | `pip install anona` succeeded, still the old version | pip does not upgrade an installed package without `-U` | | Two different `anona` behaviours on one machine | Both the pip and the npm package are installed; whichever is first on `PATH` wins. `which -a anona` | ## Credentials (exit 4) | Error | Fix | | - | - | | `anona: no credential. ...` | Run `anona login --cli`, set `ANONA_API_KEY`, or `anona start` for a temporary profile | | ``the stored `anona login` credential is for MCP`` | You signed in for MCP only, which the REST API refuses. `anona login --cli` | | `refusing to send the API key from ANONA_API_KEY to ` | A key only goes to `api.anonalabs.com` / `memory.anonalabs.com` unless the server is named in the same place as the key. Pass `--api-key` and `--base-url` together, or unset `ANONA_BASE_URL`. See [Where a key may be sent](/memory/cli/sign-in#where-a-key-may-be-sent) | | `the stored credential is for https://..., not https://...` | A login only goes to the server that issued it. `anona login --base-url https://other-host` to switch | | `invalid_api_key` / `key_expired` | The key was revoked or expired. Mint a new one under **API keys** | | A command fails with an authorization error after weeks unused | The login's refresh token lapsed after 30 days of silence, or was revoked. `anona login --cli` | | `space_owner_only` / `space_read_only` | The space is shared with you without that right. Ask its owner | | `temp_profile_single_space` | A temporary profile has one space, `default`: it cannot create spaces or write anywhere else. Omit `--space`, or sign in with `anona login` for more | ## Usage (exit 2, nothing sent) | Error | Fix | | - | - | | `an API key needs a space` | Pass `--space NAME` or set `ANONA_SPACE_ID`. `anona spaces list` shows them | | `this would delete ... Pass --yes to do it` | Destructive commands need `--yes`. Confirm with whoever owns the data first | | `--batch line N: ...` | That line of the JSON Lines file is not valid JSON, has an unknown field, or has an empty `content`. Nothing was sent; fix the line | | `--wait needs --async or --batch` | A plain `record` already waits | | `name at least one field to change` | A settings `set` with no field. See [How set works](/memory/cli/settings#how-set-works) | | `invalid choice` / `unrecognized arguments` | A flag your copy does not have. `anona --help` or `anona guide` | ## Not found (exit 5) | Error | Fix | | - | - | | `space_not_found` | Check the id with `anona spaces list`. A space shared with you may need its `owner:name` form. A read never creates a space, so a space nothing was written to yet does not exist | | `space_ambiguous` (exit 1) | The bare name is both yours and a share; use the `qualified_id` from `anona spaces list` | | `memory_not_found` | Misspelled, or already deleted | | `job_not_found` | `jobs get` or a `--wait` on an id that is not a job in this space. Check `--space`; the API reports this as `200` with `"status": "not_found"` | ## Rate limits and credits (exit 6) | Error | Fix | | - | - | | `rate_limited` | The per-minute ceiling. The CLI already retried with backoff; wait and run it again | | `credits_exhausted` | The plan allowance and any top-up are spent. Reads spend credits too, so `retrieve`, `reason`, `memories list`, `documents list` and `profile` are refused at zero as well as writes. `anona usage` (or `anona doctor`) shows the balance | ## Unavailable (exit 7) | Error | Fix | | - | - | | `could not reach ` | Network, proxy or DNS. `anona doctor` checks the server's health without sending a credential | | `write_status_unknown`, or a timeout on a write | **The write may have been stored.** The CLI never re-sends a write after a timeout or `5xx`. Check with `anona retrieve` or `anona memories list --q` before running it again | | `reason_timeout` | The question took too long over this space. Ask something narrower, or retry with `--depth fast` | | `delete_unavailable` | The memory was **not** deleted. Retry | ## Other | Symptom | Fix | | - | - | | `record` exits `3`, "nothing was stored" | Not an outage: no fact could be extracted from that text. Re-record with a sentence of context | | `--wait` exits `8` | The job is still running. `anona jobs get JOB_ID --space ...` later | | `documents upload` fails with `unsupported_file_type` / `file_too_large` / `upload_too_large` | The server's message names the supported types and the limits. See [documents](/memory/cli/documents#documents-upload) | | A scoped `retrieve` misses memories you know are there | Scoped search is strict: memories written without that `--user-id` are not returned. Write and read with the same scope | | `reason` seems to hang | It is an agent loop and takes tens of seconds; it says so on stderr. `--timeout` caps one request (default 120 s) | | `anona status` exits `1`, "Not logged in" | Nothing is stored; `status` only reports that (the data commands exit `4` for the same thing). `anona start`, or `anona login` | | A second `anona start` exits `1`, "A temporary profile already exists" | One profile per home directory. Keep using it: `anona status` shows its deadline | | `anona logout` exits `1` and names an unclaimed temporary profile | It refuses to throw away a profile that has no email address on it. Claim it with `anona login` first | | `jobs cancel` exits `1`, `job_not_cancellable` | The job has already finished. `anona jobs get JOB_ID` shows how it ended | | A settings `set --model` / `--chat-model` exits `1`, `unsupported_model` | That model id is not offered. The message suggests up to three close matches; `anona llm-models` lists all of them | | `memories list --type fact` is empty while facts exist | By default a fact a note covers is listed as that note. Add `--no-prefer-observations` | # Core Concepts Source: https://docs.anonalabs.com/memory/concepts The terminology behind spaces, the three memory operations, keys, and tenant isolation. ## At a glance | Concept | What it is | Where it appears | | - | - | - | | **Memory space** | The isolation boundary for one application or tenant. | `space_id` on every call | | **Scope** | A partition *inside* a space: one end user, agent, or session. | `user_id` / `agent_id` / `session_id` | | **Record** | Storing a memory so its facts become searchable. | `POST /v1/record` | | **Retrieve** | Ranking stored memories against a natural-language query. | `POST /v1/retrieve` | | **Reason** | Synthesizing a single answer across many memories. | `POST /v1/reason` | | **API key** | The credential that identifies your organization. | `Authorization: Bearer` | | **Credits** | The unit every metered operation is billed in. | `GET /v1/usage/me` | ## Memory space A space is where the memories for a specific application or context live. Your organization can hold many spaces, and each is fully isolated from the others. You create a space with a `name`, and that name **is** its permanent `space_id`, the identifier every other call takes. Nothing separate is generated, and `space_id` is not a field you can send on create. ```bash theme={null} POST /v1/spaces/ {"name": "customer-support-bot"} # → {"space_id": "customer-support-bot", "name": "customer-support-bot", ...} ``` Reference: [Spaces API](/memory/api-reference/spaces) ## Scope A space is the coarse boundary. **Scope** partitions it, so a single space serves many end users without their memories mixing: ```bash theme={null} POST /v1/record {"space_id": "support", "user_id": "alice", "content": "..."} POST /v1/retrieve {"space_id": "support", "user_id": "alice", "query": "..."} ``` Do **not** create a space per end user. Spaces are capped per plan, and memories in different spaces can never be synthesized together, so you would lose exactly the cross-user view you built a memory layer for. One space plus `user_id` is the pattern. Scoping is strict: a scoped search returns only memories written under that same scope, never another user's, and never unscoped ones. Consolidated memories are built per user, so a synthesis cannot mix two users' facts. Reference: [Scoping](/memory/api-reference/retrieve#scoping-within-a-space) ## Record Storing a memory is not a plain insert. The content is analyzed, its durable facts are extracted, and the result is embedded and indexed for semantic search. ```python theme={null} client.record( space_id="customer-support-bot", content="Alice works at Google and specializes in distributed systems", ) ``` Reference: [Memories API](/memory/api-reference/memories) ## Retrieve Retrieve finds memories by meaning rather than keyword overlap, and returns them ranked by relevance with their entities and event time attached. ```python theme={null} results = client.retrieve( space_id="customer-support-bot", query="What is Alice's expertise?", ) ``` Reference: [Retrieve API](/memory/api-reference/retrieve) ## Reason Reason runs an agentic pass over the space, connecting related facts and weighing recency, and returns one synthesized answer instead of a ranked list. ```python theme={null} answer = client.reason( space_id="customer-support-bot", query="What have we learned about Alice's career?", ) ``` | | Retrieve | Reason | | - | - | - | | **Returns** | A ranked list of memories | A single synthesized answer | | **Cost** | Lower | Higher, it reasons across the space | | **Use when** | You want to inject context into a prompt | You want a conclusion, not the evidence | Reference: [Reason API](/memory/api-reference/reason) ## API key Every request carries `Authorization: Bearer `. Keys are created and revoked from the dashboard under **API keys**, or through the [API Keys endpoints](/memory/api-reference/api-keys). The full key is shown once at creation; afterwards only its prefix is visible. Reference: [Authentication](/memory/api-reference/authentication) ## Tenant isolation Each organization's memories live in a dedicated database schema, with no shared tables. A `space_id` belonging to one organization is both meaningless and inaccessible to another. The boundary is enforced twice: as an authorization check at the gateway, and again at the database layer. ## Next steps > [card] [API overview](/memory/api-reference/overview) > > The full endpoint surface in one table. > [card] [Python SDK](/memory/sdk/python) > > Client library reference. # GitHub Source: https://docs.anonalabs.com/memory/github Capture what your merged pull requests decide, and write what a space has learned back into the file your coding agents already read. Anona's GitHub App connects a repository to a memory space, in both directions. **In.** Capture records what a pull request settled — its title, body and review verdicts on merge, or a whole thread when somebody asks for it — into the space the repository is bound to. Mention the app in a pull request or an issue and it answers from that space, under an audience rule that never posts memory into a thread a wider audience can read. **Out.** The *knowledge pull request* takes what the space has accumulated and writes it into one file in the repository — the `AGENTS.md` or `CLAUDE.md` your coding agents already read — as a pull request your team reviews and merges. It is off until you choose that file. ## Install 1. Open **Integrations** in the dashboard. 2. On the **GitHub** row, choose **Connect**. 3. Approve the install, and grant it the repositories you want. Installing captures nothing. Until you bind a repository, the app does nothing anywhere. Only an owner or an admin can connect or disconnect an installation. Binding a repository is open to any member, because you can only bind a space you can already reach. ## Bind a repository On the **GitHub** row, open **Bind a repository**: **Repository** `string` *required* One of the repositories you granted the installation. Fixed once bound — a repository's id is what identifies the binding, so changing it would create a second binding rather than move this one. **Space** `string` *required* Which memory space this repository writes to and reads from. **API key** `string` *required* The key the app presents. Revoking it stops capture for this repository. Only live keys are offered. **What this repository may do** `string` **Read only** captures nothing. **Read & write** lets capture write into the space. A ceiling, not a grant: choosing **Read & write** requires that you can write to that space yourself. **When to capture** `string` **On merge** captures a pull request once it merges. **On request** captures a thread when somebody asks — "@anona-memory remember this". **Both** does both. Described in full under [Capture](#capture). **Knowledge pull request** `string` Which file Anona keeps a section of up to date. **Off** by default, and leaving it off is a normal configuration. See below. Binding the same repository again edits it rather than creating a second one. ## The handle The app answers to **`@anona-memory`** — the handle of the GitHub App itself, at [github.com/apps/anona-memory](https://github.com/apps/anona-memory). Mention it in a comment on an issue or a pull request, or in an inline review comment. > [!WARNING] > > **`@anona` is deprecated. It still works today, and it will be removed.** > > `github.com/anona` is a real GitHub account belonging to somebody with no > connection to Anona, so a comment written that way notifies a stranger who > never asked to be in your pull request. Nothing on our side caught it: > Anona matches the text of your comment and never asks GitHub who the > mention resolved to, so the bot answered correctly the whole time the > notifications were going somewhere else. > > The old handle is kept working only so that the comments already sitting in > your threads, and anything your team has already written down, do not break > on the day the app was renamed. Everything below uses `@anona-memory`. ## Capture Capture is what a bound repository puts *into* its space. There are two triggers, and **When to capture** on the binding decides which are live: | Setting | `capture_mode` | What triggers a capture | | - | - | - | | On merge | `merge` | A pull request is merged | | On request | `explicit` | Somebody comments `@anona-memory remember this` | | Both | `both` | Either | `merge` is the default, and a **Read only** binding captures nothing under any of them. There is deliberately no fourth setting that captures every comment. Turning prose into memories runs on one extraction queue shared by every Anona customer, so a repository streaming its whole comment traffic through it would hold up everyone else's ingestion and not only its own. `both` is as wide as this is allowed to go, and the hourly cap further down is the same constraint seen from the other end. ### On merge When a pull request **merges**, Anona captures its title, its body, and the conclusion of each review on it — "alice approved", "bob requested changes: the migration has to run first". Closing a pull request without merging captures nothing. Most closed pull requests were abandoned, and a decision nobody took is not worth keeping. What is kept is who wrote it, who merged it, the title, the body, the conversation, and each reviewer's verdict. The per-line inline review replies are never read — those are the "nit"/"done"/"rebased" chatter that a diff accumulates and nobody recalls. A review that **approved** or **requested changes** is kept even when the reviewer wrote nothing, because there the verdict is itself the decision. A review that only commented, with no text, is dropped. Every voice in a captured thread is named, including the person who opened it, so "who wrote this?", "who merged it?" and "who commented?" all have answers. A very long conversation is trimmed to its most recent comments and says so in the memory itself — whole comments only, since half a comment is a sentence that stops mid-clause and would be recalled later as a fact. **Only what the project vouches for.** GitHub stamps every pull request and review with the author's relationship to the repository, and capture keeps only those written by the owner, a member of the organization, or a collaborator. Anything else — an outside contributor, a first-time drive-by, a relationship GitHub does not report at all — is left out. The two halves are filtered separately, which is the useful outcome: an outside contributor's pull request that your team reviewed keeps your reviewers' verdicts and drops the contributor's prose. If nothing survives the filter, nothing is written and nothing is billed. This is a trust boundary rather than tidiness. On a public repository anybody can open a pull request, and captured text becomes memory the knowledge pull request may later render into a file your coding agents read as standing instruction. Capture is the last point at which it is still knowable which pull request a sentence came from, so it is the only place the filter can go. Pull requests **opened by a bot are never captured** — including Anona's own knowledge pull request. Merging that one is the whole design, and ingesting it would feed a synthesis back in as fresh evidence for the next synthesis to build on. ### On request Comment on an issue or a pull request: ``` @anona-memory remember this ``` and Anona captures **the whole thread**: its title, its body, and every human comment on it — not only the comment that asked. A decision lives in the back and forth, and keeping the conclusion alone throws away the reason for it. Also understood: `save this`, `record this conversation`, `remember the above`, `store this discussion`, `note this`, and similar. The instruction has to be **the whole comment** — the mention, the instruction, and nothing else. A word of address may come first, so `please @anona-memory save this` is the same instruction; "did" is not one of those words, which is what keeps `@anona-memory did we ever record that?` a question rather than a silent save. It also means a comment that merely *quotes* a directive is not one — and it is not a question either. What counts as addressing Anona is what the commenter typed, not what GitHub's **Quote reply** button pasted in above it, so a comment that is entirely quote is met with silence: nothing was addressed to us, so there is nothing to decline and nowhere a decline would be welcome. A quote with your own `@anona-memory …` underneath is a question, answered as one. A quote with a fresh directive underneath is a save. Two things have to be true: * **Your GitHub account is connected to Anona** in the dashboard. Until it is, there is no person to attribute the memory to and no membership to check, so the directive is declined. * **You can write to the bound space yourself.** Checked at the moment of the capture rather than at bind time, so losing write access stops it immediately. The binding's **Read & write** setting is a ceiling on top of that, not a substitute for it. Anona's own comments are never captured, and neither is any other bot's — CI status, Dependabot, stale-bot. An answer is a synthesis of what the space already holds, and writing it back in as fresh evidence is a photocopy of a photocopy. Every *human* comment on the thread is kept, whoever wrote it. The vouched-for filter that merge capture applies does not run here: somebody who can write to the space asked for this thread by name, and that judgement is what the filter stands in for when nobody made one. The directive works in an issue comment and in a pull request's conversation. It does **not** work in an inline review comment on a line of code. #### Reactions Anona never comments back when you ask it to remember something — a comment on every save is noise on a page people are trying to review code on. It reacts to the comment you wrote, the way a person would: | Reaction | What it means | | - | - | | 👀 | Taken. The thread is queued for extraction, which runs in the background | | 👀 👍 | Stored. The memory exists and is recallable | | 😕 | Declined. Nothing was saved | | 👀 😕 | Taken, then extraction failed. Nothing was saved | The two-step is deliberate: extraction is not instant, and 👀 is the difference between "Anona has it" and "Anona never saw it". **They accumulate. They never replace each other.** GitHub can add a reaction and it can remove one, and removing is a second call that can fail and leave a stale acknowledgement standing where a person will read it as the truth — so nothing is ever removed. They are a trail, which is what makes 👀 on its own, 👀 👍 and 👀 😕 three distinct states rather than one that keeps being overwritten. There is no reaction meaning "still going"; the absence of the second one is that. The second reaction arrives once the write finishes, which can take a little longer while a deploy is in progress. If Anona cannot tell what became of the write, it adds nothing, and the 👀 alone stays *true* — it says queued, and a write that has not finished is still queued. That is the whole reason the accepted state is two reactions and not one: the honest outcome of losing track is an acknowledgement that has gone stale, where a single terminal reaction would have had to either lie or never arrive. **The 😕 never says why**, because a pull request thread can be the whole internet and "you do not have access to that space" would tell it that the space exists and that you cannot read it. The reasons are a binding set to capture on merge only, a read-only binding, a GitHub account that is not connected, the hourly cap, an Anona account out of credits, a revoked API key, no write access to the bound space, GitHub refusing to hand over the thread, a thread with nothing in it to save, and a write the memory engine would not take. Which one it was is in the Anona dashboard, behind your session. Reactions have an hourly allowance of their own — **a hundred and twenty per installation**, counted across every repository bound to it and separate from both the capture cap below and the reply budget further down. Separate so that a repository being drowned in directives cannot silence its own questions, and generous because a reaction, unlike a comment, costs a thread's readers no attention at all. Past it Anona reacts to nothing, which looks exactly like the integration being broken: it is the one case where no reaction does not mean nothing happened. A **merged pull request** is captured with no reaction at all. Nobody asked for it, so there is nobody waiting to be told. ### What capture posts back The only thing Anona puts back on a thread it captured is the reaction above, and only when somebody asked. It never opens a comment to report an outcome — not a save, not a skip, not a cap. A merged pull request is captured silently, so the space itself is where to confirm one landed. Extraction is asynchronous, and a memory does not appear the instant a pull request merges. ### Duplicate captures A pull request captures once; a comment captures once. A redelivery, or two events racing each other, collapse to that one capture. Editing a comment into a directive after the fact does not capture it either — only a freshly posted comment counts. ### Importing recent history Binding a repository imports none of its past. Capture is driven by webhooks, so it begins at the *next* merged pull request — everything merged before you bound the repository stays uncaptured, permanently, unless you ask for it. **Import recent history** on a bound repository asks for it. You choose how much: a **count of the most recent merged pull requests** — 50 by default, up to 500 — or **all of them**. Either way they are captured exactly as a merge would be: same filter on who wrote what, same bot exclusion, same charge. A count rather than a date range, deliberately. Ninety days of a quiet repository is five pull requests and of a busy one is eight hundred, so a date bounds nothing that matters; a count bounds the cost, which is the thing worth bounding. Newest first because recency is the best available guess at relevance. The dialog says how many merged pull requests the repository actually holds, so the count you pick is against a real number rather than a guess. **Nothing is spent until you confirm a number.** Ticking the control quotes first: how many pull requests would be imported, what they would cost at most, and what your balance would be afterwards. The cost is an exact ceiling, not an average — a capture is charged a flat `add`, so the arithmetic is the count times that. Some of the matched pull requests will cost nothing at all (a bot author, nothing left after the author filter, one already captured), so the real spend can only come out lower. An import that your balance cannot fund is **refused before it starts**, not run half way. A job that dies at 60% having spent your allowance is worse than one that never began, because you have paid for an import you now have to repeat. It runs in the background at fifteen pull requests an hour, under the cap below, starting within the hour of your confirming it. Fifty therefore takes about four hours, and a whole history of several hundred takes days. Progress shows on the repository's row — how many have been walked, how many were captured, and when it last moved — and it survives restarts, so you can close the page. **You are emailed when it ends**, whichever way it ends: once with what it captured, or once with why it stopped and whether starting again would help. A multi-day import is not something to sit and watch. Re-importing is safe and is not re-charged: each pull request is claimed before it is captured, so a second import of an overlapping window skips what the first already did. Once a window is fully imported the quote comes back as nothing to import. Four kinds of repository cannot be imported, and the control says so rather than quoting a cost it could not honour: * one bound **Read only** — there is nothing an import could write; * one set to capture **On request** only — there is no merge history for it to take; * one whose **API key** has been revoked or has expired; * one that is **already importing** — wait for it to finish, then import again. What it does not do: issues, and pull requests closed without merging. Both are what live capture already leaves out. ### Importing a whole history **Import all pull requests** takes the repository's entire merged history rather than a window of it. It is a separate tickbox because the cost is not small and you should see it before agreeing to it: the dialog quotes the exact ceiling, which for a repository of several hundred pull requests can be thousands of credits. It is quoted on **what is left**, not on what the repository holds. Pull requests an earlier import already took are excluded from both the count and the price, so importing 50 and then importing the rest costs what the rest costs — not the whole history over again. The walk goes **oldest first** and resumes where it stopped, so it survives restarts and spreads across as many days as it needs. It is bounded at the start: the newest pull request at the moment you confirm is where it ends, so a pull request merged during a multi-day import is not chased — the next import takes it. **How long it takes is arithmetic, and it is worth doing before you confirm.** An import runs to fifteen pull requests an hour (see [the hourly capture cap](#the-hourly-capture-cap) for why fifteen and not twenty), and the scheduler looks for work once an hour. So: | Pull requests | Roughly | | - | - | | 50 — the default window | 4 hours | | 200 | 14 hours | | 500 — the most you may request | a day and a half | Those are floors, not estimates. The allowance is shared with live merge capture, so an import on a repository that is actively merging gets less than fifteen in the hours when merges are landing — which is the intended behaviour, not a delay to work around. Nothing is lost to the wait. The job keeps its place, reports progress while it runs, and emails you when it finishes, so a long import needs no supervision — you do not have to leave the page open, and closing it does not stop anything. Nothing is charged twice. Each pull request is claimed before it is captured, so a pull request a live merge captured while the import ran is skipped rather than paid for again. One caveat worth knowing before you import a *count* rather than everything: a pull request that merges *while* the import is running enters the window and can push the oldest one out of it. The job says so when it happens, and names how many. Those displaced pull requests now sit below the window the page asked for, so a repeat of the same count walks past them — a wider count reaches them, and importing everything reaches all of them. ### The hourly capture cap Each **installation** can capture twenty times in a rolling hour, counted across every repository bound to it and across all three of its consumers together: merge capture, `@anona-memory remember this`, and [importing history](#importing-recent-history). **An import never takes the last of it.** The import runs to fifteen an hour, not twenty, so five of the allowance are always left for a merge landing while it runs. The asymmetry is the reason: a merge refused for want of allowance is gone — the delivery is not retried, so that pull request is never captured — while an import that is held back simply resumes on the next pass an hour later. History that has waited months can wait another hour; a merge happening now cannot. Fifteen an hour is what makes a large import take as long as it does — [the table above](#importing-a-whole-history) turns it into hours. It counts captures that *happened*. A directive that was refused — a read-only binding, an unlinked commenter — costs nothing against it, so the allowance is never eaten by people who were never going to be able to save anything anyway. The cap is real rather than arbitrary — the shared extraction queue again — and it will rise when that queue stops being shared. Twenty is twice Slack's ten because merging is automatic: a release window produces more merges in an hour than anybody tidying up threads by hand ever produces saves. This is one of two hourly limits, and they ration different things. This one rations *writing into memory*. The [reply budget](#the-hourly-reply-budget) rations *comments Anona posts back*, which is a different resource with a different abuser, and neither one draws on the other. ### Attribution If the person who merged, or who asked, has connected their GitHub account, the memory carries their Anona identity. A merge by somebody who has not connected one is recorded **unattributed** rather than dropped — losing it because a contributor never visited a settings page throws away something real. **Whoever installs the app is connected automatically.** The install already proves both identities at once: your Anona session starts it, and GitHub's own authorization proves which GitHub account approved it. Nobody should have to perform a separate linking step for a connector they just installed, so that link is recorded there and then. This matters most for **on request** capture, which — unlike merge capture — needs a connected account: it has to know whose access to check before it saves anything into a space. Until a contributor connects theirs, `@anona-memory remember this` from them is declined. Merge capture also keeps running on the grant of whoever **bound** the repository, re-checked on every merge. If that person loses write access to the space, the repository stops capturing on merge. **Imported history is always unattributed**, even where live capture would have attributed it. The list GitHub returns for a history import does not say who merged each pull request, so there is nobody to attribute it to, and fetching every pull request individually to find out would cost a request each. This matters in one case: in a space **shared across organizations**, a read scoped to one member matches only memories carrying that member's tag, so imported history is invisible to it while live captures from the same repository are not. In a space that is not shared, no *member* tag is written either way and there is no difference to see. ### Where a memory came from Every memory this connector writes is stamped with tags saying where it came from, whether it arrived from a merge, from a request in a thread, or from a history import: | Tag | Meaning | | - | - | | `anona:source:github` | Written by the GitHub connector rather than through the API | | `anona:repo:` | GitHub's **numeric** repository id | | `anona:pr:` | The pull request or issue number | | `anona:author:` | Who **wrote** it, as GitHub's **numeric** user id | | `anona:merged_by:` | Who **merged** it, same form — usually a different person | Read where a memory came from on any retrieve result or memory list row, in its `provenance` object: ```json theme={null} { "memory_id": "...", "content": "CD swaps only the image; Terraform ships the env.", "provenance": { "platform": "github", "repo": "1282949371", "pull_request": 658, "author": "7001", "merged_by": "9001" } } ``` `provenance` is `null` for anything written through the API, which is most memories. Note it is **not** the `source` field on a retrieve result — that one is the original passage an `include_source` request returns. Select on it with the `source`, `repo`, `pull_request`, `author` and `merged_by` fields, on retrieve and on the memory list: ```bash theme={null} curl "$ANONA/v1/spaces/my-space/memories?repo=1282949371&author=7001" \ -H "Authorization: Bearer $ANONA_API_KEY" ``` ```json theme={null} { "query": "how do we handle deploys?", "source": "github" } ``` Several are AND-ed, and a filtered read never matches memories that carry no provenance — "what came from GitHub" cannot answer with a memory that did not. Combining `author` with `repo` is what answers "what did this person decide about this part of the system", which neither field answers alone. `author` and `merged_by` are separate fields because they are usually different people, and the two questions have different answers: one is who wrote the change, the other is who decided to take it. Both are GitHub's numeric user id rather than the login, for the same reason the repository is: a login can be renamed and the id cannot. Neither is the same as `member_id` — that one is who they are to Anona and is set only once they have connected an account, while these are always known. Imported history carries `author` but no `merged_by`: GitHub's pull request list endpoint says who wrote each one and not who merged it. > [!NOTE] > > `author` and `merged_by` were a single field until October 2026, and it was > stamped from whoever clicked merge. Memories captured before then therefore > carry the **merger's** id under `author` and no `merged_by` at all. Those rows > are not corrected in place — re-deriving the author would mean re-reading every > pull request from GitHub — so a filter on `author` over a repository's older > history is matching mergers. Captures from October 2026 onward carry both > fields, each meaning what it says. The names are in the memory's text as well as in these tags, which is what lets you simply ask. Tags filter; text is what the answer is built from, so a numeric id alone could say what a repository decided and never who decided it. A captured pull request reads like this: ``` Author: bob Merged by: alice Add the thing Because we needed the thing. Conversation: bob: I went with the simpler fix. carol: Why not the other approach? Review conclusions: - carol approved: LGTM, ship it ``` > [!NOTE] > > You filter with these fields rather than by naming the tags, and that is > deliberate. The tags live in the reserved `anona:` namespace, which a caller > may never name — it is the same rule that stops somebody writing > `{"not": {"tags": ["anona:user:alice"]}}` and reading around another user's > scope. The fields let you select on provenance without opening that door. The repository is identified by its numeric id and not by `owner/repo`, because a repository can be renamed and its id cannot — a name-keyed tag would split one repository's history in two the day somebody renames it. These tags are **stamped by Anona, never accepted from a caller.** Tags beginning with `anona:` are reserved: sending one yourself is rejected with `422 reserved_tag`. That is what makes the answer to "what did this repository teach us?" trustworthy — nobody can attribute their own writes to a repository they do not have access to. > [!NOTE] > > One thing to know if you already filter by your own tags. A query that sends > `tags` with `tags_match: "any"` matches untagged memories as well as matching > ones. Captures used to be untagged when the author had not connected a GitHub > account, so they came back from every such query; now they carry the source > tags above and come back only when a tag matches. Reads scoped by > `user_id`, `agent_id`, `session_id` or `member_id` are unaffected — those > already exclude untagged memories — and so is any query that sends no tags > at all. ## Ask Mention the app in an issue or a pull request in a bound repository: ``` @anona-memory what did we decide about the retry policy? ``` The answer is the same synthesis `POST /v1/reason` returns, against the space the repository is bound to. Inline review comments work too, which is where reviewers actually argue. The app **answers only when mentioned**. It never volunteers on an opened pull request, and it never answers another bot. There is no "Thinking…" placeholder the way there is in Slack: the thread stays quiet until the answer or the link arrives. An answer is a multi-step reasoning loop and takes tens of seconds. One question at a time per repository. Ask a second while the first is still running and it says so rather than queueing it. A **Read only** binding still answers questions. The role governs writing, not reading. ### The audience rule A pull request thread is read by whoever can read the repository. So an answer goes *into* the thread only when Anona can establish that the whole readership already holds the space: | Repository | Every collaborator holds the grant | Reply | | - | - | - | | private | yes | the answer, in the thread | | private | no | a link, carrying no content | | public | not knowable | a link, carrying no content | **A public repository never gets an in-thread answer** — on any roster state, and no setting changes that. A public repository has no membership to check, so nothing can establish that everyone who can read the thread already holds the space. That is the design rather than a limitation to work around. **An unreadable or not-yet-synced roster is also a link.** A repository whose collaborator list has never been read, or whose read GitHub refused, knows nothing about who is standing in the room — and "I know of nobody outstanding" must not be confused with "nobody is outstanding". The collaborator list is re-read when it is more than five minutes old. A repository GitHub calls **internal** — visible to everyone in your enterprise — gets the link too. Its readers are the enterprise rather than its collaborators, so the one list that can be checked does not describe who can see the thread. The link reply is a comment saying the answer is in Anona, and it carries nothing else: no excerpt, no first line, no "I found three memories". Where it leads is [below](#the-answer-page). There is one wording for every link-only reason. Distinguishing them — public repository, somebody outstanding, a roster that would not read — would publish the roster state into the thread. GitHub has no ephemeral comment and no direct message. "Answer privately" can therefore only mean a public comment carrying a link the reader has to authenticate to open. That is worse than Slack's ephemeral message, and it is the best the platform offers. > [!NOTE] > > **"Nothing on that in this space yet" follows the audience rule too.** It is > the result of a search and it reports what the space holds, so on a public > repository you get a link to it rather than the sentence. Whether a space has > anything on a subject is the same kind of disclosure as the answer itself. ### The answer page The link goes to a page in the Anona dashboard. The page is the access check, not a viewer with one bolted on. **It resolves your own access to the bound space before it shows anything**, the same way every other space-scoped surface in the dashboard resolves it. So forwarding the link — to a colleague, into another thread, out of the company — hands nobody anything. Somebody without the grant is told they do not have access to that answer and to ask whoever shared the link, or a space owner, to invite them. Somebody who is not signed in — the ordinary case, since the link is followed out of GitHub — is sent to sign in by whichever method their account uses, and returned to the same page afterwards. **Holding the URL is not access**, and that is the only reason the reply is allowed to be a public comment at all. **It never names the repository, the binding or the space.** It shows the question, the answer, and when it was answered. Nothing else, and that is deliberate rather than minimal: naming the space would undo the discretion the thread's own comment is keeping, and turn a forwarded link into a way to learn which memory space a repository is wired to. The answer is stored as it was given. Memory moves, so a page opened a month later shows what the thread was discussing rather than what the same question would return today. Ask again in the thread for that. Unbinding the repository, or disconnecting the installation, retires its links. The page can no longer establish which space the answer came out of, so it says the answer is not there rather than showing it to whoever is holding the URL. ### Refusals Two different people get the same comment: * somebody who has not connected their GitHub account to Anona; * somebody who has, but holds no access to the space this repository reads. The same reply, word for word, pointing at the dashboard. The refusal is deliberately identical: naming the second would tell everyone reading the thread that a particular space exists and that this person cannot read it. The dashboard, behind your session, says which it actually was. The generic refusal is therefore not a vague error. It is the only thing the thread is allowed to say. ### When it cannot answer These are facts about the integration rather than about the space, so they are posted in the thread whatever the repository's visibility: | What you see | What happened | | - | - | | out of credits | The paying organization's balance is spent. | | the API key was revoked | Pick a new one on the binding. | | that space no longer exists | The bound space was deleted. Re-bind the repository. | | still working on the last question | Another question is already running in this repository. | | could not answer that one | Something failed on the way to an answer. Ask again. | | it can't answer that here | You are not connected, or you do not hold the space — see above. | ### The hourly reply budget Each **installation** may post sixty replies in a rolling hour, counted across every repository bound to it: answers, link replies, refusals and every message in the table above, all out of the one allowance. Past it the app goes quiet. There is deliberately no "you have hit the cap" comment. **The comment is the thing being rationed**, so announcing the cap to whoever is driving it would be exactly the noise the cap exists to stop. Capture can afford to say so with a 😕, because a directive is a deliberate act by somebody who already holds the space; a question is not — on a public repository anybody with a GitHub account can post one. This is the other half of the [capture cap](#the-hourly-capture-cap), and the two are sized against different resources. Capture's twenty ration the shared extraction queue. These sixty ration GitHub's rate limit and the attention of everyone watching the thread. Sixty is high because the budget binds almost entirely on the paths that cost nothing. An answered question is a multi-step reasoning loop, already serialised one at a time per repository, so an hour cannot physically hold many of them. A refusal to somebody who has not connected their account is free and can be typed as fast as anybody likes. That is the case being bounded, which is also why the budget is claimed *before* Anona works out who asked — a question that was never going to be answered still spends one. Per installation rather than per repository, for the same reason: somebody with several of your public repositories to comment on would otherwise get a fresh sixty in each. It is counted per gateway process, so across a deployment running several the real ceiling is a small multiple of sixty. It is a ceiling where there was none, not a precise number to plan against. ### Restarts mid-question An answer is produced by a background task, and a background task dies with the machine running it. When that happens the question is left with no reply at all — no error, no timeout, nothing at all to tell it apart from having been ignored, which is the worst shape a failure on this surface can take. So every time the gateway starts it sweeps for questions that were accepted and never answered, and posts one comment into the thread that asked: > Could not answer that one. Try again in a moment. **It means the machine restarted rather than that the question failed on its merits.** Nothing here legitimately runs for five minutes — the reasoning loop is bounded well inside that — so anything still open when the sweep looks cannot still be running. A question Anona could not answer *on its merits* gets one of the replies in the table above, straight away. A question it would not answer *for you* gets the generic refusal, also straight away. This sentence, arriving minutes late and by itself, is the third thing: the task went with its container. Ask again. It is posted in the thread whatever the repository's visibility, for the same reason the table above is: it carries nothing out of the space. Only questions are swept. Everything else Anona receives from a repository — a merge, a push, a label, a review request — leaves nobody waiting, and apologising in a thread where nothing was asked would be worse than saying nothing. A merged pull request that failed to capture is therefore silent here too; look at the space. If the repository cannot be reached at the moment the sweep runs — the installation removed, the repository archived, GitHub down — the question is left open and tried again at the next start, rather than written off as handled and forgotten. ## Costs | What | Charged to | | - | - | | A capture, on merge or on request | The organization that owns the installation | | A capture from an [imported pull request](#importing-recent-history) | The organization that owns the installation | | An answer to a question | The organization of the person who asked | The split is not an oversight. A merge has no asker — the installation configured capture, and whoever clicked merge did not request a charge and may not know Anona exists — so the installation pays, and `@anona-memory remember this` follows it for consistency. A question has an asker, and every other Anona surface charges them. For an ordinary team both are the same organization. ## The knowledge pull request Once a week — or daily, or monthly, whichever you pick — Anona renders what your memory space has learned about this repository into a section of one file, and opens a pull request. You review the diff and merge it, or you close it. Nothing reaches your repository until somebody with write access merges. The point is the file your coding agents already read. A synthesis that lives only in a dashboard is one nobody's agent sees; the same text in `AGENTS.md` is context on every task. ### Choosing the file You choose the file, and Anona never picks one for you. When you choose a repository in the bind form, Anona scans its default branch for the conventions a team might already keep: ``` AGENTS.md CLAUDE.md .cursorrules .github/copilot-instructions.md CONTRIBUTING.md ``` What it finds is **offered**, not applied. You pick one, or you type a path of your own, or you leave it off — and off is what a binding does when you say nothing. Proposing `AGENTS.md` to a team who maintain a 1,600-line `CLAUDE.md` by hand is how a bot pull request gets closed unread, so Anona does not propose; it asks. Each file on offer says what the first pull request will do to it: | What the picker says | What the first pull request does | | - | - | | No Anona section yet | Adds one at the end of the file | | Anona already keeps a section here | Updates that section, in place | | This file's Anona markers cannot be read | Nothing, at any cadence, until somebody fixes them | Two more entries carry no promise at all, and both are deliberately still on the list. **Anona cannot write here** means the file is there and can never be edited — a directory by that name, a file past the size GitHub will serve as text, bytes that are not UTF-8. **Could not check this file** means the read itself failed, so nothing at all is known about that one. Neither is quietly dropped: a file nobody managed to read is not a repository without one, and telling you what a diff will look like is not a claim a failed read is entitled to make. If the scan cannot read the *repository* at all, it says so and offers a retry. That is deliberately not the same screen as a repository which keeps none of these files — "we could not look" and "there is nothing there" are different facts, and only one of them means a path should be typed. ### The delimited section Everything Anona writes lives between two markers: ```markdown theme={null} Your own notes, untouched. ## What this repository's history shows …regenerated in full on every refresh… Also yours, also untouched. ``` The section between the markers is replaced wholesale on every refresh, and the file is cut at the two markers so the parts you own are put back exactly as they were, down to the trailing newline — not reformatted, not re-wrapped, not re-serialized. **Nothing outside the markers is modified unless you turn on corrections**, which is off by default and described in [Correcting your own sentences](#correcting-your-own-sentences) below. With it off, prose kept outside the markers will still be there in a year. With it on, the only thing that ever changes outside them is a sentence your memory space contradicts, and every such edit is wrapped in a tag you can delete to make it permanent. Three consequences worth knowing: * A file with no markers yet gets the section **appended** at the end. Your existing content is the start of the new file, byte for byte. * A file that does not exist is **created**, containing only the section. * A file whose markers cannot be read unambiguously — one half missing, two copies, an end before a begin — is **left alone**, and no pull request is opened. Anona will not guess which of your paragraphs was meant to be its own. This is the one failure here that waiting does not fix, so it is also the one recorded on the binding and shown on its row in **Integrations**, rather than left to be inferred from a repository that went quiet. Editing inside the markers is not forbidden, but the next refresh replaces it. That shows up as a deletion in the pull request's own diff, in front of you, before you merge. ### What the section contains Every entry in the section is a quotation, under a heading naming the memory model it came from — with the question that model answers on the line below, when it has one — and all of it beneath a preamble that names the space and says the whole section is evidence. It is evidence, not instruction: a memory model may well contain a sentence like "always do X", and presented as a quoted, attributed claim it is something a reviewer can evaluate, where the same sentence in the document's own voice is something an agent obeys. At most twenty models are rendered, and a very long one is cut off with a line saying so and pointing at the dashboard. A pull request nobody can read is a pull request that gets merged unread, which would defeat the only real protection this feature has. The section reads like a document a colleague wrote about your repository. That is what it is. ### Correcting your own sentences The section above is Anona's to write. **Corrections** are the opposite: Anona proposes edits to sentences *you* wrote, where your memory space contradicts them. A file that says sessions live in Postgres, in a repository whose memory records the move to Redis, is a file that will mislead the next agent to read it — and the memory that knows better is already sitting there. This is **off by default**, and turning it on needs a knowledge file chosen first: the setting is per repository, beside the cadence. Enabling it without a file is refused with `github_knowledge_corrections_need_path`. When it is on, a pass proposes at most **three** sentence rewrites, and every one of them: * **carries the memories that contradict it**, quoted with their source, in the pull request body. No evidence, no proposal — a correction you cannot check is one you should not merge; * **is wrapped in an `` tag** in the file, so it is visible in the diff and findable afterwards; * **leaves every other byte alone.** The file is rebuilt from the original text plus the corrected sentences, so an untouched line cannot be reformatted or re-wrapped even accidentally; * **never touches the delimited section.** A correction whose span reaches into the markers is refused, so the two mechanisms cannot fight over the same bytes. Three is a deliberate ceiling rather than a measured one. The diff a reviewer reads *is* the merge gate, and a twelve-hunk diff is a rubber stamp. #### The tag is the opt-out ```markdown theme={null} Sessions live in Redis. ``` Leave the tag and Anona may correct that sentence again later, if the memory moves on again. **Delete the tag and it never will** — a sentence you have untagged is one you have taken back, and no future pass proposes it. That is the whole mechanism: nothing to configure, nothing to remember, and it lives in the file rather than in a dashboard you would have to go and find. Closing a corrections pull request is the other way to say no. Whatever it proposed is never proposed again, even if the memory behind it does not change. Merging accepts it and leaves the tag in place. #### What it costs, and when it runs Corrections ride the same clock as the section — one pull request at a time, covering both — so a weekly repository gets at most one a week either way. A pass walks six sections of the file at a time and picks up where it left off, so a long file is covered over several weeks rather than all at once. ### Cadence Three cadences, set per repository beside the file: | Cadence | What it means | | - | - | | Daily | At most one pull request a day. | | Weekly | At most one pull request a week. The default. | | Monthly | At most one pull request a month. | **It is a ceiling, not a schedule.** Daily does not mean a pull request a day; it means Anona may open one as often as once a day, and it opens none at all unless this repository's memory actually changed. **There is no per-merge option, and that is deliberate.** The synthesis is refreshed in the background, so a pull request opened the moment a merge landed would show you the picture as it was *before* that merge — fresh-looking and already out of date. More importantly, the merge gate is the only thing between generated text and a file your coding agent follows, and a bot pull request that turns up on every merge is one that gets merged unread. Nothing is opened when: * the rendered section is **unchanged** since last time (there is deliberately no "last refreshed" timestamp in it, so an unchanged week produces an identical section and no pull request); * a previous knowledge pull request is **still open** — one at a time; * the space has nothing worth rendering yet; * the binding's API key has been revoked, or the organization is out of credits. Refreshing a memory model that has fallen behind is charged as one `insights` operation, to the organization that owns the installation. A quiet repository costs nothing at any cadence — there is nothing stale to refresh. ### Shared spaces If the space is shared to you by another organization, the knowledge pull request is refused — the bind form explains it before you pick a file, and the API answers `403 github_knowledge_shared_space`. Everyone who can read the repository would see what was written into it, which is a wider audience than the shared space's own members, and it is not the binding organization's decision to make. Capture is unaffected; only this feature is off. The check runs again on every pass, not just at bind time — a space can be shared *after* the binding was saved, and a check made only at bind time would never see it. ### Turning it off Set the field back to **Off** and save. The dashboard asks first, because the binding save is a full replace and "I did not touch that field" and "I turned it off" would otherwise be the same request. Turning it off stops future pull requests. It removes nothing: whatever has already been merged into your repository is yours, and stays exactly where it is. Delete the section by hand if you want it gone. ## The endpoints behind the page Everything the Integrations page does is also callable directly. **The reads take either a dashboard session token or an API key; the writes take a session only** — an API key is refused with `401 session_required`. Which is which is marked in the table. The split is not about reads being safer. Every read here needs nothing but your organization, while every write records *who* performed it, and an API key names no person. More to the point, an API key carries no role: binding a repository chooses which space a repository's history flows into, and disconnecting an installation tears the integration down — both are owner-grade and stay behind a session and a role check. Scripting them would need scoped API keys, which do not exist yet. So an API key is enough to **watch** the integration — assert in CI that it is still connected, quote an import before paying for it, poll one to completion — and never enough to change it. | Method and path | What it does | | - | - | | `GET /v1/github/status` | The connected installation and every repository binding. **Session or API key.** | | `GET /v1/github/install` | The GitHub App install URL to send the browser to. Returns JSON rather than redirecting, because a browser navigation cannot carry your session header. Owner or admin. | | `GET /v1/github/repositories` | The repositories the installation was granted. **Session or API key.** | | `GET /v1/github/repositories/{resource_id}/knowledge-files` | Which of the conventions above the repository already keeps, and whether each already carries an Anona section. **Session or API key.** | | `POST /v1/github/bindings` | **Session only.** Binds a repository to a space. `knowledge_path` chooses the knowledge file; `null` means none. `knowledge_cadence` is `daily`, `weekly` or `monthly`. `knowledge_corrections` turns sentence corrections on; it needs a `knowledge_path`. | | `GET /v1/github/bindings/{binding_id}/backfill/preview` | Quotes a history import, both ways: `matched_count` and `estimated_credits` for a window of `target_count` (default 50, maximum 500), and `complete_matched_count` with `complete_estimated_credits` for importing everything. `total_merged` is how many merged pull requests the repository holds. `estimated_hours` and `complete_estimated_hours` say how long each would take, derived from the import rate rather than stated, so they cannot drift from it. Spends nothing. **Session or API key.** | | `POST /v1/github/bindings/{binding_id}/backfill` | **Session only.** Starts one, once the quote is confirmed. `mode` is `recent` (the default) or `complete`. `expected_credits` must equal the estimate the preview just returned, or `409 backfill_estimate_changed`. Returns `201` and the job. | | `GET /v1/github/bindings/{binding_id}/backfill` | The repository's most recent import, or `null`. `updated_at` is when it last moved, which is how a stalled import is told from one waiting for its next pass. On a failed import, `error` is the reason in plain words and `retryable` says whether starting again could help. **Session or API key.** | | `GET /v1/github/bindings/{binding_id}/knowledge-pulls` | Every knowledge pull request opened on this repository, newest first — `open`, `merged` or `closed`, with its number, title, branch and timestamps. The binding's own `knowledge_pr_url` is only the current one. Read live from GitHub, so a pull request you merged or closed reports that immediately. **Session or API key.** | | `GET /v1/github/answers/{answer_id}` | One answer the audience rule kept out of a pull request thread. | | `DELETE /v1/github/bindings/{binding_id}` | **Session only.** Unbinds one repository. | | `DELETE /v1/github/installations/{installation_id}` | **Session only.** Disconnects the installation and removes its bindings. | Two more are GitHub's to call, not yours: `POST /v1/github/events` receives every webhook delivery, and `GET /v1/github/callback` is where the browser lands after an install is approved. `GET .../knowledge-files` returns `[]` for a repository that keeps none of them, and `503 github_api_error` when the repository could not be read. Those are different answers, and a client must not render the second as the first. `total_merged`, `complete_matched_count` and `complete_estimated_credits` are `null` together when GitHub would not say how many pull requests the repository has — its search index is rate limited and can lag. `null` is **unknown**, never zero: a client must not render it as an empty repository. Counting a window still works, and `POST ... {"mode": "complete"}` answers `503 backfill_total_unavailable` until the count comes back. `mode: "complete"` takes **no** `target_count` — it imports everything that is left, so a count would mean two different things at once — and sending one is `422`. Echo `complete_estimated_credits` as `expected_credits` instead. The quote excludes pull requests an earlier import already took, so a second import of the same repository is priced on the remainder. `POST /v1/github/bindings` is a **full replace**. Send `knowledge_path` and `knowledge_cadence` on every save — omitting the path clears it rather than leaving it alone, and omitting the cadence resets it to `weekly`. ## Error codes | Code | Status | Meaning | | - | - | - | | `github_knowledge_shared_space` | 403 | A space shared from another organization cannot be written into a repository's knowledge file | | `github_bad_knowledge_path` | 400 | `knowledge_path` must be a relative path inside the repository, with no `..` segments | | `github_bad_knowledge_cadence` | 400 | `knowledge_cadence` must be exactly `daily`, `weekly` or `monthly` | | `github_knowledge_corrections_need_path` | 400 | Corrections were turned on without a `knowledge_path` — there is no file to correct sentences in | | `backfill_already_running` | 409 | An import is already in progress for this repository | | `backfill_estimate_changed` | 409 | A pull request merged since the quote; preview again | | `backfill_binding_read_only` | 409 | The binding is Read only, so an import would capture nothing | | `backfill_capture_mode_explicit` | 409 | The binding captures only on request, so there is no merge history to import | | `backfill_key_inactive` | 409 | The binding's API key is revoked or expired, so an import would capture nothing | | `backfill_total_unavailable` | 503 | GitHub would not say how many merged pull requests the repository has, so importing all of them cannot be quoted. Try again in a minute | | `insufficient_credits` | 402 | The balance cannot fund the import; nothing was started | | `github_admin_only` | 403 | Only an owner or admin can connect or disconnect an installation | | `github_not_connected` | 404 | No GitHub installation is connected for this organization | | `github_binding_not_found` | 404 | No such binding | | `github_answer_not_found` | 404 | No such stored answer, or its binding is gone | | `api_key_not_found` | 404 | `api_key_id` does not name a live key in this organization | | `space_access_denied` | 403 | The stored answer belongs to a space you cannot reach | | `space_ambiguous` | 409 | You hold your own space by that name *and* a share of another org's. Bind the qualified `owner_slug:space_name` form | | `github_api_error` | 503 | GitHub refused or failed the call | | `github_not_configured` | 503 | GitHub is not configured on this deployment | Codes are lowercase, in the standard envelope: ```json theme={null} { "error": { "code": "github_knowledge_shared_space", "message": "…" } } ``` ## Disconnect **Disconnect** on the GitHub row revokes the installation and removes its bindings. Your spaces, your memories and anything already merged into a repository are untouched. To stop one repository only, unbind it instead. # Automatic Space Routing Source: https://docs.anonalabs.com/memory/guides/automatic-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: ` | `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](/memory/legal/subprocessors) 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: ` | `rule` | A declared rule matched. The value names which field and operator did it. | | `scores: =, ...` | `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. # Bulk-Importing History with Webhooks Source: https://docs.anonalabs.com/memory/guides/bulk-import-webhooks Backfill a space from existing history without polling: batch-ingest, then let a webhook tell you when it's done. Backfilling a space (from a CRM export, chat transcripts, a support ticket history) means writing many memories at once, and each one takes a couple of seconds to extract and index. This guide covers the pattern that avoids blocking on that: batch ingest plus a webhook, so you never poll. ## Step 1: register a webhook once, instead of polling every batch You *can* poll `GET /v1/spaces/{space_id}/jobs/{job_id}` until `status` is terminal, but for a bulk import that's a lot of polling loops for not much information. Register a webhook instead and get told when each item lands: ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/customer-support-bot/webhooks \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/anona", "event_types": ["memory.created", "memory.consolidated"] }' ``` ```python Python theme={null} hook = client.create_webhook( space_id="customer-support-bot", url="https://example.com/hooks/anona", event_types=["memory.created", "memory.consolidated"], ) ``` ```typescript TypeScript theme={null} const hook = await anona.createWebhook({ spaceId: "customer-support-bot", url: "https://example.com/hooks/anona", eventTypes: ["memory.created", "memory.consolidated"], }); ``` The response includes `secret` **once**. Store it immediately, it's never shown again and you need it to verify deliveries. > [!TIP] > > A new webhook can take up to 15 seconds before it starts receiving events. If you > register the webhook and then immediately fire the batch, the first item or two may > be missed. Register it, wait a moment, then queue the import. ## Step 2: queue the batch `record/batch` accepts 1 to 100 items in one call and is always asynchronous. You get a `job_id` back immediately, the extraction happens in the background. ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record/batch \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "customer-support-bot", "items": [ { "content": "Alice works at Google." }, { "content": "Bob prefers async standups.", "context": "team sync" }, { "content": "The Q3 launch slipped to October.", "timestamp": "2026-06-01T00:00:00Z" } ] }' ``` ```python Python theme={null} job = client.record_batch( space_id="customer-support-bot", items=[ {"content": "Alice works at Google."}, {"content": "Bob prefers async standups.", "context": "team sync"}, { "content": "The Q3 launch slipped to October.", "timestamp": "2026-06-01T00:00:00Z", }, ], ) ``` ```typescript TypeScript theme={null} const job = await anona.recordBatch({ spaceId: "customer-support-bot", items: [ { content: "Alice works at Google." }, { content: "Bob prefers async standups.", context: "team sync" }, { content: "The Q3 launch slipped to October.", timestamp: "2026-06-01T00:00:00Z", }, ], }); ``` ```json theme={null} { "job_id": "job_batch_71b3", "job_ids": ["job_batch_71b3"], "status": "processing", "accepted": 3 } ``` > [!NOTE] > > Set `timestamp` on every item you can. It is the **event** time (when the thing > happened), and ranking is recency-aware, so an undated backfill arrives looking > uniformly fresh and competes with genuinely recent memories. Note that it does > not change when the memory was *recorded*, so `as_of` still sees the whole import > as having landed today. See [Searching over time](/memory/guides/temporal-search). > [!NOTE] > > More than 100 items to import? Chunk them into multiple `record/batch` calls. There's > no cost benefit to queuing versus writing synchronously, only a latency one, so > chunking costs nothing extra. ## Step 3: verify and handle deliveries Every delivery is signed. Verify the raw request body, not the parsed JSON, because reserializing changes byte order and breaks the signature: ```python Python theme={null} import hmac, hashlib def verify(raw_body: bytes, signature_header: str, secret: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header) @app.post("/hooks/anona") async def receive(request: Request): raw = await request.body() if not verify(raw, request.headers.get("X-Anona-Signature", ""), WEBHOOK_SECRET): raise HTTPException(status_code=401, detail="bad signature") event = json.loads(raw) # event["event"] == "memory.created" once the item is indexed and searchable return {"ok": True} ``` ```typescript TypeScript theme={null} import crypto from "node:crypto"; function verify(rawBody: Buffer, signatureHeader: string, secret: string) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); } // express.raw() keeps the body bytes intact for signing app.post("/hooks/anona", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req.body, req.get("X-Anona-Signature") ?? "", WEBHOOK_SECRET)) { return res.status(401).send("bad signature"); } const event = JSON.parse(req.body); // event.event === "memory.created" once the item is indexed and searchable res.json({ ok: true }); }); ``` Acknowledge with any `2xx` **fast**: deliveries time out after 10 seconds, and a slow handler gets treated as failed and retried, which turns into duplicate deliveries. Do the real work after you've returned the response, and make it idempotent on `operation_id` since delivery is at-least-once. ## Track overall progress For a large import you'll usually want a "is the whole thing done" view, not just per-item events. Two options, not mutually exclusive: * Poll the batch's `job_id` (`GET /v1/spaces/{space_id}/jobs/{job_id}`) at a slow interval (e.g. every 10-30s) purely for the top-level `completed`/`failed` status. * Count `memory.created` webhook deliveries against `accepted` from the batch response, if you want per-item completion in your own system without polling at all. ## Common mistakes * **Polling the job status quickly and often.** It's a real endpoint hit; for a bulk import a webhook is strictly better. Reserve polling for "did the whole batch finish" checks, not per-item status. * **Re-serializing the webhook body before verifying it.** Breaks the HMAC. Verify the raw bytes. * **Holding the webhook connection open while you process.** Acknowledge first, work after. * **Sending more than 100 items in one `record/batch` call.** It's rejected, so chunk it. ## Next steps > [card] [Memories API](/memory/api-reference/memories#bulk-ingest) > > Full `record` and `record/batch` reference. > [card] [Webhooks API](/memory/api-reference/webhooks) > > Events, retries, delivery debugging. # Charts, diagrams and artifacts Source: https://docs.anonalabs.com/memory/guides/charts-diagrams-artifacts Draw a chart or diagram from a space's memories, or build a page about a space that you can share, from the dashboard's Ask tab. A space's **Ask** tab answers questions about what the space holds. It also has two commands that turn those memories into something you can see or send: * **`/draw`** draws a chart or a diagram in the conversation. * **`/artifact`** builds a web page about the space, at a link you can share. Type `/` in the Ask box to see both. Use the arrow keys and Enter to pick one, or keep typing. ## Draw a chart or diagram Type `/draw` followed by what you want to see: ``` /draw memories per month /draw a flowchart of our release process /draw a mind map of the billing migration ``` You don't always need the command. A question that asks for a visual by name, such as "a pie chart of…", "a flowchart of…", "visualize…" or "a mind map of…", is drawn automatically. Everyday words like "plot", "draw up" or "timeline" on their own are answered as ordinary questions. **What you get back** * A **chart** (bar, line, area, pie or scatter) when you ask about numbers. * A **diagram** (flowchart, sequence, state, mind map or timeline) when you ask about a process or a structure. * A **Copy Mermaid** button, so you can paste the chart or diagram into any tool that reads [Mermaid](https://mermaid.js.org), such as GitHub, Notion or the Mermaid Live Editor. Scatter charts have no Mermaid equivalent, so they have no button, and neither does a pie chart holding a negative value, which Mermaid rejects. To look at a diagram closely, click it, or the expand button beside **Copy Mermaid**. It opens fitted to the window. Zoom with the **−** and **+** buttons, with Ctrl or ⌘ and the scroll wheel, or with the `+`, `-`, `0` (fit) and `1` (actual size) keys, and drag it, or scroll, to move around. Escape closes it. Every number, step and label comes from the space's memories. If they don't hold what you asked for, you get a sentence saying so instead of a made-up chart. **What it doesn't do** A drawing is marked **Not saved to memory**: neither your request nor the answer is recorded in the space. That is also why read-only members of a space can use `/draw`. **Cost:** one chat turn, the same as asking a question. ## Build an artifact An artifact is a single web page built from what a space holds: an overview of a project, a summary of decisions, a status page. It's how you give someone the gist of a space without giving them access to the space. Type `/artifact` followed by what the page should cover: ``` /artifact a one-page overview of this project for the team /artifact what we decided about pricing, and why ``` A card appears in the conversation while the page is built. This usually takes one to two minutes, and you can keep asking questions meanwhile. When it's ready, the card links to the page, which opens in a new tab. You also get an email when the page is ready, with a link to it, or, if it couldn't be built, with the reason. A build that fails is never charged. **Good to know** * You need write access to the space. Read-only members can open artifacts others share, but can't create them. * One artifact builds per space at a time. * An artifact is a snapshot. Memories you add or delete later don't change a page that's already built; build a new one instead. ## Share an artifact Every artifact starts private. Change who can open it from the card in Ask, from the **Artifacts** tab, or from the page itself: | Setting | Who can open the link | | - | - | | **Only me** | You, signed in. | | **Everyone in this space** | Anyone who can read the space, signed in. | | **Anyone with the link** | Anyone, signed in or not. | Only the space owner can choose **Anyone with the link**. A public page keeps showing what it was built from, even if those memories are deleted later, so check it before you send it. Anyone can open a page someone shared with them, but they can't change it. The person who built it and the space owner can change its sharing or delete it. Deleting an artifact breaks its link straight away. ## The Artifacts tab Each space has an **Artifacts** tab next to **Ask**. It lists every artifact you can open in the space: what each covers, whether it's still building, built or failed, and who can open it. From there you can change sharing, copy the link, or delete it. ## What artifacts cost An artifact is billed for the tokens used to build it, plus a flat 500 credits per page. Pages so far have cost between about 1,250 and 2,450 credits. A build that fails costs nothing. Before a build starts, Anona checks that your balance covers the most the page could cost: about 3,600 credits for a short request. That is more than a typical page, so a low balance can be refused even when the page itself would have fit. You can start up to 10 artifacts an hour. See [Plans and billing](/memory/guides/plans-and-billing) for how credits work. ## Use it from the API Both features are available over the API with the same keys and spaces: * [Draw](/memory/api-reference/draw): `POST /v1/spaces/{space_id}/graph` returns the chart or diagram and its Mermaid source. * [Artifacts](/memory/api-reference/artifacts): create, list, share and delete artifacts. # Export Your Memory Source: https://docs.anonalabs.com/memory/guides/export Download everything a memory space holds (Markdown, JSONL, and an entity graph) from your dashboard. Your memories are yours. Any space can be exported in full, as a zip you download from the dashboard, so moving to another platform is never blocked by us. Exports are **free** and unmetered: they don't consume credits. ## Requesting an export **1. Open the space** Dashboard → **Spaces** → the space you want → **Exports** tab. **2. Request the export** Click **Request export**. The bundle is built in the background, so you don't need to keep the page open. **3. Download it** We email you when it's ready, with a link back to this tab. Click **Download**. ## What's in the bundle The file is named `export-{space}-{date}.zip` and contains four files: | File | Purpose | | - | - | | `memories.md` | Human-readable. Grouped by month, newest first, with context and tags. | | `memories.jsonl` | One JSON object per memory, with stable ids, timestamps, tags, and metadata. **Use this to import into another system.** | | `graph.json` | Extracted entities and the connections between them. | | `manifest.json` | Export date, counts, and format version. | A `memories.jsonl` record looks like this: ```json theme={null} { "id": "01J8Z...", "content": "Alice prefers dark mode", "context": "onboarding call", "timestamp": "2026-07-15T10:00:00Z", "created_at": "2026-07-15T10:00:01Z", "tags": ["preferences"], "metadata": {"source": "call"}, "type": "fact" } ``` > [!NOTE] > > Graph edges are **co-occurrence**: two entities appearing in the same memory, > weighted by how often. They are not labeled or typed relationships, so don't > read `source → target` as a predicate. ## Availability window An export stays downloadable for **7 days**, then it is deleted automatically. Request a fresh one any time. There's no limit beyond one export per space per 24 hours. Bundles are stored encrypted and are never publicly reachable. The download link is generated when you click **Download**, is valid for five minutes, and requires you to be signed in, which is why the "ready" email links to your dashboard rather than to the file itself. ## The endpoints behind the tab The dashboard calls three endpoints. They authenticate with a **signed-in session token**, never an API key (`401 session_required`), and only the space's owner can call them: | Method and path | What it does | | - | - | | `POST /v1/spaces/{space_id}/exports` | Queues an export. Returns `202` with the job. | | `GET /v1/spaces/{space_id}/exports` | Lists the space's export jobs, newest first. | | `GET /v1/spaces/{space_id}/exports/{job_id}/download` | Returns `{download_url, expires_in}`: a five-minute link to a ready bundle. | `$SESSION` below is the session token sign-in returns; neither memory SDK wraps these, because both authenticate with an API key. ```bash cURL theme={null} # Queue an export curl -X POST https://api.anonalabs.com/v1/spaces/support/exports \ -H "Authorization: Bearer $SESSION" # List export jobs curl https://api.anonalabs.com/v1/spaces/support/exports \ -H "Authorization: Bearer $SESSION" ``` ```python Python theme={null} import httpx headers = {"Authorization": f"Bearer {SESSION}"} job = httpx.post( "https://api.anonalabs.com/v1/spaces/support/exports", headers=headers, ).json() jobs = httpx.get( "https://api.anonalabs.com/v1/spaces/support/exports", headers=headers, ).json() ``` ```typescript TypeScript theme={null} const headers = { Authorization: `Bearer ${SESSION}` }; const job = await ( await fetch("https://api.anonalabs.com/v1/spaces/support/exports", { method: "POST", headers, }) ).json(); const jobs = await ( await fetch("https://api.anonalabs.com/v1/spaces/support/exports", { headers }) ).json(); ``` | Status | Code | Cause | | - | - | - | | 401 | `session_required` | An API key was sent; exports need a signed-in session. | | 409 | `export_in_progress` | The space already has an export being built. | | 409 | `export_too_recent` | Less than 24 hours since the last export of this space. | | 409 | `export_not_ready` | The bundle is still building — poll the list until `status` is ready. | | 410 | `export_expired` | The 7-day window has passed. Request a fresh export. | | 503 | `export_unavailable` | Export storage is not available. Ours to fix, not yours. | ## Frequently asked > [accordion] Can I trigger an export with my API key? > > No. Exports require a signed-in session. An export bundle is a complete copy > of a space, so we deliberately don't let a long-lived API key produce or > fetch one — the endpoints above work with a session token only. > [accordion] Does exporting delete anything? > > No. It's a read-only copy. Your memories are untouched. > [accordion] Are uploaded documents included? > > No. The bundle contains memories and the entity graph. The original files > you uploaded aren't re-packaged, since you already hold those. > [accordion] Can I export every space at once? > > Not yet. Export one space at a time. # Migrating to the Drop-in Proxy Source: https://docs.anonalabs.com/memory/guides/migrating-to-proxy Point your existing OpenAI, Responses, or Anthropic Messages client at Anona and get memory for free, no rewrite. If you already have a chat integration, you don't need to learn the `record` / `retrieve` API to add memory. The drop-in proxy speaks the exact wire format you're already using; you change a base URL and a couple of headers, and every turn gets memory recall and storage automatically. ## The idea `/v1/chat/completions`, `/v1/responses`, and `/v1/messages` are wrapped versions of the OpenAI Chat, OpenAI Responses, and Anthropic Messages APIs. Anona: 1. Retrieves the most relevant memories for the space (and scope, if you set one). 2. Injects them into the prompt. 3. Calls the model. 4. Stores the user turn and the assistant reply back into the space. All in one request. Your app makes the same call it always made. > [!NOTE] > > You never provide an LLM key. Anona runs the model for you, and billing is metered > in Anona credits, not by the underlying provider. `model` is optional — omit it to > use the deployment default. If you name one, it has to be a model Anona actually > serves (see [model selection](/memory/api-reference/model-selection)); a provider-specific > id like an OpenAI or Anthropic model name isn't recognized and returns > `400 unsupported_model`, so drop `model` from code you're migrating rather than > carrying it over unchanged. ## Step 1: swap the base URL ```python OpenAI SDK theme={null} from openai import OpenAI client = OpenAI( api_key="anona_live_YOUR_KEY", # was your OpenAI key base_url="https://api.anonalabs.com/v1", # was https://api.openai.com/v1 default_headers={"X-Anona-Space-Id": "support-bot"}, ) response = client.chat.completions.create( model="gpt-oss", # optional: omit to use the deployment default messages=[{"role": "user", "content": "What plan did I say I was on?"}], ) ``` ```typescript TypeScript theme={null} import OpenAI from "openai"; const client = new OpenAI({ apiKey: "anona_live_YOUR_KEY", // was your OpenAI key baseURL: "https://api.anonalabs.com/v1", // was https://api.openai.com/v1 defaultHeaders: { "X-Anona-Space-Id": "support-bot" }, }); const response = await client.chat.completions.create({ model: "gpt-oss", // optional: omit to use the deployment default messages: [{ role: "user", content: "What plan did I say I was on?" }], }); ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "X-Anona-Space-Id: support-bot" \ -H "Content-Type: application/json" \ -d '{ "messages": [{"role": "user", "content": "What plan did I say I was on?"}] }' ``` Nothing else in your call changes. `messages`, `stream`, `temperature`, `max_tokens`, and any field the OpenAI SDK sends that Anona doesn't model (`top_p`, `n`, `stop`, ...) are forwarded to the provider unchanged. Using the Anthropic Messages shape or the OpenAI Responses shape instead? Same pattern: point at `/v1/messages` or `/v1/responses` respectively. See [Chat](/memory/api-reference/chat), [Responses](/memory/api-reference/responses), and [Messages](/memory/api-reference/messages). ## Step 2: pick a space (and a scope, if you have multiple users) `X-Anona-Space-Id` is the one thing worth setting explicitly. Skip it and the turn lands in a space literally named `default`, created on the first call, which is harmless for a quick test, but you don't want it in production. If your app serves more than one end user, scope the turn too: ```python Python theme={null} default_headers={ "X-Anona-Space-Id": "support-bot", "X-Anona-User-Id": "alice", } ``` ```typescript TypeScript theme={null} defaultHeaders: { "X-Anona-Space-Id": "support-bot", "X-Anona-User-Id": "alice", } ``` ```bash cURL theme={null} -H "X-Anona-Space-Id: support-bot" \ -H "X-Anona-User-Id: alice" ``` See [Scoping memory by user, agent, or session](/memory/guides/scoping-multi-tenant) for the full pattern. ## Step 3: tune what gets injected (optional) Everything is sensible by default: 5 memories injected, auto-record on. Adjust with headers so you never touch a call site: | Header | Default | What it changes | | - | - | - | | `X-Anona-Memory-Limit` | `5` | How many memories to inject, 1-20. | | `X-Anona-Memory-Tokens` | none | Approximate token cap for the injected block. | | `X-Anona-Auto-Record` | `true` | Set `false` for read-only turns, e.g. evaluating against a space without polluting it. | | `X-Anona-Memory` | `true` | Set `false` to skip recall/injection for one call entirely. | Or set the same knobs once on the space itself, so every call inherits them without a header: ```bash cURL theme={null} curl -X PUT https://api.anonalabs.com/v1/spaces/support-bot/chat-settings \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"memory_limit": 12, "auto_record": false}' ``` ```python Python theme={null} from anona import AnonaClient AnonaClient(api_key="anona_live_YOUR_KEY").set_chat_settings( space_id="support-bot", memory_limit=12, auto_record=False, ) ``` ```typescript TypeScript theme={null} import { Anona } from "@anona-labs/memory"; await new Anona({ apiKey: "anona_live_YOUR_KEY" }).setChatSettings({ spaceId: "support-bot", memoryLimit: 12, autoRecord: false, }); ``` See [Chat: memory tunables](/memory/api-reference/chat#memory-tunables) for full resolution order (body → header → space default → platform default). ## Step 4: read what happened Two extra fields on an otherwise-normal response tell you what memory did on that turn: ```json theme={null} { "choices": [ ... ], "memories_injected": 3, "space_id": "support-bot" } ``` Streaming? The same two values arrive as `X-Anona-Memories-Injected` and `X-Anona-Space-Id` response headers, from the first chunk. ## Verify it worked Ask something that depends on a fact from an earlier turn, in a fresh request. If memory is wired up, the model answers it without you re-sending the context: ```bash cURL theme={null} # Turn 1 curl .../v1/chat/completions -d '{"messages":[{"role":"user","content":"I am on the Scale plan."}], ...}' # Turn 2, new request, no history sent curl .../v1/chat/completions -d '{"messages":[{"role":"user","content":"What plan am I on?"}], ...}' # → answers "Scale" without you having resent turn 1 ``` ```python Python theme={null} # Turn 1 client.chat.completions.create( messages=[{"role": "user", "content": "I am on the Scale plan."}], ) # Turn 2, new request, no history sent answer = client.chat.completions.create( messages=[{"role": "user", "content": "What plan am I on?"}], ) print(answer.choices[0].message.content) # → "Scale" ``` ```typescript TypeScript theme={null} // Turn 1 await client.chat.completions.create({ messages: [{ role: "user", content: "I am on the Scale plan." }], }); // Turn 2, new request, no history sent const answer = await client.chat.completions.create({ messages: [{ role: "user", content: "What plan am I on?" }], }); console.log(answer.choices[0].message.content); // → "Scale" ``` ## Next steps > [card] [Chat API reference](/memory/api-reference/chat) > > Every tunable, streaming details, space defaults. > [card] [Scoping guide](/memory/guides/scoping-multi-tenant) > > Serve many end users from one space. # Plans and billing Source: https://docs.anonalabs.com/memory/guides/plans-and-billing What each plan includes, how credits are consumed, how to buy extra credits, and how to upgrade, change or cancel a subscription. Every organization starts on **Developer**. Upgrading is self-serve from the dashboard and takes effect as soon as payment completes. Anona holds the Stripe relationship, so you never handle Stripe keys, and card details never touch our servers. Payment happens on Stripe's own checkout page. ## Plans | Plan | Price | Credits / month | Effective / 1K | Rate limit | Spaces | | - | - | - | - | - | - | | **Developer** | \$0 | 5,000 | - | 20 / min | 2 | | **Growth** | \$249 / mo | 200,000 | \$1.25 | 30 / min | 100 | | **Team** | \$249 / mo | 200,000 | \$1.25 | 30 / min | 100 | | **Enterprise** | from \$60K / yr | Committed annual pool | Negotiated | Negotiable | Unlimited | **Team** is Growth plus shared organizations: you can create up to **3 shared organizations** besides your personal one and invite people into them. Everyone in a shared organization sees all of its spaces and integrations, with a role of Admin, Member (read and write) or Viewer (read only). Members do not need a plan of their own: usage in a shared organization is charged to its owner's credits, and only the owner sees the price and manages the subscription. Your personal organization is never shared. Developer and Growth share individual spaces only. Need more than 3? Talk to us, the limit can be raised per account. If a Team subscription ends, the owner's shared organizations become read-only (nothing is deleted) until the plan is restored or ownership moves to someone on Team. **Enterprise** is a committed annual credit pool rather than a monthly subscription, with pricing negotiated per contract. It is the one plan with no self-serve path; [talk to sales](https://memory.anonalabs.com/contact-sales) or email [support@anonalabs.com](mailto:support@anonalabs.com) (the form is also an [API endpoint](/memory/api-reference/sales-enquiry)). The monthly rules on this page (monthly refresh, no roll-over, flat monthly charge, self-serve upgrades and top-ups) describe the Developer, Growth and Team plans. On Enterprise, how the pool is made available, when unused credits expire and how the commitment renews are set per contract. > [!NOTE] > > Credits are a hard limit: nothing is metered or billed as overage. Once a period's > allowance and any [purchased credits](#credit-top-ups) are spent, metered requests answer > `429 credits_exhausted` until the next refresh. Upgrading, or buying a top-up on a paid > plan, is what raises the ceiling. ## When credits refresh On the self-serve plans (Developer, Growth and Team), your credits refresh on **your own billing date**, not on the 1st of the calendar month. Enterprise is covered by its contract. * On a **paid plan**, that is the day Stripe bills you. Subscribe on the 20th and your credits refresh on the 20th, so the month you pay for and the month of credits you receive are the same month. * On **Developer**, there is no subscription to follow, so the period runs from the day you signed up and refreshes on that day each month. * If a month is too short for your date (the 31st, in February), the refresh happens on the last day of that month. The exact date is on the billing page in the dashboard ("Credits renew on…"), and in `credits_reset_at` from `GET /v1/billing`. On Developer, Growth and Team, unused plan credits **do not roll over from month to month**. They do carry when you upgrade (see below), and [purchased credits](#credit-top-ups) are never reset at all. Enterprise pools follow their contract instead. ## What consumes a credit Operations cost different amounts, because they cost us different amounts: | Operation | Credits | Endpoints | | - | - | - | | Add a memory | 2 | `POST /v1/record`, `/v1/record/batch`, document upload | | Search | 1 **per space searched** | `POST /v1/retrieve` | | Insight | 5 | `POST /v1/reason`, memory-model create and refresh | | Chat turn | 3 minimum, scaled by tokens and model | `POST /v1/chat/completions`, `/v1/responses`, `/v1/messages` | Operations that run an LLM are additionally scaled by the tokens used and the [model's credit rate](/memory/api-reference/model-selection); the figures above are the base. Reads that do not involve the recall pipeline (listing spaces, listing memories, job status, reading your billing status) are free. > [!NOTE] > > **A search that names its space always costs 1.** A search that lets Anona pick > the space ([`route: "auto"`](/memory/api-reference/retrieve#let-anona-choose-the-space)) > costs 1 per space actually searched, because each space is a separate recall. > Most routed searches look in one space and so cost 1; a question that genuinely > spans two costs 2. > > `max_targets` is the ceiling on that, per request or as an organization default — > see [Searching more than one space](/memory/api-reference/retrieve#searching-more-than-one-space). > Set it to `1` to guarantee a routed search never costs more than an addressed > one. The `searched` array in every routed response lists the spaces that were > searched, so the charge is always reconstructible from the response you already > have. > > A routed search that finds no suitable space searches nothing and still costs 1, > the same as an addressed search that matches nothing. `GET /v1/billing` shows your usage for the current billing period so far, broken down by operation type. > [!NOTE] > > The `line_items` in that response are **credit** accounting, not money. What you are > charged on Growth and Team is the flat monthly plan price, plus any top-ups you buy, both > through Stripe. Enterprise billing is set per contract. ## Credit top-ups On a paid plan you can buy extra credits at any time, without changing plan. They are one-time card payments through Stripe, not a subscription, and every pack is priced at \$1.50 per 1,000 credits: | Pack | Credits | Price | | - | - | - | | Small | 5,000 | \$7.50 | | Medium | 25,000 | \$37.50 | | Large | 100,000 | \$150.00 | **Who can buy.** An owner or admin of an organization with a paid subscription. A Developer organization gets `403 topup_requires_paid_plan` and upgrades first. Inside a shared organization the credits are its owner's, so packs are bought from the owner's personal organization. **How.** In the dashboard, **Billing → Top up credits** and pick a pack; you pay on Stripe's checkout page and come back to the billing page, which shows the credits once Stripe confirms the payment. From code, `POST /v1/billing/topup` with `{"pack": "small"}` returns a `checkout_url` to send the buyer to. See the [API reference](/memory/api-reference/billing#top-up-packs). **Spend order.** Every request draws on your plan's allowance first and only uses purchased credits once the allowance for the period is gone. Artifact builds draw on them the same way. `GET /v1/billing` reports the purchased balance as `credits_banked`, and `credits_remaining` is the allowance still unspent plus `credits_banked`. The billing page shows the two separately, with a history of past purchases underneath the packs (`GET /v1/billing/topups`). **They never expire.** Purchased credits are not touched by your monthly refresh, and a plan change or a cancellation leaves them in place. **Refunds and disputes.** If a purchase is refunded, the refunded share of its credits is removed; a chargeback you lose removes the whole pack. Credits from that pack you had already spent are not reversed, and the balance never goes below zero. If a refund later fails or is cancelled, the credits it removed are put back. The purchase history marks a pack as *Partly refunded* or *Refunded*. **Low-balance emails.** Once you have bought a pack, we email the organization's owner when purchased credits fall below 20% of the most recent pack, and again when they run out. Each is sent once, and your next purchase re-arms both. ### Top-up FAQ > [accordion] Can I buy credits on the free Developer plan? > > No. Top-ups are for paid plans; upgrade first, then buy as many packs as you need. > [accordion] Do purchased credits expire or reset each month? > > No. They stay until they are spent, across refreshes, plan changes and cancellation. > [accordion] Are my plan credits used first? > > Yes. Purchased credits are only spent once the period's allowance has run out. > [accordion] Can I pay by bank transfer or another method? > > Not for top-ups. Checkout accepts cards only, so the credits can be added as soon as > the payment confirms. > [accordion] Is there automatic overage billing instead? > > No. Credits are a hard cap. Nothing is charged unless you buy a pack or change plan. ## Upgrading **1. Open Billing** In the dashboard, go to **Billing**. Each plan you can move to shows an **Upgrade** button. **2. Review the breakdown** You will see exactly what you are buying (price, credits, rate limit and space allowance) before anything is charged. Opening this makes no charge and no commitment. **3. Pay** **Pay now** takes you to Stripe's checkout page. Card details are entered there, on Stripe's domain, and are never sent to or stored by Anona. **4. Return** You come back to the billing page, which confirms the payment and shows your new limits. ### Your unused credits come with you When you move from one paid plan to a larger one, whatever you have not spent this period is **added on top** of the new plan's allowance. With 10,000 credits left on a smaller paid plan, moving to Growth gives you 210,000 to spend: Growth's 200,000 plus the 10,000 you had already paid for. The bonus covers the current period only. At your next refresh you are back to the new plan's normal allowance (200,000, in that example). > [!NOTE] > > Upgrading **from Developer** carries nothing across. Free credits are not something you > paid for, so a Developer organization moving to Growth gets Growth's 200,000, not 200,000 > plus whatever was left of the free allowance. **Once per period.** If you upgrade more than once before your next refresh, the carry-over applies to the first of those moves. The later upgrade still takes effect immediately, and you still get the larger plan's full allowance; you just do not collect a second bonus on top of the first. It resets with everything else at your next refresh date. **Closing the tab mid-checkout is safe.** Your plan changes when Stripe confirms the payment to us directly, server to server, not when your browser returns. If you close the page after paying, the upgrade still applies. If you close it *before* paying, nothing is charged and nothing changes. If the confirmation says your plan is still updating, that is normal: Stripe's confirmation occasionally arrives a moment after you do. Refresh shortly and it will be there. ## Changing or cancelling Once you are on a paid plan, **Manage billing** opens the Stripe Customer Portal, where you can: * switch to a different paid plan, and Stripe prorates the difference * update the card on file * download past invoices * cancel the subscription **Upgrades and downgrades are deliberately not symmetric:** | Change | When it takes effect | Credits | | - | - | - | | **Upgrade** to a larger plan | Immediately | New allowance, plus whatever you had not spent | | **Downgrade** to a smaller plan | End of the period you have already paid for | Smaller plan's allowance, from that date | | **Cancel** | End of the period you have already paid for | Developer allowance, from that date | You paid for the bigger plan through the end of the current period, so you keep it and its limits until then. A downgrade is scheduled rather than applied on the spot, and the smaller plan starts at your next billing date. Cancelling behaves the same way, and the organization returns to Developer afterwards. To move from a paid plan back to Developer, cancel in the portal rather than calling the API. `POST /v1/billing/upgrade` refuses a downgrade while a subscription is live, so that you cannot lose your limits while Stripe is still billing you. ## If a payment fails A declined card **does not** immediately downgrade you. Stripe retries automatically over roughly three weeks, and we email you to update the card. Your plan and limits stay exactly as they are throughout. Only if every retry fails does Stripe cancel the subscription, at which point the organization returns to Developer. ## Receipts Stripe emails the official receipt and invoice for every payment, to the address on the card. Anona separately emails a confirmation of what changed (your new plan and limits) to both the card address and the organization owner. When those are the same address, you get one of each, not two. ## Limits when you run out Running out of credits does not delete anything. Metered operations return `429 credits_exhausted` until your credits refresh on your billing date, until you upgrade, or, on a paid plan, until you [buy a top-up](#credit-top-ups). Upgrading raises the limit immediately, and brings your unused credits with it; a top-up adds its credits as soon as Stripe confirms the payment. The rate limit is separate and per-minute: exceeding it returns `429 rate_limited` and is worth retrying with backoff rather than treating as a failure. > [card] [Billing API reference](/memory/api-reference/billing) > > Endpoint-level detail for viewing status, starting checkout, buying top-ups and > opening the portal. # Scoping Memory by User, Agent, or Session Source: https://docs.anonalabs.com/memory/guides/scoping-multi-tenant Serve many end users from one space, without their memories mixing, using user_id, agent_id, and session_id. If your app has more than one end user, you need one space with scoped writes and reads, not one space per user. This guide walks through why, and how. ## The mistake to avoid It is tempting to create a space per end user, since a space is already an isolation boundary. Don't: * Spaces are capped per plan. * Memories in different spaces are never synthesized together, so you lose the cross-conversation, cross-session learning a memory layer exists to give you. One space, plus a scope key on every call, is the pattern. ## Write under a scope Add `user_id` (or `agent_id`, or `session_id`) to `record`: ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "support", "content": "Prefers email over phone.", "user_id": "alice" }' ``` ```python Python theme={null} client.record( space_id="support", content="Prefers email over phone.", user_id="alice", ) client.record( space_id="support", content="Escalates billing questions to a human.", user_id="bob", ) ``` ```typescript TypeScript theme={null} await anona.record({ spaceId: "support", content: "Prefers email over phone.", userId: "alice", }); await anona.record({ spaceId: "support", content: "Escalates billing questions to a human.", userId: "bob", }); ``` > [!NOTE] > > `user_id` is a real field, not `metadata`. Putting a user id in `metadata` stores it > but does **not** isolate anything. `metadata` is returned with results, never > filtered on. ## Read under the same scope Pass the identical key to `retrieve` (or `reason`, or the chat proxy): ```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": "how should we contact them?", "user_id": "alice" }' ``` ```python Python theme={null} results = client.retrieve( space_id="support", query="how should we contact them?", user_id="alice", ) # Only Alice's memories come back. Bob's never appear here. ``` ```typescript TypeScript theme={null} const results = await anona.retrieve({ spaceId: "support", query: "how should we contact them?", userId: "alice", }); // Only Alice's memories come back. Bob's never appear here. ``` ## What "strict" means in practice * A scoped search returns only memories written under the **same** scope. Never another user's, and never an unscoped memory either. * If a space already has history and you turn scoping on today, that old history stays visible to unscoped searches but invisible to scoped ones. Backfill the scope onto it if scoped callers need to see it. * Passing more than one key ANDs them: `user_id` + `session_id` returns only that user's memories from that specific session. * Consolidated synthesis (`note` memories) is built **per user**, so a synthesis never blends two users' facts, while still rolling a user's sessions up together so the memory improves across conversations, not just within one. * A `note` carries **only that one identity**: the user, or the agent when the facts have no user, or the session when they have neither. It keeps its source facts' own tags and provenance, but not their `agent_id` or `session_id`. So a fact written with `user_id: alice, agent_id: support-a` produces a note scoped to `alice` alone. Two consequences: * An `agent_id`- or `session_id`-scoped read sees the **facts**, not the note. The memory list returns those facts even under the default `prefer_observations=true`, so nothing is hidden behind a note the filter cannot reach. * A `user_id`-scoped read sees the note, and that note can draw on facts written by **any** of your agents for that user. That is the point: what you know about Alice is shared across agents. For a per-agent synthesis, use a [memory model](/memory/api-reference/models); for agents that must never share anything, use separate spaces (see [two agents, one space](/memory/use-cases/two-agents-one-space)). ## Choosing which key | Key | Use for | | - | - | | `user_id` | The end user of your product. Almost always the one you want. | | `agent_id` | Which of *your* agents or bots produced the memory, when you run more than one. | | `session_id` | One conversation or run, when you need per-conversation isolation *within* a user (rare: most apps want memory to persist across sessions, which is what `user_id` alone gives you). | ## In the drop-in proxy The same scope keys work as tunables on the chat/responses/messages proxy, as a body field or a header: ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "X-Anona-Space-Id: support-bot" \ -H "X-Anona-User-Id: alice" \ -H "Content-Type: application/json" \ -d '{"messages": [{"role": "user", "content": "What plan am I on?"}]}' ``` ```python Python theme={null} from openai import OpenAI client = OpenAI( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com/v1", default_headers={ "X-Anona-Space-Id": "support-bot", "X-Anona-User-Id": "alice", }, ) ``` ```typescript TypeScript theme={null} import OpenAI from "openai"; const client = new OpenAI({ apiKey: "anona_live_YOUR_KEY", baseURL: "https://api.anonalabs.com/v1", defaultHeaders: { "X-Anona-Space-Id": "support-bot", "X-Anona-User-Id": "alice", }, }); ``` See [Migrating to the drop-in proxy](/memory/guides/migrating-to-proxy) if you haven't wired that up yet. ## Reserved tags Tags beginning with `anona:` are rejected with `422 reserved_tag`. Scope is implemented as tags internally, and this stops a hand-written tag from forging someone else's scope. Always use the `user_id`/`agent_id`/`session_id` fields, never a tag that looks like one. ## Reading back one user's history Once you're writing under `user_id`, [User Profiles](/memory/api-reference/user-profiles) is the direct way to read it back: everything a space knows about one user, or a question answered from that user's memories only, without hand-rolling a wide `retrieve` call yourself. It uses the same `user_id` scope described above, so it only ever sees what was recorded under it. ## Next steps > [card] [User Profiles](/memory/api-reference/user-profiles) > > Read back everything a space knows about one scoped user. > [card] [Retrieve API](/memory/api-reference/retrieve#scoping-within-a-space) > > The full scoping reference, including tag filters. > [card] [Core Concepts](/memory/concepts) > > Spaces vs. scope, in one page. # Searching Over Time Source: https://docs.anonalabs.com/memory/guides/temporal-search Date your memories, filter and rank by event time, and reproduce a past answer. Memory that only answers "what is true now" can't answer "what did we think in June", and can't tell a fact from last week apart from one from last year. This guide covers the time controls Anona exposes, which one to reach for, and where the edge is. ## Two clocks, not one Every memory carries two independent times. Almost every confusion about temporal search comes from conflating them. | | Set by | Returned as | Meaning | | - | - | - | - | | **Event time** | `timestamp` on `record` | `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. | The `timestamp` you send comes back as `timestamp` — it is **not** copied into `occurred_start`. Those two fill only from a date the memory's text itself names, and are `null` otherwise, which is the common case and not a gap in the data. For a memory written as the conversation happens they're the same instant. They diverge the moment you import history: a note about last June, uploaded today, happened in June and was recorded today. ## Dating a memory Pass `timestamp` (ISO 8601) on `record` or on any item in `record/batch`: ```python Python theme={null} client.record( space_id="support", content="The Q3 launch slipped to October.", timestamp="2026-06-01T00:00:00Z", ) ``` ```typescript TypeScript theme={null} await client.record({ spaceId: "support", content: "The Q3 launch slipped to October.", timestamp: "2026-06-01T00:00:00Z", }); ``` ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "support", "content": "The Q3 launch slipped to October.", "timestamp": "2026-06-01T00:00:00Z" }' ``` Omit it and it defaults to now. Dating imported memories is worth the effort: ranking is recency-aware, so an undated backfill arrives looking uniformly fresh and competes with genuinely recent memories. ## Reproducing a past answer `as_of` drops every memory **recorded** after an instant, so retrieval answers from what the space knew at that moment: ```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", }); ``` This is the one to reach for when auditing a decision ("the agent told the customer X on June 1st, what was it working from?") or when regression-testing retrieval against a frozen corpus while new memories keep arriving. > [!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, not a ranking artifact. An `as_of` > search is therefore slightly narrower than the same search without it. ## Moving "now" `query_timestamp` changes the instant that recency scoring and relative dates in the query are measured against. It **re-ranks and never removes**, which is the whole difference from `as_of`: ```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", }); ``` Here "last quarter" resolves against June 1st rather than today, and memories near that date rank higher. A memory recorded last week can still come back. If you need it excluded, that's `as_of`. Reach for `query_timestamp` when the query text itself contains a relative date, or when replaying a historical conversation and you want period-appropriate memories to surface without hiding anything. ## Which one | You want | Use | | - | - | | Date a memory to when the event happened | `timestamp` on `record` | | Memories about things that **happened** in a window | `occurred_after` / `occurred_before` | | What did the space know at time T | `as_of` | | Resolve "last June" / "two weeks ago" in the query | `query_timestamp` | | Rank older-but-time-relevant memories higher | `query_timestamp` | | Exclude memories recorded after a cutoff | `as_of` | All of these take an ISO 8601 instant and are rejected as `422` before the search runs if malformed, so a typo never costs a charged call. They compose with each other and with scoping and tags. ## Filtering on event time `occurred_after` and `occurred_before` keep only memories describing something that **happened** inside the window — the filter `as_of` is not: ```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 shipped?", "occurred_after": "2026-06-01T00:00:00Z", "occurred_before": "2026-07-01T00:00:00Z" }' ``` ```python Python theme={null} results = client.retrieve( space_id="support", query="what shipped?", occurred_after="2026-06-01T00:00:00Z", occurred_before="2026-07-01T00:00:00Z", ) ``` ```typescript TypeScript theme={null} const results = await anona.retrieve({ spaceId: "support", query: "what shipped?", occurredAfter: "2026-06-01T00:00:00Z", occurredBefore: "2026-07-01T00:00:00Z", }); ``` A memory matches on its `occurred_start` / `occurred_end` window when its text named a date, and **falls back to `timestamp`** when it did not — so a dated import matches a June window even though nothing in its text says June. An event spanning either edge counts as inside. Details and edge cases: [Filtering on event time](/memory/api-reference/retrieve#filtering-on-event-time). This is what makes a dated bulk import addressable: every item was *recorded* today whatever `timestamp` it carries, so `as_of` can't separate the periods — `occurred_after` / `occurred_before` can. Two techniques still worth knowing alongside the filter: 1. **Tag by period at write time.** `tags: ["2026-Q2"]` on import, then `tags: ["2026-Q2"], tags_match: "all_strict"` on retrieve — an exact filter that also works for periods that aren't calendar windows (a sprint, a campaign). 2. **Put the period in the query text.** Retrieval parses relative and absolute dates in the query and has a dedicated temporal retrieval arm, so "what happened in June 2026" is a meaningfully better query than "what happened". ## Related * [Record](/memory/api-reference/memories): `timestamp` on write * [Retrieve](/memory/api-reference/retrieve#searching-over-time): `as_of` and `query_timestamp` * [Bulk import](/memory/guides/bulk-import-webhooks): dating a backfill * [Tuning recall](/memory/guides/tuning-recall): the non-temporal knobs # Tuning Recall Quality Source: https://docs.anonalabs.com/memory/guides/tuning-recall Retrieve or the drop-in proxy returning too much, too little, or the wrong memories? Here's what to change, and in what order. Retrieve and the drop-in proxy both ship with sensible defaults, but "sensible" isn't always right for your data. This guide is a symptom-to-fix map for the knobs that actually move recall quality. ## "It's returning duplicates of the same fact" This is by design, and there's a knob for it. Anona keeps two layers (raw `fact` memories and consolidated `note` memories synthesized from them), and by default (`prefer_observations: true`) a `note` replaces the facts it was built from, so you see one distilled result instead of the same content several times. If you're still seeing what looks like duplication, you're probably retrieving with `prefer_observations: false` (or an old space that predates consolidation catching up). Leave it at the default unless you specifically need the raw evidence behind a synthesis: ```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?" }' ``` ```python Python theme={null} results = client.retrieve( space_id="support", query="What plan are they on?", # prefer_observations defaults to True, so omit it ) ``` ```typescript TypeScript theme={null} const results = await anona.retrieve({ spaceId: "support", query: "What plan are they on?", // preferObservations defaults to true, so omit it }); ``` ## "It's returning too little" / "missing an obvious memory" Check these in order: 1. **Is `min_score` filtering it out?** This is the first thing to check, because almost any floor is a high one. In the default `accurate` mode `relevance_score` is close to two-valued: 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. A floor also drops results *without* shrinking `limit`, so it returns fewer than you asked for with nothing to say it did. Omit it. 2. **Is a scope key excluding it?** A scoped query (`user_id`/`agent_id`/`session_id`) returns *only* memories written under that exact scope, never unscoped ones, never another scope's. If the memory was written without a scope and you're querying with one, it won't come back. See [Scoping](/memory/guides/scoping-multi-tenant). 3. **Is `limit` too low?** Default is 10. Raise it (`limit` / `top_k`, up to 100) if the memory exists but is being cut off by rank. 4. **Is it filtered by `memory_type` or `tags`?** Both narrow the result set; drop them to confirm the memory shows up unfiltered, then add them back deliberately. 5. **Is the memory in a different space from the one you searched?** No amount of tuning finds a memory that is somewhere else — the space you named is the whole search. If you are not sure which space holds the answer, use [`route: "auto"`](/memory/api-reference/retrieve#let-anona-choose-the-space) and read the `searched` array in the response to see where it looked. A question whose answer genuinely spans two spaces — the charge in one, the account it landed on in another — needs [more than one space searched](/memory/api-reference/retrieve#searching-more-than-one-space). ## "It's returning too much" / "the wrong things are ranking high" 1. **Lower `limit`.** This is the lever for precision vs. breadth, because results come back ranked and the ones you do not want are at the bottom. Reach for `limit` (or `top_k`) rather than `min_score`: as above, the score is close to two-valued, so `min_score` is a switch for "near-verbatim only" rather than a dial you can trim with. Set it only when that switch is what you want — and never to a round number like `0.1` or `0.5` picked to shave off the weakest results, which drops every paraphrase match instead. See [`relevance_score`](/memory/api-reference/retrieve#response). 2. **Add `memory_type` or `tags` filters** if you know which kind of memory you actually want (e.g. `memory_type: ["fact"]` to skip synthesized notes and summaries). 3. **Check you're on `mode: "accurate"`, not `"fast"`.** `fast` skips the neural rerank pass and ranks on retrieval fusion alone: faster, but lower precision. It trades exactly the quality you're trying to improve. ## "The proxy is injecting too much / too little context into the model" This is the chat/responses/messages proxy, not raw `retrieve`, so the knobs are the memory tunables, not retrieve parameters: | Symptom | Fix | | - | - | | Prompt is bloated, costs are high | Lower `memory_limit` (default 5, max 20), or set `memory_token_budget` to cap the injected block by size instead of count. | | Model seems to be missing relevant context | Raise `memory_limit`. | | One call needs to skip memory entirely (e.g. a meta/system message) | `memory: false` for that call only. | | A read-only/eval call shouldn't pollute the space | `auto_record: false` for that call only. | Set these once as **space defaults** (`PUT /v1/spaces/{space_id}/chat-settings`) instead of a header on every call if the whole space should behave differently. Resolution order is `body → header → space default → platform default`, so a space default is a safe place to change behavior without touching call sites. See [Migrating to the drop-in proxy](/memory/guides/migrating-to-proxy#step-3-tune-what-gets-injected-optional). ## "I want the model to see the memories exactly, not rely on the proxy's injection" Build the prompt yourself with `format: "block"` on `retrieve`, which returns a ready-to-paste string with the token budget already applied: ```json theme={null} { "space_id": "support", "query": "what plan are they on?", "format": "block", "context_max_tokens": 500 } ``` `context_max_tokens` drops **whole memories**, lowest-ranked first, until the block fits, and text is never truncated mid-sentence. `results` is still returned alongside the block, so you get both for one round trip. ## "Recall is too slow" Latency-sensitive path (an agent loop, autocomplete-style lookups)? Set `mode: "fast"` on `retrieve`, which skips the neural cross-encoder rerank and ranks on retrieval fusion alone. Accept the precision tradeoff deliberately, and don't set it globally as a default without checking result quality first. ## Quick reference | Parameter | Surface | Effect | | - | - | - | | `min_score` | `retrieve` | A switch for "near-verbatim only", not a dial — the score is close to two-valued. Doesn't shrink `limit`. | | `limit` / `top_k` | `retrieve` | How many results, 1-100. | | `mode` | `retrieve` | `accurate` (default, reranked) vs. `fast` (fusion only). | | `max_targets` | `retrieve` with `route: "auto"` | Ceiling on how many spaces a routed search may look in, 1-20. **A cost control, not a quality knob**: only likely spaces are searched, and each one searched is charged. | | `prefer_observations` | `retrieve` | `true` (default) collapses facts into their note; `false` shows both. | | `memory_limit` | chat proxy | How many memories injected, 1-20. | | `memory_token_budget` | chat proxy | Token cap on the injected block. | | `context_max_tokens` | `retrieve` with `format: "block"` | Token cap on the returned block. | ## Next steps > [card] [Retrieve API](/memory/api-reference/retrieve) > > Full parameter and response reference. > [card] [Chat API: memory tunables](/memory/api-reference/chat#memory-tunables) > > Every proxy tunable, body and header form. # CrewAI Source: https://docs.anonalabs.com/memory/integrations/crewai Shared, persistent memory tools for a CrewAI crew. Anona gives a CrewAI crew memory that persists across runs and is shared by every agent in it, with no vector database to run and no LLM key to supply. ```bash theme={null} pip install 'anona[crewai]' ``` ## Setup Give one or more agents Anona-backed memory tools. Each agent can then search past memories and save new ones as part of its own reasoning, the same way it would use any other tool. ```python theme={null} from crewai import Agent from anona.integrations import MemoryBridge from anona.integrations.crewai import AnonaStorage bridge = MemoryBridge(api_key="anona_live_...", space_id="research-crew") storage = AnonaStorage(bridge=bridge) researcher = Agent( role="Researcher", goal="Answer questions using both current research and what the crew " "already knows", backstory=( "An experienced researcher who keeps notes across projects. Before " "digging into a new question, check memory for anything relevant " "already known about it. After learning something worth keeping, a " "decision, a fact, a preference, save it for later runs." ), tools=[*existing_tools, *storage.as_tools()], ) ``` `as_tools()` returns two tools, named `Anona: Search memory` and `Anona: Save memory`. Give them to every agent that should share the crew's memory. Like any CrewAI tool, these are **agent-discretionary**: nothing calls them automatically. The agent's LLM decides whether and when to reach for a tool based on its `backstory`/`goal` and the task at hand, the same way it decides whether to use a web-search or file-read tool. Say so explicitly, as in the `backstory` above, or the model may never call `Anona: Search memory` even though it's sitting right there in its tool list. The `Anona:` prefix is deliberate: if the same agent also has CrewAI's own memory enabled (`Crew(memory=True)` or an agent-level `memory=True`), CrewAI auto-attaches its own memory tools too, named `Search memory` and `Save to memory`. CrewAI dedups tools by name and silently keeps whichever set was attached last, so an unprefixed name here could lose to, or quietly shadow, CrewAI's own tool with no error and nothing in any log. The prefix means Anona's tools and CrewAI's can coexist on the same agent without either one going missing. ## Why tools, not `Crew(memory=...)` CrewAI's built-in memory (`Crew(memory=True)` or `Memory(storage=...)`) does its own embedding and LLM-driven analysis of everything it stores and recalls, and hands a pluggable storage backend only a raw vector on search, never the query text. That can't be bridged to Anona's managed, text-based retrieval without running a local embedding model, which is exactly the vector database this integration exists to remove. CrewAI's memory tools sidestep all of that: an agent calls them with plain text and gets plain text back, and Anona does the actual search and ranking server-side. It also means adopting Anona adds no second LLM requirement on top of whatever model your agents already use. CrewAI's built-in memory needs its own LLM and embedder for content analysis regardless of storage backend; Anona-backed tools don't. ## Scoping Pass `user_id`, `agent_id` or `session_id` to the bridge to isolate memory inside a space, so one crew deployment can serve many end customers without their memories mixing. ```python theme={null} bridge = MemoryBridge( api_key="anona_live_...", space_id="research-crew", user_id="customer-42", ) ``` ## `AnonaStorage` directly `AnonaStorage(bridge=bridge)` also exposes plain `save(value)` / `search(query)` / `reset()` methods (what `as_tools()` is built from), if you want to call Anona from your own code rather than through the generated tools. `reset()` is a deliberate no-op that logs a warning: Anona has no all-or-nothing delete endpoint, and a partial wipe (list-then-delete, with no transaction) is worse than none. Clear a space from the Anona dashboard instead. ## Failure behaviour If Anona is unreachable, the search tool reports no memories found and the crew keeps running. The save tool still returns `"Saved to memory."`, even when the write failed. The bridge behind every adapter never raises, so there's no failure for the tool to report. Read `"Saved to memory."` as "handed off," not as confirmation the memory is durably stored. # Examples Source: https://docs.anonalabs.com/memory/integrations/examples A complete, runnable script for every framework adapter. Each adapter ships a full working program, not a snippet. They live in the SDK repository under [`examples/`](https://github.com/anonalabs/Anona-Memory-SDK/tree/main/examples). ```bash theme={null} git clone https://github.com/anonalabs/Anona-Memory-SDK.git cd Anona-Memory-SDK export ANONA_API_KEY=anona_live_YOUR_KEY export ANONA_SPACE_ID=my-space # optional, each script has a default python examples/langchain_agent.py ``` | Framework | Script | Install | | - | - | - | | [LangChain / LangGraph](/memory/integrations/langchain) | [`langchain_agent.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/langchain_agent.py) | `pip install 'anona[langchain]' langchain-openai` | | [CrewAI](/memory/integrations/crewai) | [`crewai_crew.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/crewai_crew.py) | `pip install 'anona[crewai]'` | | [LlamaIndex](/memory/integrations/llamaindex) | [`llamaindex_agent.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/llamaindex_agent.py) | `pip install 'anona[llamaindex]' llama-index-llms-openai aiosqlite` | | [Google ADK](/memory/integrations/google-adk) | [`google_adk_agent.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/google_adk_agent.py) | `pip install 'anona[adk]'` | | [Microsoft Agent Framework](/memory/integrations/microsoft-agent-framework) | [`ms_agent_framework.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/ms_agent_framework.py) | `pip install 'anona[msagent]' agent-framework-openai` | | [AWS Strands](/memory/integrations/strands) | [`strands_agent.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/strands_agent.py) | `pip install 'anona[strands]'` | The model and provider in each script are only there to make it run. Swap in whatever your app already uses; nothing about the memory wiring depends on them. ## What to look for Every script asks two questions in a row, where the second is only answerable from what the first one stored. **Run it twice**: the second run starts with the first run's memories already in the space, so it can answer the opening question too. They differ from each other on purpose, because the frameworks do: > [card] Automatic > > **LangChain** and **Microsoft Agent Framework**: one middleware or one > context provider, and every turn is recalled and stored. > [card] Model's choice > > **CrewAI** and **Strands** expose memory as tools. Both scripts spend prompt > text telling the model when to call them; that wording is part of the > integration. > [card] Wire it yourself > > **Google ADK** automates neither direction: `load_memory` gives the model a > search tool, an `after_agent_callback` stores the turn. > [card] Explicit capture > > **LlamaIndex** only flushes a turn once its own buffer overflows, so the last > turn never lands by itself. That script calls `bridge.remember(...)`. ## Failure behaviour Every adapter fails open. If Anona is unreachable, the recall or the store is logged and the agent runs on without memory. The one thing that does raise is a usage error, not a failure: LangChain's `AnonaRetriever` is async-only, so its sync `invoke()` raises `NotImplementedError` — call `ainvoke()`. No runtime memory failure reaches your code. To see it for yourself, run any script with a deliberately wrong API key: it still answers, it just remembers nothing. > [!NOTE] > > The scripts are executed by the SDK's own test suite against each real > framework, so an example that stopped working would fail CI rather than reach > you broken. # Google ADK Source: https://docs.anonalabs.com/memory/integrations/google-adk Per-user long-term memory for Google Agent Development Kit agents. ADK's memory service is an interface; Anona implements it. Wire `AnonaMemoryService` in as your agent's `memory_service` and it can recall across sessions, deployments and restarts, scoped to the right user automatically, because ADK hands you `user_id` on every call. ```bash theme={null} pip install 'anona[adk]' ``` ## Setup ```python theme={null} from google.adk.agents import LlmAgent from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.adk.tools import load_memory from anona.integrations import MemoryBridge from anona.integrations.google_adk import AnonaMemoryService bridge = MemoryBridge(api_key="anona_live_...", space_id="my-agent") async def save_to_memory(callback_context): await callback_context.add_session_to_memory() agent = LlmAgent( name="assistant", model="gemini-2.0-flash", tools=[load_memory], # lets the model search memory after_agent_callback=save_to_memory, # stores the finished turn ) runner = Runner( agent=agent, app_name="my-app", session_service=InMemorySessionService(), memory_service=AnonaMemoryService(bridge=bridge), ) ``` ## Nothing here is automatic Both directions require you to wire something. This is how ADK's memory services work in general, not a limitation of this adapter. **Reading: two options, different cost.** Attach the built-in `load_memory` tool (above) and the model decides for itself when to call it, with zero calls if it never asks. Attach `preload_memory` instead (`from google.adk.tools import preload_memory`) and it runs on its own, with no model decision involved. But "automatic" here is more expensive than it sounds: it fires before **every model call**, not once per turn. A single tool-calling turn (the model calls one tool, then answers) measured **2** separate retrieve calls, both for the identical query, because ADK re-runs it once per model step, not once per turn. Unlike this SDK's LangChain adapter, there is no per-turn cache to collapse that back down. A three-tool-call turn costs four retrieves for one logical question, all billed. Reach for `preload_memory` only once that cost is a deliberate choice; `load_memory` is the lower-cost default for most agents. You can also call `await tool_context.search_memory(query)` directly from inside your own tool or callback code if neither built-in shape fits. **Writing.** A session is never saved to memory just because a run finished. The supported hook is `Context.add_session_to_memory()`, reachable from any callback that receives a context. `after_agent_callback`, as above, is the natural place, since it fires once per `runner.run_async()` call, after any internal tool-calling loop has finished. Skip this and Anona never receives a single turn; the agent still runs, silently, with no memory. Note the callback's parameter must be named exactly `callback_context`, because ADK invokes it as a keyword argument. You can also call `memory_service.add_session_to_memory(session)` directly: `add_session_to_memory` on the callback context is just a shortcut for that, using the callback's own session. Calling this again on a session you've already saved, the normal shape of a multi-turn conversation with one call per turn on the same `session.id`, is safe: you don't need to track what you've already sent yourself. In the common case (each call sees the same, growing event history, true of the `after_agent_callback` idiom above, and of a fresh `session_service.get_session(...)` fetch) only what's new goes out. If a call instead receives a *different*, shorter view of the same session, for example a trimmed fetch via `GetSessionConfig(num_recent_events=N)`, this can't always tell which of those events it already saw, and resends the whole view rather than guess and risk dropping something new. So: never a silently lost turn; occasionally, in that specific case, a duplicate. ## Per-user isolation, at no cost ADK passes `user_id` and `app_name` on every memory call, and this adapter maps them onto Anona's scope, asymmetrically between writing and reading: | ADK | `add_session_to_memory` (write) | `search_memory` (read) | | - | - | - | | `user_id` | `user_id` | `user_id` | | `app_name` | `agent_id` | *not forwarded* | | `session.id` | `session_id` | *(ADK's `search_memory` takes no session)* | `app_name` is written as `agent_id` but deliberately not forwarded on a read. Anona consolidates raw memories into synthesized observations over time, and an observation is tagged by *user* only, not by the agent that happened to write the originating turns, so a two-key `user_id` + `agent_id` search would never match a consolidated observation, only raw, unconsolidated facts. Since `app_name` is the one scope key ADK supplies automatically with no way to opt out, forwarding it on every read would have silently hidden your agent's best answers behind its rawest ones, for every ADK user, all the time. Isolation itself is unaffected: `user_id` (the key that actually keeps one end user's memories from another's) is forwarded on every call, read and write alike. One space serves every user of your app, and a memory written under one `user_id` is only ever returned to that same user. You configure nothing. ## Failure behaviour If Anona is unreachable, `search_memory` returns no memories and the agent continues. Failures are logged, never raised. # LangChain & LangGraph Source: https://docs.anonalabs.com/memory/integrations/langchain Persistent memory for LangChain chains and LangGraph agents. Anona gives a LangChain chain or LangGraph agent memory that survives the process. You bring no LLM key and run no vector database. Anona is managed, and every call is scoped so one space can serve many end users without their memories mixing. ```bash theme={null} pip install 'anona[langchain]' ``` ## LangGraph agents ```python theme={null} from anona.integrations import MemoryBridge from anona.integrations.langchain import AnonaMemory from langchain.agents import create_agent bridge = MemoryBridge( api_key="anona_live_...", space_id="support-bot", user_id="customer-42", # this user's memories only ) agent = create_agent(model="gpt-4o", middleware=[AnonaMemory(bridge=bridge)]) ``` The context block is fetched once per turn and injected as a system message before every model call in that turn, reused across each step of a tool-calling loop rather than re-fetched. If you also pass `system_prompt` to `create_agent`, both survive: your instructions first, the memory block after. The completed turn as a whole, not each intermediate step, is stored once the agent run finishes. Nothing else changes. > [!NOTE] > > That per-turn reuse is implemented as a declared field on LangGraph's own > graph state, which is exactly what a checkpointer persists. If you compile > `create_agent(..., checkpointer=...)` with a durable checkpointer (SQLite, > Postgres, ...), the retrieved memory text is written into *your* checkpoint > store verbatim (once per checkpoint saved during that run), not just held > in process memory. That copy lives outside Anona's storage and retention: > deleting or expiring a memory on Anona's side does not remove it from > checkpoints already written. Expected given what the cache is for, not a > bug, just worth knowing if your own checkpoint store needs to honor a > retention or deletion policy. ## Retrieval chains ```python theme={null} from anona.integrations.langchain import AnonaRetriever retriever = AnonaRetriever(bridge=bridge) docs = await retriever.ainvoke("what did we decide about pricing?") ``` `AnonaRetriever` is async-only: call `ainvoke()` (shown above), not `invoke()`. The sync form raises `NotImplementedError` rather than silently blocking an event loop. It is the one place across any of these six adapters that raises into the caller instead of failing open. Every other failure mode on this page (Anona unreachable, no matches, ...) still returns normally with an empty result. ## Scoping `user_id`, `agent_id` and `session_id` on the bridge isolate memory inside one space. A memory written under `user_id="customer-42"` is only ever returned to a query carrying the same `user_id`. ## Turning off writes ```python theme={null} AnonaMemory(bridge=bridge, record=False) ``` Keeps recall, skips storing new turns. ## Why not a LangGraph Store? A `BaseStore` is namespaced key-value: `get(namespace, key)` returns one exact record. Anona is search: you ask a question and get ranked, deduplicated, token-budgeted context. A partial Store implementation would break LangGraph's built-in memory tools at runtime, so we ship the two shapes that fit instead. ## Failure behaviour If Anona is unreachable, your agent keeps running without memory. Memory failures are logged, never raised. # LlamaIndex Source: https://docs.anonalabs.com/memory/integrations/llamaindex Long-term memory blocks for LlamaIndex agents. LlamaIndex's `Memory` composes blocks. Anona plugs in as one of them, supplying long-term memory alongside the short-term buffer rather than replacing it. No vector store to operate, no LLM key to supply. ```bash theme={null} pip install 'anona[llamaindex]' ``` > [!NOTE] > > This extra excludes `llama-index-workflows==2.24.0`, so installing it may > downgrade that package to 2.23.3. The exclusion is not about Anona: on > 2.24.0, every LlamaIndex agent run — `FunctionAgent`, `ReActAgent`, with or > without a memory block — fails with `TypeError: unhashable type: > 'FunctionAgent'` before the first model call > ([run-llama/llama\_index#23131](https://github.com/run-llama/llama_index/issues/23131), > open at time of writing). `llama-index-core` itself is unaffected and > unconstrained; you stay on the current release. If you pin > `llama-index-workflows` yourself, keep 2.24.0 out until that issue closes. ## Setup ```python theme={null} from llama_index.core.memory import Memory from anona.integrations import MemoryBridge from anona.integrations.llamaindex import AnonaMemoryBlock bridge = MemoryBridge(api_key="anona_live_...", space_id="assistant") memory = Memory.from_defaults( session_id="chat-1", # A real database, not the in-memory default, see "Use a durable # database" below. async_database_uri="sqlite+aiosqlite:////path/to/chat.db", memory_blocks=[AnonaMemoryBlock(bridge=bridge)], ) ``` Pass `memory` to any LlamaIndex agent (`FunctionAgent`, `ReActAgent`, ...). `Memory` calls this block's context into its own template and prepends it to the system message on every model call, so relevant memories are visible before the agent's first response of a turn, including before each follow-up model call inside a tool-calling round, not just the first one. LlamaIndex re-asks for the same query several times over the course of one turn; this block keeps a short-lived cache so those repeats reuse the first answer instead of calling Anona again each time. ## How and when a turn is stored Unlike Anona's LangChain or CrewAI adapters, this block does not store a turn the moment it finishes. LlamaIndex's `Memory` keeps its own short-term FIFO of recent messages (`token_limit` / `chat_history_token_ratio`, 30,000 tokens / 70% by default) and only pushes the oldest messages out to each memory block, Anona's included, once that buffer fills up. That is the same contract LlamaIndex's own built-in blocks (`VectorMemoryBlock`, `FactExtractionMemoryBlock`) are written against: a memory block is long-term storage for what falls out of the short-term window, not a mirror of every turn. In practice: * A short conversation may never cross the default \~21,000-token threshold, in which case nothing reaches Anona at all. The whole conversation still lives in LlamaIndex's own short-term buffer for the life of the session. * When the buffer does flush, whole turns move together. A tool-calling round (the question, the tool call, the tool result, the final answer), arrives as one batch, never split across separate writes. * **The most recent turn is never flushed by itself, at any `token_limit`.** `Memory`'s eviction always keeps at least one complete turn resident, so the newest turn only leaves the buffer once *another* turn pushes it out. `Memory` has no flush-now or close method. Concretely: **the last turn of a conversation is never stored by this block at all**, no matter how low you set `token_limit`. There is always one more turn that never arrives to evict it. Lowering `token_limit` and/or `chat_history_token_ratio` on `Memory.from_defaults(...)` makes every *earlier* turn reach Anona sooner. It does not change the point above. The last turn is still excluded either way. ### Guaranteeing the last turn is captured If losing the final exchange of a conversation is a problem for your use case (it usually is, since closing questions and final decisions are often the most worth remembering), don't rely on eviction for it. Call the bridge directly at whatever point your application considers a turn or a session finished: ```python theme={null} # After the agent responds, alongside (or instead of) leaving it to Memory: await bridge.remember(f"User: {user_message}\nAssistant: {agent_reply}") ``` This is the same `MemoryBridge` the block itself uses. `bridge.remember(...)` is already public API, so this needs no new code, just a call at your own turn or session boundary. It fires immediately rather than waiting on buffer pressure, so it's a reasonable choice even for turns that aren't the last one, if you'd rather not depend on `token_limit` timing at all. ### Use a durable database URI `Memory.from_defaults()` defaults to an in-memory SQLite database when `async_database_uri` is left unset. That default does not survive across separate agent runs: reusing the same `memory` object for a second `agent.run(...)` call (the normal one-run-per-turn pattern) starts the short-term buffer over from empty, so it never accumulates enough history across turns for this block to receive anything. Point `async_database_uri` at a real file or database for any multi-turn session. ## Scoping ```python theme={null} bridge = MemoryBridge( api_key="anona_live_...", space_id="assistant", user_id="customer-42", ) ``` A memory written under one `user_id` is only ever returned to a query carrying the same `user_id`, so a single space can serve every end user of your app. > [!NOTE] > > `Memory.from_defaults(session_id=...)` is LlamaIndex's own key for its > short-term buffer, and it never reaches Anona. `Memory.aget()`/`Memory.aput()` > call this block with only `messages`, never `session_id`, so Anona-side > isolation comes exclusively from the `user_id` / `agent_id` / `session_id` > fixed on the `MemoryBridge` at construction time. Two different LlamaIndex > sessions sharing one `MemoryBridge` share the same Anona memories, so scope > each end user's `Memory` to its own `MemoryBridge` (or at least its own > `user_id`), not just its own `session_id`. ## Failure behaviour If Anona is unreachable the block yields no memories and the agent continues. Failures are logged, never raised. # Microsoft Agent Framework Source: https://docs.anonalabs.com/memory/integrations/microsoft-agent-framework Persistent memory for Microsoft Agent Framework agents. Agent Framework has a first-class extension point for context: `ContextProvider`. Anona implements it, so memory is injected before every `agent.run()` call and the turn is stored after, with no vector database and no LLM key of your own. ```bash theme={null} pip install 'anona[msagent]' ``` > [!NOTE] > > This installs `agent-framework-core`, the lean package this adapter > actually imports, not the full `agent-framework` meta package, which > additionally pulls in every vendor integration the framework ships (Azure, > Anthropic, Bedrock, Redis, and more). If your project already depends on > `agent-framework` for a real chat client, you already have everything this > adapter needs. ## Setup ```python theme={null} from agent_framework import Agent from anona.integrations import MemoryBridge from anona.integrations.ms_agent import AnonaContextProvider bridge = MemoryBridge(api_key="anona_live_...", space_id="assistant") agent = Agent( client=chat_client, instructions="You are a helpful assistant.", context_providers=[AnonaContextProvider(bridge=bridge)], ) response = await agent.run("What's the weather in Paris?") ``` That's the whole integration. `context_providers=[...]` is all the wiring this needs. `agent.run(...)` calls this adapter automatically, before and after every run, including runs where the model calls a tool one or more times before answering. ## What actually happens on a turn * **Before the model runs**, the adapter searches Anona for the current turn's question and appends the result to the agent's instructions. It is appended, never a replacement: your own `instructions=` text always reaches the model, with Anona's block added after it. * **After the model responds**, the adapter stores the turn: your question and the model's final answer. * Both happen **exactly once per `agent.run()` call**, no matter how many times the model calls a tool along the way. A tool-calling turn still produces exactly one memory search and one stored memory, not one per model step. ## Scoping ```python theme={null} bridge = MemoryBridge( api_key="anona_live_...", space_id="assistant", user_id="customer-42", ) ``` Memories written under one `user_id` are only ever returned to that user, so a single space can back every customer of your app. Set `user_id`/`agent_id`/ `session_id` when you construct `MemoryBridge`, because Agent Framework's own session object doesn't carry a stable per-user identity to forward automatically, so scope here is fixed once, at construction time. > [!NOTE] > > If you don't pass your own `session=` into `agent.run(...)`, Agent > Framework creates a new session (and a new random session ID) on every > call. That's fine: this adapter doesn't depend on session persistence for > memory to work across turns; Anona already keeps continuity through your > `space_id`/`user_id` scope, independent of whatever the framework does > with sessions. ## Turning off writes ```python theme={null} AnonaContextProvider(bridge=bridge, record=False) ``` Keeps recall, skips storing new turns. ## Failure behaviour If Anona is unreachable, the provider contributes no instructions and the agent runs normally. Failures are logged, never raised. # Integrations Source: https://docs.anonalabs.com/memory/integrations/overview Every place Anona plugs into a tool or framework you already use. Anona is reachable five ways: as an installed skill, from an agent client over MCP, from Slack, from GitHub, and from inside an agent framework. All of them use the same spaces, the same API keys and the same scoping rules, so a memory written from one is readable from the others. ## Agent clients > [card] [Agent Skills](/memory/integrations/skills) > > One command, and Claude Code, Codex, Hermes, OpenClaw or Cursor recalls before a task > and records after it, without being asked. > [card] [MCP](/memory/mcp-integration) > > Claude Code, Claude Desktop and Cursor, over local stdio or the remote > endpoint. API key or OAuth. > [card] [Slack](/memory/slack) > > Ask in a channel, answered from that channel's space, and save a thread into > it. Read only per channel by default; read and write when you set it. > [card] [GitHub](/memory/github) > > Capture what a merged pull request decided, and open a weekly pull request > that keeps a section of your `AGENTS.md` current. ## Frameworks Each of these wraps the same `MemoryBridge`: install the extra, pass a space and an optional scope, and the framework's own memory hook does the rest. > [card] [LangChain & LangGraph](/memory/integrations/langchain) > > `pip install 'anona[langchain]'` > [card] [CrewAI](/memory/integrations/crewai) > > `pip install 'anona[crewai]'` > [card] [LlamaIndex](/memory/integrations/llamaindex) > > `pip install 'anona[llamaindex]'` > [card] [Google ADK](/memory/integrations/google-adk) > > `pip install 'anona[adk]'` > [card] [Microsoft Agent Framework](/memory/integrations/microsoft-agent-framework) > > `pip install 'anona[msagent]'` > [card] [Strands](/memory/integrations/strands) > > `pip install 'anona[strands]'` > [card] [Softmax](/memory/integrations/softmax) > > Memory across episodes for a league policy. `pip install anona` > [card] [Runnable examples](/memory/integrations/examples) > > One script per framework, end to end. ## No framework If none of the above fits, the SDKs and the REST API are the direct path. > [card] [Python SDK](/memory/sdk/python) > > `pip install anona` > [card] [TypeScript SDK](/memory/sdk/javascript) > > `npm install @anona-labs/memory` > [card] [API reference](/memory/api-reference/overview) > > Record, retrieve, reason, and the drop-in LLM proxy. # Agent Skills Source: https://docs.anonalabs.com/memory/integrations/skills Install Anona into Claude Code, Codex, Hermes, OpenClaw and other agents so they remember, without being told to. An MCP server gives an agent the *ability* to remember. It does not give it the *habit*. You still end up typing "remember that" and "what did we decide about the deploy" every session. Agent Skills close that gap. A skill is a Markdown file the agent loads when its description matches what you are doing, so recall at the start of a task and recording at the end become part of how the agent works. ## Install ```bash Installer theme={null} curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/skills/install.sh | bash ``` ```bash npx skills theme={null} npx skills add anonalabs/Anona-Memory-SDK ``` ```bash Claude Code plugin theme={null} /plugin marketplace add anonalabs/Anona-Memory-SDK /plugin install anona-memory@anona ``` The installer detects the agents already on your machine, copies the skills in, stores your key at `~/.anona/config.env` with mode 600, and registers the MCP server with Claude Code when the `claude` CLI is on your PATH. It edits no shell profile and writes nothing outside the directories it prints. > [!NOTE] > > Skills load at session start. Restart your agent once after installing, or the > skill is on disk and invisible. ## The three skills > [card] anona-memory > > The agent's own memory. Recalls what is already known before a task, records > what the task settled after it. > [card] anona-memory-sdk > > Building on Anona. The SDK and API surface, scoping rules, error codes and > framework adapters, so generated code compiles the first time. > [card] [anona-cli](#the-anona-cli-skill) > > Driving the `anona` command line from a shell, a script or CI: uploads, > batch ingest, jobs, settings, and what every exit code means. Install only some of them with `--skill`, repeated: ```bash theme={null} install.sh --skill anona-memory ``` ## Options ```bash theme={null} install.sh --app claude --api-key anona_live_... --space my-project install.sh --project # into ./.claude/skills rather than ~/.claude/skills install.sh --dir /path/to/skills install.sh --zip # build uploadable .zip files, see below install.sh --no-mcp install.sh --uninstall install.sh --help ``` | Option | Effect | | - | - | | `--app ` | `claude`, `codex`, `opencode`, `cursor`, `hermes`, `openclaw`, or `all`. Defaults to every one already present. | | `--skill ` | `anona-memory`, `anona-memory-sdk` or `anona-cli`. Repeatable. Defaults to all three. | | `--project` | Installs into `./.claude/skills`, so the skill ships with the repository. | | `--dir ` | Installs into one directory and nowhere else. Use this for a harness the installer does not know. | | `--api-key ` | Verified against the API before it is written. Prompted for if omitted and a terminal is attached. | | `--space ` | The default space the skill records into. | | `--zip [dir]` | Builds one `.zip` per skill instead of installing, for upload to Claude Desktop or claude.ai. Defaults to the current directory. | | `--no-mcp` | Skips MCP registration and prints the config snippets instead. | | `--uninstall` | Removes the skills. Leaves `~/.anona/config.env` alone. | An existing install is moved under `~/.anona/backups/` rather than deleted, and deliberately not left beside the skill: an agent reads every directory under `skills/`, so a backup parked there would load as a second skill claiming the same name. ## How the skill reaches your memory Four ways, tried in order. You only need one. **1. MCP tools** If the host exposes `record`, `retrieve`, `reason` and `list_spaces`, the skill uses them and there is nothing to configure. ```bash theme={null} claude mcp add --transport http anona https://memory.anonalabs.com/mcp \ --header "Authorization: Bearer $ANONA_API_KEY" ``` See [MCP](/memory/mcp-integration) for OAuth, the local stdio server, Hermes and OpenClaw. **2. The anona CLI** Otherwise, if the [`anona` CLI](/memory/cli/overview) is installed (pip `anona` 0.24.0 or later), the skill runs `anona status` once, then `anona retrieve`, `anona record` and `anona reason` with `--json`, and branches on the exit code. The CLI finds its own credential: an API key (`--api-key`, `ANONA_API_KEY`, or `~/.anona/config.env`), the command-line login from `anona login`, or a temporary profile from `anona start`. It never uses the MCP login, so a machine signed in for MCP only exits 4 here and the skill moves on. Before a command it has not used, it reads `anona guide --json` rather than guessing a flag. **3. REST with curl** Otherwise the skill reads `ANONA_API_KEY` from the environment or from `~/.anona/config.env` and calls `https://api.anonalabs.com` directly. This is what makes the skill work in a harness with no MCP support and no Python. **4. Neither** If `anona` or `uvx` is available, the skill may run `anona start` for a temporary profile and tell you so in one line; you keep it with `anona login`. Otherwise it says once that memory is not configured and does the task without it. A memory layer that blocks the work is worse than no memory layer. > [!NOTE] > > MCP OAuth sign-in needs no API key. An organization that has never created one > works from its first tool call. If you see > [`403 no_api_key`](/memory/api-reference/errors#status-and-error-codes), that is a fault > on our side, not a missing step on yours; the one case where it is intended is an > organization that revoked all of its keys. ## The anona-cli skill `anona-memory` uses the CLI only for the memory loop: recall, record, reason. `anona-cli` teaches an agent the rest of the [`anona` CLI](/memory/cli/overview), and fires when there are no MCP tools, in a script or CI job, for a file upload or a batch ingest, for managing spaces, jobs or settings, or on any `anona` exit code. It teaches four habits: * **Look before guessing.** Run `anona guide --json` (or `anona --help`) before composing an unfamiliar command. It is generated from the installed CLI, so it never lists a flag that copy lacks. See [For AI agents](/memory/cli/agents#anona-guide). * **Always `--json`, branch on the exit code.** `3` is an accepted write that stored nothing, not a failure to retry; `8` is a job still running, to poll with `anona jobs get`. * **Never re-send a write after exit `7`** without checking: it may have been stored. The CLI itself never re-sends one. * **`--yes` is the user's decision.** Destructive commands refuse without it, and the agent confirms with the user before adding it. It also carries the credential order, how to set one up without running `anona login` on a person's behalf, and scoping with `--user-id`. The details live in three reference files the agent opens only when needed: commands by task, JSON shapes and exit codes, and credentials. ```bash theme={null} install.sh --skill anona-cli ``` ## Which space The skill picks one per session, in this order: 1. `ANONA_SPACE_ID`, set by `--space` or in your environment. 2. The git repository name, lowercased, when working in a repo. 3. `default`. With more than one space and no `ANONA_SPACE_ID`, the skill lets Anona choose: it sends [`route: "auto"`](/memory/api-reference/retrieve#let-anona-choose-the-space) in place of a space, with the space picked above as `fallback_space_id`, and reads `searched` / `routed_to` to see where the call went. A pinned `ANONA_SPACE_ID` always wins. Routing works over MCP and REST; the `anona` CLI has no route flag, so on the CLI the skill keeps naming the space. A write creates the space. A read does not, so [`404 space_not_found`](/memory/api-reference/errors#status-and-error-codes) on a space nothing has been written to yet means empty, not broken. > [!NOTE] > > Serving several people through one space? The skill passes `user_id` when you > give it one, and the match is strict in both directions: a scoped read never > returns an unscoped memory. See [Scoping](/memory/guides/scoping-multi-tenant). ## What gets recorded Preferences, decisions and their reasons, fixes that cost time, constraints the code does not state, and outcomes. One fact per memory. Not recorded: secrets, anything already in the repository, narration of the session, or anything still a hypothesis. The skill carries the judgment call and worked examples of both sides. ## Saving a session Ask the agent to save, remember or export the session ("save this to Anona", "save the deploy decisions") and it makes a full record pass: every durable fact the conversation settled that is not in Anona yet, one memory each, then one line saying how many it wrote and to which space. It never stores the transcript or a summary as a single memory, because a blob retrieves badly and is mostly narration. In Claude Code, the plugin adds a command for the same thing: ```text theme={null} /anona-memory:save /anona-memory:save the deploy decisions ``` Claude Code names a plugin's commands after the plugin, which is why it is `anona-memory:` and not `anona:`. **An exported conversation** (a Claude Code `/export` file, or a conversation from a claude.ai data export) works the same way: hand it to the agent and ask it to save what is in it. For many conversations, a pass each is slow and costly; the skill suggests writing the facts to a JSON Lines file and loading it with [`anona record --batch`](/memory/cli/overview) instead. No hook is involved. Claude Code fires none on `/export`, and the [hooks](#hooks) already keep a redacted excerpt at every compaction and a work log at every session end, so the command is for the moments you want saved now. ## Compaction Compacting a conversation swaps it for a summary, and whatever the summary drops is gone. The skill treats that as a deadline: what the session settled goes to Anona before it can be lost. * **Before.** Tell the agent you are about to compact and it records what the session settled first, then says how many memories it wrote. * **After.** When a conversation resumes from a summary, the agent records the durable facts in it before going back to the task. It sends facts, one per memory and into the session's usual space, never the transcript or the summary itself. Over REST each one carries `"trigger": "compaction"` in `metadata`; the MCP `record` tool has no metadata field, so there it goes without the marker. The same rules apply: no secrets, nothing already in the repository, nothing still a hypothesis. ## Hooks Skill instructions only run when the agent has a turn, and some of the moments that matter most happen without one: Claude Code compacts without asking, and a session can end mid-thought. A Claude Code [hook](https://docs.claude.com/en/docs/claude-code/hooks) is a command the client runs on a lifecycle event, so it covers exactly those moments. | Event | Direction | What it does | | - | - | - | | `SessionStart` (startup, resume, clear, compact) | Pull | Retrieves what the space knows about the repository, branch and recent commits, and adds it to the agent's context inside an `` fence (a fresh random name each time, so stored text cannot close it), marked as untrusted data rather than instructions. | | `PreCompact` | Push | Sends an excerpt of the conversation about to be summarised away as one async `record`, with `"trigger": "pre-compact"` in `metadata`. Anona's extraction keeps the durable parts. | | `SessionStart` (compact) | Prompt | Tells the agent to record the facts the summary kept, as described under [Compaction](#compaction). Prints only. | | `SessionEnd` | Push | Records a short work log (the task, branch, files changed and commits made) with `"trigger": "session-end"`, so the next session and every other agent on the space know what was being worked on. Skipped when nothing changed. | **What leaves your machine.** The pre-compact excerpt holds the user's prompts and the agent's prose. It never holds tool output, which is where file contents and command output live; pasted blocks and system text are dropped, the excerpt is capped at the last 40,000 characters, and key-shaped strings (API keys from common providers, tokens, bearer headers, webhook URLs, private keys, passwords, credentials in URLs) are replaced with `[redacted]` before it is sent. Redaction is pattern matching, not a guarantee: turn the digest off with `ANONA_HOOK_DIGEST=0` in sessions that handle secrets in plain prose. **What it costs.** Each pull is one retrieve. Each push is one async record, billed like any other write: an excerpt costs more than a one-line fact because extraction reads all of it. **Which space.** The same order as the skill: `ANONA_SPACE_ID`, then the repository name, then `default`. When you can see more than one space and none is pinned, the pull sends `route: "auto"` with that space as the fallback. The pushes do not route by default. They carry raw session text the agent never reviewed, and routing chooses among every space you can see, including one shared with you by another organization or one a Slack channel or GitHub repository reads from. Filing a conversation excerpt there would show it to everyone on that space. So the pushes write only to a space that is your own: the repository's space once it exists, or `ANONA_SPACE_ID`. They route only when you set `ANONA_HOOK_ROUTE=auto`, and a routed write that cannot be placed then goes to that same space. While the repository has no space of your own, the pushes are skipped rather than sent by bare name, since a bare name resolves to a space someone shared with you under that name when you have none. The agent's first `record`, or creating the space in the dashboard, starts them. The agent's own records, made under the skill, still route. Space ids are case-sensitive, so the repository name is matched against your spaces ignoring case: a checkout of `anona-memory` uses an existing `Anona-memory`. The space list is cached for an hour. **No API key needed.** The hooks use the first of these that is available: 1. `ANONA_API_KEY`, in the environment or `~/.anona/config.env`: REST calls. 2. The [`anona` CLI](/memory/cli/overview) on `PATH` with a login from `anona login` (or a temporary profile from `anona start`): the hooks start `anona mcp`, the CLI's stdio bridge, and call its `retrieve` and `record` tools. The bridge holds the login and refreshes it, so it keeps working for as long as the login does (30 days from the last use). This is the same login the `anona` MCP server in Claude Code uses. 3. Neither: every hook is a silent no-op. Over the bridge, a write carries no `metadata` (the MCP `record` tool has no such field), so `trigger` is not stored; the excerpt and the work log still say in their first line where they came from. Force one path with `ANONA_HOOK_TRANSPORT=rest` or `mcp`. **Safety.** The hooks never fail or slow a session. The pull is an async hook: startup does not wait for it, and what it finds arrives with your first prompt. Pushes are handed to a detached process and the hook returns at once. The script is standard-library Python 3 and needs no install. | Install path | Hooks | | - | - | | Claude Code plugin | Installed and enabled automatically (plugin `0.3.0` or later; `0.2.0` had the compaction prompt only). | | Installer, `npx skills` | Not installed. Add them by hand, below. | | Other agents | Not available. The skill instructions still cover recall, recording and a compaction you announce. | ### Adding them by hand Sign in once with `anona login` (or set `ANONA_API_KEY`), download the script, then register it in `~/.claude/settings.json` (every project) or `.claude/settings.json` (one project): ```bash theme={null} mkdir -p ~/.anona/hooks curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/hooks/anona_sync.py \ -o ~/.anona/hooks/anona_sync.py ``` ```json theme={null} { "hooks": { "SessionStart": [ { "matcher": "startup|resume|clear|compact", "hooks": [ { "type": "command", "command": "python3 ~/.anona/hooks/anona_sync.py session-start", "timeout": 30, "async": true } ] }, { "matcher": "compact", "hooks": [ { "type": "command", "command": "echo 'This conversation was just compacted. Follow the anona-memory skill Compaction section: record the durable facts in the summary above before resuming.'" } ] } ], "PreCompact": [ { "hooks": [ { "type": "command", "command": "python3 ~/.anona/hooks/anona_sync.py pre-compact", "timeout": 10 } ] } ], "SessionEnd": [ { "hooks": [ { "type": "command", "command": "python3 ~/.anona/hooks/anona_sync.py session-end" } ] } ] } } ``` If the file already has a `hooks` object, merge these events into it rather than replacing it. Run `/hooks` in Claude Code to confirm they are registered. ### Settings Read from the environment, then from `~/.anona/config.env`: | Variable | Effect | | - | - | | `ANONA_HOOKS=off` | Turns every hook off. | | `ANONA_HOOK_RECALL=0` | No pull at session start. | | `ANONA_HOOK_DIGEST=0` | No pre-compact excerpt. | | `ANONA_HOOK_WORKLOG=0` | No session-end work log. | | `ANONA_HOOK_ROUTE=auto` / `off` | `auto` routes the pushes too (into your own spaces' fallback); `off` routes nothing. Default: the pull routes when you have more than one space, the pushes never do. | | `ANONA_HOOK_TRANSPORT=rest` / `mcp` | Use only the REST API, or only `anona mcp`. Default: REST with a key, else the bridge. | | `ANONA_HOOK_DIGEST_MAX_CHARS` | Size of the excerpt's tail. Default `40000`. | > [!NOTE] > > The `anona` CLI reads only `ANONA_API_KEY`, `ANONA_SPACE_ID` and > `ANONA_BASE_URL` from `config.env`. Put the `ANONA_HOOK*` switches in your shell > environment, or in the `env` object of Claude Code's `settings.json`, which > applies them to hooks too. **Turning them off.** For the plugin, set `ANONA_HOOKS=off`, or disable the plugin under `/plugin`, which also unloads the skills. If you added them by hand, delete the entries. **Checking they work.** Start a session in a repository whose space has memories: the first turn's context should open with "Anona memory recalled at session start". Edit a file and end the session; a minute later, a retrieve for "what was worked on" in that space returns the work log. ## Claude Desktop and claude.ai Those two do not read a skills directory. They take a **ZIP upload** under Customize > Skills, so build one: ```bash theme={null} curl -fsSL https://raw.githubusercontent.com/anonalabs/Anona-Memory-SDK/main/skills/install.sh | bash -s -- --zip ``` That writes `anona-memory.zip`, `anona-memory-sdk.zip` and `anona-cli.zip` into the current directory, with the skill folder as the archive root, which is what the upload expects. Every skill stays inside the caps that surface enforces on `name` and `description`, 64 and 200 characters. `anona-cli` is of no use there, since that surface has no shell. Then give them tools. There is no shell there, so the CLI and `curl` fallbacks cannot run: add Anona as a **custom connector** under Settings > Connectors. ``` https://memory.anonalabs.com/mcp ``` Sign in with OAuth when prompted. See [MCP](/memory/mcp-integration) for the same flow in other clients. > [!NOTE] > > The Claude Code that runs inside Claude Desktop is a different surface, and it > reads `~/.claude/skills/` normally. The installer at the top of this page > already covers it. The ZIP is only for the chat surface. ## Supported agents | Surface | Skills | Tools | | - | - | - | | Claude Code, in a terminal or inside Claude Desktop | Installer, `npx skills`, or the plugin marketplace | MCP server, or the `anona` CLI or `curl` fallback | | Codex, OpenCode, Cursor, Hermes, OpenClaw | Installer or `npx skills` | MCP server, or the `anona` CLI or `curl` fallback | | Claude Desktop chat, claude.ai | `--zip`, then upload under Customize > Skills | Custom connector only | The installer knows Claude Code, Codex, OpenCode, Cursor, Hermes and OpenClaw (into `~/.openclaw/skills`, or `$OPENCLAW_STATE_DIR/skills` if you moved it), and takes `--dir` for anything else. The community `npx skills` CLI covers 75+ harnesses from the same repository: ```bash theme={null} npx skills add anonalabs/Anona-Memory-SDK --agent codex --global ``` ## Source The skills live in [`skills/`](https://github.com/anonalabs/Anona-Memory-SDK/tree/main/skills) in the SDK repository. Read them before you install them; they are Markdown files and one shell script. ## Next steps > [card] [MCP](/memory/mcp-integration) > > The transport the skill prefers, with OAuth and stdio. > [card] [Python SDK](/memory/sdk/python) > > The programmatic client the second skill documents. # Softmax Source: https://docs.anonalabs.com/memory/integrations/softmax Memory across episodes for a Softmax league policy. Softmax runs competitive multi-agent leagues. A policy is a container: the runner hands it a game socket, the episode ends, the container exits, and it is never restarted. So a policy replays the same opponents for weeks and starts every match knowing nothing. Anona carries what it learned into the next episode. ```bash theme={null} pip install anona ``` No extra to install. The half that runs inside the pod uses the standard library alone. ## The shape is different from the other adapters Every other integration here recalls and stores around a model call. A Softmax player cannot do that: **its pod has no outbound network**. DNS does not resolve, and no proxy is offered, so a policy cannot reach Anona while it plays. What it can reach is the in-cluster artifact endpoint. Memory therefore leaves an episode as an artifact, is ingested from outside the cluster, and comes back into the next generation as an environment variable set at upload time. ``` episode N -> artifact -> your machine or CI -> Anona -> episode N+1 SoftmaxMemory SoftmaxSync SoftmaxMemory ``` The two halves never share a process, and only one of them needs a network. ## In your player ```python theme={null} from anona.integrations.softmax import SoftmaxMemory mem = SoftmaxMemory() for note in mem.recall(): # empty on generation 0, which is normal print("known:", note) mem.remember({"opponent": "catlock", "map": "arrows", "lost": True}) mem.remember("Firing with a teammate in the line of fire costs 60 points.") mem.flush() # before the process exits ``` `mem.generation` is 0 on the first run and increments each time recall is carried in. Every method degrades to a no-op rather than raising, the same fail-open policy the framework adapters take. A policy that crashes because memory was unavailable is strictly worse than one that plays without it, and the middle of a league episode is not where you want to find out an environment variable was missing. ## Outside the cluster ```python theme={null} from anona.integrations.softmax import SoftmaxSync with SoftmaxSync( softmax_token="...", # uv run softmax get-token anona_key="anona_live_...", coworld="paintbot", policy="my-policy", ) as sync: sync.ingest_episode(episode_request_id, policy_version_id) sync.wait_for_ingest() blob = sync.compile("how do I beat catlock?") ``` Then upload the next generation with it: ```bash theme={null} uv run coworld upload-policy my-policy:local --name my-policy \ --secret-env "ANONA_MEMORY=$blob" ``` or let the adapter build the argv: ```python theme={null} subprocess.run(["uv", "run", "coworld", "upload-policy", image, "--name", policy, *sync.upload_args("how do I beat catlock?")]) ``` > [!WARNING] > > `record_batch` is queued, and extraction is one model call per chunk. A > `compile()` issued straight after `ingest_episode()` recalls nothing, and the > space looks broken when it is merely still working. Call `wait_for_ingest()` > between them. ## Latency is why this works Recall is read once before the first action and written once before exit, never per tick. Retrieve is about 2.1s at p50, far outside a per-decision window, so the artifact round trip costs a policy nothing it was going to use. What it gives up is adapting to something first seen in the **current** episode. For league play, where the same opponents recur for weeks, that is a small fraction of the value. ## Scoping One space per coworld, with `user_id` set to the policy name. That makes the policy the subject the space accumulates knowledge about, so `get_user_profile(space, policy)` is a standing scouting report rather than something you assemble. Scoped reads are strict, so use the same convention for every write into a space or an earlier generation becomes invisible to later ones. See [Scoping](/memory/guides/scoping-multi-tenant). ## Two platform details this handles for you The artifact URL has two shapes. A hosted episode sets `http://job--game:9091/...`; `coworld run-episode` sets `file:///coworld-artifact/policy_artifact_0.zip`. A client that handles only the hosted form loses every local write silently, which is where you develop. `flush()` handles both. The artifact endpoint takes `PUT`, not `POST`, and answers 201 in about 100ms. > [!NOTE] > > `compile()` trims the blob to 3000 bytes, dropping the weakest match first, > because the result becomes an environment variable at upload time. An > oversized recall is a deploy failure, not merely a large memory. ## What this does not do It moves information between episodes. Whether recall improves play depends on a policy able to act on it, which is your side of the problem, not the memory layer's. # AWS Strands Source: https://docs.anonalabs.com/memory/integrations/strands Long-term memory tools for Strands agents. Anona gives a Strands agent two tools: one to recall, one to remember. No vector database to run, no LLM key of your own, and memory that outlives the process. ```bash theme={null} pip install 'anona[strands]' ``` ## Setup ```python theme={null} from strands import Agent from anona.integrations import MemoryBridge from anona.integrations.strands import anona_tools bridge = MemoryBridge(api_key="anona_live_...", space_id="assistant") agent = Agent( tools=anona_tools(bridge), system_prompt=( "You have long-term memory. Call anona_recall_memory before answering " "questions about the user, and anona_save_memory when you learn " "something worth keeping." ), ) ``` The system prompt matters here: the model decides when to call the tools, so tell it when to. `anona_tools()` returns two tools, `anona_recall_memory` and `anona_save_memory`. The `anona_` prefix is deliberate, not decorative: Strands' tool registry silently keeps whichever tool was registered last when two tools share a name, with no error and nothing logged, so a generic name like `recall_memory` or `memory` risks colliding with, and quietly losing to, another tool attached to the same agent. ## Why tools rather than a `SessionManager` Strands' `SessionManager` saves and restores the exact message list. Anona stores synthesized memories, not transcripts, so a restore would hand back something that is not your conversation. Tools describe what we actually do. Use a `SessionManager` alongside these tools if you also want verbatim session persistence: the two solve different problems and compose fine. > [!NOTE] > > Recent Strands releases also ship a native `MemoryManager`/`MemoryStore` > system with its own automatic search and add tools. It's a real > synthesized-memory interface, closer in shape to what Anona does than > `SessionManager` is, but you still have to supply and run the backing > store yourself. Anona's tools are the managed alternative: nothing to host, > and the search behind `anona_recall_memory` is the same ranked, > deduplicated retrieval the rest of the API uses. ## Scoping ```python theme={null} bridge = MemoryBridge( api_key="anona_live_...", space_id="assistant", user_id="customer-42", ) ``` Memories written under one `user_id` are only ever returned to that user, so a single space can back every end customer of your app. ## Failure behaviour If Anona is unreachable, `anona_recall_memory` reports that it found nothing and the agent continues. `anona_save_memory` always returns `"Saved."`, even when the write failed. The bridge behind every adapter never raises, so there's no failure for the tool to report. Read `"Saved."` as "handed off," not as confirmation the memory is durably stored. # Anona Memory Source: https://docs.anonalabs.com/memory/introduction A managed, multi-tenant memory layer that lets any LLM application record, retrieve, and reason over user context across sessions. ## Set up with your agent Copy the line below and paste it into your coding agent: Claude Code, Codex, OpenCode, Goose, Gemini CLI, Cursor, Windsurf, Cline, or any agent that can run shell commands. Your agent reads the page, installs or updates the `anona` CLI, and connects itself. It starts on a temporary profile, so it works at once with nothing to sign in to; one browser approval later claims everything it stored. **If you have no Anona account, that approval is also how you get one** - it opens the sign-up page, and creating the account creates your organization. ```text Paste this into your coding agent theme={null} Fetch the full text of https://docs.anonalabs.com/memory/agents/onboarding-prompt.md with curl (not a tool that summarizes it) and follow it to set up and use Anona Memory. ``` Want to try it first? You do not need an account: `anona start` gives you a temporary profile (1,000 credits, 72 hours) that you can claim later. See [Temporary profiles](/memory/agents/temp-profiles). > [card] [Set up with your agent](/memory/agents/self-onboarding) > > One paste, one browser approval. What the flow does, and what to do if it stalls. > [card] [Set up by hand](/memory/quickstart) > > Mint an API key and call the REST API or an SDK directly. > [!NOTE] > > The same paste works in every agent above; only the last step differs by client. > Claude Code can also connect through its native OAuth flow, while the others use > `anona login` + `anona mcp`, which needs no API key either. See > [The four ways to connect](/memory/agents/self-onboarding#the-four-ways-to-connect) > for the one table of client against path, and where each client stands. A key > from the dashboard is needed only for the REST API and the SDKs. > [!NOTE] > > The data plane (record, retrieve, reason) is stable and we run it in > production. Breaking changes are announced in advance, and the old shape stays live > through a deprecation window. > Questions: [support@anonalabs.com](mailto:support@anonalabs.com). Large language models are stateless. Every conversation starts from scratch unless your application explicitly retrieves and injects context. Building a reliable memory layer for that means operating vector databases, building retrieval pipelines, managing embedding models, and handling multi-tenancy: substantial infrastructure work that is not core to most AI products. Anona Memory abstracts all of it behind one REST API. ## The three operations | Operation | Endpoint | What it does | | - | - | - | | **Record** | `POST /v1/record` | Stores a memory. Anona extracts the durable facts and indexes them for semantic search. | | **Retrieve** | `POST /v1/retrieve` | Ranks memories by meaning, not keyword overlap, and returns the most relevant context. | | **Reason** | `POST /v1/reason` | Runs an agentic pass across the space and returns a synthesized answer instead of a ranked list. | ```python theme={null} from anona import AnonaClient client = AnonaClient(api_key="anona_live_YOUR_KEY") client.record(space_id="customer-support-bot", content="Alice is a senior engineer at Google") client.retrieve(space_id="customer-support-bot", query="Where does Alice work?") client.reason(space_id="customer-support-bot", query="How has Alice's career progressed?") ``` ## What you get | Capability | Detail | | - | - | | **Semantic search** | Retrieval ranks by meaning, with entity extraction and event-time awareness built in. | | **Multi-tenant isolation** | Each organization's data lives in its own database schema. No shared tables. | | **Managed embeddings** | Anona generates and indexes every embedding. There is no model to choose or host. | | **Documents and RAG** | Upload PDFs, Office files, web pages, text, images, audio and video. Their content becomes searchable memory. | | **Webhooks** | Get notified when memories are stored or consolidated, instead of polling. | | **Usage metering** | Credit quotas and rate limits are enforced per organization and reported through the API. See [plans and billing](/memory/guides/plans-and-billing). | | **REST plus MCP** | Call the API directly, use the Python SDK, or connect an MCP client with no integration code. | ## Who it is for | Audience | Use case | | - | - | | AI product developers | User-facing agents that need to remember context across sessions. | | Platform teams | Internal assistants that accumulate organizational knowledge over time. | | ML engineers | A production memory store without operating a vector database directly. | ## Docs for AI agents Every page here is machine readable. Point a coding agent at one of these instead of pasting screenshots into a prompt. | Source | URL | What it is | | - | - | - | | **Whole docs** | `https://docs.anonalabs.com/llms-full.txt` | Every page concatenated into one Markdown file. | | **Docs index** | `https://docs.anonalabs.com/llms.txt` | A link map of every page, for agents that fetch on demand. | | **One page** | Append `.md` to any docs URL | `https://docs.anonalabs.com/memory/api-reference/retrieve.md` | | **Docs MCP server** | `https://docs.anonalabs.com/mcp` | Search these docs from any MCP client, with no key. | The menu at the top of every page also copies that page as Markdown, or opens it directly in ChatGPT, Claude, Cursor, or VS Code. ```bash theme={null} claude mcp add --transport http anona-docs https://docs.anonalabs.com/mcp ``` > [!NOTE] > > That MCP server serves the **documentation**. The memory API has its own MCP server at > `https://memory.anonalabs.com/mcp`, which needs an API key. See > [MCP integration](/memory/mcp-integration). ## Next steps > [card] [Quickstart](/memory/quickstart) > > Record and retrieve your first memory in five minutes. > [card] [Core concepts](/memory/concepts) > > Spaces, memory types, and tenant isolation. > [card] [Plans and billing](/memory/guides/plans-and-billing) > > What a credit is, what each plan includes, and when credits renew. > [card] [API reference](/memory/api-reference/overview) > > Every endpoint, grouped by data plane and control plane. > [card] [Framework adapters](/memory/integrations/langchain) > > LangChain, CrewAI, LlamaIndex, Google ADK, Agent Framework, Strands. > [card] [SDKs](/memory/sdk/python) > > Official clients for Python and TypeScript. # Subprocessors Source: https://docs.anonalabs.com/memory/legal/subprocessors Third-party services that receive customer content, what reaches them, and on which requests. This page lists the third parties that can receive customer content when you use Anona Memory, and exactly which requests send it. ## TypeSafe AI **Used for:** the decision model behind [automatic space routing](/memory/guides/automatic-routing). **Endpoint:** `https://api.typesafe.ai/v1/systemone` ### When content is sent Only on a request that asks for routing, that is, one carrying `route: "auto"`, and only when that request reaches the decision model stage of the cascade. There are **two such requests, and they send different text:** | Request | What reaches TypeSafe | | - | - | | A **routed write** — `route: "auto"` on `POST /v1/record` or `POST /v1/record/batch`, or on the MCP `record` tool | The content of the memory being filed | | A **routed read** — `route: "auto"` on `POST /v1/retrieve`, or on the MCP `retrieve` tool | The text of the query being answered | Both also send the space catalogue described below. Both ask the same single question of the same endpoint: which of these spaces does this text belong to, or should be searched for it. **A request that names its `space_id` sends TypeSafe nothing.** So does a routed request that is answered earlier by one of your own declared rules, and a routed request made by an organization with no candidate spaces: in all of those cases no request to TypeSafe is made at all. On a routed read, `fallback_space_id` changes only where Anona looks when nothing fits — it does not change whether the call is made or what it carries. No other Anona surface calls TypeSafe. `POST /v1/reason`, the chat proxy, document upload, the memory list, the graph and entity endpoints, and any retrieve that names its `space_id` are all unaffected. ### What is sent | Sent | Detail | | - | - | | On a write, the memory text | The `content` of the memory being routed. On `POST /v1/record/batch`, the content of up to the first 20 items, joined and truncated to 4,000 characters. | | On a read, the query text | The `query` string of the retrieve, as you sent it. It is not truncated and not rewritten. | | The space catalogue | For each candidate space: its id, and the description Anona renders for it (your charter and topics, plus the terms derived from that space's contents). | The space catalogue is built the same way for a read as for a write, with one difference: a read's candidates include spaces shared with you as a **viewer**, because a viewer can read them, while a write's do not. Your organization's default space is never a candidate on either path, and neither is any space whose routing profile is disabled or which has no description yet. Anona does not send API keys, memory ids, member identifiers, `user_id`, `agent_id` or `session_id` values, tags, metadata, or any memory other than the one being routed. On a read in particular, TypeSafe receives the question and never the answer: no stored memory, no search result and no score from our own retrieval is sent, and the only thing that comes back is a per-space suitability figure. One caveat on identifiers: a space shared with you by another organization appears in the catalogue under its qualified `owner-slug:space-name` id, so that organization's slug is part of the space id sent. ### Data handling We have not independently verified TypeSafe's retention period, whether submitted content is used to train their models, or which region processes a request. We are not going to imply otherwise here: treat those three as unknown to us, and read TypeSafe's own terms if they matter to you. This page is updated when we have confirmed answers. What is within our control, and is true today: * **Nothing is sent unless a request asks for it.** Routing is opt in on every individual request, read and write alike. An account that never sends `route: "auto"` never has any content reach TypeSafe, and there is no setting that changes that. * **The smallest useful payload.** One memory's text, or one query's text, and the descriptions of the spaces it could go to or be looked for in. Not your other memories, not your keys, not your end users' identifiers. * **A routed query does not come to rest in our logs either.** Anona records the decision and the query's length, never the query text. * **You can exclude a space** from the catalogue entirely with `auto_route_enabled: false`, so a space holding sensitive material need never be described to them. This applies to both paths: a space that is not a candidate is neither a routing target nor a search target. If your organization requires that every subprocessor's terms be established before use, do not use `route: "auto"` on anything. Naming a `space_id` on every record and every retrieve keeps all routing decisions, all memory content and all query text inside Anona. ### Turning it off Automatic routing is opt in on every single request, read and write alike, so not sending `route` is sufficient: nothing is ever routed on your behalf. To make a space ineligible regardless, set `auto_route_enabled: false` on its [routing settings](/memory/guides/automatic-routing#routing-settings), or delete its routing profile. A space that is not a candidate is never described to TypeSafe. # MCP Integration Source: https://docs.anonalabs.com/memory/mcp-integration Connect Anona Memory to Claude Code, Claude Desktop, and other MCP clients, with no integration code. Anona exposes memory over the Model Context Protocol, so an MCP-capable assistant can record and recall context directly. ## Tools | Tool | What it does | Transport | | - | - | - | | `record` | Stores a memory in a space. Optional `user_id` / `agent_id` / `session_id` scope it. | All | | `retrieve` | Semantically searches memories in a space. Pass the same scope keys to see only that user's memories, and `tag_groups` for a [boolean tag filter](/memory/api-reference/retrieve#compound-tag-filters). With `route: "auto"` it picks the space, and `max_targets` caps how many it may search — when more than one is searched each memory is labelled with the space it came from. | All | | `list_spaces` | Lists the available memory spaces. | All | | `reason` | Synthesizes an answer from accumulated memories. Takes `depth: "thorough"` to read past the space's [memory models](/memory/api-reference/models) — see [Depth](/memory/api-reference/reason#depth). | All | | `get_profile` | Reads a space's standing profile and the [memory models](/memory/api-reference/models) it keeps current. | Remote and `anona mcp` (not `anona-mcp`) | | `get_model` | Reads one memory model, the space's current answer to a standing question, such as how this user prefers to be communicated with. | Remote and `anona mcp` (not `anona-mcp`) | | `get_user_profile` | Reads what a space knows about one end user, by their `user_id`. | Remote and `anona mcp` (not `anona-mcp`) | There are three ways in: the remote `/mcp` endpoint, `anona mcp` (the local server in the [CLI](/memory/cli/overview), in both the Python and npm packages), and `anona-mcp` (the Python SDK's local server, under the Local tab below). `anona mcp` forwards every call to the remote endpoint, so it exposes all seven tools. `anona-mcp` ships the first four. ### Parameters by transport The two servers are not the same surface. The four shared tools take the same *required* argument on both, but the hosted gateway accepts a good deal more besides. `anona-mcp`, the Python SDK's local stdio server, declares only the arguments listed here — one it does not declare is not passed on, so asking for it there is not a slower version of the hosted behaviour, it is nothing at all. | Tool | Argument | `anona-mcp` (stdio) | Remote `/mcp` | | - | - | - | - | | `record` | `content` (required) | Yes | Yes | | | `space_id` | Yes | Yes | | | `user_id`, `agent_id`, `session_id` | Yes | Yes | | | `tags` | No | Yes | | | `timestamp` | No | Yes | | | `route: "auto"`, `fallback_space_id`, `max_targets` | No | Yes | | | `org_id` | No | Yes | | `retrieve` | `query` (required) | Yes | Yes | | | `space_id` | Yes | Yes | | | `limit` | Yes | Yes | | | `user_id`, `agent_id`, `session_id` | Yes | Yes | | | `mode: "fast"` | No | Yes | | | `as_of`, `occurred_after`, `occurred_before` | No | Yes | | | `tag_groups` | No | Yes | | | `route: "auto"`, `fallback_space_id`, `max_targets` | No | Yes | | | `org_id` | No | Yes | | `list_spaces` | `org_id` | No | Yes | | `reason` | `query` (required) | Yes | Yes | | | `space_id` | Yes | Yes | | | `model` | Yes | Yes | | | `user_id`, `agent_id`, `session_id` | Yes | Yes | | | `depth: "thorough"` | No | Yes | | | `org_id` | No | Yes | What the remote-only arguments do: * `tags` and `tag_groups` — visibility-scope tags on a write, and a [boolean tag filter](/memory/api-reference/retrieve#compound-tag-filters) on a read. * `timestamp`, `as_of`, `occurred_after`, `occurred_before` — event time, so a memory can be recorded after the fact and found by when it happened rather than when it was stored. * `mode: "fast"` — skips the reranker for much lower latency, at a real cost in recall quality. * `route: "auto"` — the gateway picks the space, on `record` and on `retrieve` alike. `fallback_space_id` says where to go when nothing fits, and on `retrieve` `max_targets` caps how many spaces may be searched; when more than one is searched, each memory is labelled with the space it came from. Note that a routed request sends text to a [third-party decision model](/memory/legal/subprocessors). * `depth: "thorough"` — makes `reason` read past the space's [memory models](/memory/api-reference/models). See [Depth](/memory/api-reference/reason#depth). **Organization argument.** On the hosted gateway every tool takes an optional `org_id`, and the `/mcp` endpoint accepts the `X-Anona-Org` header. A signed-in (OAuth) client starts in your personal organization; naming a shared organization selects your membership there (`not_an_org_member` if you hold none). An API key belongs to one organization, so naming another is `org_mismatch`. If both header and argument are sent they must agree. See [Choosing an organization](/memory/api-reference/organization#choosing-an-organization-per-request). The `anona-mcp` stdio server has no `org_id` argument and no header: it acts in whichever organization owns the `ANONA_API_KEY` it was given. **Space argument.** On the hosted gateway, on a signed-in account, the tools that act on a space need a `space_id` (or `route: "auto"` on `record` and `retrieve`), because an account may hold several spaces. On a [temporary profile](/memory/agents/temp-profiles) they do not: leave it out and the call uses the profile's only space, `default`. On `anona-mcp` a space must always be named, either as `space_id` or through `ANONA_SPACE_ID`; omitting both is an error that tells you to call `list_spaces`. Per-tool failures, such as a denied space or exhausted credits, come back as structured tool errors rather than broken connections. The MCP session stays alive. ## Choosing a transport | | Local (stdio) | Remote (Streamable HTTP) | | - | - | - | | **Runs** | On your machine, via `uvx` | On the hosted gateway | | **Install** | Python SDK required | Nothing to install | | **Auth** | `ANONA_API_KEY` environment variable | API key header, or OAuth sign-in | | **Best for** | Local development, scripted setups | Everyday use, and teams | > [tab] Remote (recommended) > > Connect directly to the hosted gateway. There is no local process to run. > > ```bash theme={null} > claude mcp add --transport http anona https://memory.anonalabs.com/mcp \ > --header "Authorization: Bearer anona_live_YOUR_KEY" > ``` > > Authentication uses the same API key as the REST API, so there is no separate credential > to manage. > > **Hermes Agent:** add a `mcp_servers` entry to `~/.hermes/config.yaml`: > > ```yaml theme={null} > mcp_servers: > anona: > url: "https://memory.anonalabs.com/mcp" > headers: > Authorization: "Bearer ${env:ANONA_API_KEY}" > ``` > > Hermes also supports `auth: oauth` in place of a static header, the same OAuth flow > described below for Claude Code. > > **OpenClaw:** add the server under `nodeHost.mcp.servers` in `openclaw.json` on the > node machine. See [OpenClaw](#openclaw) below for the full block. That path takes an > API key only, not OAuth. > > **Connecting without a key.** Leave off `--header` and an OAuth-capable client such as > Claude Code, Claude Desktop, or Cursor signs you in instead. > > ```bash theme={null} > claude mcp add --transport http anona-oauth https://memory.anonalabs.com/mcp > ``` > > > [!NOTE] > > > > `add` does not open a browser, and that is expected. It only writes the server to your > > config file. OAuth sign-in does not run until the client actually connects, which > > happens at session start or when you run `/mcp`. After `add` you are not signed in yet. > > > [!NOTE] > > > > **No API key is needed for this path.** An organization that has never created > > one works from its first tool call: the gateway proves your tenancy to the > > memory service itself. This was not true before 2026-10-01, when a keyless > > organization got `403 No active API key for this organization` and had to > > create a key once to work around it. If you still see that error, something is > > wrong on our side rather than with your setup, so please report it instead of > > minting a key. > > > > One deliberate exception: an organization that created keys and then revoked > > all of them is refused, because revoking every key is how you cut off access. > > Create a new key to restore it. > > To trigger sign-in: > > 1. Run `/mcp` in Claude Code, or restart it. This connects to the server for the first time. > 2. Your browser opens the Anona consent screen. Sign in with email and password, or with Google, GitHub, or Microsoft, whichever your account uses. > 3. Approve, and the client stores the token. No API key ever touches its config. > > Manage or revoke authorized apps at any time from the dashboard, under > **MCP → Connected apps**. Tokens refresh automatically, and revoking cuts an app's > access immediately. > > **Connecting with a temporary profile.** A token starting `anona_tmp_`, created by > `anona start`, authenticates `/mcp` the same way an API key does, so no account or > sign-in is needed. On such a profile no tool needs a `space_id`. See > [Temporary profiles](/memory/agents/temp-profiles) for the limits and how to claim one. > > > [!WARNING] > > > > Opening `https://memory.anonalabs.com/mcp` in a browser returns `Method Not Allowed`. > > That endpoint speaks the MCP protocol over POST and is not a web page. Point an MCP > > client at it rather than visiting it. > [tab] Local (stdio) > > Runs on your machine via `uvx`, and ships with the Python SDK. > > ```bash theme={null} > uvx --from 'anona[mcp]' anona-mcp > ``` > > Set the environment before starting your client: > > ```bash theme={null} > export ANONA_API_KEY="anona_live_YOUR_KEY" > export ANONA_SPACE_ID="customer-support-bot" # optional, scopes the server to one space > ``` > > Or install the extra explicitly: > > ```bash theme={null} > pip install "anona[mcp]" > ``` > > **Register with Claude Code:** > > ```bash theme={null} > claude mcp add --transport stdio anona -- uvx --from 'anona[mcp]' anona-mcp > ``` > > Set `ANONA_API_KEY`, and optionally `ANONA_SPACE_ID`, in your shell environment before > starting Claude Code. ## Things worth knowing | Behavior | Detail | | - | - | | **Tools only load at session start.** | Adding or changing an MCP server mid-session will not expose its tools until you restart the client. | | **`ANONA_SPACE_ID` is the stdio server's default space.** | Set it and the local tools use it whenever a call leaves `space_id` out; leave it unset and such a call fails, telling you to run `list_spaces`. It is not read by the remote server. A temporary profile on the remote server is the one case where no space need ever be named: it has exactly one. | | **Same quota, same billing.** | MCP calls consume credits and count against your usage exactly as REST calls do. | ## Example session Once registered, talk naturally: > Remember that the user prefers dark mode and uses VS Code. > What do we know about this user's editor preferences? Claude Code calls `record` and `retrieve` behind the scenes. > [!NOTE] > > Serving several people through one MCP connection? Pass `user_id` on `record` and > `retrieve` so each person's memories stay separate inside the shared space. See > [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). ## Client setup guides ### Claude Code 1. Get a key from the dashboard, under **API keys**. 2. Register the server: ```bash theme={null} claude mcp add --transport http anona https://memory.anonalabs.com/mcp \ --header "Authorization: Bearer anona_live_YOUR_KEY" ``` 3. Run `/mcp` in Claude Code, or restart it, to connect. 4. Try it: "Remember that I prefer dark mode and use VS Code." ### Hermes Agent 1. Get a key from the dashboard, under **API keys**. 2. Add to `~/.hermes/config.yaml`: ```yaml theme={null} mcp_servers: anona: url: "https://memory.anonalabs.com/mcp" headers: Authorization: "Bearer ${env:ANONA_API_KEY}" ``` 3. Set `ANONA_API_KEY` in `~/.hermes/.env` or your shell environment. 4. If Hermes is already running, it auto-reloads the config within 30s, or run `/reload-mcp` in-session. Otherwise the server picks it up on next start. 5. Try it: "Remember that I prefer dark mode and use VS Code." ### OpenClaw 1. Get a key from the dashboard, under **API keys**. 2. On the node machine (not the OpenClaw gateway), add to `openclaw.json`. `openclaw config file` prints its path. ```json5 theme={null} { nodeHost: { mcp: { servers: { anona: { url: "https://memory.anonalabs.com/mcp", transport: "streamable-http", headers: { Authorization: "Bearer ${ANONA_API_KEY}" }, }, }, }, }, } ``` 3. Set `ANONA_API_KEY` in the node's environment, then `openclaw node restart`. The node host does not watch this file. 4. This path takes an API key only, not OAuth. 5. Try it: "Remember that I prefer dark mode and use VS Code." Without the MCP server, the [Agent Skill](/memory/integrations/skills) still works in OpenClaw: it calls the API with `curl` and the key in `~/.anona/config.env`. ## Next steps > [card] [Agent Skills](/memory/integrations/skills) > > Make the client use these tools by itself, instead of waiting to be told. > [card] [Python SDK](/memory/sdk/python) > > The programmatic client library. > [card] [API overview](/memory/api-reference/overview) > > The full REST surface. # Quickstart Source: https://docs.anonalabs.com/memory/quickstart Change your base URL. Your agent remembers. Anona Memory is a hosted service. There is no infrastructure to run, no schema to design, and no vector database to operate. The fastest way in is the **memory proxy**: point an OpenAI-compatible client at Anona instead of the model provider. Every request recalls what matters from the space and every reply is stored back, so the conversation carries across sessions, processes and machines, with no `record` or `retrieve` calls in your code. > [!TIP] > > **No account yet?** You can try Anona before signing up. `anona start` (from > `pip install -U anona` or `npm i -g @anona-labs/memory`) creates a temporary > profile with 1,000 credits for 72 hours, and its `anona_tmp_` token is accepted > by the REST API, the MCP endpoint and this drop-in proxy, anywhere you would > put an API key. Claim it later with `anona login` to keep what it stored. > See [Temporary profiles](/memory/agents/temp-profiles). ```python Python theme={null} from openai import OpenAI client = OpenAI( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com/v1", # ← the only change default_headers={"X-Anona-Space-Id": "my-app"}, ) client.chat.completions.create( model="gpt-oss", messages=[{"role": "user", "content": "I'm on the Scale plan, 4 seats."}], ) # New process, new day, no history passed in: answer = client.chat.completions.create( model="gpt-oss", messages=[{"role": "user", "content": "What plan am I on?"}], ) print(answer.choices[0].message.content) # → "You're on the Scale plan, with 4 seats." ``` ```typescript TypeScript theme={null} import OpenAI from "openai"; const client = new OpenAI({ apiKey: process.env.ANONA_API_KEY, baseURL: "https://api.anonalabs.com/v1", // ← the only change defaultHeaders: { "X-Anona-Space-Id": "my-app" }, }); await client.chat.completions.create({ model: "gpt-oss", messages: [{ role: "user", content: "I'm on the Scale plan, 4 seats." }], }); const answer = await client.chat.completions.create({ model: "gpt-oss", messages: [{ role: "user", content: "What plan am I on?" }], }); console.log(answer.choices[0].message.content); ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "space_id": "my-app", "messages": [{"role": "user", "content": "What plan am I on?"}] }' ``` That is the whole integration. You never supply an LLM key. Anona runs the model and meters the turn in Anona credits. > [!NOTE] > > Already using the **Responses API** or the **Anthropic SDK**? Both work the same way: > `/v1/responses` and `/v1/messages` speak those shapes with the identical memory > wrapper. Streaming is supported on all three. ## Get set up **1. Create an account and a key** Sign up at the [Anona Memory dashboard](https://memory.anonalabs.com) with your organization name, email, and a password of 8 or more characters. Verify your email before your first sign-in. Signing up does not create a key: go to **API keys → New key**, pick an environment, and copy it. The full key is shown once, at the moment you create it, and never again. | Key prefix | Environment | | - | - | | `anona_live_...` | Production | | `anona_test_...` | Development and testing | **2. Create a memory space** Every memory lives inside a space: one per app, per tenant, or per end user, whichever boundary you want memories isolated along. Create one from **Spaces → New space** in the dashboard, or over the API: ```bash cURL theme={null} curl -X POST https://api.anonalabs.com/v1/spaces/ \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "customer-support-bot"}' ``` ```python Python theme={null} from anona import AnonaClient client = AnonaClient(api_key="anona_live_YOUR_KEY") client.create_space("customer-support-bot") ``` ```typescript TypeScript theme={null} import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: "anona_live_YOUR_KEY" }); await anona.createSpace({ name: "customer-support-bot" }); ``` ```json theme={null} { "space_id": "customer-support-bot", "name": "customer-support-bot", "created_at": "2026-07-14T12:00:00Z" } ``` The name you chose **is** the `space_id`. Nothing is generated for you. Pass it as `X-Anona-Space-Id` (or `space_id` in the body) on every proxied call. **3. Tune what gets injected (optional)** Each knob has a header form, so a stock client sets them once in `default_headers` and never touches the request body. | Header | Body field | Default | Effect | | - | - | - | - | | `X-Anona-Space-Id` | `space_id` | `default` | Which space to recall from and store into | | `X-Anona-Memory-Limit` | `memory_limit` | `5` | How many memories to inject | | `X-Anona-Memory-Tokens` | `memory_token_budget` | none | Approximate token cap on the injected block | | `X-Anona-Auto-Record` | `auto_record` | `true` | Store this turn back into the space | | `X-Anona-Memory` | `memory` | `true` | `false` skips recall entirely for this call | > [!NOTE] > > Set `X-Anona-Space-Id` even for a quick test. Leave it out and the turn is > recalled from and stored into a space literally named `default`, created on the > first proxied call, so a space you never created shows up in your space list > and counts against your plan's space limit. Serving many end users from one space? Add `X-Anona-User-Id` and each user gets their own memory, with no space per user and no leakage between them: ```python Python theme={null} client = OpenAI( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com/v1", default_headers={ "X-Anona-Space-Id": "my-app", "X-Anona-User-Id": current_user.id, # ← per end user }, ) ``` ```typescript TypeScript theme={null} const client = new OpenAI({ apiKey: "anona_live_YOUR_KEY", baseURL: "https://api.anonalabs.com/v1", defaultHeaders: { "X-Anona-Space-Id": "my-app", "X-Anona-User-Id": currentUser.id, // ← per end user }, }); ``` ```bash cURL theme={null} curl https://api.anonalabs.com/v1/chat/completions \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "X-Anona-Space-Id: my-app" \ -H "X-Anona-User-Id: user_123" \ -d '{"messages": [{"role": "user", "content": "What plan am I on?"}]}' ``` See [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). The same knobs can be stored on the space itself (`PUT /v1/spaces/{space_id}/chat-settings`, or the **Chat defaults** tab in the dashboard), so they apply to every call without touching your code. A request that sets a field still wins over the stored default. See [Chat](/memory/api-reference/chat) for the full endpoint reference. ## Or call memory directly The proxy is memory with the wiring done for you. When you want the memory layer on its own (ingesting documents, storing facts your model never said, searching without generating), use the memory API directly. ```bash cURL theme={null} # Store a memory curl -X POST https://api.anonalabs.com/v1/record \ -H "Authorization: Bearer anona_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"space_id": "customer-support-bot", "content": "Alice is a senior engineer at Google"}' # Search memories 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?"}' ``` ```python Python SDK theme={null} # pip install anona from anona import AnonaClient client = AnonaClient(api_key="anona_live_YOUR_KEY") client.record(space_id="customer-support-bot", content="Alice is a senior engineer at Google") results = client.retrieve(space_id="customer-support-bot", query="Where does Alice work?") print(results) ``` ```typescript TypeScript SDK theme={null} // npm install @anona-labs/memory import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: process.env.ANONA_API_KEY! }); await anona.record({ spaceId: "customer-support-bot", content: "Alice is a senior engineer at Google", }); const results = await anona.retrieve({ spaceId: "customer-support-bot", query: "Where does Alice work?", }); console.log(results); ``` > [!NOTE] > > The `record` call above is synchronous, and synchronous writes are slow: the text is > analyzed by a language model and its facts extracted before the call returns. Measured > against production, that is **about 3-12 seconds** for a short memory, and longer > for long content, against **1-3 seconds** for the same write queued. > > It is written that way here only so the `retrieve` on the next line finds the memory. > **In real code, pass `"async": true`** (`background=True` in the Python SDK, > `background: true` in TypeScript) and poll the returned job: same work, same cost, a > fraction of the wait. See > [Memories](/memory/api-reference/memories#store-a-memory-asynchronously). ## Where to go next > [card] [Chat](/memory/api-reference/chat) > > Streaming, tunables, and the full proxy reference. > [card] [Core concepts](/memory/concepts) > > Spaces, memory types, and tenant isolation. > [card] [Retrieve API](/memory/api-reference/retrieve) > > Filters, latency modes, and relevance scoring. > [card] [Framework adapters](/memory/integrations/langchain) > > Already on LangChain, CrewAI, LlamaIndex, ADK, Agent Framework or Strands? > Wire memory in without calling record and retrieve yourself. > [card] [Examples](/memory/integrations/examples) > > A complete runnable script for every adapter. > [card] [MCP](/memory/mcp-integration) > > Connect Claude Code and other MCP clients. # TypeScript SDK Source: https://docs.anonalabs.com/memory/sdk/javascript The official TypeScript client for Anona Memory, with adapters for the Vercel AI SDK and the OpenAI Agents SDK. ```bash theme={null} npm install @anona-labs/memory ``` Zero runtime dependencies. Runs on Node 18+, Bun, Deno, and Cloudflare Workers. ```typescript theme={null} import { Anona } from "@anona-labs/memory"; const anona = new Anona({ apiKey: process.env.ANONA_API_KEY! }); await anona.record({ spaceId: "support", content: "Alice is a senior engineer at Google" }); const results = await anona.retrieve({ spaceId: "support", query: "Where does Alice work?" }); console.log(results[0]?.content); ``` Every method returns a promise. There is no separate async variant. ## Client initialization ```typescript theme={null} const anona = new Anona({ apiKey: "anona_live_YOUR_KEY", baseUrl: "https://api.anonalabs.com", // optional, this is the default timeoutMs: 30_000, // optional, per attempt — this is the default maxRetries: 2, // optional — this is the default; 0 turns retrying off }); ``` `timeoutMs` is per attempt, so with `maxRetries: 2` a call can take up to three timeouts plus its backoffs. `reason` and `askAboutUser` override it to 90 seconds of their own, because a thorough synthesis legitimately runs that long. The client does not read an environment variable automatically, so pass `apiKey` explicitly. Requests carry no state between calls, so a single client can be shared across your whole application. `fetch` is injectable, which is useful for tests or a custom transport: ```typescript theme={null} const anona = new Anona({ apiKey: "...", fetch: myInstrumentedFetch }); ``` ## Methods | Method | Returns | Reference | | - | - | - | | `record({ spaceId \| route, content, context?, timestamp?, metadata?, tags?, background?, userId?, agentId?, sessionId?, fallbackSpaceId?, maxTargets? })` | `RecordResult` | [Memories](/memory/api-reference/memories) | | `recordBatch({ spaceId \| route, items, userId?, agentId?, sessionId?, fallbackSpaceId?, maxTargets? })` | `BatchRecordResult` | [Memories](/memory/api-reference/memories) | | `retrieve({ spaceId \| route, query, limit?, topK?, mode?, memoryType?, userId?, agentId?, sessionId?, memberId?, tags?, tagsMatch?, tagGroups?, preferObservations?, minScore?, asOf?, queryTimestamp?, occurredAfter?, occurredBefore?, fallbackSpaceId?, maxTargets? })` | `SearchResult[]` | [Retrieve](/memory/api-reference/retrieve) | | `getContext({ spaceId, query, maxTokens?, ... })` | `string` | [Prompt-ready context](/memory/api-reference/retrieve#prompt-ready-context) | | `retrieveReceipt({ spaceId, query, receiptDetail?, ... })` | `RetrieveWithReceipt` | [Context receipts](/memory/api-reference/context-receipts) | | `getReceipt(requestId)` | `ContextReceipt` | [Context receipts](/memory/api-reference/context-receipts) | | `explain(requestId, memoryId)` | `MemoryExplanation` | [Context receipts](/memory/api-reference/context-receipts) | | `reason({ spaceId, query, userId?, agentId?, sessionId?, model?, depth?, tagGroups? })` | `InsightsResult` | [Reason](/memory/api-reference/reason) | | `getJob({ spaceId, jobId })` | `JobStatus` | [Job status](/memory/api-reference/memories#job-status) | | `listSpaces()` | `Space[]` | [Spaces](/memory/api-reference/spaces) | | `getSpace(spaceId)` | `Space` | [Spaces](/memory/api-reference/spaces) | | `createSpace({ name, description?, spaceType? })` · `listSpaceTypes()` | `Space` / `SpaceType[]` | [Spaces](/memory/api-reference/spaces) | | `deleteSpace(spaceId)` | `void` | [Spaces](/memory/api-reference/spaces) | | `listMemories({ spaceId, limit?, offset?, query?, memoryType?, state?, includeSources?, userId?, agentId?, sessionId?, memberId? })` | `MemoryListPage` | [Memories](/memory/api-reference/memories) | | `getMemoryHistory({ spaceId, memoryId })` | `MemoryHistory` | [Memories](/memory/api-reference/memories) | | `updateMemory({ spaceId, memoryId, ... })` | `MemoryItem` | [Memories](/memory/api-reference/memories) | | `deleteMemory({ spaceId, memoryId })` | `void` | [Memories](/memory/api-reference/memories) | | `uploadFiles({ spaceId, files, tags?, strategy?, userId?, agentId?, sessionId? })` | `UploadResult` | [Documents](/memory/api-reference/documents) | | `listDocuments({ spaceId, limit?, offset? })` | `DocumentListPage` | [Documents](/memory/api-reference/documents) | | `getDocument({ spaceId, documentId })` | `DocumentItem` | [Documents](/memory/api-reference/documents) | | `deleteDocument({ spaceId, documentId })` | `void` | [Documents](/memory/api-reference/documents) | | `getGraph({ spaceId, limit?, minCount? })` | `Graph` | [Graph](/memory/api-reference/graph) | | `listEntities({ spaceId, limit?, offset? })` | `EntityListPage` | [Graph](/memory/api-reference/graph) | | `getEntity({ spaceId, entityId })` | `EntityDetail` | [Graph](/memory/api-reference/graph) | | `getUserProfile({ spaceId, userId, limit?, offset?, memoryType?, format?, contextMaxTokens? })` | `UserProfile` | [User profiles](/memory/api-reference/user-profiles) | | `askAboutUser({ spaceId, userId, query, model? })` | `AskUserResult` | [User profiles](/memory/api-reference/user-profiles) | | `getUsage()` | `UsageSnapshot` | [Usage](/memory/api-reference/usage) | | `cancelJob({ spaceId, jobId })` | `JobCancelResult` | [Job status](/memory/api-reference/memories#job-status) | | `getSpaceProfile(spaceId)` · `setSpaceProfile({ spaceId, mission, disposition })` — both keys required, either may be `null`, since the PUT is a full replace · `deleteSpaceProfile(spaceId)` | `SpaceProfile` / `void` | [Memory models](/memory/api-reference/models) | | `listMemoryModels({ spaceId, limit?, offset?, tags? })` | `MemoryModelList` | [Memory models](/memory/api-reference/models) | | `createMemoryModel({ spaceId, name, query, modelId?, tags?, maxTokens?, trigger? })` | `MemoryModel` — but see the note below: the API answers `202` with a job | [Memory models](/memory/api-reference/models) | | `getMemoryModel({ spaceId, modelId })` · `updateMemoryModel({ spaceId, modelId, ... })` · `deleteMemoryModel({ spaceId, modelId })` | `MemoryModel` / `void` | [Memory models](/memory/api-reference/models) | | `refreshMemoryModel({ spaceId, modelId })` · `clearMemoryModel({ spaceId, modelId })` · `getMemoryModelHistory({ spaceId, modelId })` | `MemoryModelJob` / `MemoryModel` / `MemoryModelHistory` | [Memory models](/memory/api-reference/models) | | `listCatalogModels()` | `CatalogModelList` | [Model selection](/memory/api-reference/model-selection) | | `getReasonSettings(spaceId)` · `setReasonSettings({ spaceId, model, depth })` · `resetReasonSettings(spaceId)` | `ReasonSettings` / `void` | [Model selection](/memory/api-reference/model-selection) | | `listRules(spaceId)` · `createRule({ spaceId, name, content, priority?, isActive?, tags? })` · `updateRule({ spaceId, ruleId, ... })` · `deleteRule({ spaceId, ruleId })` | `Rule[]` / `Rule` / `void` | [Rules](/memory/api-reference/spaces#rules) | | `getChatSettings(spaceId)` · `setChatSettings({ spaceId, memoryLimit?, memoryTokenBudget?, autoRecord?, memory? })` · `resetChatSettings(spaceId)` | `ChatSettings` / `void` | [Chat defaults](/memory/api-reference/chat#space-defaults) | | `getExtractionSettings(spaceId)` · `setExtractionSettings({ spaceId, mode?, guidance?, customPrompt?, labels?, freeFormEntities? })` · `resetExtractionSettings(spaceId)` | `ExtractionSettings` / `void` | [Extraction settings](/memory/api-reference/extraction-settings) | | `createWebhook({ spaceId, url, eventTypes?, enabled? })` · `listWebhooks(spaceId)` · `updateWebhook({ spaceId, webhookId, ... })` · `deleteWebhook({ spaceId, webhookId })` · `listWebhookDeliveries({ spaceId, webhookId, limit?, cursor? })` | `Webhook` / `WebhookDeliveryPage` / `void` | [Webhooks](/memory/api-reference/webhooks) | Response objects keep the API's field names, so they are `snake_case`: `memory_id`, `relevance_score`, `space_id`, `job_ids`. `record`, `recordBatch` and `retrieve` take **either** `spaceId` **or** `route: "auto"`, never both and never neither — the types make the wrong pairing a compile error rather than a `422 route_conflict` found at runtime. `fallbackSpaceId` and `maxTargets` apply only to the routed form. > [!NOTE] > > Two places where the published types are currently narrower than the API, both > tracked in ANO-443: > > * **`CatalogModel` is missing three fields that `GET /v1/models` always > returns**: `default` (whether this is the model a caller gets by omitting the > field), `provider` (which host serves it) and `supports_memory_operations` > (`false` for a model the chat proxy serves but `reason` and the memory models > cannot). They are present in the response, so reading them works — the type > just will not admit they exist. Cast, or read > [the catalog fields](/memory/api-reference/model-selection). > * **`createMemoryModel` is typed `Promise`, and the API answers > `202` with a job** — `{ model_id, job_id, status }`, no `content`. Poll > `getJob` and then `getMemoryModel`. `refreshMemoryModel` is typed correctly as > a job. ### Parameters the REST API has and this client does not | Endpoint | REST-only fields | | - | - | | `POST /v1/retrieve` | `expand_query`, `include_source`, `source_max_tokens`, and the connector provenance filters `source` / `repo` / `pull_request` / `author` / `merged_by`. (`format`, `context_max_tokens` and `block_order` are not missing — `getContext` is the method that sends them, and `retrieveReceipt` sends `receipt` / `receipt_detail`.) | | `GET /v1/spaces/{space_id}/memories` | `tag_groups`, and the same provenance filters. | | `GET /v1/spaces/{space_id}/documents` | `q`, `source`. | | `POST /v1/spaces/{space_id}/users/{user_id}/ask` | `depth`. `askAboutUser` takes `model` only, so the depth falls through to the space's reason default. | | Every endpoint | `org_id`. An API key already belongs to one organization, so there is nothing to choose — the field exists so a caller can *assert* which organization it meant and get `400 org_mismatch` rather than being served the key's. | Also note `topK` is an **alias for `limit`**, not a candidate bound: it is sent in place of `limit` and wins when both are given. ### record ```typescript theme={null} const memory = await anona.record({ spaceId: "support", content: "Alice works at Google and specializes in distributed systems", userId: "alice_123", tags: ["work"], metadata: { source: "crm" }, }); console.log(memory.memory_id); ``` Pass `background: true` to queue the write instead of waiting on extraction. The call then returns `job_id` with `status: "processing"` and `memory_id: null`, so poll it with `getJob`. > [!WARNING] > > **`background: true` is several times faster, and should be your default.** A blocking > write waits for a language model to extract facts from the text before it returns. > Measured against production, about 3-12 seconds for a short memory (longer content takes > longer) versus 1-3 seconds queued. Use a blocking > write only when you search for the memory immediately afterwards. > > ```typescript theme={null} > const job = await anona.record({ > spaceId: "customer-support-bot", > content: "Alice is a senior engineer at Google", > background: true, > }); > await anona.getJob({ spaceId: "customer-support-bot", jobId: job.job_id }); > ``` `userId`, `agentId` and `sessionId` scope the memory inside the space, so one space can serve many end users without their memories mixing: only a `retrieve` carrying the same scope sees it. See [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). > [!WARNING] > > A user id passed in `metadata` is stored but isolates nothing: `metadata` is returned > with results, never filtered on. Use `userId`. ```typescript theme={null} // Only Alice's memories const results = await anona.retrieve({ spaceId: "support", query: "Where does Alice work?", userId: "alice_123", }); ``` ### recordBatch Up to 100 memories in one call, always queued. ```typescript theme={null} const batch = await anona.recordBatch({ spaceId: "support", items: [{ content: "Bob works in Berlin" }, { content: "Carol joined in March" }], }); console.log(batch.job_id, batch.accepted); ``` The 1-100 range is checked locally, so an oversized batch fails before the round trip. ### retrieve ```typescript theme={null} const results = await anona.retrieve({ spaceId: "support", query: "Where does Alice work?", limit: 10, }); for (const r of results) { console.log(r.content, r.relevance_score); // A `note` names the facts it was synthesized from. Empty on a `fact`, // which is the evidence itself. for (const sourceId of r.source_ids) { console.log(" built from", sourceId); } } ``` Returns the results array directly, and an empty array when nothing matches. `source_ids` is always present, so it is safe to iterate without a guard. Because a note is derived, its own `metadata` is empty and its `document_id` is `null`: those belong to the facts behind it. Pass `preferObservations: false` to get those facts back alongside their note, each carrying the metadata you wrote it with. > [!NOTE] > > `relevance_score` is a composite of four factors, not a normalized probability, so it can > exceed 1.0. It is `null` for memories returned outside a ranked recall. `mode: "fast"` skips the neural rerank pass for lower latency, at some cost to relevance quality. See [Latency modes](/memory/api-reference/retrieve#latency-modes). Retrieval collapses a consolidated memory and the raw facts behind it into one result by default. Pass `preferObservations: false` to see both layers. Other filters: `memoryType`, `tags`, `tagsMatch`, `tagGroups`, `minScore`, `asOf`. `tagGroups` is a boolean tag filter, for what a flat list under one `tagsMatch` cannot say: an OR of alternatives, or an exclusion. It is typed as a `TagGroup` union, so a malformed filter is a compile error rather than a 422. ```ts theme={null} const results = await anona.retrieve({ spaceId: "support", query: "what is left to do", tagGroups: [ { or: [{ tags: ["project:alpha"] }, { tags: ["project:beta"] }] }, { not: { tags: ["status:archived"] } }, ], }); ``` See [Compound tag filters](/memory/api-reference/retrieve#compound-tag-filters). `asOf` restricts the search to memories **recorded** at or before an ISO 8601 instant, so you get what the space knew then. `queryTimestamp` is not a filter. It moves the "now" that recency scoring and relative dates in the query are measured against, and never removes a result. See [Searching over time](/memory/api-reference/retrieve#searching-over-time). ### retrieveReceipt, getReceipt and explain `retrieve` returns a plain array, which has nowhere to carry the id of the receipt for that search. `retrieveReceipt` is the same search and returns both. ```typescript theme={null} const res = await anona.retrieveReceipt({ spaceId: "support", query: "who is on call for billing?", receiptDetail: "full", }); console.log(res.memories.map((m) => m.content)); const receipt = await anona.getReceipt(res.receipt_id!); for (const cut of receipt.excluded) { console.log(cut.reason, cut.detail); } ``` `receiptDetail: "full"` also asks the search to account for its own cuts, so the receipt can explain memories that were ranked and dropped before `limit` or a relevance floor ever applied. It can cost latency on the first call for a given query, so leave it `"basic"` for production traffic. `receipt_id` is `null` only when the receipt could not be stored. A receipt is a debugging aid and never load-bearing, so the search itself still succeeded and `memories` is complete either way. `explain` answers the question the receipt cannot: why one specific memory is not in your results. ```typescript theme={null} const verdict = await anona.explain(res.receipt_id!, "mem_abc123"); verdict.outcome; // "included" | "excluded" | "not_retrieved" verdict.stage; // "retrieval" | "fusion" | "rerank", when excluded verdict.arms; // { semantic: 812, keyword: null, ... } ``` `not_retrieved` is the one to act on: nothing matched the memory at all, so a bigger `limit` or a lower floor will not bring it back, and the query wording or the scope you searched is what to change. `arms` shows which kind of matching found it, and how well: found by `keyword` but not `semantic` usually means your query shares words with the memory but not meaning. Every search builds a receipt whether or not you asked for one, so `getReceipt` and `explain` also work on a request id from your own logs. See [Context receipts](/memory/api-reference/context-receipts). ### reason ```typescript theme={null} const answer = await anona.reason({ spaceId: "support", query: "What have we learned about Alice's career?", }); console.log(answer.insights); ``` Returns the whole envelope, so `usage` is available alongside `insights`. ### getUserProfile and askAboutUser Everything a space has learned about one end user, and a question answered from that user's memories only. Both need `userId` to be the same value your writes are scoped with. ```typescript theme={null} const profile = await anona.getUserProfile({ spaceId: "support", userId: "alice_123" }); console.log(profile.memory_count, profile.last_active); // Prompt-ready instead of a list const { context } = await anona.getUserProfile({ spaceId: "support", userId: "alice_123", format: "block", contextMaxTokens: 500, }); const answer = await anona.askAboutUser({ spaceId: "support", userId: "alice_123", query: "How does she prefer to be contacted?", }); console.log(answer.insights, answer.model); ``` A `userId` nobody has recorded under is not an error: it comes back with `memory_count: 0` and an empty `memories`, because a user is a scope tag created by the first write naming it rather than a resource you register. An unknown *space* is still a 404. `memory_count` can also go **down** between two reads, since consolidation folds several raw facts into one note and the default view counts the note. Read it as how many distinct things are currently known about this user, not as an ingestion counter. See [User profiles](/memory/api-reference/user-profiles). ### updateMemory Correct a memory, or retire it without losing it. ```typescript theme={null} // Fix the content await anona.updateMemory({ spaceId: "support", memoryId: "mem_1", text: "Alice moved to Berlin in March", }); // Retire it. Reversible, unlike deleteMemory await anona.updateMemory({ spaceId: "support", memoryId: "mem_1", state: "invalidated", reason: "superseded by a later correction", }); ``` `state: "invalidated"` drops the memory out of retrieve, consolidation and reasoning while keeping it for audit; `"active"` puts it back. That is a supersession, not a delete, so prefer it over `deleteMemory`, which is permanent. Editable fields: `text`, `context`, `occurredStart`, `occurredEnd`, `memoryType`, `entities`, `state`, `reason`. At least one is required. A call with nothing to change fails locally, before any request. `reason` is kept on the memory's history so an audit shows why it changed, not merely that it did. > [!NOTE] > > Memories the system synthesised from your raw facts cannot be edited. They are > derived, so the API rejects the attempt rather than letting a synthesis drift > away from the evidence under it. ### getMemoryHistory ```typescript theme={null} const { history } = await anona.getMemoryHistory({ spaceId: "support", memoryId: "mem_1" }); for (const entry of history) { console.log(entry.changed_at, entry.previous_content); } ``` Returns `{ memory_id, history }`, where each entry carries the previous content and when it changed. > [!NOTE] > > Entries come from two places: Anona's own consolidation rewriting a memory, and > your edits with `updateMemory`. Editing a memory adds one entry carrying its > `previous_content`, `changed_at` and the `reason` you passed, so an edit can be > audited straight away. An empty `history` means the memory has never changed. ### uploadFiles Uploads files into a space so retrieval can draw on their content. Ingestion is asynchronous and returns `job_ids`, so poll each with `getJob`. ```typescript theme={null} import { readFile } from "node:fs/promises"; const upload = await anona.uploadFiles({ spaceId: "support", files: [{ data: await readFile("handbook.pdf"), filename: "handbook.pdf" }], tags: ["hr", "policy"], }); console.log(upload.job_ids); ``` `data` accepts a `Blob`, `File`, `ArrayBuffer`, or `Uint8Array`. A Node `Buffer` works unchanged. There is no filesystem-path overload, which is what keeps the package usable on edge runtimes. All three limits are enforced before anything is sent, so an oversized upload fails instantly rather than after transferring the body: | Limit | Value | | - | - | | Files per request | 20 | | Size per file | 25 MB | | Total per request | 50 MB | Supported formats are PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, HTML, TXT, Markdown, CSV, JPEG/PNG images, MP3/WAV audio and MP4/MOV/WEBM/MKV video. Images, audio and video are read into text. See [Documents](/memory/api-reference/documents). ## Vercel AI SDK Wrap any model to give it memory. Relevant memories are recalled before the call and the turn is recorded after it. ```typescript theme={null} import { openai } from "@ai-sdk/openai"; import { generateText, wrapLanguageModel } from "ai"; import { Anona } from "@anona-labs/memory"; import { anonaMemory } from "@anona-labs/memory/vercel"; const anona = new Anona({ apiKey: process.env.ANONA_API_KEY! }); const model = wrapLanguageModel({ model: openai("gpt-4o"), middleware: anonaMemory({ client: anona, spaceId: "support", limit: 8 }), }); const { text } = await generateText({ model, prompt: "How should I contact Alice?" }); ``` Requires `ai` v5 or newer. Recording is fire-and-forget by default so memory never adds latency to a response; pass `await: true` to block until the write lands, or `record: false` to recall without writing. Streaming is supported, and the turn is recorded when the stream completes, not at the first token. Memory failures never break the model call. If recall or recording fails, the request proceeds without it. ## OpenAI Agents SDK Exposes memory as two tools the model can call: `remember` and `recall`. ```typescript theme={null} import { Agent, run } from "@openai/agents"; import { Anona } from "@anona-labs/memory"; import { anonaTools } from "@anona-labs/memory/openai-agents"; const anona = new Anona({ apiKey: process.env.ANONA_API_KEY! }); const agent = new Agent({ name: "support", tools: anonaTools({ client: anona, spaceId: "support" }), }); await run(agent, "What do you know about Alice?"); ``` A failing tool returns its error message as text rather than throwing, so a memory outage degrades the run instead of aborting it. ## Error handling ```typescript theme={null} import { Anona, AnonaError } from "@anona-labs/memory"; try { await anona.retrieve({ spaceId: "support", query: "Where does Alice work?" }); } catch (error) { if (error instanceof AnonaError) { console.error(error.statusCode, error.code, error.requestId); } } ``` A `429` is retried automatically on every call, with the wait the server named. A `5xx` and a dropped connection are retried only where a replay is safe, because a `5xx` may mean the request landed. These ten calls opt out: `record`, `recordBatch`, `reason`, `askAboutUser`, `uploadFiles`, `createSpace`, `createWebhook`, `createRule`, `createMemoryModel` and `refreshMemoryModel`. Everything else — `retrieve`, `getContext`, `retrieveReceipt` and every `GET`, `PUT` and `DELETE` — is retried. `4xx` is never retried — it means the request itself was wrong — and `credits_exhausted` is not retried either, since it is a balance rather than a window. The full matrix, including where this client and the Python one differ, is [in the error reference](/memory/api-reference/errors#what-the-sdks-already-retry). > [!NOTE] > > A `503` arriving without a `requestId` may be a gateway timeout whose body was stripped > in transit. Report it with a timestamp rather than treating it as a malformed response. See the [error reference](/memory/api-reference/errors) for every code and which are safe to retry. ## Browser usage > [!WARNING] > > Never call the Anona API directly from client-side JavaScript. The API key would be > visible to anyone who opens the network tab. Route every call through your own backend. The package runs in the browser so it can be used in trusted contexts such as an internal tool behind auth, or a browser extension. For a public web app, keep the key on your server: ```typescript theme={null} // Backend route app.post("/api/memories/search", async (req, res) => { const results = await anona.retrieve({ spaceId: req.body.spaceId, query: req.body.query, }); res.json(results); }); ``` ## Next steps > [card] [Python SDK](/memory/sdk/python) > > The same surface, for Python. > [card] [API overview](/memory/api-reference/overview) > > Every endpoint, request shape, and error code. # Python SDK Source: https://docs.anonalabs.com/memory/sdk/python The official Python client for Anona Memory, with sync and async methods for every core operation. ```bash theme={null} pip install anona ``` Requires Python 3.10 or newer. ```python theme={null} from anona import AnonaClient client = AnonaClient(api_key="anona_live_YOUR_KEY") client.record(space_id="customer-support-bot", content="Alice is a senior engineer at Google") results = client.retrieve(space_id="customer-support-bot", query="Where does Alice work?") print(results) ``` ## Client initialization ```python theme={null} client = AnonaClient( api_key="anona_live_YOUR_KEY", base_url="https://api.anonalabs.com", # optional, this is the default timeout=120.0, # optional, seconds, per attempt max_retries=2, # optional retry_max_wait=60.0, # optional, seconds ) ``` | Argument | Default | What it does | | - | - | - | | `api_key` | required | Your key. Not read from the environment — pass it. | | `base_url` | `https://api.anonalabs.com` | The API to call. | | `timeout` | `120.0` | Seconds for one attempt. High on purpose: `reason` at `depth="thorough"` can run well past a minute while the server is still working, and a short timeout kills a call that was going to succeed. | | `max_retries` | `2` | Retries *per call*, on the statuses below. `0` turns retrying off. | | `retry_max_wait` | `60.0` | Ceiling on one wait. A `429` names the seconds to wait and the rate-limit window is a whole minute, so the default is high enough to actually sit the window out. | `timeout` is per attempt, not per call: with `max_retries=2` a call can take up to three timeouts plus its backoffs. What gets retried is **not** every call — see [What the SDKs already retry](/memory/api-reference/errors#what-the-sdks-already-retry). The short version: a `429` on every method, a `5xx` only on `GET`, `PUT` and `DELETE`, and never on a multipart upload. `retrieve` and `reason` are `POST` requests, so this client does not retry them on a `5xx`. The client does not read an environment variable automatically. Pass `api_key` explicitly: ```python theme={null} import os client = AnonaClient(api_key=os.environ["ANONA_API_KEY"]) ``` Use it as a context manager so connections close cleanly: ```python theme={null} with AnonaClient(api_key="anona_live_YOUR_KEY") as client: client.record(space_id="customer-support-bot", content="...") ``` ## Methods | Method | Returns | Reference | | - | - | - | | `record(space_id, content, metadata=None, tags=None, background=False, user_id=None, agent_id=None, session_id=None, timestamp=None, context=None, *, route=None, fallback_space_id=None, max_targets=None)` | `dict` | [Memories](/memory/api-reference/memories) | | `record_batch(space_id, items, user_id=None, agent_id=None, session_id=None, *, route=None, fallback_space_id=None, max_targets=None)` | `dict` | [Bulk ingest](/memory/api-reference/memories#bulk-ingest) | | `retrieve(space_id, query, limit=10, mode="accurate", user_id=None, agent_id=None, session_id=None, as_of=None, query_timestamp=None, occurred_after=None, occurred_before=None, top_k=None, memory_type=None, tags=None, tags_match=None, tag_groups=None, prefer_observations=None, min_score=None, member_id=None, *, route=None, fallback_space_id=None, max_targets=None)` | `RetrieveResults` (a `list[dict]`) | [Retrieve](/memory/api-reference/retrieve) | | `get_context(space_id, query, limit=10, max_tokens=None, block_order=None, …)` | `str` | [Prompt-ready context](/memory/api-reference/retrieve#prompt-ready-context) | | `retrieve_receipt(space_id, query, limit=10, mode="accurate", receipt_detail="basic", …)` | `RetrieveWithReceipt` | [Context receipts](/memory/api-reference/context-receipts) | | `get_receipt(request_id)` | `dict` | [Context receipts](/memory/api-reference/context-receipts) | | `explain(request_id, memory_id)` | `dict` | [Context receipts](/memory/api-reference/context-receipts) | | `reason(space_id, query, user_id=None, agent_id=None, session_id=None, model=None, depth=None, tag_groups=None)` | `str` or `None` | [Reason](/memory/api-reference/reason) | | `reason_receipt(space_id, query, user_id=None, agent_id=None, session_id=None, model=None, depth=None, tag_groups=None)` | `ReasonWithReceipt` | [Reason](/memory/api-reference/reason) | | `get_user_profile(space_id, user_id, *, limit=None, offset=None, memory_type=None, format=None, context_max_tokens=None)` | `dict` | [User profiles](/memory/api-reference/user-profiles) | | `ask_about_user(space_id, user_id, query, *, model=None)` | `dict` | [User profiles](/memory/api-reference/user-profiles) | | `list_spaces()` · `list_space_types()` | `list[dict]` | [Spaces](/memory/api-reference/spaces) | | `create_space(name, description=None, *, space_type=None)` · `delete_space(space_id)` | `dict` / `None` | [Spaces](/memory/api-reference/spaces) | | `get_job(space_id, job_id)` | `dict` | [Job status](/memory/api-reference/memories#job-status) | | `upload_file(space_id, file, *, filename=None, strategy=None, tags=None, user_id=None, agent_id=None, session_id=None)` | `dict` | [Documents](/memory/api-reference/documents) | | `list_documents(space_id, *, limit=100, offset=0)` | `list[dict]` | [Documents](/memory/api-reference/documents) | | `delete_document(space_id, document_id)` | `None` | [Documents](/memory/api-reference/documents) | | `delete_memory(space_id, memory_id)` | `None` | [Memories](/memory/api-reference/memories) | | `get_graph(space_id, *, limit=500, min_count=1)` | `dict` | [Graph](/memory/api-reference/graph) | | `list_entities(space_id, *, limit=100, offset=0)` · `get_entity(space_id, entity_id)` | `list[dict]` / `dict` | [Graph](/memory/api-reference/graph) | | `create_webhook` · `list_webhooks` · `update_webhook` · `delete_webhook` · `list_webhook_deliveries` | — | [Webhooks](/memory/api-reference/webhooks) | | `get_chat_settings` · `set_chat_settings` · `reset_chat_settings` | `dict` | [Chat defaults](/memory/api-reference/chat#space-defaults) | | `get_extraction_settings` · `set_extraction_settings` · `reset_extraction_settings` | `dict` | [Extraction settings](/memory/api-reference/extraction-settings) | | `get_space(space_id)` | `dict` | [Spaces](/memory/api-reference/spaces) | | `list_memories(space_id, *, limit=50, offset=0, q=None, memory_type=None, state=None, prefer_observations=None, user_id=None, agent_id=None, session_id=None, member_id=None)` | `dict` | [Memories](/memory/api-reference/memories) | | `get_memory_history(space_id, memory_id)` · `update_memory(space_id, memory_id, *, text=None, …)` | `dict` | [Memories](/memory/api-reference/memories) | | `get_document(space_id, document_id)` | `dict` | [Documents](/memory/api-reference/documents) | | `cancel_job(space_id, job_id)` | `dict` | [Job status](/memory/api-reference/memories#job-status) | | `get_usage()` | `dict` | [Usage](/memory/api-reference/usage) | | `get_space_profile(space_id)` · `set_space_profile(space_id, *, mission, disposition)` — both keyword-only and both required, either may be `None`, since the PUT is a full replace · `reset_space_profile(space_id)` | `dict` / `None` | [Memory models](/memory/api-reference/models) | | `list_memory_models(space_id, *, limit=50, offset=0, tags=None)` · `create_memory_model(space_id, *, name, query, model_id=None, tags=None, max_tokens=None, trigger=None)` | `dict` — create answers `202` with `{model_id, job_id, status}`, not the model | [Memory models](/memory/api-reference/models) | | `get_memory_model(space_id, model_id)` · `update_memory_model(space_id, model_id, *, name=None, …)` · `delete_memory_model(space_id, model_id)` | `dict` / `None` | [Memory models](/memory/api-reference/models) | | `refresh_memory_model(space_id, model_id)` · `clear_memory_model(space_id, model_id)` · `get_memory_model_history(space_id, model_id)` | `dict` | [Memory models](/memory/api-reference/models) | | `list_catalog_models()` | `dict` | [Model selection](/memory/api-reference/model-selection) | | `get_reason_settings(space_id)` · `set_reason_settings(space_id, *, model, depth)` · `reset_reason_settings(space_id)` | `dict` / `None` | [Model selection](/memory/api-reference/model-selection) | | `list_rules(space_id)` · `create_rule(space_id, *, name, content, priority=0, is_active=True, tags=None)` · `update_rule(space_id, rule_id, *, name=None, content=None, priority=None, is_active=None, tags=None)` · `delete_rule(space_id, rule_id)` | `list[dict]` / `dict` / `None` | [Rules](/memory/api-reference/spaces#rules) | `get_context` and `retrieve_receipt` take the same filters as `retrieve` — scope, temporal, tag, `memory_type`, `top_k`, `prefer_observations`, `min_score`, `member_id` — which is what the `…` stands for above. Every method also has an `async_`-prefixed counterpart — see [Async support](#async-support). > [!NOTE] > > `top_k` is an **alias for `limit`**, not a candidate bound: the API takes it in > place of `limit` and it wins when both are sent. The docstring on `retrieve` > describes it as bounding the candidates considered before ranking, which the API > does not do — ignore the docstring and read > [the retrieve parameters](/memory/api-reference/retrieve#parameters). ### Parameters the REST API has and this client does not The API reference documents the wire, and these fields have no keyword argument here. Reach for `httpx` (or the equivalent) for the few that you need; nothing is lost by a plain request, since the client adds no behaviour beyond auth, retrying and error translation. | Endpoint | REST-only fields | | - | - | | `POST /v1/retrieve` | `expand_query`, `include_source`, `source_max_tokens`, and the connector provenance filters `source` / `repo` / `pull_request` / `author` / `merged_by`. (`format`, `context_max_tokens` and `block_order` are not missing — `get_context` is the method that sends them, and `retrieve_receipt` sends `receipt` / `receipt_detail`.) | | `GET /v1/spaces/{space_id}/memories` | `tag_groups`, and the same provenance filters. | | `GET /v1/spaces/{space_id}/documents` | `q`, `source`. | | `POST /v1/spaces/{space_id}/users/{user_id}/ask` | `depth`. `ask_about_user` takes `model` only, so the depth falls through to the space's reason default. | | Every endpoint | `org_id`. An API key already belongs to one organization, so there is nothing for the client to choose — the field exists to let a caller *assert* which organization it meant and get `400 org_mismatch` instead of being served the key's. | ### record ```python theme={null} memory = client.record( space_id="customer-support-bot", content="Alice works at Google and specializes in distributed systems", user_id="alice_123", tags=["work"], metadata={"source": "crm"}, ) print(memory["memory_id"]) ``` Pass `background=True` to queue the write instead of waiting. The call then returns a job rather than a stored memory; poll it with `get_job`. > [!WARNING] > > **`background=True` is several times faster, and should be your default.** A blocking > write waits for a language model to extract facts from the text before it returns. > Measured against production, about 3-12 seconds for a short memory (longer content takes > longer) versus 1-3 seconds queued. Use a blocking > write only when you search for the memory immediately afterwards. > > ```python theme={null} > job = client.record( > space_id="customer-support-bot", > content="Alice is a senior engineer at Google", > background=True, > ) > client.get_job(space_id="customer-support-bot", job_id=job["job_id"]) > ``` `user_id`, `agent_id` and `session_id` scope the memory inside the space, so only a `retrieve` carrying the same scope sees it. That is how one space serves many end users. See [Scoping](/memory/api-reference/retrieve#scoping-within-a-space). `timestamp` is when the event happened, not when you are recording it. Set it when importing history so a memory about last June is dated last June. See [Searching over time](/memory/api-reference/retrieve#searching-over-time). > [!WARNING] > > A user id passed in `metadata` is stored but isolates nothing: `metadata` is returned > with results, never filtered on. Use the `user_id` argument. ### retrieve ```python theme={null} results = client.retrieve( space_id="customer-support-bot", query="Where does Alice work?", limit=10, user_id="alice_123", # only Alice's memories ) for r in results: print(r["content"], r["relevance_score"]) # A `note` names the facts it was synthesized from. Empty on a `fact`, # which is the evidence itself. for source_id in r["source_ids"]: print(" built from", source_id) ``` `source_ids` is always present, so it is safe to iterate without a guard. Because a note is derived, its own `metadata` is empty and its `document_id` is `None`: those belong to the facts behind it. Pass `prefer_observations=False` to get those facts back alongside their note, each carrying the metadata you wrote it with. `as_of` restricts the search to memories **recorded** at or before an instant, so you get what the space knew then. `query_timestamp` moves the "now" that recency scoring and relative dates in the query are measured against, and never removes a result. See [Searching over time](/memory/api-reference/retrieve#searching-over-time). Returns the `results` list directly, and an empty list when nothing matches. A scoped search is strict: it never returns another user's memories, nor memories stored without a scope. `mode="fast"` skips the neural rerank pass for lower latency, at some cost to relevance quality. See [Latency modes](/memory/api-reference/retrieve#latency-modes). ### retrieve\_receipt, get\_receipt and explain `retrieve` returns a plain list, which has nowhere to carry the id of the receipt for that search. `retrieve_receipt` is the same search and returns both. ```python theme={null} res = client.retrieve_receipt( space_id="customer-support-bot", query="who is on call for billing?", receipt_detail="full", ) for row in res.memories: print(row["content"]) receipt = client.get_receipt(res.receipt_id) for cut in receipt["excluded"]: print(cut["reason"], cut["detail"]) ``` The result iterates and indexes like the list `retrieve` returns, so `len(res)`, `res[0]` and `for row in res` all work and swapping one method for the other rarely means changing the code around it. `receipt_detail="full"` also asks the search to account for its own cuts, so the receipt can explain memories that were ranked and dropped before `limit` or a relevance floor ever applied. It can cost latency on the first call for a given query. Leave it `"basic"` for production traffic. `explain` answers the question the receipt cannot: why one specific memory is not in your results. ```python theme={null} verdict = client.explain(res.receipt_id, "mem_abc123") verdict["outcome"] # "included" | "excluded" | "not_retrieved" verdict["stage"] # "retrieval" | "fusion" | "rerank", when excluded verdict["arms"] # {"semantic": 812, "keyword": None, ...} ``` `not_retrieved` is the one to act on: nothing matched the memory at all, so a bigger `limit` or a lower floor will not bring it back, and the query wording or the scope you searched is what to change. `arms` shows which kind of matching found it, and how well: found by `keyword` but not `semantic` usually means your query shares words with the memory but not meaning. Every search builds a receipt whether or not you asked for one, so `get_receipt` and `explain` also work on a request id you pulled from your own logs. See [Context receipts](/memory/api-reference/context-receipts). ### reason ```python theme={null} answer = client.reason( space_id="customer-support-bot", query="What have we learned about Alice's career?", ) print(answer) ``` Returns the synthesized answer string, or `None`. ### get\_user\_profile and ask\_about\_user Everything a space has learned about one end user, and a question answered from that user's memories only. Both need `user_id` to be the same value your writes are scoped with. ```python theme={null} profile = client.get_user_profile("customer-support-bot", "alice_123") print(profile["memory_count"], profile["last_active"]) # Prompt-ready instead of a list block = client.get_user_profile( "customer-support-bot", "alice_123", format="block", context_max_tokens=500 ) print(block["context"]) answer = client.ask_about_user( "customer-support-bot", "alice_123", "How does she prefer to be contacted?" ) print(answer["insights"], answer["model"]) ``` `ask_about_user` returns the whole response rather than the answer string, so `model` is available: that is the model which actually answered, and what the credits on the call were charged at. It differs from what you asked for whenever you asked for nothing. A `user_id` nobody has recorded under is not an error: it comes back with `memory_count` of `0` and an empty `memories` list, because a user is a scope tag created by the first write naming it rather than a resource you register. An unknown *space* is still a 404. `memory_count` can also go **down** between two reads, since consolidation folds several raw facts into one note and the default view counts the note. Read it as how many distinct things are currently known about this user, not as an ingestion counter. See [User profiles](/memory/api-reference/user-profiles). ### list\_spaces ```python theme={null} spaces = client.list_spaces() for s in spaces: print(s["space_id"], s["name"]) ``` ### upload\_file Uploads a file into a space so retrieval can draw on its content. `file` may be a path, `bytes`, or a binary file-like object. Ingestion is asynchronous and returns `{"job_ids": [...]}`; poll each with `get_job`. Files over 25 MB are rejected client-side, before upload. ```python theme={null} job = client.upload_file( space_id="customer-support-bot", file="handbook.pdf", tags=["hr", "policy"], ) print(job["job_ids"]) ``` Supported formats are PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, HTML, TXT, Markdown, CSV, JPEG/PNG images, MP3/WAV audio and MP4/MOV/WEBM/MKV video. Images, audio and video are read into text. See [Documents](/memory/api-reference/documents). ### list\_documents and delete\_document ```python theme={null} docs = client.list_documents(space_id="customer-support-bot") for d in docs: print(d["document_id"], d["memory_count"]) client.delete_document(space_id="customer-support-bot", document_id="file_9c2f1a") ``` ## Async support Every method has an `async_` counterpart, backed by a separate `httpx.AsyncClient`. ```python theme={null} import asyncio from anona import AnonaClient async def main(): client = AnonaClient(api_key="anona_live_YOUR_KEY") await client.async_record(space_id="customer-support-bot", content="Alice works at Google") results = await client.async_retrieve( space_id="customer-support-bot", query="Where does Alice work?" ) print(results) await client.aclose() asyncio.run(main()) ``` Use `async with AnonaClient(...) as client:` for automatic cleanup. ## Error handling ```python theme={null} from anona import AnonaClient, AnonaError client = AnonaClient(api_key="anona_live_YOUR_KEY") try: results = client.retrieve(space_id="customer-support-bot", query="Where does Alice work?") except AnonaError as e: print(e.status_code) # int print(e.detail) # parsed error body (dict), or raw text ``` See the [error reference](/memory/api-reference/errors) for every code and which are safe to retry. ## Framework adapters If you already build on an agent framework, you do not need to call `record` and `retrieve` yourself. Each adapter wires recall and storage into that framework's own memory seam, and scopes memories per end user. | Framework | Install | Guide | | - | - | - | | LangChain / LangGraph | `pip install 'anona[langchain]'` | [LangChain](/memory/integrations/langchain) | | CrewAI | `pip install 'anona[crewai]'` | [CrewAI](/memory/integrations/crewai) | | LlamaIndex | `pip install 'anona[llamaindex]'` | [LlamaIndex](/memory/integrations/llamaindex) | | Google ADK | `pip install 'anona[adk]'` | [Google ADK](/memory/integrations/google-adk) | | Microsoft Agent Framework | `pip install 'anona[msagent]'` | [Agent Framework](/memory/integrations/microsoft-agent-framework) | | AWS Strands | `pip install 'anona[strands]'` | [Strands](/memory/integrations/strands) | A complete runnable script for each one is on the [Examples](/memory/integrations/examples) page. All six share one `MemoryBridge`, which resolves scope and owns the failure contract: ```python theme={null} from anona.integrations import MemoryBridge bridge = MemoryBridge( api_key="anona_live_YOUR_KEY", space_id="customer-support-bot", user_id="customer-42", # optional scope: this user's memories only ) ``` > [!NOTE] > > **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. This > is deliberate and applies to every adapter. See each guide's *Failure behaviour* > section for what the agent sees instead. ## MCP server The SDK also ships an MCP server for connecting Anona Memory to Claude Code, Claude Desktop, and other MCP clients. See the [MCP guide](/memory/mcp-integration). ## Next steps > [card] [Framework adapters](/memory/integrations/langchain) > > LangChain, CrewAI, LlamaIndex, ADK, Agent Framework, Strands. > [card] [MCP](/memory/mcp-integration) > > Connect Claude Code and other MCP clients. > [card] [API overview](/memory/api-reference/overview) > > The full REST surface. # Slack Source: https://docs.anonalabs.com/memory/slack Ask a question in a Slack channel and get an answer from that channel's memory space. Anona's Slack app answers questions in the channels you choose, from the memory space you point each channel at, and can **save a thread into that space** when somebody asks it to. **It reads and writes, and each channel chooses which.** A channel is bound **Read only** by default; set it to **Read & write** and [`@Anona save this conversation`](#saving-a-thread-to-memory) works there for members who can already write to the space. Everything else the bot does is a read. Access follows your Anona space membership, not the channel. Binding a channel invites the people in it to that space, and the bot answers each person as themselves once they accept. Somebody who has not accepted is not answered, and nothing in the space is shown to them. ## Install 1. Open **Integrations** in the dashboard. 2. On the **Slack** row, choose **Connect**. 3. Approve the install in your workspace. The app asks for nine scopes and no more: `app_mentions:read` so it can see questions addressed to it, `chat:write` so it can answer, `commands` for the `/anona` command, `channels:history` plus `groups:history` so it can read the thread a question was asked in, `channels:read` plus `groups:read` for the channel's member list, `users:read` to tell people from bots, and `im:write` to send you an invitation by direct message. It does not ask for `users:read.email`, and that is deliberate. Matching a Slack user to the Anona account with the same address would be the obvious shortcut, and it is a way in: a workspace admin can change somebody's email address. Your identity is proved by a private message plus a signed-in Anona session instead, and neither half is derived from the other. Installing does not make anything readable. Until you bind a channel, the bot is silent everywhere. ## Connect your Anona account Required, and the first thing to do. Run this in Slack: ``` /anona link ``` Open the private link it replies with **while signed in to Anona**, and the Slack identity that ran the command is connected to the account you are signed in as. The page says so, naming the Slack user it connected. The bot answers **as you**, against the spaces you can reach and your organization's credits. So until Slack knows which Anona account is yours, it cannot bind a channel on your behalf, cannot answer your questions, and cannot attribute a saved thread. Each of those refuses with an explanation rather than failing quietly, but the fix is always this step. Running `/anona link` again from a different Anona account moves the connection. Linking does not filter what a channel returns. A channel answers from the space it is bound to, for everyone in it who has accepted. Filtering per person would mean posting one person's private memories into a shared channel, which is a leak rather than a feature. ## Bind a channel Run this in the channel you want to use: ``` /anona bind ``` Slack replies with one link, visible only to you. If you have not connected your account yet it carries both steps at once: opening it connects the account and then fills the bind form with this channel. Open it **while signed in to Anona**, or the channel details are lost on the way through sign-in and you will be retyping the channel id. The Slack row opens on arrival with the channel already filled. Choose: **Space** `string` *required* Which memory space this channel reads from. Everyone in the channel can see anything the bot answers from it. **API key** `string` *required* The key the bot presents. Revoking it stops the bot in this channel. Only live keys are offered: a revoked or expired one is refused by the API, so the picker leaves it out and tells you when there is nothing to choose. **Model** `string` Which model answers here. Leave it on **Inherit from the space** to follow the space's own default. Binding the same channel again edits it rather than creating a second one. ## Change or review a channel's settings On the **Slack** row in Integrations, click any bound channel. Its settings open inside that channel's own row: space, API key, what the channel may do, and model. Change what you need and choose **Save changes**, or **Cancel** to leave it as it was. Clicking the row again closes it, and only one channel is open at a time. **What the channel may do** is the channel's ceiling, not a grant. **Read only** answers questions. **Read & write** also allows saving a thread from that channel, and only for people who can write to the bound space themselves. A channel left on Read only refuses a save and says so. The channel ID is fixed while editing. A channel's ID is what identifies the binding, so changing it would not move the binding, it would create a second one against a different channel. To stop the bot in a channel entirely, use the delete button on its row. ## Ask Mention the bot in a bound channel: ``` @Anona what did we decide about the pricing change? ``` The bot posts **Thinking...** in the thread as soon as it starts, and that message becomes the answer when it lands. An answer is a multi step reasoning loop and takes tens of seconds, so the marker is how you tell a bot that is working from one that is down. The answer arrives in a thread on your message. It is the same synthesis `POST /v1/reason` returns, so it costs credits from **the organization of the person who asked**, at that model's rate — see [Costs](#costs). Answers arrive formatted for Slack, so bold, lists, links and code blocks read the way they should rather than showing their markup. ### Asking inside a thread Mention the bot part-way through a thread and it reads the conversation before answering, so "so what did we land on?" works without restating the question. The thread is used only to understand what is being asked. The answer still comes from the memories in the bound space, and nothing in the thread is recorded. Asking never writes. Saving a thread is a separate and explicit action, described further down. A very long thread is trimmed to its most recent messages, and the bot says when that happened rather than answering from half a conversation as though it were the whole one. One question runs at a time per channel. Ask a second while the first is still working and the bot says so rather than queueing it. ## Permissions Your Anona space membership is the permission boundary. * The bot answers only in channels someone deliberately bound. * It answers you as **you**. Your question runs against your own access to that space, and it costs your organization's credits. * Somebody who has not accepted their invitation is not answered at all. * **Answers are public only when everybody in the channel has accepted.** If even one person has not, the answer is shown only to whoever asked, and a copy is sent to them by direct message. The channel is left with "Replied to @someone in a DM", so the room can tell a private answer from a bot that is down. It says only that, never why: whether the reply was an answer or a refusal is about one person. Once everybody accepts, answers go back to being posted for the channel. * Only an owner or an admin can connect or disconnect a Slack workspace. * You can only bind a channel you are in, and you can only bind a space for writing if you can write to it yourself. ### Accepting an invitation When a channel is bound, everybody in it gets a direct message from the bot with a link. Opening it signs you in to Anona and connects your Slack identity in one step. That is the whole of the setup for a person. Those invitations go out only when the person who bound the channel is an **owner or an admin of the organization that owns the space**. Handing a space to a roomful of people is giving access away, so it takes the same authority as inviting somebody through the dashboard. A bind by anybody else still works and the channel still answers them, but nobody is invited — if a channel went quiet and no DMs arrived, that is the first thing to check. An owner or admin can send them with **Resend**. If you ignore it, nothing breaks. The channel keeps working for everyone who has accepted, and your colleagues' answers simply stay private to them until you do. ### Checking where you stand Run this anywhere: ``` /anona whoami ``` It tells you which Anona account this Slack identity is connected to, which space the channel reads, and whether answers here are public or private right now, naming who has not accepted yet. It shows the email address on purpose. Connecting the wrong account is otherwise silent: everything keeps working, against the wrong spaces and the wrong credit balance, and nothing would ever tell you. ## When it cannot answer The bot always replies, even when the answer is that it cannot: | What you see | What happened | | - | - | | out of credits | The organization's credit balance is spent. | | the API key was revoked | Click the channel on the Integrations page and pick a new key. | | that space no longer exists | The bound space was deleted. Re-bind the channel. | | nothing on that yet | The space has no memories matching the question. | | connect your Anona account | Your Slack identity is not linked yet. Open the invitation the bot sent you, or run `/anona link`. | | you have not been given access | You are connected, but not a member of the space this channel reads. | ## Costs **One rule, for everything the bot does: the person who asked pays.** An answer is a `reason` call and a save is a `record`, and both are billed to the Anona organization of the Slack user who triggered them, at the rate of whichever model ran. For an ordinary team that is the organization that owns the workspace, because everybody is in it. Somebody using a personal Anona account spends their own credits. This is the same rule the API follows on a shared space: the space's owner supplies the tenancy, and the caller supplies the credits. The organization that owns the workspace is never charged for somebody else's question on the strength of owning the install. Leaving the model on **Inherit from the space** means a channel follows the space's own default, so you set the model once rather than per channel. ## Disconnect **Disconnect** on the Slack row revokes the workspace and removes its bindings. Your spaces and memories are untouched. To stop the bot in one channel only, unbind that channel instead. ## Seeing why a channel is answering privately A channel answering privately says so in the channel, so it no longer reads as a broken bot. What it does not say is why, and the reason lives on the Integrations page. Each bound channel shows: * **Public** or **Private**, and the reason. Private reads "answers are visible only to whoever asked, because 2 of 9 have not accepted their invitation", naming them. * **Resend invitations**, which sends the direct message again to everybody outstanding, whatever state their invitation is in. Ignoring it, letting it expire, or declining it are all recoverable without rebinding the channel. Nobody who has already accepted is messaged, because they have nothing left to accept. Owner or admin only, because it sends a direct message to real people, and on a large channel that is a lot of them at once. * A warning when somebody in the channel uses an Anona account outside your organization. They can read the space once they accept, but their questions spend their own credits. Invite them to the organization instead if that is not what you want. `/anona whoami` reports the same thing from inside Slack. ## Seeing it from the space instead The Integrations page answers "what is this channel doing". The space answers "who was asked, and who got in", which is the same question from the other end. A space's **Members** tab lists everyone a bound Slack channel offered it to who has not accepted, under **Invited from Slack**. Each row names the person, the channel they were asked in, the role they were offered, and where the offer got to: | Status | What it means | | - | - | | Invited | The direct message went out and they have not answered. | | Declined | They said no. The offer is recorded rather than deleted, so refusal and silence stay distinguishable. | | Expired | Fourteen days passed without an answer. | Somebody who accepts leaves this list and appears in the member table above it as an ordinary member, so nobody is counted twice. There is no Cancel button on these rows, and that is deliberate rather than missing. A channel re-offers to everyone standing in it on its next roster sync, so cancelling one offer would only make it come back. To stop one, remove the person from the Slack channel or re-point the binding. The block is owner or admin only, like the member directory it sits under, and it is absent entirely for a space no Slack channel points at. > [!NOTE] > > Anona stores a Slack display name, never a Slack email address. The bot asks for > `users:read` and deliberately not `users:read.email`, because a workspace admin > can change a member's address, and an identity resting on one is an identity an > admin can hand themselves. A roster synced before names were stored shows the > raw Slack id instead; asking one question in the channel refreshes it. ## The endpoints behind the page Everything the Integrations page does is also callable with a **dashboard session token** (never an API key: `401 session_required`): | Method and path | What it does | | - | - | | `GET /v1/slack/status` | The workspace connection and every channel binding. | | `GET /v1/slack/install` | The Slack consent URL to send the browser to. Returns JSON rather than redirecting, because a browser navigation cannot carry your session header. Owner or admin. | | `POST /v1/slack/bindings` | Binds a channel to a space (what the `/anona bind` link submits). | | `POST /v1/slack/invites/accept` | Accepts a space invitation sent over Slack. | | `POST /v1/slack/bindings/{binding_id}/resend` | Re-invites everyone in a channel who has not accepted. Owner or admin. | | `POST /v1/slack/links` | Connects a Slack identity to your account, from the `/anona link` deep link. | | `DELETE /v1/slack/links/{link_id}` | Disconnects one Slack identity. | | `DELETE /v1/slack/bindings/{binding_id}` | Unbinds one channel. | | `DELETE /v1/slack/installations/{installation_id}` | Disconnects the workspace and removes its bindings. | | `GET /v1/spaces/{space_id}/slack-invites` | Everyone a bound channel offered this space to who has not accepted, with their state. Owner or admin. | `/v1/slack/events`, `/v1/slack/commands`, `/v1/slack/interactions` and `/v1/slack/oauth/callback` are Slack's to call, not yours. ## Saving a thread to memory Two ways, both saving the whole thread rather than one message: a decision lives in the back and forth, and keeping only the conclusion throws away the reason for it. **Ask the bot.** In the thread you want to keep: ``` @Anona save this conversation ``` Also understood: `save this`, `record this chat`, `remember the above`, `store this discussion`, and similar. The instruction has to start with the verb, so a question like *"did we ever record that decision?"* is still answered as a question rather than quietly saving something. **Or use the menu.** Open the `...` menu on any message and choose **Save to Anona**. Four things have to be true, and a save that fails says which: 1. The channel is set to **Read & write** on the Integrations page. This setting is a **ceiling rather than a grant**, so a space you can write to can still be bound read only into a wide channel. 2. **You** have write access to the space yourself — a Read Only member of the space cannot save, however the channel is set. The channel setting can only narrow your own access, never widen it. 3. Your Slack identity is linked to your Anona account. If it is not, the bot tells you to run `/anona link`. 4. The workspace has not spent its hourly save budget (see below). The default is read only, because saving changes what the space recalls: consolidation synthesizes across the saved conversation and whatever your agents have written, together. Saving the same thread twice does nothing the second time. The bot says so rather than making a duplicate. ### The hourly cap Each workspace can save ten threads an hour, and the bot says so when that is spent. The cap is real rather than arbitrary. Extraction runs on a queue shared by every Anona customer, and a thread is many pieces of work in one click, so a team importing a backlog at once would hold up everyone else's uploads. It will rise once that queue is per customer rather than shared. # A shelf of standing questions Source: https://docs.anonalabs.com/memory/use-cases/a-shelf-of-standing-questions Not one self-updating briefing — a dozen, listed, edited, cleared and retired as the work moves. [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/models) ## The scenario [A briefing that keeps itself current](/memory/use-cases/self-refreshing-briefing) covers making one. In practice you end up with a dozen — one per account, per service, per ongoing question — and then you have a collection to manage rather than a document to read. **What this recipe shows that no other does:** the full lifecycle of memory models, including the two operations people reach for late and wish they had known about — clearing content without losing the definition, and editing the question without rewriting the answer. ## Step 1 — See the shelf ```python theme={null} shelf = client.list_memory_models(space_id="accounts", limit=50) for m in shelf["items"]: flag = "stale" if m["is_stale"] else "current" print(f"{m['name']:30} {flag:8} {m['last_refreshed_at']}") ``` `is_stale` across the whole shelf is the view worth having: it tells you which standing answers have drifted from the memories underneath them. ## Step 2 — Group them with tags ```python theme={null} client.list_memory_models(space_id="accounts", tags=["quarterly"]) ``` ## Step 3 — Sharpen the question without losing the answer ```python theme={null} client.update_memory_model( space_id="accounts", model_id=mid, query="What is blocking the Acme renewal, and who owns each blocker?", ) ``` Editing the definition deliberately does **not** trigger a rewrite. Refreshing costs credits, and most edits are a sharpened question or a typo — you choose when to pay. ## Step 4 — Clear content, keep the definition ```python theme={null} client.clear_memory_model(space_id="accounts", model_id=mid) ``` This is the one that is hard to discover and exactly right after a correction: when the underlying memories were wrong and have been [superseded](/memory/use-cases/correct-a-memory), the model's text is built on the old belief. Clearing wipes the answer and keeps the question, so the next refresh starts clean rather than editing its way out of a wrong premise. ## Step 5 — Retire it ```python theme={null} client.delete_memory_model(space_id="accounts", model_id=mid) ``` Delete really deletes — it is not an alias for clear. The definition goes too. ## Evals 1. Create three models, refresh them, confirm all three read `current`. 2. Write a memory that contradicts one. Only that one should turn `is_stale`. 3. Clear it, then read it — content gone, name and query intact. 4. Refresh and confirm the new text reflects the correction rather than blending it with the old answer. Blending is the failure that clearing exists to prevent. ## Guardrails > [!WARNING] > > **Refresh costs credits and is charged when submitted, not when it succeeds.** > A shelf of twenty models on `refresh_on_new_memories` over a busy space is a > standing bill. Trigger on the cadence you actually read them. * **A newly created model has no content** until its first refresh completes. Render that state rather than an empty string that reads like an answer. * **Deleting is owner-only**, like every other space-configuration call. * **Stale is not wrong.** It means the memories moved, not that the text is false — often it is still the best answer you have. # Account memory that outlives the rep Source: https://docs.anonalabs.com/memory/use-cases/account-memory Stakeholders, commitments and objections stay with the account when the person handling it changes. [← All use cases](/memory/use-cases/overview) [support\_bot\_scoping.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py)[user\_profiles.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py) ## The scenario A rep leaves. Their accounts go to someone else, along with a CRM full of activity records and none of the understanding — who actually decides, what they objected to last time, what was promised on a call in March. The next conversation starts from zero, and the customer notices. ## Step 1 — Scope every note to the account ```python theme={null} client.record( space_id="accounts", content="Their security lead blocks anything without SSO; procurement follows whatever he says.", user_id="acme-corp", agent_id="rep-jordan", ) ``` Two scopes, two jobs: `user_id` is the account the note is *about*, `agent_id` is who wrote it. The account is what survives the handover. ## Step 2 — Brief the new owner in one call ```python theme={null} profile = client.get_user_profile( space_id="accounts", user_id="acme-corp", format="block" ) print(profile["context"]) ``` `format="block"` is what fills `context`. Leave it off and you get the `memories` array instead, with `context` present but null — so a brief that prints `None` is a missing `format`, not an empty account. ## Step 3 — Ask the questions a handover should answer ```python theme={null} print(client.ask_about_user( space_id="accounts", user_id="acme-corp", query="What have we committed to, and what has blocked this deal before?", )) ``` ## Step 4 — See what the previous rep was working on ```python theme={null} client.retrieve(space_id="accounts", query="open commitments", user_id="acme-corp", agent_id="rep-jordan") ``` Scoping to both gives you one person's working view of one account — useful during a handover, and correctly irrelevant afterwards. ## Evals 1. Write fifteen notes on one account across three months from two different reps. 2. Run the handover question. Check it surfaces commitments from *both* reps. 3. Ask about an account with no notes — it should say so rather than generalise from similar accounts. 4. Have the receiving rep read the briefing before their first call and tell you what was missing. That gap list is your note-taking convention. ## Guardrails > [!WARNING] > > **These notes are about identifiable people and they are not private.** "Seems > disengaged", "not the real decision maker" — write nothing you would not want > read aloud. In many jurisdictions the individual can request a copy, which > means [the deletion runbook](/memory/use-cases/delete-a-users-memories) applies here > too. * **A commitment recorded is not a commitment tracked.** Memory will tell you what was said; it does not chase it. Keep the obligation in the system that has dates and owners. * **`user_id` is your identifier for the account.** Keep it stable — renaming it orphans every memory written under the old one. * **Don't mirror the CRM.** Record the understanding, not the activity log; the CRM is already better at the latter and the noise degrades retrieval. ## Built from Both are complete, runnable scripts: [`support_bot_scoping.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py), [`user_profiles.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py). # Backfill a year of history Source: https://docs.anonalabs.com/memory/use-cases/backfill-history Queue the writes, keep serving, poll for the outcome. A blocking record waits on an LLM call; this does not. [← All use cases](/memory/use-cases/overview) [background\_ingestion.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/background_ingestion.py) ## The scenario A plain `record` waits for fact extraction — an LLM call — before it returns. On a request path that latency belongs somewhere else, and for a bulk import it is unusable. ## Step 1 — Queue a single write ```python theme={null} queued = client.record( space_id=space, content="The incident review moved to Wednesdays.", background=True, ) print("queued as", queued["job_id"]) ``` Sub-second return instead of waiting on extraction. ## Step 2 — Send them in batches ```python theme={null} batch = client.record_batch( space_id=space, items=[ {"content": "Sprint 41 closed with 34 points."}, {"content": "Sprint 42 planning is Thursday.", "user_id": "scrum-bot"}, ], ) print("accepted:", batch["accepted"]) ``` `record_batch` is always asynchronous. Each item takes the same fields a single write does, scope keys included. ## Step 3 — Poll for the outcome ```python theme={null} def wait(job_id): while True: job = client.get_job(space_id=space, job_id=job_id) if job["status"] in ("completed", "failed"): return job time.sleep(5) done = wait(batch["job_id"]) print("stored", done["memory_count"], "memories") ``` ## Evals 1. Queue one write, poll to completion, assert `memory_ids` is non-empty. 2. Time a blocking `record` against a background one. The difference is why this exists. 3. Import a hundred items and check `memory_count` against what you sent — extraction can produce more memories than items, and that is expected. ## Guardrails > [!WARNING] > > **Set `timestamp` on every backfilled item.** Without it, a year of history > imported this afternoon all happened this afternoon, and every > [temporal question](/memory/use-cases/what-was-true-back-then) becomes unanswerable. > There is no way to fix this in bulk afterwards. * **Throughput is bounded platform-side.** A large import takes real time; size your expectations from a measured batch rather than from the accept latency. * **`accepted` is not `stored`.** It means the queue took them. The job is what tells you they landed. * **A failed job needs handling.** Poll for `failed` as well as `completed`, or a silent import gap looks like a retrieval problem weeks later. ## The script Complete and runnable: [`background_ingestion.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/background_ingestion.py). # Browse what it knows Source: https://docs.anonalabs.com/memory/use-cases/browse-what-it-knows Page through the memories themselves — no query, no ranking. The view that answers 'what did it actually store?' [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/memories) ## The scenario Search answers "what is relevant to this question". It cannot answer "what is in here", and those are different questions — the second one is what you need when recall is disappointing and you are trying to work out whether the memory is missing or merely unranked. **What this recipe shows that no other does:** reading memories without a query at all. Everything else in this cookbook searches. ## Step 1 — Page through it ```python theme={null} page = client.list_memories(space_id="ops", limit=50, offset=0) print(page["total"], "memories") for m in page["items"]: print(m["date"], m["content"][:90]) ``` `total` is the number people want first and search will never give you. ## Step 2 — Filter without ranking ```python theme={null} client.list_memories(space_id="ops", memory_type="note") client.list_memories(space_id="ops", state="invalidated") ``` `memory_type` is one of `fact`, `note`, `experience` or `summary`; anything else is a `422`. `state="invalidated"` is the interesting one: it lists what has been [superseded](/memory/use-cases/correct-a-memory) — a view of what the space used to believe, which no search returns because superseded memories are excluded from recall by design. ## Step 3 — Follow a memory back to its document ```python theme={null} hits = client.retrieve(space_id="ops", query="refund window", prefer_observations=False) m = next(h for h in hits if h.get("document_id")) doc = client.get_document(space_id="ops", document_id=m["document_id"]) print(doc["source"], "->", doc["memory_count"], "memories") ``` The list does not carry `document_id`; a retrieve result does, and only on a **fact**. A note is a synthesis that can span several documents, so its `document_id` is `null` and its `source_ids` point at the facts instead — which is why this asks for the facts with `prefer_observations=False`. ## Step 4 — Remove a document and everything it produced ```python theme={null} client.delete_document(space_id="ops", document_id=doc["document_id"]) ``` One call removes the file and every memory extracted from it. That is what makes "unpublish that outdated policy" a single operation instead of a hunt through search results. ## Evals 1. Upload one document, note `memory_count` on it. 2. `list_memories(memory_type="fact", prefer_observations=False)` and confirm `total` moved by that amount. `memory_count` counts the **facts** extracted from the file; the default list counts notes too and hides facts a note covers, so its `total` moves by a different number, by design. 3. Delete the document, list again, confirm `total` came back down. 4. Search for something only that document said. Empty is the correct answer — if it still returns, the deletion did not cascade and that is worth reporting. ## Guardrails > [!WARNING] > > **Deleting a document is irreversible and takes its memories with it.** That > is usually what you want and it is never undoable. Check `memory_count` first > so the blast radius is a number you have seen. * **The list dedups by default.** `prefer_observations=True` hides raw facts a consolidation already covers, so `total` is smaller than the raw row count. Pass `False` to see the evidence layer underneath. * **Browsing is not free of cost in attention.** A space with 40,000 memories cannot be read; use it to spot-check extraction, not to audit a corpus. * **`offset` paging drifts under active writing.** For a full export, use [Take everything with you](/memory/use-cases/take-everything-with-you) instead. # Candidate memory across interviewers Source: https://docs.anonalabs.com/memory/use-cases/candidate-memory Every interviewer sees what the others already covered, so the fourth conversation is not the first one again. [← All use cases](/memory/use-cases/overview) [support\_bot\_scoping.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py)[knowledge\_graph.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/knowledge_graph.py) ## The scenario Four interviews, four interviewers, and the candidate explains the same career history four times. Meanwhile the thing the second interviewer was unsure about never reaches the fourth, who could have probed it. Scorecards fix the decision. They do not fix the conversation. ## Step 1 — Scope notes to the candidate ```python theme={null} client.record( space_id="hiring", content="Strong on distributed systems; vague on how they handled the rollback, worth probing.", user_id="cand-8812", agent_id="interviewer-sam", ) ``` ## Step 2 — Brief the next interviewer ```python theme={null} print(client.ask_about_user( space_id="hiring", user_id="cand-8812", query="What is already covered, and what did earlier interviewers want probed?", )) ``` That is a thirty-second read that changes what the next hour is spent on. ## Step 3 — See the shape of the panel ```python theme={null} entities = client.list_entities(space_id="hiring", limit=20) ``` Extraction names the technologies, companies and projects that came up. Across a pipeline that is a genuinely useful read: which skills you are actually seeing, rather than which ones you advertised for. ## Evals 1. Run one real loop, recording after each interview. 2. Before the final interview, generate the briefing and ask that interviewer whether it changed their plan. 3. Check no note leaks across candidates: query with a different `user_id` and confirm nothing returns. 4. Compare time-to-decision against loops run without it. ## Guardrails > [!WARNING] > > **Hiring notes are regulated personal data in most jurisdictions**, candidates > can usually request them, and they carry real discrimination risk. Record > evidence about the role — what was demonstrated, what was not — never > impressions about the person. Set a retention period and honour it with > [the deletion runbook](/memory/use-cases/delete-a-users-memories). * **Do not let a briefing replace an independent read.** Handing interviewer four a summary of three opinions is how a panel converges on the first opinion. Share what was *covered*; be careful about sharing what was *concluded*. * **Never use this to score or rank candidates automatically.** It is a retrieval system, not an assessment, and in several jurisdictions automated decision-making here carries specific legal obligations. * **Keep the space off-limits to the hiring manager's wider org.** Scope is relevance, not access. ## Built from Both are complete, runnable scripts: [`support_bot_scoping.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py), [`knowledge_graph.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/knowledge_graph.py). # Chat with your documents Source: https://docs.anonalabs.com/memory/use-cases/chat-with-your-documents Upload a file, wait for extraction, ask. Uploads land in the same space as everything else you recorded. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py) ## The scenario Half of what your agent should know is in files — a policy, a spec, a handbook. You do not want a second retrieval system for them, and you do not want to pre-chunk anything. Uploads land in the same space as recorded memories, so one `retrieve` answers from both. ## Step 1 — Upload ```python theme={null} job = client.upload_file(space_id=space, file="refund-policy.txt") ``` `file` takes a path, raw bytes, or a file object. PDF, DOCX, PPTX, XLSX, HTML, TXT/MD/CSV, images, MP3/WAV audio and MP4/MOV/WEBM/MKV video are all read into text. ## Step 2 — Wait for it to be searchable Ingestion is asynchronous. The upload returns job ids; `completed` means that file is searchable. ```python theme={null} for job_id in job["job_ids"]: while client.get_job(space_id=space, job_id=job_id)["status"] not in ("completed", "failed"): time.sleep(5) ``` In production use [webhooks](/memory/use-cases/events-not-polling) instead of a loop. ## Step 3 — Ask, and see which file answered ```python theme={null} rows = client.retrieve( space_id=space, query="How long do I have to return hardware?", prefer_observations=False, ) for row in rows: if row.get("document_id"): print(row["content"], "from", row["document_id"]) ``` By default an answer is often a **note** — a synthesis built from several facts, possibly from several files — and a note has no single `document_id` (it is `null`; its `source_ids` name the facts behind it). The facts are what carry the file, so ask for them with `prefer_observations=False` and keep the rows that have one. ## Step 4 — The document stays a first-class object ```python theme={null} for doc in client.list_documents(space_id=space): print(doc["document_id"], "->", doc["memory_count"], "memories") ``` Deleting a document removes every memory extracted from it in one call — which is what makes "remove that outdated policy" a single operation rather than a hunt. ## Evals 1. Upload one file you know the answers in. Ask three questions from it. 2. Ask something the file does not cover. Empty is correct. 3. Check `document_id` resolves on every fact you would act on (with `prefer_observations=False` — a note never carries one). 4. Delete the document and re-ask. Everything from it should be gone. ## Guardrails * **A question asked seconds after upload may legitimately find nothing.** Wait for `completed`; do not treat the gap as a bug. * **Per-file limits apply** — 25 MB generally, 64 MB for video, with request totals above that. One oversized file rejects the whole request. * **Uploads take scope too.** Pass `tags` on upload if later reads will be scoped, or the document is invisible to every scoped query. ## The script Complete and runnable: [`document_qa.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py). # Choose the model a space thinks with Source: https://docs.anonalabs.com/memory/use-cases/choose-the-model A catalog of models, priced per operation. Pin a cheap one to a noisy space and a strong one to the space that matters. [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/model-selection) ## The scenario Every `reason` call costs credits, and the cost difference between the cheapest and the most expensive model in the catalog is more than a hundredfold. Most spaces do not need the expensive one; one or two do. **What this recipe shows that no other does:** reading the model catalog and pinning a model **per space**, so cost follows importance instead of being one global setting. ## Step 1 — See what you can actually use ```python theme={null} catalog = client.list_catalog_models() for m in catalog["data"]: print(f"{m['id']:45} {m['credits_per_1k_input']} in / {m['credits_per_1k_output']} out") ``` `GET /v1/models` answers in OpenAI's list shape, so the entries are under `data` — that is deliberate, because a stock OpenAI client's `models.list()` hits this same path. The catalog is generated and checked in, not fetched live, so pricing is reproducible — a price change arrives as a reviewed release, never as a surprise on your bill. Aliases exist for the common cases: `fast`, `balanced`. Naming an alias means "whatever we currently think is the sensible default at that tier" — at the moment you name it. The catalog grows as models are added, so count it from `GET /v1/models` rather than from any number on this page. ## Step 2 — Pin one to a space ```python theme={null} client.set_reason_settings(space_id="support-logs", model="fast", depth="fast") client.set_reason_settings(space_id="board-papers", model="balanced", depth="thorough") ``` `model` and `depth` are both required, and that is on purpose: this PUT is a full replace, so a call naming only the model clears the depth. Pass both every time. To change one, read the pair back with `get_reason_settings` first. ## Step 3 — Check what a space is actually on ```python theme={null} print(client.get_reason_settings(space_id="board-papers")) ``` `null` means the space is on the deployment default — which is the right answer for most spaces, and worth confirming before you debug a cost you did not expect. An alias does not read back as an alias. `model="fast"` is resolved when you set it and the space stores the model id it resolved to (today something like `us.amazon.nova-lite-v1:0`), so `get_reason_settings` shows the id. That also means a space set to `fast` stays on that model when the alias later moves — re-set it to follow. ## Step 4 — Undo it ```python theme={null} client.reset_reason_settings(space_id="board-papers") ``` Resetting is better than pinning to today's default by name: a space pinned to an explicit id keeps that model forever, including after we move the default to something better and cheaper. ## Evals 1. Ask the same question of one space on `fast` and on `balanced`. 2. Compare the answers side by side, not in isolation — the cheap model is often indistinguishable on factual recall and noticeably worse on synthesis. 3. Check `get_usage()["credits_used"]` before and after to see the real credit difference. 4. Pick per space based on that, not on the model's reputation. ## Guardrails > [!WARNING] > > **A pinned model is pinned forever.** It will not follow the platform default > forward, so a space pinned today is still on today's model in a year. Reset > rather than re-pin unless you have a reason to freeze it. * **Reason settings are owner-only.** A read-only member of a shared space cannot change what it thinks with. * **This is `reason` only.** The drop-in proxy's model is chosen per call or via [chat settings](/memory/use-cases/events-not-polling); they are separate knobs. * **An unknown model id is a 400, not a fallback.** That is deliberate — silently answering with a different model than you asked for would corrupt any comparison you were trying to make. ## Where to go next [Cut the bill without cutting recall](/memory/use-cases/cut-the-bill) — the other half of the cost story, which is how much context you send rather than which model reads it. # Search across everything the company knows Source: https://docs.anonalabs.com/memory/use-cases/company-wide-search One question, answered from the documents, decisions and conversations you have already fed it. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py)[memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario The answer exists. It is in a design doc, or a thread, or a deck from last quarter, and the person who knows which is on leave. Keyword search fails here because the document does not use your words. You are asking "why do we bill on ingest?" and the doc says "retention-time accounting". ## Step 1 — Put the sources in one space ```python theme={null} for path in ["rfcs/", "decisions/", "postmortems/"]: for f in Path(path).glob("*.md"): client.upload_file(space_id="company", file=str(f), tags=[path.strip("/")]) ``` Tag by source so a later question can be narrowed without a second space. ## Step 2 — Ask, in your own words ```python theme={null} print(client.reason( space_id="company", query="Why do we bill on ingest rather than on storage?", )) ``` Extraction stores meaning, not just tokens, so a question phrased in the words you actually have finds a document written in the words you do not. ## Step 3 — Show where it came from ```python theme={null} res = client.retrieve_receipt( space_id="company", query="billing model rationale", receipt_detail="full", prefer_observations=False, # the facts carry the file; a note does not ) for row in res.memories: if row.get("document_id"): print(row["content"][:100], "→ document", row["document_id"]) ``` A default retrieve often answers with a **note** — a synthesis across several facts, and possibly several documents — whose `document_id` is `null`. Asking for the facts underneath is what gets you a file per answer. For internal search this matters more than it looks. An answer with no source is a rumour, and people are right not to act on it. ## Evals 1. Collect ten questions your team asked in Slack that were answered by a link. 2. Ask all ten and score against the linked document. 3. Deliberately ask one using vocabulary that appears nowhere in the corpus. A semantic system should still find it; if it does not, the source is missing rather than the search being bad. 4. Check `document_id` resolves for every fact you would act on. ## Guardrails > [!WARNING] > > **Access control is per space, not per document.** Everything you upload into > one space is readable by everyone with access to it. Do not mix a public > handbook with board material and rely on the question not being asked — use > separate spaces. * **Stale beats missing, but only just.** An archived RFC answers as confidently as a current one. Tag by status and filter, or [supersede explicitly](/memory/use-cases/correct-a-memory). * **`reason` costs more than `retrieve`.** Use retrieve where the user can read the results themselves, and reason where they need them synthesised. * **Don't ingest everything.** A corpus of drafts, duplicates and superseded versions produces confident contradictions; curate at the folder level. ## Built from Both are complete, runnable scripts: [`document_qa.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py), [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py). # Competitive intelligence that accumulates Source: https://docs.anonalabs.com/memory/use-cases/competitive-intelligence Press releases, pricing changes and job postings become a position you can query, not a folder nobody reads. [← All use cases](/memory/use-cases/overview) [time\_travel.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py)[knowledge\_graph.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/knowledge_graph.py) ## The scenario Everyone on the team notices things about competitors. Someone spots a pricing page change, someone else reads an earnings call, someone notices they are hiring six infrastructure engineers. All of it lands in Slack, scrolls away, and is gone. Three months later nobody can answer "when did they start moving upmarket?" — even though four people watched it happen. ## Step 1 — Record signals with the date they happened ```python theme={null} client.record( space_id="competitors", content="Competitor X moved to usage-based pricing at $0.80 per thousand calls.", timestamp="2026-06-02T00:00:00Z", metadata={"source": "pricing-page", "competitor": "X"}, ) ``` The `timestamp` is the point. A signal recorded without one is undated evidence, and undated evidence cannot answer a question about change. ## Step 2 — Ask what changed over a window ```python theme={null} print(client.reason( space_id="competitors", query="How has Competitor X's pricing and positioning moved since June?", )) ``` ## Step 3 — See who and what keeps coming up ```python theme={null} graph = client.get_graph(space_id="competitors", min_count=3) for node in graph["nodes"][:10]: print(node["label"], node["mention_count"]) ``` Entities are extracted as memories are stored, so the graph is already there. The interesting read is usually the *rising* one: a name that appeared twice last quarter and eleven times this one. ## Evals 1. Record ten dated signals across six months, including two that contradict each other. 2. Ask what changed. A good answer notes the contradiction rather than picking one silently. 3. Re-ask with `occurred_after` set to the midpoint. The answer should narrow. 4. Check `get_graph` surfaces the competitor names you actually recorded, and not a long tail of noise — if it does, your memories are too chatty and want tighter extraction settings. ## Guardrails > [!WARNING] > > **Record public sources only.** Competitor intelligence assembled from > confidential or improperly obtained material does not become acceptable by > being in a database. Keep `metadata["source"]` filled in so anyone can check > where a claim came from. * **Edges are co-occurrence, not typed relationships.** The graph tells you two entities appear together and how often; it does not say one acquired the other. Do not present it as though it does. * **Contradictions are signal, not error.** When a newer fact supersedes an older one, [correct it explicitly](/memory/use-cases/correct-a-memory) with a reason rather than deleting the old one — the change is the intelligence. * **A source that stops reporting looks the same as nothing happening.** Memory cannot tell you about the press release nobody fed it. ## Built from Both are complete, runnable scripts: [`time_travel.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py), [`knowledge_graph.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/knowledge_graph.py). # Contract and policy memory Source: https://docs.anonalabs.com/memory/use-cases/contract-and-policy-memory Which clause, in which agreement, agreed when — with the provenance to prove where the answer came from. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py)[context\_receipts.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/context_receipts.py) ## The scenario Someone asks whether the Acme contract lets them sublicense. The answer is in a PDF, in a clause that amends another clause, in a folder with four versions of the same agreement. Getting it wrong is not a bad search result. It is a commercial exposure. ## Step 1 — Upload the agreements, tagged by counterparty ```python theme={null} client.upload_file( space_id="contracts", file="acme-msa-2026.pdf", tags=["counterparty:acme", "type:msa"], ) ``` ## Step 2 — Ask with the scope pinned ```python theme={null} res = client.retrieve_receipt( space_id="contracts", query="sublicensing rights", tags=["counterparty:acme"], receipt_detail="full", prefer_observations=False, ) for row in res.memories: if row.get("document_id"): print(row["content"], "\n from document", row["document_id"]) ``` Every fact carries the document it came from, so the answer is never detached from its source. An answer is often a **note** — a synthesis built from several facts, possibly from several files — and a note has no single `document_id` (it is `null`; its `source_ids` name the facts behind it). The facts are what carry the file, which is why this asks for them with `prefer_observations=False`. ## Step 3 — Keep the proof ```python theme={null} receipt = client.get_receipt(res.receipt_id) for cut in receipt["excluded"]: print("not shown:", cut["reason"], cut["detail"]) ``` For legal work the *excluded* list matters as much as the results. A clause that was found and cut for budget is a different situation from one that was never matched — and only the receipt distinguishes them. ## Evals 1. Load two versions of the same agreement, one superseding the other. 2. Ask a question the two answer differently. Check which one comes back, and whether the other appears in the receipt's excluded list. 3. Ask about a counterparty you have no contract for. It must return nothing rather than the nearest other agreement. 4. Pick three answers and trace each to its `document_id` by hand. If any cannot be traced, stop using it for anything that matters. ## Guardrails > [!WARNING] > > **This does not give legal advice and must never be presented as doing so.** > It finds and summarises text. A summary of a clause is not the clause, and a > retrieval system has no view on which of two conflicting provisions governs. > Route anything consequential to a lawyer with the source document attached. * **Superseded versions stay retrievable unless you say otherwise.** That is usually right for contracts — but mark the old one explicitly with [a correction and a reason](/memory/use-cases/correct-a-memory) so the status is recorded rather than inferred. * **Never paste a clause into a prompt from memory alone** when the exact wording is operative. Use the result to find the document, then read it. * **A receipt carries ids and reasons, never content**, which is what makes a receipt id safe to put in a ticket even when the underlying agreement is confidential. ## Built from Both are complete, runnable scripts: [`document_qa.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py), [`context_receipts.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/context_receipts.py). # Correct a memory without losing the record Source: https://docs.anonalabs.com/memory/use-cases/correct-a-memory Supersede what turned out to be wrong, with the reason attached and the old version still readable. [← All use cases](/memory/use-cases/overview) [memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py)[time\_travel.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py) ## The scenario The system learned that Priya owns the billing service. Priya moved teams in August. Every answer since has been confidently wrong, and the customer-facing agent has been routing escalations to someone who left. Deleting the memory fixes today's answer and destroys the record. Six months from now nobody can explain why the agent said what it said in July — and for a regulated workload, "we deleted it" is the wrong answer to an auditor. ## Step 1 — Supersede it, with the reason ```python theme={null} client.update_memory( space_id="platform", memory_id=memory_id, state="invalidated", reason="Priya moved to the data team on 2026-08-01; Marcus now owns billing.", ) ``` `state="invalidated"` takes it out of retrieval without removing it. The `reason` is stored alongside, so the correction is self-documenting rather than a mysterious gap. ## Step 2 — Record what is true now ```python theme={null} client.record( space_id="platform", content="Marcus owns the billing service and is on call for it.", timestamp="2026-08-01T00:00:00Z", ) ``` Give it the date the change *happened*, not today. That is what makes "who owned billing in July?" answerable later. ## Step 3 — Read the history ```python theme={null} history = client.get_memory_history(space_id="platform", memory_id=memory_id) for entry in history["history"]: print(entry["changed_at"], "|", entry["reason"], "|", entry["previous_content"][:80]) ``` This is the audit trail. Not "the value changed" but *what it was, when it changed, and why somebody changed it*. ## Step 4 — Fix a detail without invalidating For a wrong date or a typo, correct the field rather than superseding the whole memory: ```python theme={null} client.update_memory( space_id="platform", memory_id=memory_id, occurred_start="2026-08-01T00:00:00Z", reason="Recorded with the wrong start date.", ) ``` ## Evals 1. Record a fact, retrieve it, confirm it comes back. 2. Invalidate it with a reason. Retrieve again — it must not come back. 3. Read the history and confirm the old content and the reason are both intact. 4. Ask a question that the *superseded* fact would have answered. Check the answer now reflects the replacement rather than sitting between the two. ## Guardrails > [!WARNING] > > **Invalidating is not deleting, and for a data-subject request you need > deleting.** An invalidated memory is still stored and still readable through > its history. If someone has asked you to erase their data, follow > [the deletion runbook](/memory/use-cases/delete-a-users-memories) instead. * **Always pass a `reason`.** A correction with no reason is indistinguishable six months later from a mistake, and it is the field an auditor reads first. * **Correcting is owner-only.** A read-only member of a shared space cannot invalidate, by design. * **Do not fix a wrong fact by recording a right one and leaving both.** Retrieval will happily return the pair, and the model will average them. ## Built from Both are complete, runnable scripts: [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py), [`time_travel.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py). The full field list is on [Memories](/memory/api-reference/memories). # Cut the bill without cutting recall Source: https://docs.anonalabs.com/memory/use-cases/cut-the-bill Cap what memory is allowed to spend of your prompt, and measure what the provider's cache gave back. [← All use cases](/memory/use-cases/overview) [drop\_in\_proxy.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/drop_in_proxy.py)[memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario Memory makes every prompt bigger. That is the deal — and it is fine until the bill arrives and nobody can say how much of it was memory, or whether the tenth recalled item was worth anything. Two levers, and both are measurable rather than vibes. ## Step 1 — Put a hard ceiling on the block ```python theme={null} context = client.get_context( space_id="support", query="why did the proxy fix not work?", user_id="acme-corp", max_tokens=600, ) ``` `get_context` returns a prompt-ready string with the budget enforced server-side. Whole memories are dropped, lowest-ranked first — never text cut mid-sentence, which would leave the model reading half a fact. This beats a client-side loop because your loop does not know the ranking. ## Step 2 — Make the block cacheable Providers discount an unchanged prompt *prefix*. A memory block reordered by relevance every turn destroys that prefix on every turn. ```python theme={null} llm = OpenAI( api_key=os.environ["ANONA_API_KEY"], base_url="https://api.anonalabs.com/v1", default_headers={ "X-Anona-Space-Id": "support", "X-Anona-Block-Order": "stable", }, ) ``` `stable` orders by when memories were created rather than by score, so the block mostly does not change between turns and the discount can apply. ## Step 3 — Read what it actually cost Dashboard → **Usage** → **Memory in your prompts**. It appears once the proxy has carried a turn, and shows three readings for the last 30 days: * **Context injected** (`context_tokens`) — what memory added to your prompts. * **Cache-eligible** (`cache_prefix_tokens`) — how much of that was unchanged since the previous turn: the measured eligibility for a provider discount, not an estimate of savings. * **Stable share** — the second as a percentage of the first. These are dashboard figures. `client.get_usage()` is the API-key view of your **credits** (`credits_remaining`, `credits_limit`, `credits_used`, `rate_limit_per_min`) and does not carry them. ## Evals 1. Run twenty representative turns with `max_tokens` unset. Record **Context injected** from the Usage page. 2. Re-run with a ceiling at roughly half. Score answer quality on the same questions. 3. Find the point where quality drops. That number is your budget; most spaces sit well below the default. 4. Run the same twenty turns in `relevance` and then `stable` order and compare **Cache-eligible** after each. Relevance mode should read near zero — that contrast is the evidence the setting is doing anything. ## Guardrails > [!WARNING] > > **`cache_prefix_tokens` reports eligibility, not money saved.** Whether a > discount applies is the provider's business, and it depends on their cache > state. Do not present this figure to a customer as a saving. * **Stable ordering trades freshness for cacheability.** The most relevant memory is no longer first in the block. If answer quality drops, this is the first thing to turn off. * **Budget selection happens in relevance order, then emission is reordered** — so a tighter budget drops the *weakest* memory, not the newest one. * **There is no baseline to compare against.** What you would have spent without a memory layer is unobservable, and any "X% saved" number built on a guessed baseline will not survive being asked how it was calculated. ## Built from Both are complete, runnable scripts: [`drop_in_proxy.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/drop_in_proxy.py), [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py). # Delete a user's memories, provably Source: https://docs.anonalabs.com/memory/use-cases/delete-a-users-memories A data-subject request answered end to end: find everything about one person, export it, remove it, show your work. [← All use cases](/memory/use-cases/overview) [support\_bot\_scoping.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py)[user\_profiles.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py) ## The scenario Someone emails asking what you hold about them, and then asks you to delete it. You have a deadline measured in days, and "it is spread across a memory system" is not an answer you can send. The whole thing turns on one earlier decision: whether writes were scoped. If every memory about a person carries their scope, this is a runbook. If not, it is a manual search you cannot certify. ## Step 1 — Find everything about them ```python theme={null} def everything_about(user_id): rows, offset = [], 0 while True: page = client.list_memories( space_id="support", user_id=user_id, prefer_observations=False, # the raw facts as well as the notes limit=200, offset=offset, ) rows += page["items"] offset += len(page["items"]) if not page["items"] or offset >= page["total"]: return rows hits = everything_about("user-4417") ``` This is a list, not a search. `retrieve` ranks by relevance to a query and stops at `limit`, which is the wrong tool for "everything" — and it has no match-all query (`query="*"` is refused with `422`). `list_memories` pages the whole scope and tells you the `total`, so you know when you have all of it. `prefer_observations=False` matters: by default a note hides the raw facts it was built from, and those facts are data about the person too. A scoped read is strict — it matches only memories carrying that scope, and never untagged ones. That strictness is exactly what you want here: the result is defensible rather than best-effort. ## Step 2 — Give them a copy first The subject is usually entitled to the data before it goes. `hits` from step 1 *is* the per-person copy — every row, with its content and dates — so write it to a file before anything is deleted: ```python theme={null} import json with open("user-4417-export.json", "w") as f: json.dump(hits, f, indent=2) ``` If you also want the full-space bundle for your own records, take it from the dashboard first — see [Export Your Memory](/memory/guides/export). Full-space export is dashboard-only; there is no API-key endpoint for it. ## Step 3 — Delete, one by one, and keep the ids ```python theme={null} from anona import AnonaError removed, already_gone = [], [] for row in hits: try: client.delete_memory(space_id="support", memory_id=row["id"]) removed.append(row["id"]) except AnonaError as e: if e.code != "memory_not_found": raise already_gone.append(row["id"]) print(len(removed), "deleted,", len(already_gone), "removed with a fact they came from") ``` Deleting a fact also deletes every note built from it, so a note further down `hits` can already be gone by the time the loop reaches it. That is a `404 memory_not_found`, and it is success, not failure — keep it in its own list rather than stopping the loop. Keep both lists. The ids, with a timestamp, are what you attach to your response to the request. ## Step 4 — Prove it ```python theme={null} assert everything_about("user-4417") == [] ``` Re-running the same scoped query and getting nothing is the evidence. Run it as a test, not by eye. ## Evals 1. Write ten memories for one user and five for another. 2. Run the runbook for the first. Confirm every id step 1 found ends up in `removed` or `already_gone`, and none of them raised anything else. 3. Confirm the second user's five are untouched — the most important assertion here, and the one a manual process gets wrong. 4. Re-run the scoped list and assert empty. ## Guardrails > [!WARNING] > > **Deletion is permanent and there is no undo.** Export first, every time, and > have the exported bundle in hand before the first `delete_memory` call. * **Unscoped memories are invisible to this process.** If you adopted scoping late, memories written before it carry no `user_id` and a scoped query will never find them — meaning your deletion is incomplete and you will not be told. Scope from day one, or backfill before you promise anything. * **A shared space needs the same runbook run by its owner.** Deleting is owner-only; a visitor cannot erase data from a space they were invited to. * **Consolidated observations are derived from raw facts, and they go with them.** Deleting a fact removes every observation built from it and re-consolidates that observation's other sources without it. Re-check with a scoped read afterwards anyway — that read, not the delete's status code, is the evidence you keep. * **This page is a runbook, not legal advice.** What your obligations are, and within what deadline, depends on your jurisdiction and your role as controller or processor. ## Built from Both are complete, runnable scripts: [`support_bot_scoping.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py), [`user_profiles.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py). # One line, keep your OpenAI SDK Source: https://docs.anonalabs.com/memory/use-cases/drop-in-proxy Point a stock OpenAI client at Anona. Every call recalls what matters and stores what is new. No rewrite. [← All use cases](/memory/use-cases/overview) [drop\_in\_proxy.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/drop_in_proxy.py) ## The scenario You already have working code against the OpenAI SDK. You want memory, and you do not want to restructure the app, learn a retrieval API, or hand-assemble a prompt. Change the base URL. ## Step 1 — Repoint the client ```python theme={null} llm = OpenAI( api_key=os.environ["ANONA_API_KEY"], # your Anona key, not an OpenAI key base_url="https://api.anonalabs.com/v1", default_headers={"X-Anona-Space-Id": "support"}, ) ``` The header is set once and applies to every call, which is the point — a stock SDK has `default_headers` and no other hook. ## Step 2 — Use it exactly as before ```python theme={null} first = llm.chat.completions.create( model="balanced", messages=[{"role": "user", "content": "My cat is named Miso and she only eats salmon."}], ) ``` The turn is recalled into the prompt and recorded back, in the background of the call. ## Step 3 — A new conversation still knows ```python theme={null} second = llm.chat.completions.create( model="balanced", messages=[{"role": "user", "content": "What is my cat's name?"}], ) ``` A brand-new message list, no history passed, and the answer is right. That is the whole product in two calls. ## Step 4 — Tune per call, or per space Every option has a header, so a stock SDK can set it: `X-Anona-User-Id`, `X-Anona-Memory-Limit`, `X-Anona-Memory` (off), `X-Anona-Auto-Record`, `X-Anona-Block-Order`. Set them once on the space instead with [chat settings](/memory/use-cases/events-not-polling). `X-Anona-User-Id` per end user turns a single-tenant app multi-tenant with no call-site change at all. ## Evals 1. Run two conversations as above. The second must answer from the first. 2. Set `X-Anona-Memory: false` and confirm it does not — [that comparison is your A/B](/memory/use-cases/prove-memory-helps). 3. Set `X-Anona-User-Id` to two different values and confirm they do not share. ## Guardrails * **Extraction is not instant.** A question asked immediately after the fact was mentioned may miss it — allow up to a minute. The example waits until the turn's memory has landed (it polls `list_memories`) rather than sleeping for a fixed time, because a fixed 10 seconds was not enough on a busy day. * **`model` is ours, not OpenAI's.** Use a tier alias like `balanced` or an id from `GET /v1/models`. An unknown name is a `400`, not a silent fallback. * **Anthropic and Responses shapes work too**, on `/v1/messages` and `/v1/responses`, with the same headers. `x-api-key` is accepted anywhere `Authorization` is, because the Anthropic SDK never sets the latter. ## The script Complete and runnable: [`drop_in_proxy.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/drop_in_proxy.py). # Who your agent keeps meeting Source: https://docs.anonalabs.com/memory/use-cases/entity-graph Extraction names the people, companies and systems in every memory. Read the graph it has been building. [← All use cases](/memory/use-cases/overview) [knowledge\_graph.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/knowledge_graph.py) ## The scenario You have been recording facts for months. Nobody built an ontology, nobody tagged anything — and there is already a graph of who and what keeps appearing together, because extraction names entities as it stores each memory. ## Step 1 — Read the graph ```python theme={null} graph = client.get_graph(space_id=space) for edge in graph["edges"]: print(edge["source_label"], "—", edge["target_label"], f"(x{edge['weight']})") ``` Nodes are entities; edges are **co-occurrence** — two entities appearing in the same memory, weighted by how often. ## Step 2 — Address one entity ```python theme={null} entities = client.list_entities(space_id=space) first = client.get_entity(space_id=space, entity_id=entities[0]["id"]) print(first["name"], "mentioned", first["mention_count"], "times") for obs in first["observations"]: print(obs) ``` `observations` fills as the space consolidates what it has stored about that entity — which is how you get a dossier nobody wrote. ## Step 3 — Cut the noise ```python theme={null} client.get_graph(space_id=space, min_count=3) ``` On a real space most entities are mentioned once. `min_count` is what turns a hairball into something readable. ## Evals 1. Record four facts that connect three entities. Check the expected edges exist with the expected weights. 2. Raise `min_count` and confirm the long tail drops out. 3. Fetch an entity and check `observations` is populated — if it is empty on a busy space, consolidation has not run yet rather than failed. ## Guardrails > [!WARNING] > > **Edges are co-occurrence, not typed relationships.** The graph says two > entities keep showing up together. It does not say one *owns*, *acquired* or > *reports to* the other. Do not build a UI, or a sales claim, that implies > typed predicates. * **Entity names come from extraction**, so the same person can appear under two spellings if your source text does. * **Read it as a map, not a database.** It is excellent for "what is this space mostly about" and wrong for anything needing exactness. ## The script Complete and runnable: [`knowledge_graph.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/knowledge_graph.py). # Know the moment ingestion finishes Source: https://docs.anonalabs.com/memory/use-cases/events-not-polling Webhooks instead of polling, plus the per-space defaults that stop you resending the same options. [← All use cases](/memory/use-cases/overview) [webhooks\_and\_settings.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/webhooks_and_settings.py) ## The scenario Polling a job every five seconds works in a script and is wrong in a pipeline. And if every call in your codebase passes the same three options, those belong on the space, not in the call. Three per-space configuration surfaces, all free to call. ## Step 1 — Register a webhook ```python theme={null} hook = client.create_webhook( space_id=space, url="https://example.com/anona-hook", event_types=["memory.created"], ) print(hook["id"], hook["event_types"]) ``` > [!WARNING] > > **The signing secret appears exactly once, in this response.** Store it now and > verify every delivery against it. There is no way to read it back. ## Step 2 — Manage and inspect it ```python theme={null} client.update_webhook(space_id=space, webhook_id=hook["id"], enabled=False) deliveries = client.list_webhook_deliveries(space_id=space, webhook_id=hook["id"]) ``` Deliveries are inspectable after the fact — status, attempt count, response codes. That is the difference between "the webhook is broken" and "your endpoint returned 500 four times". ## Step 3 — Set the proxy's defaults on the space ```python theme={null} client.set_chat_settings(space_id=space, memory_limit=8, auto_record=True) print(client.get_chat_settings(space_id=space)) ``` A stock OpenAI SDK pointed at the proxy now gets these without per-call knobs. Every field is nullable, and **null means unset, not off** — which is what lets a half-configured space inherit the rest of its defaults from the platform. Read before you write, because a PUT is a full replace: an omitted field is cleared. ```python theme={null} client.reset_chat_settings(space_id=space) ``` ## Step 4 — Tune extraction ```python theme={null} client.set_extraction_settings(space_id=space, mode="verbose") ``` ## Evals 1. Register a webhook against a request-bin URL, write a memory, confirm a delivery arrives and its signature verifies. 2. Point it at an endpoint that returns 500 and check the delivery list records the attempts. 3. Set chat settings, make a proxy call with no options, confirm the defaults applied. ## Guardrails * **A settings write is a full replace.** An omitted field is cleared, not left alone — read, modify, write. * **Resetting extraction settings clears everything on that surface**, including a file-RAG strategy set at space creation. Prefer a targeted write. * **Config routes are free and are not credit-gated** on purpose: an org out of credits must still be able to turn things off. ## The script Complete and runnable: [`webhooks_and_settings.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/webhooks_and_settings.py). # Financial analyst across quarters Source: https://docs.anonalabs.com/memory/use-cases/financial-analyst Ask what changed between Q2 and Q4 and get an answer grounded in what was said at the time. [← All use cases](/memory/use-cases/overview) [time\_travel.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py)[memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario The numbers are in a spreadsheet. The *reasons* are in eight earnings transcripts, a dozen board memos and a year of management commentary — and the question you actually have is "what did they say about margin in Q2, and does it still hold?" A search engine over those documents returns the paragraphs. It does not tell you what changed. ## Step 1 — Load the documents, dated ```python theme={null} for path, when in [("q2-earnings.pdf", "2026-06-30"), ("q3-earnings.pdf", "2026-09-30")]: client.upload_file(space_id="filings", file=path, tags=[f"period:{when[:7]}"]) ``` Tags carry the period so a later question can be pinned to one, and uploads are asynchronous — poll `get_job`, or take the [webhook route](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/webhooks_and_settings.py). ## Step 2 — Bound the question by period ```python theme={null} client.retrieve( space_id="filings", query="gross margin commentary", tags=["period:2026-06"], tags_match="any_strict", ) ``` The period tag is what pins the question to a quarter, and it has to be: everything was uploaded on one afternoon and none of it happened then, but an uploaded file does not carry event time today — the `occurred_after` / `occurred_before` filters match nothing extracted from a file. They work on memories you [`record` with a timestamp](/memory/use-cases/what-was-true-back-then); for documents, the tag you set on upload is the date. `any_strict` matters: the plain `any` mode also matches untagged memories, which would let undated material back into a pinned question. ## Step 3 — Ask the comparative question ```python theme={null} print(client.reason( space_id="filings", query="What did management say about margin pressure in Q2, and how did that change by Q3?", )) ``` ## Step 4 — Keep the receipt ```python theme={null} res = client.retrieve_receipt(space_id="filings", query="margin pressure", receipt_detail="full") print(res.receipt_id) ``` Store the receipt id next to the answer. When somebody challenges the number six months from now, [the receipt says exactly what the model saw](/memory/use-cases/why-the-answer-used-what-it-used). ## Evals 1. Load two consecutive quarters where you know a metric moved. 2. Ask the comparative question. Check both periods are actually represented in the answer, not just the more recent one. 3. Re-run pinned to one quarter's tag and confirm the other drops out — if it does not, check the tag actually went on at upload. 4. Pull the receipt and confirm the cited memories come from both filings. ## Guardrails > [!WARNING] > > **This is not investment advice, and it must not be presented as any.** The > system retrieves and summarises what was said. It does not know whether the > guidance was met, and it will summarise a misleading statement as confidently > as an accurate one. * **Numbers in prose are the weakest part.** An extracted memory is text, and a figure that matters should be checked against the filing rather than trusted from a summary. The receipt gives you the path back. * **`relevance_score` can exceed 1.0** and is not a confidence. It is a ranking signal within one result set. * **Uploads carry no event time.** Every fact from a file is undated, so the `occurred_*` filters and point-in-time reads cannot tell one filing's quarter from another's. Tag the period on upload, as above, and filter on the tag. ## Built from Both are complete, runnable scripts: [`time_travel.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py), [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py). # Give the space a mission Source: https://docs.anonalabs.com/memory/use-cases/give-the-space-a-mission Tell it what it is for, and how skeptical to be. It changes what gets kept and how it is read back. [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/models) ## The scenario Two spaces with identical contents should not behave identically. A space of incident postmortems wants literal, cautious reading. A space of customer conversations wants inference — reading between the lines is the job. **What this recipe shows that no other does:** a space has a *disposition*, and it is readable. ## Step 1 — Read the profile ```python theme={null} profile = client.get_space_profile(space_id="incidents") print(profile["mission"]) print(profile["disposition"]) ``` You get back two things: * **`mission`** — what this space is for, in prose. It rides along with extraction and synthesis, so it shapes what is considered worth keeping. * **`disposition`** — three dials: `skepticism`, `literalism`, `empathy`. ## What the dials actually mean | Dial | Low | High | | - | - | - | | **skepticism** | Takes statements at face value | Treats claims as claims, keeps the hedge | | **literalism** | Infers intent, reads between lines | Keeps what was said, not what was meant | | **empathy** | Records facts | Keeps the emotional register — frustration, hesitancy | A support space usually wants low literalism and some empathy: "the customer was clearly fed up" is the useful memory, not a transcript. A postmortem space wants the opposite, because an inferred cause recalled later as fact is how one incident becomes two. ## Step 2 — Read it before you trust a synthesis Disposition explains answers that otherwise look like bugs. A space that keeps hedges will answer "they *said* the migration was complete" where a low-skepticism space answers "the migration was complete" — and only one of those is safe to act on. When a synthesis reads oddly confident, check the profile before you blame recall. ## Evals 1. Record the same ambiguous statement into two spaces with different profiles — something like *"I think we probably shipped it last week."* 2. Ask both what happened. 3. The literal space should keep the hedge; the inferring one should commit. If they answer identically, the profile is not doing what you think and the rest of your tuning is built on a wrong assumption. ## Guardrails > [!WARNING] > > **Disposition is not a safety control.** A high-skepticism space still stores > what it was told; it is a reading style, not a filter, and it will not stop a > false statement from being recalled. * **The profile is per space**, so it is another reason to split by audience — see [One space or many?](/memory/use-cases/one-space-or-many). * **Do not echo the resolved profile back as your own config.** What you read includes platform defaults; writing those back as explicit overrides pins the space to today's defaults forever. * **A mission is not a prompt.** It biases what is kept and how it is read; it does not instruct the model to answer in a particular way. For that, use [extraction guidance](/memory/use-cases/teach-it-your-vocabulary). # Incident copilot that has read every postmortem Source: https://docs.anonalabs.com/memory/use-cases/incident-copilot The last four times this broke, what fixed it, and who knew — surfaced while it is still broken. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py)[time\_travel.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py) ## The scenario It is 3am and the symptom is familiar. Somebody has seen this before. That person is asleep, and the postmortem they wrote is in a folder nobody can search under pressure. The cost of not finding it is measured in minutes of downtime, which is the one currency where a memory layer's value is uncontroversial. ## Step 1 — Load the postmortems, dated ```python theme={null} client.upload_file( space_id="incidents", file="2026-08-08-pgbouncer-exhaustion.md", tags=["service:gateway", "severity:sev1"], ) ``` ## Step 2 — Ask with the symptom, not the cause ```python theme={null} print(client.reason( space_id="incidents", query="Gateway tasks failing to start, connection errors, database connections pinned at 21. Seen before?", )) ``` You do not know the cause yet — that is the situation. Describe what you can see, which is exactly the query a semantic system handles and a keyword search does not. ## Step 3 — Narrow to what happened recently ```python theme={null} client.retrieve( space_id="incidents", query="connection pool exhaustion", occurred_after="2026-01-01T00:00:00Z", tags=["service:gateway"], ) ``` ## Step 4 — Write the new one back ```python theme={null} client.record( space_id="incidents", content="Recovery order matters: redeploy pgbouncer first, then the gateway. The reverse fails because new tasks cannot boot.", timestamp="2026-08-08T04:10:00Z", tags=["service:gateway"], ) ``` The loop only compounds if this step happens. A corpus that is read but never written to decays into history. ## Evals 1. Take your last five incidents. For each, ask using only the symptoms visible in the first ten minutes. 2. Score whether the matching postmortem surfaced in the top three results. 3. Include one novel symptom. It must return nothing rather than the nearest familiar incident — a confident wrong match at 3am is worse than silence. 4. Re-run monthly. A falling score means the corpus is drifting from production. ## Guardrails > [!WARNING] > > **Do not let it run remediation.** This surfaces what happened last time; a > human decides whether this time is the same. Past fixes applied to > superficially similar symptoms are a well-known way to turn one incident into > two. * **Postmortems name people.** Write about decisions and systems, not blame — it is retrievable forever and reads worse out of context. * **Set the incident's own timestamp.** A year of postmortems uploaded in one afternoon all happened that afternoon otherwise, and "has this been getting more frequent?" becomes unanswerable. * **Tag by service.** Cross-service noise is what makes an incident search useless at the moment you need it. ## Built from Both are complete, runnable scripts: [`document_qa.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py), [`time_travel.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py). # Keep your own promises Source: https://docs.anonalabs.com/memory/use-cases/keep-your-promises What you said you'd do, who you said it to, and which ones you quietly dropped. [← All use cases](/memory/use-cases/overview) ## The scenario Commitments are made in passing. "I'll send that over." "I'll look at it this week." "Let's pick this up after the release." None of them reach a task list, because at the moment you say them you are in the middle of something else. A week later the other person remembers and you don't. **What this recipe shows that no other does:** asking a question bounded by *time* — not "what do I know" but "what did I say between Monday and Friday". ## Setup The same one-time MCP connection as [Remember the people you meet](/memory/use-cases/remember-people). Nothing extra. ## Using it Commitments get recorded as you make them, because you mention them out loud while working: > *"Told the design team I'd have the API shapes to them by Thursday."* Then, on Friday: > *"What did I say I'd do this week, and what have I not done?"* And the one that actually changes behaviour: > *"What have I been promising for more than a month?"* That question is only answerable because every memory carries **when the thing happened**, separately from when it was written down. It is the same mechanism as [What was true back then](/memory/use-cases/what-was-true-back-then), pointed at yourself. ## Evals 1. Record four commitments across two weeks, with two deliberately stale. 2. Ask what is outstanding this week. Expect the recent two. 3. Ask what has been outstanding longest. Expect the stale two, oldest first. 4. Complete one out loud ("sent the API shapes"). Ask again — a good answer stops listing it. If it keeps listing it, you need to [supersede the old memory](/memory/use-cases/correct-a-memory) rather than add to it. Step 4 is the one people skip, and it is why these systems rot: a list that only grows is a list you stop reading. ## Guardrails > [!WARNING] > > **This is not a task manager and should not be trusted like one.** It recalls > what you said; it has no deadlines, no reminders and no notion of done unless > you tell it. Anything with a real consequence belongs in a system with dates > and alarms. * **It only knows what you said near it.** A promise made in a meeting you didn't narrate is invisible — see [Think out loud](/memory/use-cases/think-out-loud) for closing that gap. * **Saying you did something does not remove the original.** Both memories exist; the assistant reconciles them at read time, which is usually right and occasionally confidently wrong. * **Beware self-incrimination by search.** This produces a tidy, dated, quotable list of everything you failed to deliver. Keep the space private. # Turn a meeting recording into memory Source: https://docs.anonalabs.com/memory/use-cases/meeting-recording-to-memory Drop in the audio. It comes back as transcribed, searchable memory nobody had to write up. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py)[background\_ingestion.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/background_ingestion.py) ## The scenario The decision was made on a call. Nobody wrote it down, or somebody wrote down half of it, and three weeks later the team is relitigating a thing that was already settled. The recording exists. It is just not *answerable* — nobody is going to scrub forty minutes of audio to check who agreed to what. ## Step 1 — Upload the audio Audio is a file like any other. `upload_file` takes MP3 and WAV, transcribes them, and stores the result as ordinary memory in the same space as everything you have recorded by hand. ```python theme={null} job = client.upload_file( space_id="team-meetings", file="standup-2026-09-14.mp3", tags=["meeting", "platform-team"], ) ``` Ingestion is asynchronous, so this returns `{"job_ids": [...]}` rather than waiting. Tag it on the way in — that is what lets you later ask about one team's meetings and not everyone's. ## Step 2 — Wait for it, or be told ```python theme={null} while True: status = client.get_job(space_id="team-meetings", job_id=job["job_ids"][0]) if status["status"] in ("completed", "failed"): break time.sleep(5) ``` For anything beyond a script, register a webhook instead of polling — see [Know the moment ingestion finishes](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/webhooks_and_settings.py). ## Step 3 — Ask the meeting a question ```python theme={null} print(client.reason( space_id="team-meetings", query="What did we decide about the pricing change, and who raised concerns?", )) ``` The transcript is memory now, so it composes with everything else: a question can draw on the call, the design doc you uploaded, and the notes somebody typed. ## Evals 1. Upload one recording where you know the answer to a specific question. 2. Ask that question. Check the answer names the right person. 3. Ask something the recording does **not** cover. A useful system says it does not know; if it invents an answer, your `min_score` floor is too low — see [Tuning Recall Quality](/memory/guides/tuning-recall). 4. Upload a second recording from a different team with different tags, and confirm a tagged query does not cross over. ## Guardrails > [!WARNING] > > **Record people knowingly.** A meeting recording is personal data about > everyone on the call, and it becomes searchable by everyone with access to > the space. Get consent, and scope the space so it reaches only who it should. > If someone later asks to be removed, [that is a runbook](/memory/use-cases/delete-a-users-memories), > not an afternoon of grep. * **Per-file limits apply**: 25 MB for audio, 64 MB for video. A long call may need splitting. * **A transcript is not a verbatim record.** It is good enough to answer questions and to point you at the moment; it is not evidence. Keep the original file if the exact words matter. * **Tag on upload, not afterwards.** There is no bulk retag, so an untagged corpus stays untagged. ## Built from Every call above appears in one of these two runnable scripts: [`document_qa.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py), [`background_ingestion.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/background_ingestion.py). # Memory for your coding agent Source: https://docs.anonalabs.com/memory/use-cases/memory-for-your-coding-agent Architecture decisions, past fixes and house conventions that survive the end of the session. [← All use cases](/memory/use-cases/overview) [memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py)[multi\_agent\_shared\_space.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/multi_agent_shared_space.py) ## The scenario Your coding agent is excellent for an hour and then the session ends. Next time it re-proposes the pattern you rejected, re-introduces the bug you fixed in March, and re-asks why the module is structured that way. The codebase is in its context. The *reasoning about* the codebase is not — that lived in conversations, pull request threads and a decision nobody wrote down. ## Step 1 — Connect over MCP The fastest path is no code at all. Point the agent at our MCP endpoint and it gets `record`, `retrieve`, `reason` and `list_spaces` as tools. ```bash theme={null} claude mcp add --transport http anona https://memory.anonalabs.com/mcp \ --header "Authorization: Bearer anona_live_YOUR_KEY" ``` MCP tools load at session start, so restart the agent after adding it. Full setup, including keyless OAuth, is on the [MCP page](/memory/mcp-integration). ## Step 2 — Give it the habit, not just the ability MCP gives an agent the *ability* to remember. It will not reliably do so unless told when. Our [Agent Skill](/memory/integrations/skills) installs that habit — recall before a task, record after it — without you writing prompt scaffolding. ```bash theme={null} npx skills add anonalabs/Anona-Memory-SDK ``` ## Step 3 — Record decisions, not diffs Git already has your diffs. What it does not have is why. ```python theme={null} client.record( space_id="platform", content="We rejected connection pooling in the worker: it pins a pgbouncer slot for the poll interval.", agent_id="claude-code", metadata={"repo": "platform", "kind": "decision"}, ) ``` Scoping by `agent_id` keeps one agent's working notes separable while leaving the decisions readable by everyone — the same shape as [Two agents, one space](/memory/use-cases/two-agents-one-space). ## Evals 1. Record three decisions. Start a fresh session. Ask the agent why one of them was made, and check it retrieves rather than reasons from the code. 2. Ask about something never recorded. It should say so — an agent that confabulates a decision is worse than one with no memory. 3. Re-run a task the agent previously got wrong. The correction should surface. 4. Check the write actually happened: `list_memories` after the session, rather than trusting that the tool was called. ## Guardrails > [!WARNING] > > **Never record secrets.** Agents paste freely, and a key or connection string > written into memory is retrievable by everyone with access to the space and > will resurface in later prompts. Keep credentials out of the content you store. * **A shared team space needs a convention** for what is worth recording. Without one it fills with restatements of the code, which crowds out the decisions. * **Record the rejected option and why.** "We use X" is weak; "we rejected Y because Z" stops the next agent proposing Y. * **MCP tools only load at session start** — adding the server mid-session exposes nothing until you restart. ## Built from Both are complete, runnable scripts: [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py), [`multi_agent_shared_space.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/multi_agent_shared_space.py). # Onboarding agent for new hires Source: https://docs.anonalabs.com/memory/use-cases/onboarding-agent New starters ask the questions they are embarrassed to ask, and get answers from your real docs. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py)[memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario A new engineer has thirty questions in week one and will ask maybe six of them. The other twenty-four are the ones that feel too basic to interrupt someone over — what does this acronym mean, who owns this service, why is there a second staging environment. Those are exactly the questions a document set can answer, and exactly the ones nobody searches for because they do not know the vocabulary yet. ## Step 1 — Load what already exists ```python theme={null} for path in ["onboarding.md", "architecture.md", "oncall-runbook.pdf", "org-chart.csv"]: client.upload_file(space_id="handbook", file=path, tags=["internal"]) ``` No curation pass. The point of extraction is that you do not have to pre-chunk or pre-summarise; upload what you have. ## Step 2 — Answer in the new hire's words, not yours ```python theme={null} print(client.reason( space_id="handbook", query="Who do I ask about deploys, and is there anything I should not do in my first week?", )) ``` `reason` runs a multi-step loop rather than a single search, which is what lets a vague question find a specific answer. ## Step 3 — Let it learn what was missing When the agent cannot answer, that is a gap in your documentation, and it is worth capturing. ```python theme={null} client.record( space_id="handbook", content="New hires keep asking which environment is safe to test migrations in; it is not documented.", metadata={"kind": "doc-gap"}, ) ``` After a month, ask it what people keep asking about. That list is your documentation backlog, written by the people who needed it. ## Evals 1. Take ten real questions from your last new starter's Slack history. 2. Ask all ten. Score how many are answered correctly from the docs alone. 3. For each failure, check the receipt: was the answer *not retrieved*, or retrieved and cut? Those have different fixes — [see the receipt recipe](/memory/use-cases/why-the-answer-used-what-it-used). 4. Re-run after adding the missing document. The score is the thing to track. ## Guardrails > [!WARNING] > > **An internal handbook contains things not everyone should read** — salary > bands, security procedures, unreleased plans. A space is readable by everyone > it is shared with, so put restricted material in a different space rather than > relying on the question not being asked. * **Stale docs answer confidently.** Memory does not know your runbook is two reorganisations old. Date what you upload, and prefer [a correction with a reason](/memory/use-cases/correct-a-memory) to a silent re-upload. * **Do not point it at a wiki dump and walk away.** A large corpus of contradictory drafts produces confident contradictions. * **Uploads are asynchronous.** The first question asked thirty seconds after an upload may legitimately find nothing. ## Built from Both are complete, runnable scripts: [`document_qa.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py), [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py). # One space or many? Source: https://docs.anonalabs.com/memory/use-cases/one-space-or-many The decision you make once, early, and cannot cheaply undo. Here is how to make it right. [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/spaces) ## The scenario A space is the unit of isolation, of sharing, of configuration and of deletion. Split too finely and nothing can see anything else; too coarsely and you cannot share one part without sharing all of it. **What this recipe shows that no other does:** the space itself as a design decision, and the four calls that manage its lifecycle. ## The rule **Split by who may read it, never by what it is about.** Topics are what search is for — two subjects in one space cost you nothing, because a query about one will not return the other. Audiences are what a space is for, and no query filter can retrofit an audience boundary. So: one space per readership. Within it, use [scope keys](/memory/use-cases/support-agent-per-customer) for per-user separation and tags for topic. ## Step 1 — Create deliberately ```python theme={null} space = client.create_space("Customer support", description="Ticket history, shared with the support vendor") print(space["space_id"]) ``` A space is also created implicitly by the first write to an unknown id — which is convenient and is how typos become permanent. Creating on purpose means a mistyped id fails loudly later instead of silently becoming a second space. ## Step 2 — See what you have ```python theme={null} for s in client.list_spaces(): print(s["space_id"], "|", s.get("shared_by") or "yours") ``` `shared_by` names the org that shared it with you, and is `null` for your own. When a shared space collides with one of your own names, `qualified_id` gives you the `owner:name` form to address it unambiguously. ## Step 3 — Inspect one ```python theme={null} print(client.get_space(space_id="support")) ``` ## Step 4 — Delete, and mean it ```python theme={null} client.delete_space(space_id="scratch-experiment") ``` > [!WARNING] > > **This deletes every memory in the space and cannot be undone.** There is no > soft delete and no recycle bin. If there is any chance the contents matter, > [export first](/memory/use-cases/take-everything-with-you) — the export is the only > thing standing between a typo and permanent loss. ## Evals Before committing to a layout, test the two things that are expensive to change: 1. Can you share exactly one audience's data without the others? If sharing the space would over-share, it is too coarse. 2. Does any single question need to draw on two spaces at once? If yes, it is too fine — there is no cross-space query, and stitching results client-side loses the ranking that makes recall work. ## Guardrails * **A space's id is its name.** There is no separate identifier to rename later, so a name with spaces is legal and means URL-encoding every path that uses it. * **Plans cap how many spaces you get**; `max_spaces = -1` means unlimited. * **Scratch spaces are cheap and worth it.** Tuning [extraction](/memory/use-cases/teach-it-your-vocabulary) against a real space leaves it with two eras of settings mixed together permanently. # Everything you know about one user Source: https://docs.anonalabs.com/memory/use-cases/one-user-profile Two calls turn scoped writes into a profile, and a question you can ask about that person alone. [← All use cases](/memory/use-cases/overview) [user\_profiles.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py) ## The scenario Once writes are scoped per user, the scope is worth more than isolation. It is a profile — and two calls turn it into product features without you assembling anything. ## Step 1 — The profile, as data ```python theme={null} profile = client.get_user_profile(space_id=space, user_id="priya") print(profile["memory_count"], "things known") for m in profile["memories"]: print(m["text"]) ``` ## Step 2 — The same profile, as a prompt block ```python theme={null} block = client.get_user_profile( space_id=space, user_id="priya", format="block", context_max_tokens=300 ) print(block["context"]) ``` Budgeted server-side, so a heavy user cannot quietly blow your prompt. ## Step 3 — Ask a question about that person ```python theme={null} answer = client.ask_about_user( space_id=space, user_id="priya", query="How should we schedule a call with her?" ) print(answer["insights"]) print("answered by:", answer["model"]) ``` The response names the model that answered — which is what the call's credits were billed at, so it is worth logging rather than discarding. ## Evals 1. Record three facts about one user, then read the profile. All three present. 2. Request the block form with a small budget and confirm it truncates by dropping whole memories, not mid-sentence. 3. Ask about a user nobody has written about. `memory_count` is 0 and that is **not an error** — a user is a scope tag, not a resource you register. ## Guardrails > [!WARNING] > > **`memory_count` is non-monotonic.** Consolidation merges related memories, so > the number can fall while the system knows strictly more. Never show it to an > end user as "things we know about you", and never alert on it dropping. * **A profile is personal data.** Whatever goes in is retrievable, exportable and — when someone asks — [deletable](/memory/use-cases/delete-a-users-memories). * **An unknown user returns an empty profile, not a 404.** Handle the empty case; do not treat it as a failure. ## The script Complete and runnable: [`user_profiles.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py). Prerequisite: [Support agent that knows each customer](/memory/use-cases/support-agent-per-customer). # Use Cases Source: https://docs.anonalabs.com/memory/use-cases/overview Every recipe, searchable — from a support bot that knows each customer to a data-subject deletion runbook. The [Guides](/memory/guides/tuning-recall) explain how a knob works. These explain how to build the thing you came here to build. Search use cases Filter by category Nothing matches that. Clear the search, or tell us what you were looking for at [support@anonalabs.com](mailto:support@anonalabs.com) — a miss here is a recipe we should write. ## Running any of them ```bash theme={null} git clone https://github.com/anonalabs/Anona-Memory-SDK.git cd Anona-Memory-SDK export ANONA_API_KEY=anona_live_YOUR_KEY export ANONA_SPACE_ID=my-space # optional, each script has a default python examples/memory_basics.py ``` > [!TIP] > > **Run any script twice.** Each one asks two questions in a row where the > second is only answerable from what the first stored. On the second run the > opening question is answerable too, because the first run's memories are > already in the space. That difference is the whole product. ## What if my case isn't here Tell us at [support@anonalabs.com](mailto:support@anonalabs.com). A gap in this list is a recipe we should write, and we would rather hear it from you than guess. The building blocks underneath every recipe are the same three calls — [`record`](/memory/api-reference/memories), [`retrieve`](/memory/api-reference/retrieve) and [`reason`](/memory/api-reference/reason) — so if you can describe your case in terms of what to store and what to ask, you can usually assemble it from [Record, retrieve, reason](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) plus one other recipe. ## A question we get asked, answered here **"Where is the self-hosting guide?"** There isn't one, and that is a product decision rather than an omission. Anona holds the model credentials so that you never have to provision, rotate or pay for them, and so that a model change is something we ship rather than something you migrate. That is the trade: you cannot run Anona on your own hardware, and in exchange there is no inference bill, no key rotation and no capacity planning on your side. If you need memory inside your own network, say so — it is the single most useful thing you can tell us about your requirements. ## How to read these **Every recipe is anchored to code you can run.** Many are a complete program in the SDK repository — clone it, set a key, run it. The rest are recombinations rather than new mechanisms: a support workflow is scoping plus sharing, a financial analyst is temporal filtering plus reason. Those carry no script of their own, and the rule they follow instead is that **every call they show appears in one that does** — each names the scripts it was built from, so you can run the mechanism before you trust the recipe. ## Every recipe Each one is a written walkthrough — the scenario, the steps, how to check it worked, and the mistakes worth avoiding. **Personal** * [Remember the people you meet](/memory/use-cases/remember-people) — Who they are, what they're working on, what they said last time — without keeping a CRM you'll never update. * [Keep your own promises](/memory/use-cases/keep-your-promises) — What you said you'd do, who you said it to, and which ones you quietly dropped. * [Think out loud, keep the thought](/memory/use-cases/think-out-loud) — Say it into your phone on the walk home. It arrives as memory you can question later. * [Stop re-solving the same bug](/memory/use-cases/stop-resolving-the-same-bug) — The fix you found in March, surfaced in September — before you start debugging, not after. **Build** * [The three calls you actually need](/memory/use-cases/the-three-calls) — Record what happened, retrieve what matters, reason across it. Almost every integration is these three. * [One line, keep your OpenAI SDK](/memory/use-cases/drop-in-proxy) — Point a stock OpenAI client at Anona. Every call recalls what matters and stores what is new. No rewrite. * [Chat with your documents](/memory/use-cases/chat-with-your-documents) — Upload a file, wait for extraction, ask. Uploads land in the same space as everything else you recorded. * [Turn a meeting recording into memory](/memory/use-cases/meeting-recording-to-memory) — Drop in the audio. It comes back as transcribed, searchable memory nobody had to write up. * [Backfill a year of history](/memory/use-cases/backfill-history) — Queue the writes, keep serving, poll for the outcome. A blocking record waits on an LLM call; this does not. * [Know the moment ingestion finishes](/memory/use-cases/events-not-polling) — Webhooks instead of polling, plus the per-space defaults that stop you resending the same options. * [Teach it your vocabulary](/memory/use-cases/teach-it-your-vocabulary) — Your domain has words that mean something specific. Extraction can be told, once, per space. * [Browse what it knows](/memory/use-cases/browse-what-it-knows) — Page through the memories themselves — no query, no ranking. The view that answers 'what did it actually store?' * [One space or many?](/memory/use-cases/one-space-or-many) — The decision you make once, early, and cannot cheaply undo. Here is how to make it right. * [Stop a runaway import](/memory/use-cases/stop-a-runaway-import) — You queued fifty thousand rows from the wrong table. Here is the kill switch, and the cleanup. **Support** * [Support agent that knows each customer](/memory/use-cases/support-agent-per-customer) — One space serves every customer. Scoping keeps their histories apart, so nobody is told someone else's story. * [Everything you know about one user](/memory/use-cases/one-user-profile) — Two calls turn scoped writes into a profile, and a question you can ask about that person alone. * [Support memory the whole team shares](/memory/use-cases/shared-team-support-memory) — What one agent learns on a ticket, the next one already knows — across reps, shifts and organisations. * [Tutor that remembers the learner](/memory/use-cases/tutor-that-remembers) — Progress, sticking points and preferences that persist between sessions, months apart. **Research** * [Who your agent keeps meeting](/memory/use-cases/entity-graph) — Extraction names the people, companies and systems in every memory. Read the graph it has been building. * [What was true back then](/memory/use-cases/what-was-true-back-then) — Every memory carries two clocks. Filter on when things happened, not just when you stored them. * [Competitive intelligence that accumulates](/memory/use-cases/competitive-intelligence) — Press releases, pricing changes and job postings become a position you can query, not a folder nobody reads. * [Financial analyst across quarters](/memory/use-cases/financial-analyst) — Ask what changed between Q2 and Q4 and get an answer grounded in what was said at the time. * [Contract and policy memory](/memory/use-cases/contract-and-policy-memory) — Which clause, in which agreement, agreed when — with the provenance to prove where the answer came from. * [A briefing that keeps itself current](/memory/use-cases/self-refreshing-briefing) — Ask a standing question once. It rewrites its own answer as new memories arrive, and tells you when it is stale. * [Give the space a mission](/memory/use-cases/give-the-space-a-mission) — Tell it what it is for, and how skeptical to be. It changes what gets kept and how it is read back. * [A shelf of standing questions](/memory/use-cases/a-shelf-of-standing-questions) — Not one self-updating briefing — a dozen, listed, edited, cleared and retired as the work moves. **Team** * [Two agents, one space](/memory/use-cases/two-agents-one-space) — Shared knowledge with per-agent working sets, so a handoff carries everything the next agent needs. * [Memory for your coding agent](/memory/use-cases/memory-for-your-coding-agent) — Architecture decisions, past fixes and house conventions that survive the end of the session. * [Onboarding agent for new hires](/memory/use-cases/onboarding-agent) — New starters ask the questions they are embarrassed to ask, and get answers from your real docs. * [Search across everything the company knows](/memory/use-cases/company-wide-search) — One question, answered from the documents, decisions and conversations you have already fed it. * [Incident copilot that has read every postmortem](/memory/use-cases/incident-copilot) — The last four times this broke, what fixed it, and who knew — surfaced while it is still broken. * [Account memory that outlives the rep](/memory/use-cases/account-memory) — Stakeholders, commitments and objections stay with the account when the person handling it changes. * [Candidate memory across interviewers](/memory/use-cases/candidate-memory) — Every interviewer sees what the others already covered, so the fourth conversation is not the first one again. **Governance** * [Why the answer used what it used](/memory/use-cases/why-the-answer-used-what-it-used) — A receipt for every retrieval: what entered the prompt, what was cut, and which of four reasons cut it. * [Correct a memory without losing the record](/memory/use-cases/correct-a-memory) — Supersede what turned out to be wrong, with the reason attached and the old version still readable. * [Delete a user's memories, provably](/memory/use-cases/delete-a-users-memories) — A data-subject request answered end to end: find everything about one person, export it, remove it, show your work. * [Cut the bill without cutting recall](/memory/use-cases/cut-the-bill) — Cap what memory is allowed to spend of your prompt, and measure what the provider's cache gave back. * [Prove the memory is earning its place](/memory/use-cases/prove-memory-helps) — Run the same questions with memory on and off, and keep the number instead of the impression. * [Choose the model a space thinks with](/memory/use-cases/choose-the-model) — A catalog of models, priced per operation. Pin a cheap one to a noisy space and a strong one to the space that matters. * [Take everything with you](/memory/use-cases/take-everything-with-you) — A full export of a space — every memory and entity — as a file you hold. Dashboard-only: an API key cannot take one. # Prove the memory is earning its place Source: https://docs.anonalabs.com/memory/use-cases/prove-memory-helps Run the same questions with memory on and off, and keep the number instead of the impression. [← All use cases](/memory/use-cases/overview) [drop\_in\_proxy.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/drop_in_proxy.py)[memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario Six weeks in, somebody asks whether the memory layer is actually helping. The honest answer is usually "it feels like it" — which is not an answer, and it is the same answer you would give if it had silently broken a month ago. The drop-in proxy makes this measurable in a way most stacks cannot, because memory is one header rather than an architecture. ## Step 1 — Turn memory off for one call ```python theme={null} off = llm.chat.completions.create( model="balanced", messages=[{"role": "user", "content": question}], extra_headers={"X-Anona-Memory": "false"}, ) ``` `X-Anona-Memory: false` skips recall and injection for that call only. Everything else — model, prompt, parameters — is identical, which is what makes it a comparison rather than two anecdotes. ## Step 2 — Run the same question with it on ```python theme={null} on = llm.chat.completions.create( model="balanced", messages=[{"role": "user", "content": question}], ) print(on.memories_injected, "memories injected") ``` ## Step 3 — Score a real set, not one question ```python theme={null} for question, expected in eval_set: a = ask(question, memory=False) b = ask(question, memory=True) record_result(question, a, b, expected) ``` Twenty to fifty questions drawn from real traffic, where the answer depends on something said in an earlier session. Questions answerable from the current turn alone will score identically and tell you nothing — that is the control. ## Step 4 — Keep running it Put the eval set in CI. A recall regression is otherwise invisible: nothing errors, answers just quietly get worse. ## Evals 1. Build the set from real conversations, including five questions memory *cannot* help with. 2. Expect no difference on those five. A difference there means something else is varying, and your measurement is not measuring what you think. 3. Report per-question, not just an average — memory usually helps a lot on a few and not at all on most, and the average hides both. 4. Re-run after any change to scoping, extraction settings or recall tuning. ## Guardrails > [!WARNING] > > **Do not publish a benchmark number without the harness that produced it.** > This category has already seen a headline score fall by twenty points under > independent measurement. An internal number is for deciding; a public number > needs a script someone else can run. * **Both arms cost credits.** An A/B over fifty questions is a hundred calls, and the memory-off arm bills like any other. * **`memory: false` still records the turn** unless you also set `X-Anona-Auto-Record: false` — so a naive A/B slowly teaches the space the answers to its own eval set. Turn both off for the control arm. * **Fresh spaces score badly and that is correct.** Memory helps in proportion to what it has seen; measure on a space with real history. ## Built from Both are complete, runnable scripts: [`drop_in_proxy.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/drop_in_proxy.py), [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py). # Remember the people you meet Source: https://docs.anonalabs.com/memory/use-cases/remember-people Who they are, what they're working on, what they said last time — without keeping a CRM you'll never update. [← All use cases](/memory/use-cases/overview) ## The scenario You meet someone at a conference. Six months later they message you and you have nothing — not their company, not what they were building, not the thing you promised to send them. A CRM would fix this if you ever updated it. You won't. The reason is that the useful stuff arrives as sentences in conversation, not as fields in a form. **What this recipe shows that no other does:** treating a *person* as the unit of memory, so everything you ever learn about them accumulates under one name and comes back as a briefing. ## Setup — once, then never again ```bash theme={null} claude mcp add --transport http anona https://memory.anonalabs.com/mcp \ --header "Authorization: Bearer anona_live_YOUR_KEY" ``` Add the [Agent Skill](/memory/integrations/skills) too. MCP gives the assistant the *ability* to remember; the skill gives it the *habit*, so you stop having to say "remember this". ```bash theme={null} npx skills add anonalabs/Anona-Memory-SDK ``` ## Using it — there is no step 2 You talk normally: > *"Met Priya at the Berlin meetup. She's building an agent for warehouse > logistics, hit the same pgbouncer problem we did. Said she'd share their > config. Two kids, one just started school."* Nothing else happens. No form, no tags, no "save" button. Months later: > *"What do I know about Priya?"* > *"Who have I met who works on logistics?"* > *"Did I owe anyone a config file?"* The third one is the interesting one — you have forgotten the person's name and you are searching by the *obligation*. A notes app cannot answer that. ## Evals Worth checking before you trust it with anything that matters: 1. Tell it three things about one person across three separate sessions. 2. In a fresh session, ask what you know about them. All three should come back. 3. Ask something about them you never said. It should say it doesn't know — an assistant that fills the gap plausibly is worse than one that forgets. 4. Ask a question that names no one, only a detail ("who was the warehouse person?"). That is the query that proves this is memory and not a contact list. ## Guardrails > [!WARNING] > > **This is other people's personal data, kept by you.** Notes about someone's > family, health or job situation are exactly the kind of thing that reads > badly out of context, and the person never consented to it. Record what helps > you be a decent correspondent, not a dossier. * **Don't record what you were told in confidence.** The assistant will recall it cheerfully, months later, in front of whoever is watching your screen. * **Names collide.** Two people called Priya will merge into one confusing briefing unless you give the assistant a distinguishing detail when you first mention them. * **This is a private space by default.** If you share it, everything about everyone goes with it — see [Support memory the whole team shares](/memory/use-cases/shared-team-support-memory) for how sharing actually works before you turn it on. ## Where to go next The same shape, but for a product you are building for other people rather than for yourself: [Everything you know about one user](/memory/use-cases/one-user-profile). # A briefing that keeps itself current Source: https://docs.anonalabs.com/memory/use-cases/self-refreshing-briefing Ask a standing question once. It rewrites its own answer as new memories arrive, and tells you when it is stale. [← All use cases](/memory/use-cases/overview) [memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario There is a question you ask every week. What is this customer's situation. Where is the migration up to. What do we know about this account. Asking it every week costs a call every week and gives you a slightly different answer each time. Writing the answer down costs nothing and is wrong within days. ## Step 1 — Define the standing question ```python theme={null} model = client.create_memory_model( space_id="accounts", name="Acme situation", query="What is the current state of the Acme account — blockers, owners, and commitments?", trigger={"mode": "delta", "refresh_on_new_memories": True}, ) ``` A memory model is a question plus its maintained answer. `refresh_on_new_memories` is what makes it standing rather than a snapshot: new memories in the space cause it to rewrite itself. ## Step 2 — Read the answer, not the search ```python theme={null} current = client.get_memory_model(space_id="accounts", model_id=model["model_id"]) print(current["content"]) print("stale:", current["is_stale"]) ``` `is_stale` is the honest bit. It tells you the underlying memories have moved since this text was written, so you know whether you are reading a current answer or a recent one. ## Step 3 — Force a rewrite when you need it fresh ```python theme={null} client.refresh_memory_model(space_id="accounts", model_id=model["model_id"]) ``` Refresh is asynchronous and billed, so trigger it on the cadence you actually need rather than before every read. ## Step 4 — See how the answer changed ```python theme={null} for entry in client.get_memory_model_history(space_id="accounts", model_id=model["model_id"])["entries"]: print(entry["changed_at"], entry["previous_content"][:120]) ``` The history is often more useful than the current text: it is a record of when your understanding of the account changed, which nobody was writing down. ## Evals 1. Create a model, record three relevant memories, refresh, read it. 2. Record a memory that contradicts the briefing. Check `is_stale` flips. 3. Refresh and confirm the new text reflects the contradiction rather than averaging it away. 4. Read the history and check the previous version is preserved verbatim. ## Guardrails > [!WARNING] > > **A briefing is a summary, and summaries lose the exception.** Do not make an > irreversible decision from the model text alone — retrieve the underlying > memories when it matters. The model is for orientation, not evidence. * **Creating and refreshing cost credits; reading is free.** A model refreshed on every write to a busy space is an expensive way to be slightly more current. * **A brand-new model briefly has no content.** It is generated on the first refresh, and until then there is genuinely nothing to show — render that state rather than an empty string that reads like an answer. * **Editing a model's text does not refresh it**, deliberately: a typo fix should not cost a full rewrite. ## Built from Every call above belongs to this runnable script's territory — [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) — and the model surface is documented in full at [Memory models](/memory/api-reference/models). # Support memory the whole team shares Source: https://docs.anonalabs.com/memory/use-cases/shared-team-support-memory What one agent learns on a ticket, the next one already knows — across reps, shifts and organisations. [← All use cases](/memory/use-cases/overview) [support\_bot\_scoping.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py)[multi\_agent\_shared\_space.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/multi_agent_shared_space.py) ## The scenario A customer explains their setup to the first agent. The ticket is escalated. They explain it again. It gets handed to the weekend shift. They explain it a third time. Every one of those agents had access to the same ticket history and none of them had the *knowledge* — because the knowledge was in a paragraph halfway down a thread nobody re-read. ## Step 1 — One space, scoped per customer Each write carries the customer it is about, so one space serves everybody without their histories mixing. ```python theme={null} client.record( space_id="support", content="Acme is on the self-hosted runner setup; the standard proxy fix does not apply to them.", user_id="acme-corp", ) ``` This is the same mechanism as [Support agent that knows each customer](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py) — the difference here is who gets to read it. ## Step 2 — Any agent reads the same memory The agent identity does not gate the read. A scoped retrieve returns what is known about that customer, whoever learned it. ```python theme={null} context = client.get_context( space_id="support", query="why did the proxy fix not work for them?", user_id="acme-corp", max_tokens=800, ) ``` `get_context` returns a prompt-ready block rather than a list, with the token budget enforced server-side — so a busy account cannot quietly blow your prompt. ## Step 3 — Share the space beyond your own organisation An outsourced support partner, or a customer's own team, can be invited to the space rather than handed an API key. Grants are per space and carry a role: `viewer` is read-only, `developer` may write. See [Spaces](/memory/api-reference/spaces). This is the part with no equivalent elsewhere: the visitor's own credits pay for their reads, the owner's key never leaves our gateway, and a read-only invitee genuinely cannot write. ## Evals 1. Write a fact as "agent A", read it back with no agent scope. It should appear. 2. Read with a *different* `user_id`. It must not appear — that is the isolation guarantee, and it is worth asserting in your test suite rather than trusting. 3. Invite a second account as `viewer` and confirm a write from them is refused with `space_read_only`. This one needs a **second Anona account** — a separate sign-up, with its own API key — because the refusal is about who is writing, and one key cannot be both the owner and the invitee. 4. Time the second and third explanation of the same setup. That number is the thing this is meant to move. ## Guardrails > [!WARNING] > > **Scoping separates customers; roles separate people.** `user_id` is about > relevance, not access — anyone holding your API key can read any scope. A > boundary that has to hold against another *organisation* is a space share with > a role, not a scope key. * **A scoped query is strict.** Memories written before you adopted `user_id` carry no scope and will never match a scoped read. Adopt it at the start, or backfill. * **Never derive `user_id` from something the customer controls.** It is your identifier for them, not theirs for themselves. * **A shared space is addressed by a bare name until it collides**, then by `owner:space`. If you hold your own `support` and a share of someone else's, the API will tell you which form to use rather than guess. ## Built from Both are complete, runnable scripts: [`support_bot_scoping.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py), [`multi_agent_shared_space.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/multi_agent_shared_space.py). # Stop a runaway import Source: https://docs.anonalabs.com/memory/use-cases/stop-a-runaway-import You queued fifty thousand rows from the wrong table. Here is the kill switch, and the cleanup. [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/memories) ## The scenario The backfill is running. Twenty seconds in you realise it is pointed at the staging table, or the date filter was wrong, or it is one job per row instead of one job per batch. Every queued chunk is an LLM call you are paying for, and it will keep going until it finishes. **What this recipe shows that no other does:** operational control of work already in flight — cancelling it, and tearing down the plumbing around it. ## Step 1 — Cancel the job ```python theme={null} from anona import AnonaError for job_id in job_ids: # one per record_batch call — see below try: result = client.cancel_job(space_id="import", job_id=job_id) print(job_id, result["cancelled_parts"], "cancelled,", result["running_parts"], "already running") except AnonaError as e: if e.code != "job_not_cancellable": raise print(job_id, "nothing left to stop:", e.message) ``` The two numbers matter and they are not the same thing. **Cancelled parts stop. Running parts finish** — work already handed to a worker is not interrupted, so a large job keeps producing memories for a little while after you cancel it. Watch, do not assume. `409 job_not_cancellable` means there was nothing queued left to stop: every part is already running or finished. A small batch is often a single part, so once a worker has picked it up it runs to the end — the job then finishes normally rather than reading `cancelled`, and the refusal is the truth. A `record_batch` call takes **at most 100 items** (`422` above that), so a large import is many jobs. Keep every `job_id` as you queue them; the loop above is only as complete as that list. ## Step 2 — Find out what landed ```python theme={null} page = client.list_memories(space_id="import", limit=100) print(page["total"], "memories made it in") ``` ## Step 3 — Undo the damage If the import was file-based, deleting the document takes its memories with it: ```python theme={null} client.delete_document(space_id="import", document_id=doc_id) ``` If it was a write of loose records and the space existed only for the import, the honest cleanup is to [delete the space](/memory/use-cases/one-space-or-many) and start again. Picking individual bad memories out of a good space is slow and error-prone — which is an argument for importing into a scratch space first. ## Step 4 — Turn off what was watching it A cancelled import usually leaves a webhook that no longer has a job to report on. ```python theme={null} for hook in client.list_webhooks(space_id="import"): print(hook["id"], hook["url"], hook["enabled"]) client.delete_webhook(space_id="import", webhook_id=hook_id) ``` Deleting stops queued deliveries for it — worth doing before your endpoint gets a burst of events about a job you abandoned. ## Evals Practise this before you need it, on a space you do not care about: 1. Queue several 100-item batches back to back, cancel them all after a few seconds. 2. Record `cancelled_parts` and `running_parts`, and which jobs answered `job_not_cancellable`. 3. Poll `list_memories` until `total` stops moving. The gap between cancelling and stopping is the number worth knowing — it is what you will be asked. 4. Confirm cleanup actually emptied it. ## Guardrails > [!WARNING] > > **Cancelling does not refund what already ran.** Extraction that has happened > is billed. The saving is on the part that had not started, which is why the > first twenty seconds matter more than the next twenty minutes. * **Import into a scratch space.** It converts "pick out the bad memories" into "delete the space", and that is the whole difference between a bad afternoon and a bad minute. * **A cancelled job is a terminal state**, not a pause. There is no resume; re-import what you still want. * **Throughput is bounded platform-side**, so a large import runs for a long time. That is exactly why the kill switch is worth knowing before you need it. # Stop re-solving the same bug Source: https://docs.anonalabs.com/memory/use-cases/stop-resolving-the-same-bug The fix you found in March, surfaced in September — before you start debugging, not after. [← All use cases](/memory/use-cases/overview) ## The scenario You have solved this before. You know you have, because the error is familiar and vaguely annoying in a specific way. What you cannot remember is what fixed it. Shell history has the commands but not the reasoning, and cannot answer a question. The issue tracker has the ticket if someone filed one. Usually nobody did, because it took fifteen minutes and fifteen minutes does not feel worth a ticket — right up until you spend fifteen minutes on it again. **What this recipe shows that no other does:** the memory is written *by the agent, unprompted*, and read *before* the work rather than after. ## Setup MCP plus the [Agent Skill](/memory/integrations/skills). The skill is doing the real work here — it is what makes the agent recall before starting a task and record after finishing one, without you asking either time. ```bash theme={null} claude mcp add --transport http anona https://memory.anonalabs.com/mcp \ --header "Authorization: Bearer anona_live_YOUR_KEY" npx skills add anonalabs/Anona-Memory-SDK ``` ## What changes You debug as normal. When it is fixed, the agent writes down what actually worked — not the six things that didn't. The next time a similar error appears, it checks first. You see: > *"You hit this in March. It was the pgbouncer pool, not the gateway — the > recovery order matters: redeploy pgbouncer first, then the gateway."* The value is entirely in the ordering. The same fact surfaced *after* you debugged it is a curiosity; surfaced before, it is the fifteen minutes back. ## Evals This one is measurable, which most memory claims are not: 1. Work normally for two weeks with the skill installed. 2. Keep a count of how often a recalled memory arrived **before** you started versus after you had already solved it. 3. The second number being high means the recall trigger is too narrow — the agent is remembering, just too late to matter. 4. Spot-check three recorded fixes against what actually fixed it. An agent recording its first hypothesis rather than the eventual fix is the common failure, and it poisons the pile. ## Guardrails > [!WARNING] > > **Never let it record secrets.** Debugging output is full of connection > strings, tokens and keys, and an agent pasting freely will store them > verbatim. They are then retrievable by anyone with access to the space and > will resurface in later prompts. * **Record the fix, not the transcript.** A blow-by-blow of a debugging session is noise that crowds out the answer. One sentence about what was actually wrong beats forty about what wasn't. * **A wrong memory is worse than none**, because next time it will be recalled with confidence and send you down the path that didn't work. Correct them when you spot them. * **MCP tools load at session start.** Adding the server mid-session changes nothing until you restart. ## Where to go next The team-scale version, where what one person learns the whole repo knows: [Memory for your coding agent](/memory/use-cases/memory-for-your-coding-agent). # Support agent that knows each customer Source: https://docs.anonalabs.com/memory/use-cases/support-agent-per-customer One space serves every customer. Scoping keeps their histories apart, so nobody is told someone else's story. [← All use cases](/memory/use-cases/overview) [support\_bot\_scoping.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py) ## The scenario A support bot serves thousands of customers. Giving each their own space is operationally miserable; giving them all one space without separation means Alice's renewal date can surface in Bob's conversation. You want one space and a hard partition inside it. ## Step 1 — Every write carries the customer ```python theme={null} client.record(space_id=space, content="Alice's subscription renews on the 3rd of each month.", user_id="alice") client.record(space_id=space, content="Bob asked to be contacted by email only, never phone.", user_id="bob") ``` ## Step 2 — Every read carries the asker ```python theme={null} for row in client.retrieve(space_id=space, query="What issues has this customer reported?", user_id="alice"): print(row["content"]) ``` Alice's session sees Alice. Bob asking about renewal dates gets nothing, however the question is phrased — a scoped search is **strict**: it returns neither another user's memories nor memories stored with no scope at all. ## Step 3 — `reason` is scoped identically ```python theme={null} print(client.reason(space_id=space, query="How should we contact this customer?", user_id="bob")) ``` A synthesis for Bob is built only from Bob. This matters more than the retrieve case: a summary silently blended from two customers is much harder to spot than a stray row. ## Evals 1. Write for two users. Read as each. Assert neither sees the other — in your test suite, not by eye. 2. Write one memory with **no** `user_id`, then read as a user. It must not appear; that strictness is the guarantee. 3. Ask `reason` as one user a question only the other's memories answer. It should decline rather than blend. ## Guardrails > [!WARNING] > > **Scoping is relevance, not access control.** Anyone holding your API key can > read any scope. It stops a customer's data reaching another *customer* through > your agent; it does not stop a caller who already has your key. For a boundary > against another organisation, share the space with a role instead. * **Adopt scoping on day one.** Memories written before you started passing `user_id` carry no scope and are invisible to every scoped read afterwards. * **`user_id` is your identifier for them.** Never take it from something the end user controls, or one customer can read another's memories by claiming to be them. ## The script Complete and runnable: [`support_bot_scoping.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py). Next: [Everything you know about one user](/memory/use-cases/one-user-profile). # Take everything with you Source: https://docs.anonalabs.com/memory/use-cases/take-everything-with-you A full export of a space — every memory and entity — as a file you hold. Dashboard-only: an API key cannot take one. [← All use cases](/memory/use-cases/overview) [API reference](/memory/guides/export) ## The scenario Two reasons to export, and they arrive at very different moments. One is routine: somebody asks whether your memory layer is a lock-in risk, and the only answer that lands is a file. The other is urgent: you are about to [delete a space](/memory/use-cases/one-space-or-many), or answer a [deletion request](/memory/use-cases/delete-a-users-memories), and the export is the last thing standing between a mistake and permanent loss. **What this recipe shows that no other does:** a surface an API key **cannot reach**. A bundle is a complete copy of a space, so exports deliberately need a signed-in owner rather than a long-lived key — the endpoints answer an API key with `401 session_required`, and neither SDK has a method for them. This is the one recipe on this site you run from the dashboard. ## Step 1 — Ask for one Dashboard → **Spaces** → the space → **Exports** tab → **Request export**. It builds in the background — a large space takes a while — and you are emailed when it is ready. One export per space per 24 hours. ## Step 2 — Download it Back on the same tab, click **Download**. The link behind the button is generated on the click and lives for five minutes, which is why the "ready" email points at the dashboard rather than at the file. ## Step 3 — Actually keep it Move `export-{space}-{date}.zip` somewhere that is not your Downloads folder. An export you generated and never kept is not a backup: bundles are deleted after 7 days. ## Scripting it If you need this in a script — a quarterly backup job, say — the dashboard's three endpoints accept a **session token** (the one sign-in returns), never an API key. See [Export Your Memory](/memory/guides/export#the-endpoints-behind-the-tab): ```python theme={null} import httpx H = {"Authorization": f"Bearer {SESSION}"} # a signed-in session, not an API key BASE = "https://api.anonalabs.com" job = httpx.post(f"{BASE}/v1/spaces/support/exports", headers=H).json() # ...poll GET {BASE}/v1/spaces/support/exports until the job is ready, then: url = httpx.get(f"{BASE}/v1/spaces/support/exports/{job['job_id']}/download", headers=H).json() with httpx.stream("GET", url["download_url"]) as r, open("support-export.zip", "wb") as f: for chunk in r.iter_bytes(): f.write(chunk) ``` Session tokens expire, so this belongs in something a person runs, not in an unattended cron with a stored credential. ## Evals An untested backup is not a backup: 1. Export a small space and download it. 2. Open it. Count the lines in `memories.jsonl` and compare against `list_memories(prefer_observations=False)`'s `total` — the default list hides raw facts a note already covers, and the bundle keeps both. 3. Confirm it contains the **raw facts**, not just the consolidated observations — a takeout that dropped the evidence layer is not a takeout. 4. Do this once a quarter rather than once, ever. ## Guardrails > [!WARNING] > > **Export before you delete, every time.** Space deletion and memory deletion > are both irreversible with no recycle bin. The export is the only recovery > path, and it has to exist *before* the mistake. * **Exports are owner-only.** A visitor to a shared space cannot take a copy of someone else's space, which is the correct behaviour and occasionally surprising. * **Bundles expire** — `expires_at` on the job says when. Re-export rather than hoarding download URLs. * **The bundle is plain readable content.** It is the most concentrated copy of your customers' data that will ever exist; treat the file accordingly. * **There is no import endpoint to pair with this.** Exports answer "can I leave"; they do not currently give you "and come back". Do not design a migration around round-tripping one. ## Where to go next [Delete a user's memories, provably](/memory/use-cases/delete-a-users-memories) — where the export is step two of a runbook rather than an insurance policy. # Teach it your vocabulary Source: https://docs.anonalabs.com/memory/use-cases/teach-it-your-vocabulary Your domain has words that mean something specific. Extraction can be told, once, per space. [← All use cases](/memory/use-cases/overview) [API reference](/memory/api-reference/extraction-settings) ## The scenario In your company a "run" is a manufacturing batch, not a jog. "Churn" means something precise with a formula behind it. "The platform" is one specific system, not a metaphor. Default extraction reads your text as general English and stores general English, which is why recall on domain-heavy corpora feels slightly blunt. **What this recipe shows that no other does:** changing how text becomes memory in the first place, rather than changing how you search it afterwards. ## Step 1 — See what a space does today ```python theme={null} print(client.get_extraction_settings(space_id="ops")) ``` ## Step 2 — Add guidance `guidance` is additive: it rides alongside the default instructions rather than replacing them, which makes it the safe knob. ```python theme={null} client.set_extraction_settings( space_id="ops", mode="verbose", guidance=( "A 'run' is a manufacturing batch with an id like R-2214. " "Always keep batch ids and line numbers verbatim. " "'The platform' means the order management system, never a metaphor." ), ) ``` `mode` trades thoroughness for cost: `verbose` stores more per chunk, so it costs more and recalls more. ## Step 3 — Replace the guidelines entirely, if you must `custom_prompt` replaces the guidelines block rather than adding to it, and only takes effect with `mode="custom"`. It is powerful and it is how people break extraction — start with `guidance`. ## Step 4 — Undo ```python theme={null} client.reset_extraction_settings(space_id="ops") ``` > [!WARNING] > > **Reset clears the whole surface, not just your guidance.** If the space was > created with a file-retrieval strategy, resetting drops that too and every > later upload quietly degrades to default extraction. Prefer writing the fields > you want over resetting. ## Evals Extraction changes are hard to judge by feel, so measure: 1. Record twenty representative sentences in a scratch space with defaults. 2. `list_memories` and read what was actually stored — this is the step people skip, and it is the only direct view of extraction's output. 3. Apply guidance, record the same twenty into a **second** space, and compare. 4. Check the specific thing you asked for: are batch ids preserved verbatim? Never tune extraction against the space you care about. Use a scratch space — settings change future writes only, so an experiment leaves a permanent, mixed corpus behind. ## Guardrails * **Only future writes are affected.** Nothing re-extracts what is already stored; a change to guidance leaves a space with two eras in it. * **A typo in `mode` is refused, not stored.** Anything other than `concise`, `verbose`, `verbatim` or `custom` is a `422` naming the four. The free-text fields are taken as written, so read them back with `get_extraction_settings` after a change. * **More is not better.** `verbose` on a chatty space produces memories about pleasantries, which crowd out the ones that matter. # The three calls you actually need Source: https://docs.anonalabs.com/memory/use-cases/the-three-calls Record what happened, retrieve what matters, reason across it. Almost every integration is these three. [← All use cases](/memory/use-cases/overview) [memory\_basics.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py) ## The scenario You are adding memory to something and the API surface looks large. It is not. Three calls carry almost every integration, and a fourth exists only to save you writing a join. ## Step 1 — Record ```python theme={null} client.record(space_id=space, content="Dana leads the mobile team and prefers RFC-style proposals.") client.record(space_id=space, content="Dana rejected the last proposal for missing a rollback plan.") ``` You write sentences, not schemas. Extraction turns each one into facts as it stores it, which is why you do not pre-chunk or pre-tag anything. ## Step 2 — Retrieve Ranked rows, each with a score, when your code wants to do something with the results. ```python theme={null} for row in client.retrieve(space_id=space, query="How should I pitch a change to Dana?"): print(f"{row['relevance_score']:.2f} {row['content']}") ``` ## Step 3 — Reason One synthesised answer across everything the space holds, rather than rows you have to read yourself. ```python theme={null} print(client.reason(space_id=space, query="What should I prepare before proposing a change?")) ``` `reason` runs a multi-step loop, so it costs more and answers vaguer questions. Use `retrieve` when the caller can read rows; use `reason` when they need a conclusion. ## Step 4 — `get_context`, when you build the prompt yourself ```python theme={null} block = client.get_context(space_id=space, query="pitching a change to Dana", max_tokens=300) ``` The same search as `retrieve`, returned as one prompt-ready string with the token budget enforced server-side. It saves a join and a truncation bug. ## Evals 1. Record three facts where the third only makes sense given the first two. 2. `retrieve` a question answerable from one of them. Check it ranks first. 3. `reason` a question that needs all three. Check the answer uses all three. 4. Ask something unrelated. Empty is the correct answer, and a system that invents one has its score floor set too low. ## Guardrails * **Recording is asynchronous under the hood but `record` blocks** while extraction runs. In a request path, use [background writes](/memory/use-cases/backfill-history) instead. * **A space is created on first write.** A typo in `space_id` silently makes a new space rather than erroring — check `list_spaces` if memories go missing. * **`relevance_score` can exceed 1.0.** It is a ranking signal, not a probability. ## The script Complete and runnable: [`memory_basics.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/memory_basics.py). # Think out loud, keep the thought Source: https://docs.anonalabs.com/memory/use-cases/think-out-loud Say it into your phone on the walk home. It arrives as memory you can question later. [← All use cases](/memory/use-cases/overview) [document\_qa.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/document_qa.py) ## The scenario The good thinking happens away from the keyboard — walking, driving, in the shower, twenty minutes after the meeting ended. By the time you are somewhere you could type it, it has flattened into one sentence, or gone. People already solve half of this with voice notes. The unsolved half is that a folder of 200 voice notes is write-only. Nobody has ever scrubbed back through them to find the one where they worked out the pricing model. **What this recipe shows that no other does:** getting memory in through a channel that isn't text at all. ## Step 1 — Record it however you already do Any voice memo app. MP3 or WAV. iPhone Voice Memos and most Android recorders save **M4A**, which is not accepted (`415 unsupported_file_type`). Convert it first — "Share → Save as MP3" in most recorder apps, or `ffmpeg -i memo.m4a memo.mp3`. ## Step 2 — Hand the file over The assistant can upload it for you over MCP, or you can do it in one call: ```python theme={null} client.upload_file(space_id="thinking", file="walk-home-2026-09-15.mp3") ``` It comes back transcribed and extracted into ordinary memory — the same pile as everything you typed. That is the important part: it is not "your voice notes", searchable separately. It is just memory now, and it composes with the rest. ## Step 3 — Ask, weeks later > *"What was I thinking about pricing before the offsite?"* You do not have to remember that the thought was spoken rather than written, or which file it was in, or roughly when. Those are exactly the things you have forgotten, and they are the only things a folder of recordings can be searched by. ## Evals 1. Record three voice notes on different days, one containing a specific decision you can check. 2. Ask a question that only that note answers. Check it comes back. 3. Ask using words you did **not** say out loud — describe the idea differently. Matching on meaning rather than transcript is the whole point; if it fails here, the transcription probably garbled the key term. 4. Check one transcript by hand against the audio. Names and jargon are where transcription degrades, and they are usually the words that matter most. ## Guardrails > [!WARNING] > > **A transcript is not a recording.** It is good enough to find the thought and > point you back at the moment; it is not evidence and it will get proper nouns > wrong. Keep the original file if the exact words could ever matter. * **Audio limits apply**: 25 MB per file. A long walk needs splitting. * **Do not narrate other people without telling them.** A voice note recorded in company is a recording of everyone in earshot. * **Half-formed thoughts get stored as confidently as conclusions.** If you talked yourself out of something, say so out loud — otherwise the abandoned idea is recalled later with the same weight as the one you kept, and [correcting it](/memory/use-cases/correct-a-memory) is the fix. ## Where to go next Same mechanism, pointed at a meeting rather than a walk: [Turn a meeting recording into memory](/memory/use-cases/meeting-recording-to-memory). # Tutor that remembers the learner Source: https://docs.anonalabs.com/memory/use-cases/tutor-that-remembers Progress, sticking points and preferences that persist between sessions, months apart. [← All use cases](/memory/use-cases/overview) [user\_profiles.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py)[time\_travel.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py) ## The scenario A tutor that forgets is a tutor that re-teaches. It re-explains what the student already has, misses that they have failed the same kind of problem four times, and cannot tell you in March what they were struggling with in January. Session memory does not fix this. The gap between sessions is where the teaching signal lives. ## Step 1 — Record what happened, not just what was said ```python theme={null} client.record( space_id="tutoring", content="Priya solved quadratics by factoring but could not start one needing the formula.", user_id="student-4417", timestamp="2026-01-18T16:00:00Z", ) ``` `timestamp` is when the thing *happened*, which is not always when you are writing it down. Getting it right is what makes the temporal questions below answerable at all. ## Step 2 — Open the next session already knowing ```python theme={null} profile = client.get_user_profile( space_id="tutoring", user_id="student-4417", format="block" ) print(profile["context"]) ``` One call returns what is known about that learner, already formatted for a prompt. `format="block"` is what renders it: without it the response carries the `memories` array and a null `context`, so printing `context` gives you `None` rather than a briefing. Put it in the system message and the first minute of the session stops being an interview. ## Step 3 — Ask a question about one student ```python theme={null} print(client.ask_about_user( space_id="tutoring", user_id="student-4417", query="Which topics has she needed re-explaining more than once?", )) ``` ## Step 4 — Show progress over a real interval ```python theme={null} client.retrieve( space_id="tutoring", query="quadratics", user_id="student-4417", occurred_after="2026-01-01T00:00:00Z", occurred_before="2026-03-01T00:00:00Z", ) ``` That is the parents' evening answer, and it is the one no chat history can give you. ## Evals 1. Record five sessions for one student across three months. 2. Ask `ask_about_user` for recurring difficulties. Check it names the one you planted deliberately. 3. Run the same question with a two-week `occurred_after` window and confirm the answer narrows rather than repeating itself. 4. Ask about a student with no memories. It should say so, not improvise. ## Guardrails > [!WARNING] > > **This is data about a child, in many deployments.** Scope every write, keep > the space out of reach of other learners' guardians, and have the > [deletion runbook](/memory/use-cases/delete-a-users-memories) ready before you need > it — not after a parent asks. * **`memory_count` on a profile is non-monotonic.** Consolidation merges related memories, so the number can go *down* while the system knows strictly more. Do not show it to a parent as "things learned". * **Don't record judgements you would not say out loud.** Anything stored is retrievable, and "seems lazy" reads very differently in an export than it did in the moment. * **Set `timestamp` yourself for anything you are backfilling.** Without it the event time is the write time, and a year of history imported on Tuesday all happened on Tuesday. ## Built from Both are complete, runnable scripts: [`user_profiles.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/user_profiles.py), [`time_travel.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py). # Two agents, one space Source: https://docs.anonalabs.com/memory/use-cases/two-agents-one-space Shared knowledge with per-agent working sets, so a handoff carries everything the next agent needs and none of the noise it doesn't. [← All use cases](/memory/use-cases/overview) [multi\_agent\_shared\_space.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/multi_agent_shared_space.py) ## The scenario A researcher agent gathers facts. A writer agent turns them into copy. They are separate agents with separate prompts, and each accumulates notes the other should never see — the writer's drafting angles are not findings, and the researcher's raw scraps are not a brief. The naive options are both bad. One space each means the writer cannot see what the researcher learned, so you end up passing findings through the orchestrator by hand. One space with no separation means every read returns both agents' working notes mixed together, and quality degrades as the pile grows. What you want is one space, two working sets, and a handoff that costs nothing. ## Step 1 — Each agent writes under its own scope `agent_id` works like `user_id`, but it identifies the *writer* rather than the end user. ```python theme={null} client.record( space_id=space, content="Competitor X launched usage-based pricing at $0.80 per thousand calls.", agent_id="researcher", ) client.record( space_id=space, content="Draft angle: lead with predictable pricing, avoid feature tables.", agent_id="writer", ) ``` ## Step 2 — A scoped read is that agent's working set ```python theme={null} client.retrieve(space_id=space, query="drafting notes", agent_id="writer") ``` Returns the writer's notes and nothing else. The researcher's findings are in the same space and do not appear, because the read asked for one scope. ## Step 3 — An unscoped read is the handoff ```python theme={null} client.retrieve(space_id=space, query="What do we know about competitor X?") ``` Returns everything the space knows, whoever wrote it. **This is the whole mechanism** — the writer's prompt pulls the researcher's findings without knowing who produced them, without an orchestrator passing state, and without a second API call to stitch two spaces together. Add a scope to narrow. Leave it off to hand off. ## Evals A handoff either carries the knowledge or it does not, and that is testable: 1. Have agent A write three facts under `agent_id="a"`, and agent B write two notes under `agent_id="b"`. 2. Read with `agent_id="b"` — assert none of A's three come back. 3. Read with no scope, asking A's question — assert A's facts come back. 4. Grow B's notes to fifty and repeat step 3. If A's facts stop surfacing, the unscoped read is competing against B's volume and the handoff needs a narrower query, not a bigger limit. Step 4 is the one worth automating. A handoff that works on day one and degrades at scale is the failure mode this design is exposed to. ## Guardrails > [!WARNING] > > **Scoping is not a security boundary between your own agents.** Any caller > holding the API key can read any scope in the space — the separation is about > relevance, not access control. For a boundary that holds against a different > *organization*, share the space and use > [per-space roles](/memory/api-reference/spaces) instead. * **A scoped query is strict.** Memories written before you adopted `agent_id` carry no scope, so a scoped read will never match them. Adopt scoping at the start of a space's life, or backfill. * **Unscoped writes stay unscoped.** Calls without a scope key produce exactly the request they always did, so adding `agent_id` to one agent does not change what the other already stored. * **Pick the right key for the job.** `agent_id` identifies the writer, `user_id` the end user this is about, and `session_id` one run or conversation. They compose — a support agent serving many customers uses both `agent_id` and `user_id`. See [Scoping Memory by User, Agent, or Session](/memory/guides/scoping-multi-tenant). * **Observations consolidate per user, not per agent.** Synthesis across an agent's notes follows the user scope first. If you need per-agent synthesis, that is a [memory model](/memory/api-reference/models), not a scope. ## The script Complete and runnable: [`multi_agent_shared_space.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/multi_agent_shared_space.py). See also [One space, many end users](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/support_bot_scoping.py) for the same mechanism applied to end users rather than agents. # What was true back then Source: https://docs.anonalabs.com/memory/use-cases/what-was-true-back-then Every memory carries two clocks. Filter on when things happened, not just when you stored them. [← All use cases](/memory/use-cases/overview) [time\_travel.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py) ## The scenario You import a year of history on a Tuesday. Every one of those memories was *recorded* on Tuesday and *happened* months apart, and almost every interesting question is about the second clock. Three controls use the two clocks, and mixing them up is the usual bug. ## Step 1 — Write with the event's own time ```python theme={null} client.record_batch(space_id=space, items=[ {"content": "The June offsite settled on the pricing overhaul.", "timestamp": "2026-06-10T09:00:00Z"}, {"content": "The December board meeting approved the London office.", "timestamp": "2025-12-04T15:00:00Z"}, ]) ``` `timestamp` is yours to set and means *when it happened*. The record time is the system's and you cannot set it. ## Step 2 — Filter on event time ```python theme={null} client.retrieve( space_id=space, query="what was decided?", occurred_after="2026-06-01T00:00:00Z", occurred_before="2026-07-01T00:00:00Z", ) ``` ## Step 3 — Replay what the space knew ```python theme={null} client.retrieve(space_id=space, query="what was decided?", as_of="2025-01-01T00:00:00Z") ``` `as_of` is **record** time, not event time. Both rows above were written today, so a year ago the space knew nothing — and returning nothing is correct. This is the control people expect to filter by event date, and it does not. ## Step 4 — Move "now" ```python theme={null} client.retrieve(space_id=space, query="recent decisions", query_timestamp="2026-06-15T00:00:00Z") ``` `query_timestamp` re-ranks rather than filters: it moves the "now" that recency, and phrases like *last quarter*, resolve against. ## Evals 1. Write two memories with event dates months apart, both today. 2. Window to one month. Assert only one returns. 3. `as_of` a date before you wrote them. Assert **nothing** returns — if something does, you are filtering on the wrong clock. 4. Move `query_timestamp` and check the ordering changes while the set does not. ## Guardrails > [!WARNING] > > **`as_of` and `occurred_before` answer different questions and look identical > at the call site.** "What did we know then" is `as_of`; "what happened then" is > `occurred_*`. Choosing wrong returns a plausible, wrong answer rather than an > error. * **Undated writes collapse to their write time**, so a backfill without `timestamp` makes every temporal question unanswerable and there is no bulk fix. * **An event-time filter narrows hard.** If a window returns less than you expect, check the memories actually carry event time before tuning recall. ## The script Complete and runnable: [`time_travel.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/time_travel.py). # Why the answer used what it used Source: https://docs.anonalabs.com/memory/use-cases/why-the-answer-used-what-it-used A receipt for every retrieval — what entered the prompt, what was cut, and why the memory you expected did not come back. [← All use cases](/memory/use-cases/overview) [context\_receipts.py](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/context_receipts.py) ## The scenario A support agent answers a customer wrongly. You know the correct fact is in the space, because you put it there. The search did not return it. Without a record of the decision, there are only guesses: raise the limit, lower the score floor, reword the query, try again. Each is cheap to try and none of them tells you whether it was the right thing to try — **a memory the search never matched looks exactly like a memory that does not exist.** A context receipt is that decision written down. Every retrieve builds one, whether or not you ask for it, so an answer stays explainable after the fact. ## Step 1 — Ask for the receipt id `retrieve` returns a plain list and has nowhere to put a receipt id. `retrieve_receipt` runs the same search and returns both. ```python theme={null} res = client.retrieve_receipt( space_id=space, query="who is on call for billing?", limit=1, receipt_detail="full", ) print(len(res), "returned, receipt", res.receipt_id) ``` `receipt_detail="full"` also asks the search to account for **its own** cuts. Without it the receipt can only describe what happened after the search returned — which is the half of the story you already knew. ## Step 2 — Read what was cut ```python theme={null} receipt = client.get_receipt(res.receipt_id) for entry in receipt["excluded"]: print(entry["reason"], entry["detail"]) ``` Each entry carries a reason code, so "it was a near-duplicate of something you already got" is distinguishable from "it scored below your floor" and from "it fell past your limit". Those three have different fixes. `excluded_truncated` is how a capped manifest admits it is capped. An incomplete list must never read as a complete one. ## Step 3 — Ask about one specific memory This is the call the whole feature exists for. You know an id. You did not get it back. Ask why. ```python theme={null} verdict = client.explain(res.receipt_id, memory_id) print(verdict["outcome"]) # included | excluded | not_retrieved print(verdict["arms"]) # which kinds of matching found it, and where they ranked ``` The outcome splits your problem in two: * **`excluded`** — it was found and then dropped. `stage` says where. A bigger `limit` or a lower floor may well bring it back. `arms` tells you *how* it was found: a memory matched by keyword but not semantically shares words with your query but not meaning; the reverse is the more usual case. * **`not_retrieved`** — nothing matched it at any stage. **No threshold change will help**, because nothing ever found it. The query wording, or the scope you searched, is what to change. That distinction is the difference between an afternoon of threshold-tuning and a one-line fix. ## Evals Before you trust a tuning change, check it against a case you can score: 1. Write three memories you know the answer to, and one unrelated to anything. 2. Retrieve with `receipt_detail="full"` and a `limit` of 1. 3. Call `explain` on **every** id you wrote that did not come back — the [runnable script](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/context_receipts.py) does exactly this by diffing the ids the write reported against the ids the search returned. 4. Expect the unrelated memory to be `not_retrieved` and the relevant ones to be `excluded` at the limit. If a relevant memory comes back `not_retrieved`, your extraction or your scoping is the problem, not your ranking. Re-run after any change to scoping, tags or query wording. A recall change that moves nothing in the receipt has not done what you think it did. ## Guardrails > [!WARNING] > > **A receipt is not a copy of your data, and that is deliberate.** It carries > ids, scores and reason codes only — never memory content. That is exactly what > makes a receipt id safe to paste into a support ticket, a bug report or a > vendor conversation. * **Receipts are built for every retrieve**, so you can investigate an answer you did not flag at the time. You do not need to have predicted the problem. * **`relevance_score` can exceed 1.0.** It is a product of several factors, not a probability. Compare scores within one result set; do not read an absolute threshold into them across queries. * **A scoped query is strict.** If you passed a `user_id`, `agent_id` or `session_id`, untagged memories cannot match — so a memory written before you adopted scoping will read as `not_retrieved` forever until it is backfilled. Check the scope before you blame the ranking. ## The script Complete and runnable: [`context_receipts.py`](https://github.com/anonalabs/Anona-Memory-SDK/blob/main/examples/context_receipts.py). See also the [context receipts API reference](/memory/api-reference/context-receipts) and [Tuning Recall Quality](/memory/guides/tuning-recall).