Why WOS

Long-term memory for AI agents.

WOS is a memory API. You store a user's memories once, then recall only the relevant ones for each query and pass them to your model's prompt.

Retrieval is purely semantic, with no keyword or BM25 matching, so recall quality is identical across languages. Each query returns a small, bounded context no matter how much you have stored, and no model is ever run over your stored memories.

Core operations

  • store - save a memory for a user.
  • recall - get the relevant memories for a query. This is the main call.
  • search - raw semantic search over stored memories.
  • supersede - update or replace a memory that is out of date.
  • forget - delete a single memory or an entire user (GDPR).
Pick a section on the left for detail on each topic.
Model

Three models, one lineage.

WOS models are named for how people have kept knowledge through history - Tablet, Scroll, Book. Stone, scroll, bound book: each one does more for your agent than the last.

Tablet

Live
Carved in stone · store & recall

A lean, fast, low-cost way to inscribe and recall memory - the foundation every model builds on.

Scroll

Live
Unrolled · LLM-assisted recall

Adds a language model to read your question more closely and bring back a fuller context, so scattered evidence comes back together rather than one piece short.

Book

Next
Bound & indexed · self-routing

Opens to the right page on its own - choosing the memory and tools each moment needs, and getting sharper the more it's used.

Tablet 1's full benchmark report is on the benchmark page.

Cost

Pay us $2. Save many times that on your LLM.

WOS feeds your LLM ~1,200 tokens per query - a bounded, relevant slice - instead of stuffing the full history into every prompt. The gap is enormous, and it grows with your history.

LLM cost per 1,000 queries Based on Tablet 1
User history100K
Queries / month1,000
Your LLM
45× cheaper - you save $244/mo
Without WOS$250.00
With WOS$5.50

Every $1 spent on WOS saves ~$98 on the LLM. Bigger history or a pricier model → bigger ROI.

Where the savings come from

  • Without WOS you stuff the whole history into each prompt - 100K tokens × $2.50/1M = $0.25 per query, at GPT-4o input rates (about 2× that on Opus-tier models).
  • With WOS you ingest once ($2/1M), then each query is a tiny retrieve ($3/1M × 1,200) plus your LLM on just ~1,200 tokens.
  • The fewer tokens your LLM reads, the less you pay - and WOS keeps that number flat as memory grows.
Context shrink = history ÷ tokens fed, not cost (the calculator above prices each retrieve).  25K → 21× · 100K → 83× · 200K → 167×.
Multilingual

Every language, the same accuracy.

Retrieval is pure semantic - embeddings only, zero keyword or BM25 matching. So recall quality is identical whether your users write in 日本語, 中文, Español, or English.

Lexical matching like BM25 is tuned to the shape of a particular language - morphology, spacing, script. In a multilingual store that means retrieval quality varies by language. WOS uses no lexical matching at all, so every language goes through the same path.

One store, three languages at once

You don't pick a language per store - mix them freely. Below, one user's memory holds Japanese, English, and Spanish at the same time, and every question finds the right memory regardless of language. This is a real exchange against the live API:

# one user, three languages stored together
mem.add("彼女はコーヒーより紅茶が好き", user_id="alice")                      # Japanese
mem.add("she works at a design studio in Brooklyn", user_id="alice")       # English
mem.add("A ella le encanta hacer senderismo los sábados", user_id="alice")  # Spanish
Actual results - each question crosses into a different language
"¿Qué bebe ella?"               -> 彼女はコーヒーより紅茶が好き
"what does she do on weekends?" -> A ella le encanta hacer senderismo los sábados
"彼女の仕事は?"                  -> she works at a design studio in Brooklyn

No translation step, no language detection, no per-language config. Memories and questions are placed by meaning, not by language - if the meaning matches, the language doesn't matter.

Three languages here is just what fits on a page - there is no supported-language list to be on. The same live test also passes with 中文, Русский, and العربية memories, all verified against the production API.

Why we banned keywords on purpose

Lexical scoring such as BM25 strengthens retrieval for some languages more than others, which gets in the way when one store holds many languages. So we removed it from the engine entirely and enforce that rule in code review: with any lexical scoring in the path, recall quality would differ by language.

LongMemEval is English-only, so it does not measure multilingual recall. The demo above is how you can verify it directly against the live API.
Architecture

No model runs over your memories.

Storage is verbatim and the engine searches by embeddings - cheap, fast, and deterministic. A model is never run over your stored memories. Tablet uses no model at all; Scroll and Book add one around the engine for stronger results, but it only ever sees your query, never what you stored.

  • Deterministic engine. The engine returns the same memories for the same query, every time - which is why our benchmark variance comes only from the reader model.
  • Cheap at scale. No generation cost to store or retrieve, so your bill tracks storage - not model usage - as memory grows.

Your words, untouched

One common design runs a language model at write time to extract and rewrite "facts" from the text. That design trades three things: generation cost on every write, added latency, and storage of a model's paraphrase rather than the original words. WOS makes the opposite trade - it stores what was said, unchanged, and lets your LLM do the interpreting at read time with the original text in hand.

What WOS is not: not a vector DB you have to operate, and not a RAG framework you have to assemble. No model is ever run over your stored data - that path is pure embeddings. Scroll and Book do use a language model for stronger results, but it only ever sees your query, never your stored memories - and it never trains on your data or collects it.
Proof

67.5%, measured and reproducible.

67.5% on BEAM 1M, averaged over 5 independent runs (σ 0.22%, none cherry-picked), graded by gpt-4.1-mini with the benchmark's own judging prompt.

On the same benchmark, scores vary widely with the grading protocol - the judge, the prompt, and what the retrieval layer is allowed to do. We grade with the judge the authors' own repository ships, use their judging prompt as written, change nothing to fit the test, and publish the harness, the scoring code and the reader prompt so anyone can reproduce the 67.5% exactly.

The protocol, in one table

ItemWhat we do
DatasetBEAM 1M - 35 conversations, 74,630 turns, 2.2 million memories, 700 questions
Judgegpt-4.1-mini at temperature 0, running BEAM's own judging prompt - the default in the authors' repository, not a judge we picked
Runs5 independent runs, every score published, mean reported (σ 0.22%)
ReaderFixed reader model and prompt, published verbatim

What keeps it honest: a third-party judge, the reader prompt published unchanged, purely semantic retrieval, and every run reported - not just the best. The retrieval engine is deterministic - run it again and you get the same memories.

We climb harder benchmarks

We test on the hardest standard benchmark we haven't yet conquered - and the number is the high-water mark across every WOS model, rewritten each time a better one ships. Clear 94%, and we graduate to a harder benchmark.

BEAM 1MIn progress
Tablet67.5%
gpt-4.1-mini judge · five-run mean94% to graduate
Earlier benchmark LongMemEval-S Cleared
Tablet95.7%
Scroll92.3%
GPT-4o judge · best across all WOS models94% to graduate
See the full report
Pricing

Two token rates per model,
plus $0.0001 per request.

Per million tokens plus a flat $0.0001 per request, pay as you go. No subscription, no storage rent, no memory caps. You pay when your agent writes or reads - never for what it remembers.

ModelInput / 1MOutput / 1M
Tablet$2$3Live
Scroll$4$8Live
Book--TBD
  • $0.0001 per request. A flat fee on every API call, on top of token usage.
  • Storage is free. Ingest pays once; keeping it costs you nothing. No count limit, no retention limit.
  • We store it. We never train on it, use it, or look at it. Your agent's memory is yours - we only organize it so you can retrieve it.
  • Why Tablet is this cheap: its engine runs no model, so our cost is embeddings and disk - not GPUs. Scroll and Book add a model, which is what their higher price covers.
Other billing models charge monthly for stored volume or cap memory counts by plan. WOS charges nothing for stored data, regardless of volume or age.

Rate limits by usage tier →

For developers

Three calls: store, recall, answer.

One API. The recall() call returns short-term, long-term, and surrounding context in a single round-trip, ready to drop into your prompt.

1

Store

add() facts and turns: your user's words, the assistant's own (speaker "me"), or a person's by name. Embedded on the way in - no LLM.

2

Recall

recall() returns short-term + long-term + context in one call - a bounded, fixed-size context.

3

Answer

Feed that bounded context to your LLM - any provider, your key.

from wontopos import Client
mem = Client(api_key="wos-...")
mem.add("she prefers tea over coffee", user_id="alice")
mem.add("I suggested the jasmine tea", user_id="alice", speaker="me")  # its own words
# one call: short + long + context
ctx = mem.recall("what does alice drink?", user_id="alice")

Memories carry a speaker. Your user's words are the default, speaker "me" stores what the assistant itself said, and a name like "Bob" remembers who around your user said it, so recall can answer by person.

Speakers are explicit, like stores. Register a person first, then store under their name — a typo can never quietly become a new person. A store registers up to 50 people to start (we plan to raise it), and "me" never needs registration or counts.
Quickstart

Your first recall in 5 minutes.

One key, one install line, three calls - your agent has memory. Every snippet on this page was actually run; responses are shown verbatim.

1

Get an API key

Create one in the console. A 155-character key starting with wos-live- is shown once. Keep it in an environment variable - never in code.

2

Install

pip install wontopos        # Python
npm install wontopos        # TypeScript / JavaScript
cargo add wontopos          # Rust
# curl - nothing to install, just set WOS_API_KEY
# latest: SDK v2.2.32 · MCP v1.0.15
3

Create a store, then store & recall

A store is the user_id you read and write under. Stores are explicit: create one first (the call below), then store and recall under it. Store - embedded on the way in, no LLM call. Recall - short-term + long-term + context in one round-trip.

from wontopos import Client

mem = Client(api_key="wos-live-...", user_id="alice")  # set the store once
mem.create_store()              # create it (stores are explicit)
mem.add("she prefers tea over coffee")  # no user_id needed

# one call → short-term + long-term + context
ctx = mem.recall("what does alice drink?")
import { Client } from "wontopos";

const mem = new Client({ apiKey: "wos-live-...", userId: "alice" });  // set the store once
await mem.createStore();            // create it (stores are explicit)
await mem.add("she prefers tea over coffee");  // no userId needed

// one call → short-term + long-term + context
const ctx = await mem.recall("what does alice drink?");
use wontopos::Client;

let mem = Client::new("wos-live-...").with_user("alice");  // set the store once
mem.create_store(None).await?;            // create it (stores are explicit)
mem.add("she prefers tea over coffee", None, json!({})).await?;

// one call → short-term + long-term + context
let ctx = mem.recall("what does alice drink?", None).await?;
# create the store once - stores are explicit
curl -X POST https://api.wontopos.com/api/v1/memory/collection \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice"}'

# store - embedded on the way in, no LLM call
curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"she prefers tea over coffee"}'

# one call → short-term + long-term + context
curl -X POST https://api.wontopos.com/api/v1/memory/recall \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what does alice drink?"}'
Actual response - create_store()
{"user_id": "alice", "status": "created"}
Actual response - add()
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}
Set the store once. Pass user_id to the client and every call uses it - no need to repeat it; override any single call by passing user_id to it. Stores are explicit: storing into or recalling from a store that doesn't exist returns 404 - create it first. Every account starts with a default store, so with no user_id at all the zero-setup path just works. See Stores to list and manage them.

recall() returns four blocks - short_term (recent turns), long_term (relevant memories), context (what surrounded the best match), and an instruction telling the LLM how to use them. Drop the whole thing into your prompt.

Works in any language. Store in English, ask in Korean, Japanese, or Chinese - the same memory comes back. Embedding search, not keyword matching.

Every method, per language →

One client, different settings

mem = Client.from_env()                 # reads WONTOPOS_API_KEY
scroll = mem.with_model("scroll-1.2")  # this copy only: another engine
alice  = mem.with_user("alice")       # this copy only: another default store
const alice = mem.withUser("alice");
const scroll = mem.withModel("scroll-1.2");
let alice = mem.with_user("alice");
let scroll = mem.with_model("scroll-1.2");
# curl has no copies — send model and user_id with each request
curl ... -d '{"user_id":"alice","model":"scroll-1.2","query":"…"}'
Stores

Stores - create, list, delete.

A store is the user_id you read and write under - one isolated memory space per end-user, agent, or topic. Stores are explicit: create one before you store into or recall from it, or the call returns 404. Every account starts with a default store, so you can begin without a create call.

How isolation nests. An account owns workspaces; each workspace isolates its own memory, API keys, and usage (billing is shared at the account). A store lives inside a workspace: keys in the same workspace share its stores, and different workspaces never see each other's memory. account → workspace → store (user_id) → memories.
mem.create_store("alice")        # create (idempotent)
mem.list_stores()              # [{"user_id","created_at"}, ...]
mem.delete_store("alice")        # delete the store + all its memories
await mem.createStore("alice");
await mem.listStores();          // [{ user_id, created_at }, ...]
await mem.deleteStore("alice");     // store + all its memories
mem.create_store("alice").await?;
let stores = mem.list_stores().await?;
mem.delete_store("alice").await?;
# create
curl -X POST https://api.wontopos.com/api/v1/memory/collection \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" -d '{"user_id":"alice"}'
# list
curl https://api.wontopos.com/api/v1/memory/collections -H "X-API-Key: $WOS_API_KEY"
# delete (store + all its memories)
curl -X DELETE https://api.wontopos.com/api/v1/memory/collection \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" -d '{"user_id":"alice"}'
Actual response - create
{ "user_id": "alice", "status": "created" }   // "exists" if it already did
Actual response - list
{ "collections": [
  { "user_id": "default", "created_at": "2026-06-26T02:23:14Z" },
  { "user_id": "alice",   "created_at": "2026-06-26T02:24:01Z" }
], "count": 2 }
Recall on a store that doesn't exist
{ "error": { "type": "not_found_error",
  "message": "Store 'ghost' does not exist. Create it first with
              POST /api/v1/memory/collection {\"user_id\":\"ghost\"}, then store or recall." } }
Use one store per end-user ("alice", "user_42") to keep each person's memory separate, or a single default store for a personal agent. You can also create and browse stores in the console (Memory ids → Issue) without writing code. Deleting a store is permanent - it drops every memory under it. Store ids are folded before storage - lowercased, with anything outside [a-z0-9_] becoming _ - so Alice.Smith and alice-smith name the same store. Creating a second id that folds onto an existing one is refused with 409 rather than quietly shared. The id must also match [A-Za-z0-9][A-Za-z0-9._-]{0,63}, so an email address or a non-latin name cannot be a store id - key the store on an internal id instead.

List and delete stores

mem.list_stores()                # [{"user_id","created_at"}, …]
mem.delete_store("alice")      # the store and every memory in it
await mem.listStores();
await mem.deleteStore("alice");
let stores = mem.list_stores().await?;
mem.delete_store("alice").await?;
curl -X POST   .../api/v1/memory/collections -d '{}'
curl -X DELETE .../api/v1/memory/collection  -d '{"user_id":"alice"}'
Recall caching

Repeated recall, at a tenth of the price.

Opt in per request and WOS caches the search result under its query text, with the same prefix rules as LLM prompt caching. While the cache is warm, a repeated or extended query reuses the previous result, and the cached part is billed at 10% of the normal token rate.

Tablet and Scroll only. Caching works on every Tablet and Scroll model, current and future. Book does not support it: Book reasons over your memories and learns between calls, so the same question can legitimately come back with a different answer, and a cached result would be wrong by design. Sending cache_control to Book returns a clear 403.

One conversation, three turns

Here is what actually happens when an agent keeps talking to its memory. Each turn sends the conversation so far as the query, with cache_control on.

writeTurn 1 - "Alice: I moved to Lisbon last spring."

The full query is searched and cached: input at 2x (5-minute TTL).

extendTurn 2 - the same text plus "Bob: How is the weather there?"

Only Bob’s sentence is embedded and searched. The old part costs 0.1x, the new sentence 2x, and the cache now ends at it.

hitTurn 3 - exactly the same query again (a retry, a refresh)

No engine call at all. Everything at 0.1x: the 90% discount.

The rates

OperationToken billingWhat it means
Cache write - TTL 5 minutesThe first request. Its result is kept for 5 minutes, and every read slides the window forward.
Cache write - TTL 1 hourThe first request, kept for a full hour.
Cache read - hit or prefix hit0.1×Every request after the write: the cached part costs a tenth of the normal token rate.

What it saves

A concrete example: your agent sends a 3,000-token conversation as its query and repeats or continues it 10 times within five minutes. Without caching, that is 30,000 input tokens at full price. With a 5-minute cache it is 6,000 for the first write (2x) plus about 2,700 for the nine cached reads: 8,700 billed tokens, 71% less. The longer the conversation runs, the bigger the save.

The prefix rule

Matching is on the front of the query. If the front stays identical and new text is only appended, the cached part is reused and only the new part is searched. If anything before the end of the cached text changes, nothing can be reused.

prefix match
cached    [ A B C D E F G ]

○   [ A B C D E F G ] E
✗   [ B C D E F G ] E

hit - the front is unchanged, E is the only new part
miss - the front changed, so the whole query is searched and cached again

Three rules to remember

  • Extending re-caches through the new tail. After [A B C D E F G] + E, the cache now ends at E: the tail is billed once at the write rate, and the next turn can match all of A..E as its prefix again.
  • One contiguous prefix per request. A query cannot be split into two cached segments; only its front can match.
  • Writes invalidate instantly. Any store, store-turn, bulk-store, forget, supersede, or store deletion drops that store’s cache, so a cached answer can never be stale.

Turning it on

hits = mem.search(
    "...the conversation so far...", user_id="alice",
    cache_control={"ttl": "5m"},   # or "1h"
)
const hits = await mem.search(
  "...the conversation so far...", "alice", 10,
  { cache_control: { ttl: "5m" } },   // or "1h"
);
let hits = mem.search_with(
    "...the conversation so far...", "alice", 10,
    serde_json::json!({"cache_control": {"ttl": "5m"}}),   // or "1h"
).await?;
curl -X POST https://api.wontopos.com/api/v1/memory/search \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice",
       "query":"...the conversation so far...",
       "cache_control":{"ttl":"5m"}}'   # or "1h"
Response - the cache object reports what happened
{ "memories": [ ... ],
  "cache": { "status": "hit",              // "write" | "hit" | "extend"
             "ttl": "5m",
             "cache_read_input_tokens": 412,
             "cache_creation_input_tokens": 0 } }

You do not need an SDK for any of this. Caching is one field on one HTTP call, so it works from every programming language. The curl tab is the universal recipe, and the Python, TypeScript, and Rust SDKs are convenience wrappers around the exact same call.

Caching is isolated per store and per model inside your workspace, and it is off by default: without cache_control, nothing about your requests changes.
Who said it

Memory that knows who said it.

People remember by person: what Bob promised, what you said you would do. Tag each memory with a speaker and your agent does the same, on every Tablet and Scroll model.

Speakers are explicit, like stores. Register a person first, then store under their name — a typo can never quietly become a new person. A store registers up to 50 people to start (we plan to raise it), and "me" never needs registration or counts.

One team, three memories

A store keeps many voices apart. Register a person once, save each remark under its speaker, then ask by person.

addRegister Bob once: POST /speakers, or add_speaker("Bob") in the SDKs.

The store now knows Bob. The 50-person limit is counted here, at registration; store calls never return a limit error.

BobBob tells your user the deadline moved to Tuesday. Store it with speaker "Bob".

The memory belongs to Bob now: every search that returns it says so.

meYour assistant promises the summary by Friday. Store its own words with speaker "me".

Self-speech gets remembered too, and "me" never counts toward the speaker limit.

askLater: "what did Bob say about the deadline?" Search with speaker "Bob".

Only Bob's words come back. One person's words never come back as someone else's.

Three rules to remember

  • "me" is the assistant itself. Never registered, never counted. Reserved and lowercase: speaker: "Me" or "ME" returns 400 invalid_request_error instead of being silently coerced.
  • The limit lives at registration: 50 per store to start. Registering past it returns 400 invalid_request_error with speaker_limit: 50 in the error body. Storing with an unregistered name also returns 400 and stores nothing. Filtering search by an unregistered name returns 404 not_found_error. Branch on the status and fields, not the message text; we plan to raise the limit.
  • Labels live on every read. Search results, recall's long-term context, and engram results all carry their speaker, so the model always knows whose words it is holding. Pass speaker on a search to get one person's words only. A supersede keeps the speaker; forget removes it.
  • Names are Unicode: any language works. さくら, Иван, and 하늘 are all valid speakers, and attribution behaves identically in every language. Matching is exact after trimming and Unicode normalization, so Bob and bob are two different people. Names cap at 80 characters.
errors - verbatim
# POST /speakers past the limit
{ "type": "error",
  "error": { "type": "invalid_request_error",
             "message": "This store already has 50 registered speakers, ...",
             "speaker_limit": 50 } }

# store with an unregistered name → 400, nothing stored
{ "type": "error",
  "error": { "type": "invalid_request_error",
             "message": "speaker 'Bob' is not registered in this store. Register it first: ...",
             "speaker": "Bob" } }

# search filtered by an unregistered name → 404
{ "type": "error",
  "error": { "type": "not_found_error",
             "message": "speaker 'Bob' is not registered in this store.",
             "speaker": "Bob" } }

Two scoping notes. speaker rides on add / store: add_turn remembers a whole exchange, and per-person labels and the filter come from memories stored with an explicit speaker. And assembled session passages (expand) are composites of several memories, so they carry no label; a speaker filter always returns atomic, labeled memories. And a write whose meaning is close enough to a stored memory is dropped - the match is semantic, not textual: such a store returns status "duplicate" with an explicit note, saves nothing, and attaches no speaker. A genuinely new fact that only varies a detail of an existing one ("allergic to shellfish" after "allergic to peanuts") is dropped by the same rule, so read status rather than assuming the write landed.

We test this the hard way: memories stored with no names in the text, then recalled per person. Attribution comes from the speaker record, not from matching words, so it behaves the same in every language.

Using it

mem.add_speaker("Bob", user_id="alice")  # once per person; "me" needs no registration
mem.add("Bob said the deadline moved to Tuesday", user_id="alice", speaker="Bob")
mem.add("I promised the summary by Friday", user_id="alice", speaker="me")
hits = mem.search("what did Bob say about the deadline?", user_id="alice", speaker="Bob")
mem.list_speakers(user_id="alice")
mem.remove_speaker("Bob", user_id="alice")  # memories stay, the tag goes
await mem.addSpeaker("Bob", "alice");  // once per person; "me" needs no registration
await mem.add("Bob said the deadline moved to Tuesday", "alice", { speaker: "Bob" });
await mem.add("I promised the summary by Friday", "alice", { speaker: "me" });
const hits = await mem.search("what did Bob say about the deadline?", "alice", 10, { speaker: "Bob" });
await mem.listSpeakers("alice");
await mem.removeSpeaker("Bob", "alice");  // memories stay, the tag goes
mem.add_speaker("Bob", "alice").await?;  // once per person; "me" needs no registration
mem.add("Bob said the deadline moved to Tuesday", "alice", json!({"speaker": "Bob"})).await?;
mem.add("I promised the summary by Friday", "alice", json!({"speaker": "me"})).await?;
let hits = mem.search_with("what did Bob say about the deadline?", "alice", 10, json!({"speaker": "Bob"})).await?;
mem.list_speakers("alice").await?;
mem.remove_speaker("Bob", "alice").await?;  // memories stay, the tag goes
curl -X POST https://api.wontopos.com/api/v1/memory/speakers \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","speaker":"Bob"}'   # once per person

curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"Bob said the deadline moved to Tuesday","metadata":{"speaker":"Bob"}}'

curl -X POST https://api.wontopos.com/api/v1/memory/search \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what did Bob say about the deadline?","speaker":"Bob"}'

curl "https://api.wontopos.com/api/v1/memory/speakers?user_id=alice" -H "X-API-Key: $WOS_API_KEY"

curl -X DELETE https://api.wontopos.com/api/v1/memory/speakers \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","speaker":"Bob"}'   # memories stay, the tag goes
response
{ "memories": [
    { "content": "Bob said the deadline moved to Tuesday",
      "speaker": "Bob", ... } ] }
GET /speakers
{ "user_id": "alice",
  "speakers": [ { "speaker": "Bob", "memories": 2, "created_at": "2026-07-10T04:20:39Z" } ],
  "count": 1, "limit": 50 }

list shows who the store knows with per-person memory counts against the limit. remove unregisters a person: their memories stay, only the name tag goes.

Read one person's memories

by_speaker returns what one person said, newest first, without a query. "me" gives the assistant's own words. Same cursor paging as images: hand next_before and next_skip_ids back.

page = mem.by_speaker("Bob", limit=50)
page["memories"], page["chunks"]
const page = await mem.bySpeaker("Bob", undefined, { limit: 50 });
let page = mem.by_speaker("Bob", None, 50, None, None).await?;
curl -X POST https://api.wontopos.com/api/v1/memory/by-speaker \
  -H "X-API-Key: $WOS_KEY" \
  -d '{"user_id":"alice","speaker":"Bob","limit":50}'
FieldWhat it does
memoriesThe memories, newest first. Same shape a search returns.
chunksSentence-level fragments behind those memories - what a delete would actually remove. Usually larger than the number of memories, and worth showing before anyone confirms one. Also reported as points_to_delete.
next_beforeCursor for the next page, with next_skip_ids. Both are needed because memories can share a timestamp.
speaker here is the tag written at store time, not a search over the text. A memory stored without a speaker is reachable by search but never by by_speaker, including under "me".

List, browse and remove speakers

mem.list_speakers()                    # who is registered
mem.by_speaker("Bob")                 # what Bob said, newest first
mem.remove_speaker("Bob")             # unregister; the memories stay
await mem.listSpeakers();
await mem.bySpeaker("Bob");
await mem.removeSpeaker("Bob");
mem.list_speakers(None).await?;
mem.by_speaker("Bob", None, None, None, None).await?;
mem.remove_speaker("Bob", None).await?;
curl -X GET    .../api/v1/memory/speakers   -d '{"user_id":"alice"}'
curl -X POST   .../api/v1/memory/by-speaker -d '{"user_id":"alice","speaker":"Bob"}'
curl -X DELETE .../api/v1/memory/speakers   -d '{"user_id":"alice","speaker":"Bob"}'
Developers

Images

A memory can carry an image. The engine indexes the image, so a text query in any language matches it even when the record has no caption, title or alt text.

Supported on Tablet 2 and newer. An engine that does not implement images says so by name rather than answering a bare 404, so you can tell a missing feature from a missing memory. JPEG, PNG, GIF and WebP.

Storing one

Pass an image object to the ordinary add call. content may be empty; the image is then searchable on its own.

mem.add("at the beach", image={"data": b64})   # caption + image
mem.add("", image={"data": b64})               # the image IS the memory
// the image rides in the 4th argument; the 3rd is metadata
await mem.add("at the beach", undefined, {}, { image: { data: b64 } });
await mem.add("", undefined, {}, { image: { data: b64 } });   // the image IS the memory
let img = json!({"image": {"data": b64}});
mem.add_with("at the beach", None, json!({}), img.clone()).await?;
mem.add_with("", None, json!({}), img).await?;
curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"","image":{"data":"<base64>"}}'

data is required. A data:image/jpeg;base64, prefix and the line wrapping added by base64 and openssl are both stripped for you.

FieldWhat it does
dataBase64 of the image. Required. The size ceiling is a server setting, not an SDK constant - /health reports it as memory.images.max_bytes.
referenceWhere your own copy of the original lives. Stored as a string and never fetched by us.
taken_atRFC3339, usually from EXIF. Fills event_date when that is empty, so the memory sorts by when the image was taken rather than when it was uploaded.

Finding one

There is no separate image search. search and recall return images alongside text, ranked together.

Working with the ones you have

data, mime = mem.get_image(memory_id=mid)
page       = mem.list_images(limit=50)      # page["count"] = store total
mem.forget_image(memory_id=mid, preview=True)
const { bytes, contentType } = await mem.getImage(undefined, mid);
const page = await mem.listImages(undefined, { limit: 50 });
await mem.forgetImage(undefined, mid, { preview: true });
let (bytes, mime) = mem.get_image(None, mid).await?;
let page = mem.list_images(None, 50, None, None).await?;
mem.forget_image(None, mid, true).await?;
# original bytes — the one call on this plane that is not JSON
curl -X POST   .../api/v1/memory/image  -d '{"user_id":"alice","memory_id":"m_1"}'
curl -X POST   .../api/v1/memory/images -d '{"user_id":"alice","limit":50}'
curl -X DELETE .../api/v1/memory/image  -d '{"user_id":"alice","memory_id":"m_1","preview":true}'
CallWhat it does
get_imageThe original bytes, as (bytes, content_type). The type is sniffed from the bytes, not from whatever the upload was named. A memory with no image raises rather than returning something empty.
list_imagesOne page, newest first, plus count - the store's total, not the page size. Paging is by cursor: hand next_before and next_skip_ids back. Both are needed because images can share a timestamp.
forget_imageRemoves the image and keeps the text. An image stored with no caption is the memory, so there it deletes the memory too.

Pass preview=True to forget_image to get memory_kept without changing anything. iter_images pages for you.

What an image costs

Images are billed in tokens, like text. Tokens = pixel area / 556.7. Above 1,568 px on the long edge the count is taken at 1,568 px, so a 2,500 px image and a 1,568 px image cost the same.

ImageCounted atTokens
700 × 700as sent881
1000 × 1000as sent1,797
1568 × 1568as sent4,417
1920 × 10801568 × 8822,485
2500 × 18751568 × 11763,313
2500 × 25001568 × 15684,417

Ceiling: 4,417 tokens per image. The ceiling is reserved against your balance before the call; the measured value is charged after and is never higher.

Oversized and undersized images are refused with a 400. We do not resize them for you. Both edges must be ≥ 700 px, long edge ≤ 2,500 px. Below 700 px the embedder charges a flat minimum, so a smaller image costs the same to store. Resize before sending; the error gives both the size received and the size required.

How many come back

Default 1, maximum 5 images per response. Five images is close to 20,000 tokens.

FieldWhat it does
max_images0 to 5. Images a single response may carry. Default 1. 0 returns text only. Out of range is rejected rather than clamped.
An English caption on an image improves queries in English and degrades queries in other languages - 11.4 points of recall@5 on average across fourteen languages. Store images without captions if your users search in more than one language.
Developers

verify

verify lets a search run additional passes. Each pass excludes what earlier passes returned, so a second pass reaches memories the first did not.

Supported on Tablet 2 and newer. Asking an engine that does not implement it is refused before the call is made, so you are never charged for a pass that silently did nothing.

An integer 0-3 on search and recall. It is the number of additional passes, so 3 allows four retrievals. Default 0.

hits = mem.search("what did I eat", verify=3)
# the SDKs hand back the memories; `verify_used` is on the HTTP response (curl tab)
const hits = await mem.search("what did I eat", undefined, 10, { verify: 3 });
// the SDKs hand back the memories; `verify_used` is on the HTTP response (curl tab)
let hits = mem.search_opts("what did I eat", None, 10,
                            &SearchOpts { verify: Some(3), ..Default::default() }).await?;
// the SDKs hand back the memories; `verify_used` is on the HTTP response (curl tab)
curl -X POST https://api.wontopos.com/api/v1/memory/search \
  -H "X-API-Key: $WOS_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what did I eat","verify":3}'
# → {"memories":[…], "verify_used":1}

No language model runs in that loop

Passes carry the ids already returned; the engine excludes them and searches past them. The query is not reformulated, so results are deterministic for a given request and no model credentials are involved. Your code decides whether to spend another pass.

What you send and what comes back

CallWhat it does
verify0 to 3. Additional passes permitted. Out of range is rejected with a 400 rather than quietly clamped.
verify_usedHow many additional passes were actually made. Can be lower than what you asked for.

Passes stop early when one returns nothing new, and unused passes are not billed. If a later pass fails, the results gathered so far are returned.

Extra passes can lower accuracy when the first pass already held the answer - single-session-user questions on LongMemEval-S drop 4.2 points. The gain scales with how often one retrieval misses, so it is larger on big stores.
Add-on · Beta

MCP - memory for AI tools

The core of WOS is the API and SDKs. The MCP server is an add-on on top of them: the same memory, plugged into tools you did not build - Claude Code, Claude Desktop, Cursor.

One install line gives the agent nine memory tools it uses on its own. And because memory lives in your account, what one tool writes, every other tool recalls - including agents you build on the SDK.

What you can do with it

  • Claude Code that remembers your project. Decisions, bug fixes, preferences - recalled next session without re-explaining anything.
  • Start in ChatGPT, continue in Claude. Same store, same memory - the conversation crosses tools instead of restarting.
  • Your own agent stays in the loop. What Claude Code learns, an SDK agent recalls - and what your agent stores, Claude Code recalls back.

Works in Claude Code, Claude Desktop, Cursor, Windsurf, and any MCP host. ChatGPT reaches the same memory through Actions plus the OpenAPI spec.

Install

claude mcp add wontopos --env WONTOPOS_API_KEY=wos-live-... -- npx -y wontopos-mcp

The agent gets nine tools - recall · remember · search · update · forget · list_memories · engram · stats · create_store - each described so it knows on its own when to use them.

The add-on itself is free and open on npm - you pay only the normal usage pricing for the API calls it makes. Needs Node 18+ and an API key from the console.

Open the developer page

Add-on

OpenAPI spec

The complete machine-readable map of the API - every endpoint, request, response, and error.

OpenAPI is the industry-standard format for describing an HTTP API in a file machines can read.

https://api.wontopos.com/openapi.json

What you can do with it

Postman: File → Import → paste the URL, and every endpoint appears as a clickable collection. ChatGPT: create a GPT, add an Action, paste the same URL. Codegen: openapi-generator -i .../openapi.json -g go builds a client in a language we do not ship.

Import it into Postman, generate a client in a language we do not ship, wire up ChatGPT Actions, or run contract checks in CI. A test pins it to the live routes, so it cannot drift.

Add-on

llms.txt

The whole API as one page of text an AI can read.

llms.txt is a web convention: one plain-text page at the site root that tells an AI everything it needs about a product.

https://wontopos.com/llms.txt

Drop it into your IDE or coding agent and it knows how to build on WOS - auth, endpoints, patterns, errors. Updated with every release.

Same facts as the OpenAPI spec, different audience: the spec is precise structure for tools, this file is prose an AI (or a person) reads in one pass. Both update with every release.

Model Context Protocol · Beta

Your memory inside every AI tool

One command gives Claude Code, Claude Desktop, Cursor, or any MCP host a long-term memory backed by your WOS account. No integration code - the agent gets nine memory tools and decides when to use them.

MCP is in beta. The nine tools work today and are tested, but the surface may still change while we finish it. The API and SDKs underneath are stable and versioned.

Install

Claude Code - one line (create a key in the console first):

claude mcp add wontopos --env WONTOPOS_API_KEY=wos-live-... -- npx -y wontopos-mcp
# pick which store it remembers into (optional): add --env WONTOPOS_USER_ID=my-project
# ~/.cursor/mcp.json
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }
# .vscode/mcp.json
{ "servers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }
# ~/.codeium/windsurf/mcp_config.json
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }
# Claude Desktop and any other MCP host
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }

Add to Cursor →  ·  Add to VS Code →

Optional env: WONTOPOS_USER_ID picks the default store, WONTOPOS_MODEL the engine, WONTOPOS_BASE_URL a self-hosted deployment. WONTOPOS_READ_ONLY=1 switches to read-only (recall/search/list only).

Before you share one store

  • Use a dedicated key. Keys carry their workspace, so a key made just for MCP scopes what connected tools can ever touch - and you can rotate it in the console without touching your app's keys.
  • Read-only mode. WONTOPOS_READ_ONLY=1 registers no write tools at all: the agent can recall, search, list memories, run engrams, and read stats, but cannot store, update, forget, or delete. Right for agents that should consult memory, not own it.
  • Keep tool confirmation on. MCP hosts ask before running tools by default - leave that on for forget in particular, since deletes are shared by every tool on the store.
  • Anything stored is recallable by every tool holding the key. Never store secrets - API keys, passwords - as memories.
  • Recalled memories are data, not instructions. The tool descriptions tell the agent this explicitly. Still, don't store untrusted third-party text as memories in a store an autonomous agent obeys.
  • Deletes are shared too. A forget or delete_all from one tool erases for all of them.
  • "me" means whichever agent writes the store. If several agents share one store, their "me" voices merge. Give each agent its own store (WONTOPOS_USER_ID) for separate identities.
  • One account pays. Every connected tool draws from the same balance and rate limit.

Then just talk

youremember that we ship on Fridays

The agent calls the remember tool. Stored durably in your store - the session ending changes nothing.

new sessionwhen do we ship?

A fresh session has zero chat history. The agent calls recall and answers from memory: Fridays.

Things to say

  • "This repo uses pnpm, remember that" → remember stores it; the next session already knows.
  • "What error format did we settle on last week?" → recall pulls the decision back into context.
  • "Actually, the deadline moved to Friday" → the agent sees it contradicts what it recalled and calls update to fix that memory in place, keeping the history.
  • "That's wrong, forget it" → the agent finds the memory id and calls forget - your host asks for confirmation first.
  • "What do you remember about me?" → list_memories pages through everything stored, so the agent can answer or tidy up.

There is nothing special to phrase - these are plain sentences, not commands. The agent reads each tool's description and picks on its own.

The nine tools

  • recall - One-call context: recent turns plus relevant long-term memories. Its description tells the agent to call it first whenever past context matters.
  • remember - Store a durable fact or decision. speaker: "me" marks the agent's own words; a registered name marks who said it.
  • search - Semantic search, with an optional per-person speaker filter — and filters to bound it by TIME or topic ("what did we decide in June?"), the one axis meaning alone cannot narrow.
  • update - Supersede a memory whose fact changed, keeping the trail instead of deleting it.
  • forget - Delete one memory by id.
  • list_memories - Page through everything stored, so the agent can answer "what do you remember about me?" or tidy up.
  • engram - Run a built-in multi-hop pipeline (deep_recall, timeline, gather) when one search is not enough.
  • stats - How much is in a store - useful before a cleanup, and to confirm a write landed.
  • create_store - Stores are explicit - one per end-user, project, or agent.

SDK or MCP?

  • The SDK goes inside an app you are writing. Your code decides exactly when to store and what to recall - deterministic, typed, versioned. Building a product? Use the SDK.
  • MCP plugs into an AI tool you did not write. The agent decides when to use memory, guided by the tool descriptions - zero code. Right for Claude Code, Claude Desktop, Cursor, or giving a finished assistant a memory.

Same API, same stores underneath - an app built on the SDK and a Claude Code session on MCP share one memory. Pick per surface, not either-or.

One memory across every tool

Memory belongs to the account, not the tool. The same store written from ChatGPT (Actions plus the OpenAPI spec) recalls in Claude Code and in your own agents, and back - a conversation started in one tool continues in another.

And because it is one store, you can leave Claude Code and keep talking where you build: an SDK agent with the same key and store recalls everything Claude Code just learned - and what your agent stores, Claude Code recalls next session.

Runs locally over stdio (npx wontopos-mcp): with this method your key stays in your environment and is never sent to us as part of an MCP session. It wraps the TypeScript SDK, so automatic retries, redirect refusal, and key masking apply as-is.
Model Context Protocol · Beta

Claude Code

The flagship path: one command in your terminal, and every session starts with memory.

  1. Create an API key in the console. A key carries its workspace, so one key = one memory space.
  2. Register the server. --scope user makes it available in every project; without it, only the current project sees it.
  3. Check it: run /mcp inside Claude Code - wontopos should be listed with nine tools.
  4. Make it automatic: one line in your CLAUDE.md - "when past context matters, call wontopos recall first" - and every session starts with memory without being asked.
claude mcp add wontopos --scope user \
  --env WONTOPOS_API_KEY=wos-live-... -- npx -y wontopos-mcp
# pick a store (optional): add --env WONTOPOS_USER_ID=my-project
Model Context Protocol · Beta

Claude Desktop

Add the block below to claude_desktop_config.json (Settings → Developer → Edit Config), restart the app, and the nine tools appear. Note: claude.ai on the web and mobile needs a remote MCP server, which WOS does not offer yet - the desktop app is the supported path.

# claude_desktop_config.json
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }
Model Context Protocol · Beta

Cursor

Add the block below to ~/.cursor/mcp.json - or press the one-click button - and restart Cursor. The agent picks up the nine tools.

# ~/.cursor/mcp.json
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }

Add to Cursor →

Model Context Protocol · Beta

VS Code

VS Code (Copilot agent mode) reads MCP servers from .vscode/mcp.json in the project - add the block below or press the one-click button.

# .vscode/mcp.json
{ "servers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }

Add to VS Code →

Model Context Protocol · Beta

Windsurf

Windsurf (Cascade) reads ~/.codeium/windsurf/mcp_config.json: add the block below and reload - the same nine tools appear.

# ~/.codeium/windsurf/mcp_config.json
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }
Model Context Protocol · Beta

ChatGPT

ChatGPT's MCP connectors only accept remote servers, so the supported path today is a custom GPT with an Action: create a GPT, add an Action, paste the OpenAPI spec URL below, and set your API key as the auth header. The GPT then calls the same memory your other tools use.

# GPT → Configure → Actions → Import from URL
https://api.wontopos.com/openapi.json
# Authentication: API Key · Header name: X-API-Key

Same store, same memory: what ChatGPT stores through the Action, Claude Code recalls through MCP - and back.

Model Context Protocol · Beta

Gemini CLI

Gemini CLI reads MCP servers from ~/.gemini/settings.json: add the block below, restart the CLI, and the same nine tools appear there too.

# ~/.gemini/settings.json
{ "mcpServers": {
    "wontopos": {
      "command": "npx",
      "args": ["-y", "wontopos-mcp"],
      "env": { "WONTOPOS_API_KEY": "wos-live-...",
               "WONTOPOS_USER_ID": "my-project" }
    } } }
Python SDK

Python - every method, three groups.

Write, read, delete. Every example below was run against the live API on 2026-08-01; responses are verbatim.

pip install wontopos
from wontopos import Client

mem = Client(api_key="wos-live-...")  # or read from an env var

Choose a model

The API key picks which memory (your account); the model picks which engine reads it. Every model shares one memory, so you can store with one and recall with another. Set a default on the client; override a single call by passing model=.

mem = Client(api_key="wos-live-...", model="tablet-1")  # default engine
mem.recall("...", user_id="alice")                  # tablet-1
mem.recall("...", user_id="alice", model="scroll-1")  # or pick a model per call

list_models

The catalog - the ids you can pass to model and whether each is live. memory: "shared" models read the same store; "isolated" keeps its own. Needs no API key.

mem.list_models()
Actual response
[{"id": "tablet-1", "name": "Tablet 1", "available": true, "memory": "shared"},
 {"id": "tablet-2", "name": "Tablet 2", "available": true, "memory": "shared"},
 {"id": "scroll-1", "name": "Scroll 1", "available": true, "memory": "shared"},
 {"id": "scroll-1.2", "name": "Scroll 1.2", "available": true, "memory": "shared"}]

ping

Confirm connectivity and that your API key works - a one-line setup check.

mem.ping()   # True, or raises AuthenticationError / PaymentRequiredError

The catalog above always reflects the models available right now - pass any other id and you get a clear error. New models appear there automatically when they ship.

Write

add

Store one memory. Embedded on the way in - no LLM call, you pay embeddings only.

mem.add("she prefers tea over coffee", user_id="alice")
mem.add("I promised the summary by Friday", user_id="alice", speaker="me")  # its own words - no registration needed
Actual response
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}

add_turn

Store one conversation turn (user + assistant) into short-term and long-term memory at once.

mem.add_turn("hi", "hello!", user_id="alice")
Actual response
{"status": "ok"}

speaker

Every memory can carry who said it. Register a person once, then pass their name as the speaker; "me" (the assistant's own words) never needs registration. Search accepts a speaker too, so you can recall one person's words only.

mem.add_speaker("Bob", user_id="alice")  # once per person; "me" needs no registration
mem.add("I promised to send the report on Friday", user_id="alice", speaker="me")
mem.add("Bob said the deadline moved to Tuesday", user_id="alice", speaker="Bob")
mem.search("what did Bob say about deadlines?", user_id="alice", speaker="Bob")
response
[{"content": "Bob said the deadline moved to Tuesday", "speaker": "Bob", ...}]
Speakers are explicit, like stores. Register a person first, then store under their name — a typo can never quietly become a new person. A store registers up to 50 people to start (we plan to raise it), and "me" never needs registration or counts.

add_bulk

Backfill a large blob of text. Chunked and embedded server-side - ideal for importing existing history.

mem.add_bulk("Alice moved to Brooklyn in March. She works at a design studio downtown.", user_id="alice")
Actual response
{"elapsed_secs": 0.154154944, "status": "ok", "stored": 1, "total_chunks": 1}

update

A fact changed. The old memory is marked superseded (kept for history); the new one takes its place in recall.

mem.update("576700aa-...", "she switched to coffee this year", user_id="alice")
Actual response
{"new_memory_id": "07e94433-b7cc-4e49-8d8f-f37fc1a392b7",
 "old_memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "superseded"}

Read

search

Semantic search, most relevant first. Pure embedding - no keyword matching, so any language finds any memory. The SDK returns the memories array directly; the raw HTTP body is shown below. On a self-lane model (Scroll 1.2+) the service answers in two lanes and the SDK hands back both merged, so the array can hold MORE than max_results - size your prompt window on what you receive, not on the number you asked for.

r = mem.search("what does she drink?", user_id="alice", limit=1)
Actual response (HTTP body)
[{
   "id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624",
   "content": "she prefers tea over coffee",
   "category": "general",
   "time_bucket": "2026-06",
   "importance": 0.3,
   "similarity": 0.6316057443618774,
   "is_superseded": false,
   "superseded_by": null,
   "created_at": "2026-07-10T04:20:39.688276876Z"
 }]
FieldMeaning
similarityRaw embedding similarity to your query (0–1).
is_supersededTrue if this fact was replaced by update().
search_msServer-side retrieval time.

recall

One round-trip returns everything your LLM needs - paste the result straight into your prompt: a bounded, fixed-size context no matter how much you've stored.

ctx = mem.recall("what does she drink?", user_id="alice")
Actual response (shape - lists shortened)
{"short_term":  {"count": 2, "turns": [{"role": "user", "content": "hi", ...}]},
 "long_term":   {"count": 4, "memories": [{"content": "she prefers tea over coffee",
                                           "similarity": 0.63, ...}]},
 "context":     {"count": 4, "around_top_memory": [
                  "[match] she prefers tea over coffee",
                  "[after] Alice moved to Brooklyn in March. ..."]},
 "instruction": "Use short_term for recent context, long_term for relevant
                 past memories, context for surrounding conversation of the
                 most relevant memory."}

history

Recent conversation turns (short-term memory), oldest first.

turns = mem.history("alice")
Actual response (HTTP body)
{"count": 2, "turns": [
   {"role": "user",      "content": "hi",     "timestamp": "2026-07-10T04:20:40.989011337Z"},
   {"role": "assistant", "content": "hello!", "timestamp": "2026-07-10T04:20:40.989013416Z"}
 ], "user_id": "alice"}

stats

Memory counts for one user.

mem.stats("alice")
Actual response
{"short_term_turns": 2, "total_memories": 4, "user_id": "alice"}

get

Fetch one memory by id - the id that add or list_memories returned. The original text plus its metadata, never the vector. An id from another store, or a deleted or invalidated one, returns 404.

m = mem.get("alice", memory_id="576700aa-...")
response
{"id": "576700aa-...", "content": "she prefers tea over coffee",
 "category": "general", "created_at": "2026-07-10T04:20:39Z", "event_date": null,
 "is_superseded": false, "superseded_by": null}
Actual response
{"memory": {"id": "8bd090de-...", "content": "the office moved to the seventh floor in June",
  "category": "general", "created_at": "2026-07-31T18:20:30.531518060+00:00", "event_date": null,
  "is_superseded": false, "superseded_by": null}, "user_id": "docs_livetest"}

list_memories

List a store's memories - the original text you stored plus its metadata, never the vector. Paged by cursor: pass the returned next_cursor back for the next page.

page = mem.list_memories("alice", limit=100)
response
{"count": 2, "next_cursor": null, "memories": [
   {"id": "576700aa-...", "content": "she prefers tea over coffee",
    "category": "general", "created_at": "2026-07-10T04:20:39Z", "event_date": null,
 "is_superseded": false, "superseded_by": null}
 ]}

iter_memories · export_memories

Page through every memory with no cursor bookkeeping, or pull the whole store at once.

for m in mem.iter_memories("alice"):   # every page, no cursor bookkeeping
    print(m["id"], m["content"])
everything = mem.export_memories("alice")   # the whole store as a list

Delete

delete

Delete a single memory by id.

mem.delete("alice", memory_id="576700aa-...")
Actual response
{"memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "deleted"}

delete_all

Erase everything for one user - one call, GDPR-ready.

mem.delete_all("alice")
Actual response
{"memories_deleted": 4, "status": "deleted", "user_id": "alice"}

Errors and reliability

Every failure is a typed error - catch the specific one (rate limit, auth, payment) or all of them with the base WosError.

from wontopos import PaymentRequiredError, NotFoundError
try:
    mem.add("...", user_id="alice")
except NotFoundError:
    mem.create_store("alice")   # store didn't exist yet
except PaymentRequiredError:
    top_up()                       # out of credit - don't retry

rate_limit

Read the quota left after any call and slow down before you hit the limit.

mem.search("...", user_id="alice")
rl = mem.rate_limit   # {"limit": 150, "remaining": 3, "reset": ...}

search_self

Both lanes from one call on a self-memory model (Scroll 1.2+): what others said, and the agent's OWN words - kept apart so the reader never confuses who said what.

r = mem.search_self("what did I promise?", user_id="alice")
r["memories"]       # what others said / general memories
r["self_memories"]  # the agent's OWN words (speaker "me")

list_engrams

Ask the service which engrams and delivery forms the selected model can run, instead of hard-coding names that go stale the moment a new one ships.

cat = mem.list_engrams()
[e["name"] for e in cat["engrams"]]   # ask, never hard-code

filters

Narrow the search to part of a store. Applied before ranking, so you get the best matches within the filter - not a filtered top-N.

mem.search("what did we decide", user_id="alice", filters={
    "categories": ["work"],
    "event_from": "2026-01-01",   # when it HAPPENED
})
Keys: categories · event_from / event_to (when the content HAPPENED - metadata.event_date) · time_from / time_to (when it was written) · min_importance. Unlisted keys are dropped, not rejected, so a typo silently widens the search.

idempotency_key

Makes repeating one write safe. Use it when the retry is yours - a job that died and was re-run, a queue that redelivers.

mem.add("she prefers tea", "alice", idempotency_key=f"import:{row.id}")
Derive the key from the thing being stored (import:row-42), never a constant: one key reused for two different writes replays the first, and the second is silently lost. Format: 1-128 chars of [A-Za-z0-9._:-].

with_timeout / with_retries

Tune one call site without touching the client you already built: a clone with a longer timeout for a big backfill, or with retries off inside your own retry loop.

mem.with_timeout(120).add_bulk(big_blob, "alice")  # this slow call only
mem.with_retries(0).add("...", "alice")              # you retry, not the SDK
TypeScript SDK

TypeScript - every method, three groups.

Write, read, delete. Every example below was run against the live API on 2026-08-01; responses are verbatim.

npm install wontopos
import { Client } from "wontopos";

const mem = new Client({ apiKey: "wos-live-..." });

Choose a model

The API key picks which memory (your account); the model picks which engine reads it. Every model shares one memory, so you can store with one and recall with another. Set a default in the constructor; override a single call with withModel().

const mem = new Client({ apiKey: "wos-live-...", model: "tablet-1" });  // default
mem.recall("...", "alice");                          // tablet-1
mem.withModel("scroll-1").recall("...", "alice");  // or pick a model per call

listModels

The catalog - the ids you can pass to model and whether each is live. memory: "shared" models read the same store; "isolated" keeps its own. Needs no API key.

await mem.listModels();
Actual response
[{"id": "tablet-1", "name": "Tablet 1", "available": true, "memory": "shared"},
 {"id": "tablet-2", "name": "Tablet 2", "available": true, "memory": "shared"},
 {"id": "scroll-1", "name": "Scroll 1", "available": true, "memory": "shared"},
 {"id": "scroll-1.2", "name": "Scroll 1.2", "available": true, "memory": "shared"}]

ping

Confirm connectivity and that your API key works - a one-line setup check.

await mem.ping();   // true, or throws AuthenticationError / PaymentRequiredError

The catalog above always reflects the models available right now - pass any other id and you get a clear error. New models appear there automatically when they ship.

Write

add

Store one memory. Embedded on the way in - no LLM call, you pay embeddings only.

await mem.add("she prefers tea over coffee", "alice");
await mem.add("I promised the summary by Friday", "alice", { speaker: "me" });  // its own words - no registration needed
Actual response
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}

addTurn

Store one conversation turn (user + assistant) into short-term and long-term memory at once.

await mem.addTurn("hi", "hello!", "alice");
Actual response
{"status": "ok"}

speaker

Every memory can carry who said it. Register a person once, then pass their name as the speaker; "me" (the assistant's own words) never needs registration. Search accepts a speaker too, so you can recall one person's words only.

await mem.addSpeaker("Bob", "alice");  // once per person; "me" needs no registration
await mem.add("I promised to send the report on Friday", "alice", { speaker: "me" });
await mem.add("Bob said the deadline moved to Tuesday", "alice", { speaker: "Bob" });
await mem.search("what did Bob say about deadlines?", "alice", 10, { speaker: "Bob" });
Speakers are explicit, like stores. Register a person first, then store under their name — a typo can never quietly become a new person. A store registers up to 50 people to start (we plan to raise it), and "me" never needs registration or counts.

addBulk

Backfill a large blob of text. Chunked and embedded server-side - ideal for importing existing history.

await mem.addBulk("Alice moved to Brooklyn in March. She works at a design studio downtown.", "alice");
Actual response
{"elapsed_secs": 0.154154944, "status": "ok", "stored": 1, "total_chunks": 1}

update

A fact changed. The old memory is marked superseded (kept for history); the new one takes its place in recall.

await mem.update("576700aa-...", "she switched to coffee this year", "alice");
Actual response
{"new_memory_id": "07e94433-b7cc-4e49-8d8f-f37fc1a392b7",
 "old_memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "superseded"}

Read

search

Semantic search, most relevant first. Pure embedding - no keyword matching, so any language finds any memory. The SDK returns the memories array directly; the raw HTTP body is shown below. On a self-lane model (Scroll 1.2+) the service answers in two lanes and the SDK hands back both merged, so the array can hold MORE than max_results - size your prompt window on what you receive, not on the number you asked for.

const r = await mem.search("what does she drink?", "alice", 1);
Actual response (HTTP body)
[{
   "id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624",
   "content": "she prefers tea over coffee",
   "category": "general",
   "time_bucket": "2026-06",
   "importance": 0.3,
   "similarity": 0.6316057443618774,
   "is_superseded": false,
   "superseded_by": null,
   "created_at": "2026-07-10T04:20:39.688276876Z"
 }]
FieldMeaning
similarityRaw embedding similarity to your query (0–1).
is_supersededTrue if this fact was replaced by update().
search_msServer-side retrieval time.

recall

One round-trip returns everything your LLM needs - paste the result straight into your prompt: a bounded, fixed-size context no matter how much you've stored.

const ctx = await mem.recall("what does she drink?", "alice");
Actual response (shape - lists shortened)
{"short_term":  {"count": 2, "turns": [{"role": "user", "content": "hi", ...}]},
 "long_term":   {"count": 4, "memories": [{"content": "she prefers tea over coffee",
                                           "similarity": 0.63, ...}]},
 "context":     {"count": 4, "around_top_memory": [
                  "[match] she prefers tea over coffee",
                  "[after] Alice moved to Brooklyn in March. ..."]},
 "instruction": "Use short_term for recent context, long_term for relevant
                 past memories, context for surrounding conversation of the
                 most relevant memory."}

history

Recent conversation turns (short-term memory), oldest first.

const turns = await mem.history("alice");
Actual response (HTTP body)
{"count": 2, "turns": [
   {"role": "user",      "content": "hi",     "timestamp": "2026-07-10T04:20:40.989011337Z"},
   {"role": "assistant", "content": "hello!", "timestamp": "2026-07-10T04:20:40.989013416Z"}
 ], "user_id": "alice"}

stats

Memory counts for one user.

await mem.stats("alice");
Actual response
{"short_term_turns": 2, "total_memories": 4, "user_id": "alice"}

get

Fetch one memory by id - the id that add or list_memories returned. The original text plus its metadata, never the vector. An id from another store, or a deleted or invalidated one, returns 404.

const m = await mem.get("alice", "576700aa-...");
response
{"id": "576700aa-...", "content": "she prefers tea over coffee",
 "category": "general", "created_at": "2026-07-10T04:20:39Z", "event_date": null,
 "is_superseded": false, "superseded_by": null}

listMemories

List a store's memories - the original text you stored plus its metadata, never the vector. Paged by cursor: pass the returned next_cursor back for the next page.

const page = await mem.listMemories("alice", { limit: 100 });
response
{"count": 2, "next_cursor": null, "memories": [
   {"id": "576700aa-...", "content": "she prefers tea over coffee",
    "category": "general", "created_at": "2026-07-10T04:20:39Z", "event_date": null,
 "is_superseded": false, "superseded_by": null}
 ]}

iterMemories · exportMemories

Page through every memory with no cursor bookkeeping, or pull the whole store at once.

for await (const m of mem.iterMemories("alice")) console.log(m.id, m.content);
const everything = await mem.exportMemories("alice");

Delete

delete

Delete a single memory by id.

await mem.delete("alice", "576700aa-...");
Actual response
{"memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "deleted"}

deleteAll

Erase everything for one user - one call, GDPR-ready.

await mem.deleteAll("alice");
Actual response
{"memories_deleted": 4, "status": "deleted", "user_id": "alice"}

Errors and reliability

Every failure is a typed error - catch the specific one (rate limit, auth, payment) or all of them with the base WosError.

import { NotFoundError, PaymentRequiredError } from "wontopos";
try {
  await mem.add("...", "alice");
} catch (e) {
  if (e instanceof NotFoundError) await mem.createStore("alice");
  else if (e instanceof PaymentRequiredError) topUp();   // out of credit
  else throw e;
}

rateLimit

Read the quota left after any call and slow down before you hit the limit.

await mem.search("...", "alice");
const rl = mem.rateLimit;   // { limit: 150, remaining: 3, reset: ... }

searchSelf

Both lanes from one call on a self-memory model (Scroll 1.2+): what others said, and the agent's OWN words - kept apart so the reader never confuses who said what.

const { memories, self_memories } = await mem.searchSelf("what did I promise?", "alice");
// memories = what others said · self_memories = the agent's OWN words

listEngrams

Ask the service which engrams and delivery forms the selected model can run, instead of hard-coding names that go stale the moment a new one ships.

const { engrams, forms } = await mem.listEngrams();  // ask, never hard-code

filters

Narrow the search to part of a store. Applied before ranking, so you get the best matches within the filter - not a filtered top-N.

await mem.search("what did we decide", "alice", 10, {
  filters: { categories: ["work"], event_from: "2026-01-01" },  // when it HAPPENED
});
Keys: categories · event_from / event_to (when the content HAPPENED - metadata.event_date) · time_from / time_to (when it was written) · min_importance. Unlisted keys are dropped, not rejected, so a typo silently widens the search.

idempotencyKey

Makes repeating one write safe. Use it when the retry is yours - a job that died and was re-run, a queue that redelivers.

await mem.add("she prefers tea", "alice", {}, { idempotencyKey: `import:${row.id}` });
Derive the key from the thing being stored (import:row-42), never a constant: one key reused for two different writes replays the first, and the second is silently lost. Format: 1-128 chars of [A-Za-z0-9._:-].

withTimeout / withRetries

Tune one call site without touching the client you already built: a clone with a longer timeout for a big backfill, or with retries off inside your own retry loop.

await mem.withTimeout(120_000).addBulk(bigBlob, "alice");  // this slow call only
await mem.withRetries(0).add("...", "alice");            // you retry, not the SDK
Rust SDK

Rust - every method, three groups.

Write, read, delete. Every example below was run against the live API on 2026-08-01; responses are verbatim.

cargo add wontopos
use wontopos::Client;

let mem = Client::new("wos-live-...");

Choose a model

The API key picks which memory (your account); the model picks which engine reads it. Every model shares one memory, so you can store with one and recall with another. Set a default with with_model(); chain it again to override a single call.

let mem = Client::new("wos-live-...").with_model("tablet-1");  // default
mem.recall("...", "alice").await?;                       // tablet-1
mem.with_model("scroll-1").recall("...", "alice").await?;  // or pick a model per call

list_models

The catalog - the ids you can pass to with_model and whether each is live. memory: "shared" models read the same store; "isolated" keeps its own. Needs no API key.

mem.list_models().await?;
Actual response
[{"id": "tablet-1", "name": "Tablet 1", "available": true, "memory": "shared"},
 {"id": "tablet-2", "name": "Tablet 2", "available": true, "memory": "shared"},
 {"id": "scroll-1", "name": "Scroll 1", "available": true, "memory": "shared"},
 {"id": "scroll-1.2", "name": "Scroll 1.2", "available": true, "memory": "shared"}]

ping

Confirm connectivity and that your API key works - a one-line setup check.

mem.ping().await?;   // Ok(true), or Err whose .kind() is Auth / PaymentRequired

The catalog above always reflects the models available right now - pass any other id and you get a clear error. New models appear there automatically when they ship.

Write

add

Store one memory. Embedded on the way in - no LLM call, you pay embeddings only.

mem.add("she prefers tea over coffee", "alice", json!({})).await?;
mem.add("I promised the summary by Friday", "alice", json!({"speaker": "me"})).await?;  // its own words - no registration needed
Actual response
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}

add_turn

Store one conversation turn (user + assistant) into short-term and long-term memory at once.

mem.add_turn("hi", "hello!", "alice").await?;
Actual response
{"status": "ok"}

speaker

Every memory can carry who said it. Register a person once, then pass their name as the speaker; "me" (the assistant's own words) never needs registration. Search accepts a speaker too, so you can recall one person's words only.

mem.add_speaker("Bob", "alice").await?;  // once per person; "me" needs no registration
mem.add("I promised to send the report on Friday", "alice", json!({"speaker": "me"})).await?;
mem.add("Bob said the deadline moved to Tuesday", "alice", json!({"speaker": "Bob"})).await?;
mem.search_with("what did Bob say about deadlines?", "alice", 10, json!({"speaker": "Bob"})).await?;
Speakers are explicit, like stores. Register a person first, then store under their name — a typo can never quietly become a new person. A store registers up to 50 people to start (we plan to raise it), and "me" never needs registration or counts.

add_bulk

Backfill a large blob of text. Chunked and embedded server-side - ideal for importing existing history.

mem.add_bulk("Alice moved to Brooklyn in March...", "alice", "general").await?;
Actual response
{"elapsed_secs": 0.154154944, "status": "ok", "stored": 1, "total_chunks": 1}

update

A fact changed. The old memory is marked superseded (kept for history); the new one takes its place in recall.

mem.update("576700aa-...", "she switched to coffee this year", "alice").await?;
Actual response
{"new_memory_id": "07e94433-b7cc-4e49-8d8f-f37fc1a392b7",
 "old_memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "superseded"}

Read

search

Semantic search, most relevant first. Pure embedding - no keyword matching, so any language finds any memory. The SDK returns the memories array directly; the raw HTTP body is shown below. On a self-lane model (Scroll 1.2+) the service answers in two lanes and the SDK hands back both merged, so the array can hold MORE than max_results - size your prompt window on what you receive, not on the number you asked for.

let r = mem.search("what does she drink?", "alice", 1).await?;
Actual response (HTTP body)
[{
   "id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624",
   "content": "she prefers tea over coffee",
   "category": "general",
   "time_bucket": "2026-06",
   "importance": 0.3,
   "similarity": 0.6316057443618774,
   "is_superseded": false,
   "superseded_by": null,
   "created_at": "2026-07-10T04:20:39.688276876Z"
 }]
FieldMeaning
similarityRaw embedding similarity to your query (0–1).
is_supersededTrue if this fact was replaced by update().
search_msServer-side retrieval time.

recall

One round-trip returns everything your LLM needs - paste the result straight into your prompt: a bounded, fixed-size context no matter how much you've stored.

let ctx = mem.recall("what does she drink?", "alice").await?;
Actual response (shape - lists shortened)
{"short_term":  {"count": 2, "turns": [{"role": "user", "content": "hi", ...}]},
 "long_term":   {"count": 4, "memories": [{"content": "she prefers tea over coffee",
                                           "similarity": 0.63, ...}]},
 "context":     {"count": 4, "around_top_memory": [
                  "[match] she prefers tea over coffee",
                  "[after] Alice moved to Brooklyn in March. ..."]},
 "instruction": "Use short_term for recent context, long_term for relevant
                 past memories, context for surrounding conversation of the
                 most relevant memory."}

history

Recent conversation turns (short-term memory), oldest first.

let turns = mem.history("alice").await?;
Actual response (HTTP body)
{"count": 2, "turns": [
   {"role": "user",      "content": "hi",     "timestamp": "2026-07-10T04:20:40.989011337Z"},
   {"role": "assistant", "content": "hello!", "timestamp": "2026-07-10T04:20:40.989013416Z"}
 ], "user_id": "alice"}

stats

Memory counts for one user.

mem.stats("alice").await?;
Actual response
{"short_term_turns": 2, "total_memories": 4, "user_id": "alice"}

get

Fetch one memory by id - the id that add or list_memories returned. The original text plus its metadata, never the vector. An id from another store, or a deleted or invalidated one, returns 404.

let m = mem.get("alice", "576700aa-...").await?;
response
{"id": "576700aa-...", "content": "she prefers tea over coffee",
 "category": "general", "created_at": "2026-07-10T04:20:39Z", "event_date": null,
 "is_superseded": false, "superseded_by": null}

list_memories

List a store's memories - the original text you stored plus its metadata, never the vector. Paged by cursor: pass the returned next_cursor back for the next page.

mem.list_memories("alice", 100, None).await?;
response
{"count": 2, "next_cursor": null, "memories": [
   {"id": "576700aa-...", "content": "she prefers tea over coffee",
    "category": "general", "created_at": "2026-07-10T04:20:39Z", "event_date": null,
 "is_superseded": false, "superseded_by": null}
 ]}

list_all_memories

Page through every memory with no cursor bookkeeping, or pull the whole store at once.

let all = mem.list_all_memories("alice").await?;   // every page, collected

Delete

delete

Delete a single memory by id.

mem.delete("alice", "576700aa-...").await?;
Actual response
{"memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "deleted"}

delete_all

Erase everything for one user - one call, GDPR-ready.

mem.delete_all("alice").await?;
Actual response
{"memories_deleted": 4, "status": "deleted", "user_id": "alice"}

Errors and reliability

Every failure is a typed error - catch the specific one (rate limit, auth, payment) or all of them with the base WosError.

use wontopos::ErrorKind;
match mem.search("...", "alice", 10).await {
    Ok(hits) => { /* use hits */ }
    Err(e) if e.kind() == ErrorKind::NotFound => { mem.create_store("alice").await?; }
    Err(e) if e.is_rate_limited() => { /* back off */ }
    Err(e) => return Err(e),
}

rate_limit

Read the quota left after any call and slow down before you hit the limit.

mem.search("...", "alice", 10).await?;
let rl = mem.rate_limit();   // Some(RateLimit { remaining: Some(3), .. })

search_self

Both lanes from one call on a self-memory model (Scroll 1.2+): what others said, and the agent's OWN words - kept apart so the reader never confuses who said what.

let r = mem.search_self("what did I promise?", "alice", 10).await?;
// r.memories = what others said · r.self_memories = the agent's OWN words

list_engrams

Ask the service which engrams and delivery forms the selected model can run, instead of hard-coding names that go stale the moment a new one ships.

let cat = mem.list_engrams().await?;  // ask, never hard-code

filters

Narrow the search to part of a store. Applied before ranking, so you get the best matches within the filter - not a filtered top-N.

mem.search_with("what did we decide", "alice", 10, json!({"filters": {
    "categories": ["work"], "event_from": "2026-01-01"   // when it HAPPENED
}})).await?;
Keys: categories · event_from / event_to (when the content HAPPENED - metadata.event_date) · time_from / time_to (when it was written) · min_importance. Unlisted keys are dropped, not rejected, so a typo silently widens the search.

add_idempotent

Makes repeating one write safe. Use it when the retry is yours - a job that died and was re-run, a queue that redelivers.

mem.add_idempotent("she prefers tea", "alice", json!({}), &format!("import:{}", row.id)).await?;
Derive the key from the thing being stored (import:row-42), never a constant: one key reused for two different writes replays the first, and the second is silently lost. Format: 1-128 chars of [A-Za-z0-9._:-].

with_timeout / with_retries

Tune one call site without touching the client you already built: a clone with a longer timeout for a big backfill, or with retries off inside your own retry loop.

mem.with_timeout(120).add_bulk(big_blob, "alice", "general").await?;
mem.with_retries(0).add("...", "alice", json!({})).await?;
curl

curl - no install, same methods.

No SDK to install - any HTTP client works. Set your key once and call the same endpoints the SDKs wrap. Base URL https://api.wontopos.com, auth via X-API-Key, JSON in and out.

# set your key once (never hard-code it)
export WOS_API_KEY="wos-live-..."

Write

store

Store one memory. Embedded on the way in - no LLM call.

curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"she prefers tea over coffee"}'
Actual response
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}

store-turn

Store one conversation turn (user + assistant) into short and long-term memory at once.

curl -X POST https://api.wontopos.com/api/v1/memory/store-turn \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","user_msg":"hi","assistant_msg":"hello!"}'
Actual response
{"status": "ok"}

speaker

Every memory can carry who said it. Register a person once, then pass their name as the speaker; "me" (the assistant's own words) never needs registration. Search accepts a speaker too, so you can recall one person's words only.

curl -X POST https://api.wontopos.com/api/v1/memory/speakers \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","speaker":"Bob"}'   # once per person

curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"I promised to send the report on Friday","metadata":{"speaker":"me"}}'

curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"Bob said the deadline moved to Tuesday","metadata":{"speaker":"Bob"}}'

curl -X POST https://api.wontopos.com/api/v1/memory/search \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what did Bob say about deadlines?","speaker":"Bob"}'
Speakers are explicit, like stores. Register a person first, then store under their name — a typo can never quietly become a new person. A store registers up to 50 people to start (we plan to raise it), and "me" never needs registration or counts.

supersede

A fact changed - the old memory is marked superseded, the new one takes its place in recall.

curl -X POST https://api.wontopos.com/api/v1/memory/supersede \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","old_memory_id":"576700aa-...","new_content":"she switched to coffee this year"}'
Actual response
{"new_memory_id": "07e94433-...", "old_memory_id": "576700aa-...", "status": "superseded"}

bulk-store

Backfill a long history in one call - chunked and embedded server-side.

curl -X POST https://api.wontopos.com/api/v1/memory/bulk-store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"...a long history...","category":"general"}'
Actual response
{"elapsed_secs": 0.138589761, "status": "ok", "stored": 1, "total_chunks": 1}

Idempotency-Key

Makes repeating one write safe. Use it when the retry is yours - a job that died and was re-run, a queue that redelivers.

# same key + same body = the FIRST response is replayed, nothing is stored twice
curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: import:row-42" \
  -d '{"user_id":"alice","content":"she prefers tea over coffee"}'
Derive the key from the thing being stored (import:row-42), never a constant: one key reused for two different writes replays the first, and the second is silently lost. Format: 1-128 chars of [A-Za-z0-9._:-].

Read

search

Semantic search, most relevant first. Pure embedding - any language finds any memory.

curl -X POST https://api.wontopos.com/api/v1/memory/search \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what does she drink?","max_results":1}'
Actual response
{"memories": [{"id": "576700aa-...", "content": "she prefers tea over coffee",
   "similarity": 0.63, "is_superseded": false}], "search_ms": 315, "total_found": 1}

search + filters

Narrow the search to part of a store. Applied before ranking, so you get the best matches within the filter - not a filtered top-N.

curl -X POST https://api.wontopos.com/api/v1/memory/search \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what did we decide",
       "filters":{"categories":["work"],"event_from":"2026-01-01","event_to":"2026-06-30"}}'
Keys: categories · event_from / event_to (when the content HAPPENED - metadata.event_date) · time_from / time_to (when it was written) · min_importance. Unlisted keys are dropped, not rejected, so a typo silently widens the search.

get

Read one memory by the id that store or list returned - original text and metadata, no vectors.

curl -X POST https://api.wontopos.com/api/v1/memory/get \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","memory_id":"576700aa-f0e0-4c26-99a0-10e2d5b0d624"}'

list

Page through everything in a store, cursor by cursor. Use it to browse or export.

curl -X POST https://api.wontopos.com/api/v1/memory/list \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","limit":100}'   # pass next_cursor back for the next page
Actual response
{"count": 3, "memories": [{"id": "1a1cfc47-...", "content": "...", "category": "general",
   "created_at": "2026-07-31T18:04:51.937117314+00:00", "event_date": null, "is_superseded": false}],
 "next_cursor": "722c08e5-8998-4882-979e-d71995b5b4af", "user_id": "docs_livetest"}

recall

One round-trip returns short-term + long-term + context + an instruction. Paste it straight into your prompt.

curl -X POST https://api.wontopos.com/api/v1/memory/recall \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what does she drink?"}'
Actual response (shape)
{"short_term": {"count": 2, "turns": [...]},
 "long_term":  {"count": 4, "memories": [{"content": "she prefers tea over coffee", "similarity": 0.63}]},
 "context":    {"count": 4, "around_top_memory": ["[match] she prefers tea over coffee"]},
 "instruction": "Use short_term for recent context, long_term for relevant past memories..."}

Delete

forget

Delete one memory by id, or omit it to delete everything for a user (GDPR).

curl -X POST https://api.wontopos.com/api/v1/memory/forget \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice"}'  # omit memory_id = delete all
Actual response
{"memories_deleted": 1, "status": "deleted", "user_id": "alice"}

Every endpoint + body fields →

Rust-only variants

Python and TypeScript take these as optional arguments. Stable Rust has no default or keyword arguments, so each one is its own method rather than a builder you have to finish.

mem.add_with(text, None, json!({}), extra)   // add + extra body fields
mem.search_opts(q, None, 10, &opts)            // search + verify / max_images
mem.search_with(q, None, 10, extra)            // search + any other field
mem.recall_with(q, None, extra)                // recall + extra
mem.search_self_with(q, None, 10, extra)       // self lane + extra
mem.engram_with(name, q, None, extra)          // engram + extra
mem.update_idempotent(old, new, None, key)     // update + Idempotency-Key
mem.add_turn_idempotent(u, a, None, key)
mem.add_bulk_idempotent(text, None, cat, key)
mem.revisions_page(None, "revised", 20, None, None)
mem.list_all_images(None, None)              // = iter_images, collected

list_all_images is also exported as iter_images, matching the name the other two SDKs use - a reader coming from those docs types that name first.

Engrams

Engrams

Callable recall tools your model can invoke - each one a different retrieval strategy over the same memory. Use one, or run several at once.

Live now. The general engrams below are no-LLM retrieval pipelines, so they run on every tier from Tablet 1 up. Memoir and Archive, a separate model mode, are covered in their own section below.

More engrams ship regularly - this list grows.

Memoir & Archive Scroll 1.2+

This one is a delivery form, not a callable tool. On Scroll 1.2 and up, pick it per call - form: "memoir" or form: "archive" - and that recall, a plain search included, comes back with time written that way.

All engrams Engrams

Time_awareness Scroll 1.2+

A delivery form - pick it per call. Pass form - memoir or archive - with any call on a form-capable model (Scroll 1.2 and up), and the response comes back rendered that way: a plain search, a recall, or any engram. In the SDKs it is a form field, like tz; over HTTP it is the X-WOS-Form header. A Memoir reads the way a person remembers; an Archive keeps an exact record - the difference shows up most in how each writes time.

Memoir

form: "memoir"
Remembered like a person · a narrative

Tells what happened and how one moment led to the next, with the soft sense of time a person recalls - read as experience, not a list.

Archive

form: "archive"
Kept as a record · precise time

Returns matches as exact records - precise elapsed time and absolute anchors, structured for a model to read straight off.

It renders memories you've already stored - it doesn't create them. Each memory is one store / add call under a user_id (that user_id is that person's store). Store first; then any recall - the plain search below included - comes back time-tagged. See Quickstart to store.
# the memoir form on a plain search — and on recall, the LLM's one-call context
r   = mem.search("what does Alice drink?", user_id="alice", model="scroll-1.2", form="memoir", tz=9)
ctx = mem.recall("what does Alice drink?", user_id="alice", model="scroll-1.2", form="memoir", tz=9)
# every memory's .time reads "a couple weeks ago" (archive → "2 weeks ago (Jun 09)") — the LLM sees human time
// the memoir form on search — and on recall, the LLM's one-call context
const s = await mem.withModel("scroll-1.2").search("what does Alice drink?", "alice", 10, { form: "memoir", tz: 9 });
const ctx = await mem.withModel("scroll-1.2").recall("what does Alice drink?", "alice", { form: "memoir", tz: 9 });
// form on search AND recall — the _with helpers merge extra fields into the body
let s = mem.with_model("scroll-1.2").search_with("what does Alice drink?", "alice", 10, json!({"form": "memoir", "tz": 9})).await?;
let ctx = mem.with_model("scroll-1.2").recall_with("what does Alice drink?", "alice", json!({"form": "memoir", "tz": 9})).await?;
# same X-WOS-Form header on /search, /recall, or /engram/run
curl -X POST https://api.wontopos.com/api/v1/memory/recall \
  -H "X-API-Key: wos-live-..." -H "X-WOS-Model: scroll-1.2" -H "X-WOS-Form: memoir" -H "X-WOS-Timezone: 9" \
  -d '{"user_id":"alice","query":"what does Alice drink?"}'
# every memory comes back with a "time" field; use X-WOS-Form: archive for exact time

tz is the caller's UTC offset in hours - so "this morning" and the 4am day boundary land in their local time. Omit it for UTC; over HTTP it's the X-WOS-Timezone header. Roughly, by region: US East -5, US Central -6, US West -8 · UK / Lisbon 0 · Central Europe +1 · Eastern Europe +2 · India +5.5 · China / Singapore +8 · Korea / Japan +9 · Sydney +10. (Standard time - daylight saving shifts some regions by +1; pass whatever your users are actually on.)

Same search, two forms - the memories are identical, only time changes:

Result · form: memoir
{ "count": 3, "memories": [
  { "content": "Alice prefers tea over coffee", "time": "a couple weeks ago" },
  { "content": "met Alice at the cafe downtown",  "time": "yesterday afternoon" },
  { "content": "Alice moved to Brooklyn",          "time": "about half a year ago" }
] }
Result · form: archive
{ "count": 3, "memories": [
  { "content": "Alice prefers tea over coffee", "time": "2 weeks ago (Jun 09)" },
  { "content": "met Alice at the cafe downtown",  "time": "yesterday at 14:00" },
  { "content": "Alice moved to Brooklyn",          "time": "6 months ago (Dec 2025)" }
] }
ElapsedMemoirArchive
3 mina few minutes ago3 minutes ago
14 minabout 15 minutes ago14 minutes ago
30 minhalf an hour ago30 minutes ago
50 minabout an hour ago50 minutes ago
2 hra couple hours ago2 hours ago, at 13:10
8 hrthis morning8 hours ago, at 07:10
yesterday pmyesterday afternoonyesterday at 14:00
last nightlast night17 hours ago, at 22:00
2 daysa couple days ago2 days ago (Tue 15:10)
6 daysseveral days ago6 days ago (Fri 15:10)
9 daysabout a week agolast week (Jun 16)
16 daysa couple weeks ago2 weeks ago (Jun 09)
35 daysabout a month agolast month (May 21)
60 daysa couple months ago2 months ago (Apr 2026)
180 daysabout half a year ago6 months ago (Dec 2025)
380 daysabout a year agolast year (Jun 2025)
800 daysa couple years ago2 years ago (Apr 2024)
1500 daysabout 4 years ago4 years ago (May 2022)

Every value above is the renderer's real output. Look at the two "yesterday" rows: a Memoir splits afternoon from last night - a day is one sleep - while an Archive writes a single clock time and draws no day or night line.

How each mode reads time

Memoir - the way people actually say it. Recent moments stay fairly sharp (about 15 minutes, half an hour), then the wording widens the further back you go - a couple weeks, about half a year, a couple years - the way memory itself loosens with distance. Inside a day it drops the clock for a landmark: this morning, last night, yesterday afternoon. And a day is one sleep, not a calendar tick: the boundary sits around 4am local time, so a late night still reads as the same evening, not already tomorrow.

Archive - precise, always with an anchor. Every line carries the exact elapsed time plus an absolute reference a model can compute from, and the anchor tightens as it nears: a clock for today (8 hours ago, at 07:10), a weekday and clock this week (2 days ago (Tue 15:10)), a date this month (last week (Jun 16)), a month and year beyond (6 months ago (Dec 2025)). Never vague, never wrong.

Memoir and Archive render the whole recall - a plain search, a recall, or an engram. The model tier (Tablet → Scroll → Book) sets how much the engine does; the form (memoir / archive) sets how it writes time. Available on Scroll 1.2 and up.
All engrams Engrams

deep_recall

Multi-hop recall. Searches your query, then takes the top match and searches again on its content - pulling in linked context a single search would miss. Best when memories reference each other (a person → their projects → details). Returns up to ~12.

out = mem.engram("deep_recall", "what should I know about Alice?", user_id="alice")
const out = await mem.engram("deep_recall", "what should I know about Alice?", "alice");
let out = mem.engram("deep_recall", "what should I know about Alice?", "alice").await?;
curl -X POST https://api.wontopos.com/api/v1/engram/run \
  -H "X-API-Key: wos-live-..." -H "Content-Type: application/json" \
  -d '{"name":"deep_recall","user_id":"alice","query":"what should I know about Alice?"}'
Response
{ "engram": "deep_recall", "hops": 2, "count": 12,
  "memories": [ ... ],
  "usage": { "input_tokens": 200, "output_tokens": 589 } }
Metered by tokens - every call returns usage (input + output), counted by the same tokenizer as the rest of the API; no hidden per-engram fee. Need several at once? Call them concurrently - each engram is an independent request.
All engrams Engrams

timeline

Time-ordered recall. Returns memories sorted newest-first by when the event happened, not by relevance. For "when did X", history, and sequence questions. Returns up to 15.

events = mem.engram("timeline", "project milestones", user_id="alice")
const events = await mem.engram("timeline", "project milestones", "alice");
let events = mem.engram("timeline", "project milestones", "alice").await?;
curl -X POST https://api.wontopos.com/api/v1/engram/run \
  -H "X-API-Key: wos-live-..." -H "Content-Type: application/json" \
  -d '{"name":"timeline","user_id":"alice","query":"project milestones"}'
Response
{ "engram": "timeline", "hops": 1, "count": 15,
  "memories": [ ... ],
  "usage": { "input_tokens": 100, "output_tokens": 736 } }
Metered by tokens - every call returns usage (input + output), counted by the same tokenizer as the rest of the API; no hidden per-engram fee. Need several at once? Call them concurrently - each engram is an independent request.
All engrams Engrams

gather

Broad gather. Searches, then expands around the top three matches - a wider net than deep_recall. Use it to pull in everything related to a person, project, or topic in one call. Returns up to ~18.

related = mem.engram("gather", "everything about Project Atlas", user_id="alice")
const related = await mem.engram("gather", "everything about Project Atlas", "alice");
let related = mem.engram("gather", "everything about Project Atlas", "alice").await?;
curl -X POST https://api.wontopos.com/api/v1/engram/run \
  -H "X-API-Key: wos-live-..." -H "Content-Type: application/json" \
  -d '{"name":"gather","user_id":"alice","query":"everything about Project Atlas"}'
Response
{ "engram": "gather", "hops": 4, "count": 18,
  "memories": [ ... ],
  "usage": { "input_tokens": 400, "output_tokens": 637 } }
Metered by tokens - every call returns usage (input + output), counted by the same tokenizer as the rest of the API; no hidden per-engram fee. Need several at once? Call them concurrently - each engram is an independent request.
All engrams Engrams

equilibrium

Drift correction. Semantic retrieval narrows as a session runs: the query carries the current state, so it pulls same-state memories and the next turn leans further the same way. This re-widens the set along three axes the query does not control - spread across time, association away from the query, and the substantive part of the store. Use it when replies start to loop or flatten. For a specific fact use deep_recall or gather, which stay closer to the query. Returns up to 12.

wide = mem.engram("equilibrium", "how have things been lately?", user_id="alice")
const wide = await mem.engram("equilibrium", "how have things been lately?", "alice");
let wide = mem.engram("equilibrium", "how have things been lately?", "alice").await?;
curl -X POST https://api.wontopos.com/api/v1/engram/run \
  -H "X-API-Key: wos-live-..." -H "Content-Type: application/json" \
  -d '{"name":"equilibrium","user_id":"alice","query":"how have things been lately?"}'
Response
{ "engram": "equilibrium", "hops": 3, "count": 12,
  "memories": [ ... ],
  "usage": { "input_tokens": 300, "output_tokens": 293 } }
Metered by tokens - every call returns usage (input + output), counted by the same tokenizer as the rest of the API; no hidden per-engram fee. Need several at once? Call them concurrently - each engram is an independent request.
All engrams Engrams

tone_stabilizer

Its own voice. Long sessions pull an assistant off its register: replies stretch, turn into reports, or take on the mood of the last stretch. Ordinary self-recall makes that worse, because it matches the current state and hands back the most recent lines as if they were the character. This returns the assistant's own words from before that stretch instead. It needs turns stored with speaker me; with none it returns nothing rather than guessing. Returns up to 10.

# store the assistant's turns as speaker "me", then pull its own register back
mem.add("I keep answers short unless you ask for detail.", user_id="alice", speaker="me")
mine = mem.engram("tone_stabilizer", "how do I usually answer?", user_id="alice")
await mem.add("I keep answers short unless you ask for detail.", "alice", { speaker: "me" });
const mine = await mem.engram("tone_stabilizer", "how do I usually answer?", "alice");
mem.add("I keep answers short unless you ask for detail.", "alice", json!({"speaker": "me"})).await?;
let mine = mem.engram("tone_stabilizer", "how do I usually answer?", "alice").await?;
curl -X POST https://api.wontopos.com/api/v1/engram/run \
  -H "X-API-Key: wos-live-..." -H "Content-Type: application/json" \
  -d '{"name":"tone_stabilizer","user_id":"alice","query":"how do I usually answer?"}'
Response
{ "engram": "tone_stabilizer", "hops": 2, "count": 10,
  "memories": [ { "content": "I keep answers short unless you ask for detail.", "speaker": "me" }, ... ],
  "usage": { "input_tokens": 200, "output_tokens": 442 } }
Metered by tokens - every call returns usage (input + output), counted by the same tokenizer as the rest of the API; no hidden per-engram fee. Need several at once? Call them concurrently - each engram is an independent request.
HTTP API

Every endpoint, one base URL.

No SDK required - any HTTP client works. Base URL https://api.wontopos.com, auth via the X-API-Key header, JSON in and out. Memory ops are POST; managing stores uses POST / GET / DELETE on /collection. A store must exist first (see Stores) or in-store ops return 404.

Headers

HeaderWhat it does
X-API-KeyRequired on every call. Your key, issued in the console.
X-WOS-ModelOptional. Which engine answers. Omit it and the account default is used. GET /api/v1/models lists the models your key may select; an endpoint an older engine cannot serve answers 501 and names that model.
Idempotency-KeyOptional, on writes. The same key with the same body replays the first response instead of storing again - see the note below.

Endpoint

EndpointPurposeBody fields
POST /api/v1/memory/collectioncreate a storeuser_id
GET /api/v1/memory/collectionslist your stores(none)
DELETE /api/v1/memory/collectiondelete a store + its memoriesuser_id
/api/v1/memory/storestore one memoryuser_id · content · metadata? (event_date · speaker) · image?
/api/v1/memory/store-turnstore a conversation turnuser_id · user_msg · assistant_msg
POST /api/v1/memory/speakersregister a speaker (explicit, up to 50)user_id · speaker
GET /api/v1/memory/speakerslist registered speakers + memory countsuser_id
DELETE /api/v1/memory/speakersunregister a speaker (memories stay)user_id · speaker
/api/v1/memory/by-speakerwhat one person said, newest first ("me" = the agent)user_id · speaker · limit? · before? · skip_ids?
POST /api/v1/memory/imagethe original bytes of an image memoryuser_id · memory_id
DELETE /api/v1/memory/imageremove the image, keep the captionuser_id · memory_id · preview?
/api/v1/memory/imagesa store's images, newest first (+ the store total)user_id · limit? · before? · skip_ids?
/api/v1/memory/lineageone memory's chain of edits, oldest firstuser_id · memory_id
/api/v1/won/revisionshow much of a store has been rewritten. Freeuser_id · include? · limit? · before? · skip_ids?
/api/v1/memory/revisionsthe same call under the memory plane. Freeuser_id · include? · limit? · before? · skip_ids?
/api/v1/memory/bulk-storebackfill a text blobuser_id · content · category? · timestamp?
/api/v1/memory/searchsemantic searchuser_id · query · max_results? · speaker? · cache_control? · filters? · verify? · max_images?
/api/v1/memory/recallshort + long + contextuser_id · query · limit? · context_limit?
/api/v1/memory/getone memory by iduser_id · memory_id
/api/v1/memory/listpage through a storeuser_id · limit? · cursor?
/api/v1/memory/historyrecent turnsuser_id
/api/v1/memory/statsmemory countsuser_id
/api/v1/memory/supersedereplace a changed factuser_id · old_memory_id · new_content
/api/v1/memory/forgetdelete one (or all)user_id · memory_id? (omit = delete all)
GET /api/v1/engramengrams this model can run(none)
POST /api/v1/engram/runrun one engramname · user_id · query · form? · tz?
GET /api/v1/modelsavailable models(none)
Writes accept an Idempotency-Key header. Send the same key with the same body and the first response is replayed instead of storing again (10 minutes); the same key with a different body answers 422. Only 2xx are cached, so a failed call is retryable at once.
# create the store once (stores are explicit)
curl -X POST https://api.wontopos.com/api/v1/memory/collection \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice"}'

# store a memory
curl -X POST https://api.wontopos.com/api/v1/memory/store \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","content":"she prefers tea over coffee"}'

# recall - one call, ready for your prompt
curl -X POST https://api.wontopos.com/api/v1/memory/recall \
  -H "X-API-Key: $WOS_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","query":"what does alice drink?"}'
Actual response - store
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}
Usage tiers

Same features for everyone.
Tiers only raise your limits.

Every tier runs the full engine - same recall quality, same languages, every method. Tiers advance automatically through Tier 5 as your cumulative credit purchases grow, with no application or sales call. Enterprise (Tier 6) is the one exception.

Spend limits

Each tier caps how much you can spend per calendar month. You advance immediately when your cumulative credit purchases reach the next threshold.

Usage tierCredit purchaseMonthly spend limit
Tier 1$5$100
Tier 2$40$500
Tier 3$200$1,000
Tier 4$400$5,000
Tier 5$1,000$25,000
Tier 6 - EnterpriseTalk to usNo limit

Rate limits

Rate limits are per account - every API key on an account shares one limit, which scales with your tier. Exceeding it returns a 429 with a retry-after header; back off (1s → 2s → 4s) and retry. Every endpoint is idempotent-friendly, so retries are safe.

TierRequests per minute
Tier 1150
Tier 2300
Tier 3600
Tier 41,500
Tier 53,000
Tier 6 - EnterpriseCustom

Enterprise (Tier 6) gets custom rate limits, an SLA, dedicated support, and an optional self-host license - talk to us.

Free calls

A few endpoints carry no charge at all - they are gathered under Won. In place of a price they have two limits.

  • 10 requests per minute, per endpoint. Each free endpoint keeps its own bucket, so spending one does not spend another.
  • 300 requests per hour, shared. All the free endpoints draw on one hourly allowance per account.

Neither is reachable in ordinary use, and neither touches the paid limits above.

Pricing is usage-based: tokens plus a flat $0.0001 per request. Tablet is $2 per 1M input tokens, $3 per 1M output. Storage is free with no caps. See why we price it this way.
Errors & limits

When something goes wrong.

Errors come back as a JSON envelope with a stable type, a human message, and a request_id you can send us when reporting an issue.

Actual response - invalid key (HTTP 401)
{"type": "error", "error": {
   "type": "authentication_error",
   "message": "Invalid or revoked API key.",
   "request_id": "063f8b83-eee2-4383-a5cf-11e4bcd29d7c"
 }}
HTTPMeaningWhat to do
400Malformed body (missing/wrong-type field)The message names the exact field - fix and retry.
401Invalid or revoked API keyCheck the key; issue a new one in the console.
402Out of balance, no card on file, or a tier capTop up or add a card in the console. The response carries balance_cents and floor_cents, so you can tell which one stopped you.
404No such memory, store, or imageCheck the id. get_image also answers 404 when the memory exists but carries no image.
409That name is already takenStore and workspace names are unique within an account - pick another one.
413Request body over 10MBBase64 runs about 33% larger than the file it encodes, so resize the image before encoding it.
429Rate limitedThe SDK already retries these for you, with backoff and jitter, honouring Retry-After. Getting one means the retries ran out - lower your concurrency rather than wrapping a loop of your own around it.
501This model's engine does not implement that endpointImages and revision history need a newer engine. GET /api/v1/models lists which models serve what.
5xxServer-side problemRetry with backoff, but not blindly. The SDK does not auto-retry a 5xx here, because every call on this API is a POST and the server may have stored your request already. Resend with an idempotency key so a repeat cannot double-write, and include request_id if you contact us.

Every error is a WosError, and each status also has a class of its own - BadRequestError, AuthenticationError, PaymentRequiredError, NotFoundError, ConflictError, RateLimitError, ServerError, APIConnectionError. Catch the one you mean to handle instead of comparing numbers.

# SDK error handling (Python)
from wontopos import Client, WosError, RateLimitError, PaymentRequiredError

try:
    mem.search("...", user_id="alice")
except PaymentRequiredError: ...      # 402 - top up
except RateLimitError: ...            # 429 - the SDK already retried; slow down
except WosError as e: ...            # e.status, e.message, e.request_id
except (ValueError, TypeError): ...    # never left the client

Some mistakes never reach us. The API key, the store id, the idempotency key and the image are all checked before the request goes out, and those raise ValueError or TypeError - not WosError. An except WosError on its own will not catch them.

Key safety. Your key is shown once at creation and stored only as a hash on our side. Keep it in an environment variable; if it leaks, revoke it in the console - revocation is immediate.

Rate limits are per account, shared across all your keys, and scale with your tier - see Usage tiers. Your account's usage is shown in the console.

Developers

lineage

The chain of edits behind one memory, oldest first. revisions says how much a store moved; this says what happened to one fact.

Supported on Tablet 2 and newer. Read-only. Unlike revisions this is a normal billed call, because it returns memory content.

Pass the id of any memory in the chain. Superseded versions are kept rather than deleted, so a search returning only the current fact can still be traced back.

chain = mem.lineage(memory_id=mid)["chain"]
for step in chain:
    print(step["changed_at"], step["action"], step["content"])
const { chain } = await mem.lineage(undefined, mid);
for (const step of chain) {
  console.log(step.changed_at, step.action, step.content);
}
let r = mem.lineage(None, mid).await?;
for step in r["chain"].as_array().unwrap_or(&vec![]) {
    println!("{} {} {}", step["changed_at"], step["action"], step["content"]);
}
curl -X POST https://api.wontopos.com/api/v1/memory/lineage \
  -H "X-API-Key: $WOS_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice","memory_id":"m_9"}'
200
{ "memory_id": "m_9", "count": 3, "truncated": false,
  "chain": [
    { "memory_id": "m_3", "content": "lives in Seoul",
      "created_at": "2026-03-02T…", "changed_at": "2026-06-11T…",
      "action": "replaced", "confidence": 0.94,
      "superseded_by": "m_7", "is_current": false },
    { "memory_id": "m_7", "content": "moved to Busan",   … },
    { "memory_id": "m_9", "content": "Haeundae, specifically",
      "changed_at": null, "superseded_by": null, "is_current": true }
  ] }
FieldWhat it does
chainThe versions, oldest first. Each carries the same fields a memory does, plus the four below.
changed_atWhen this version was superseded (RFC3339), or null while it is still in force.
actionWhat happened at this link - how the replacement related to this version.
confidenceHow sure the engine was about that relation, 0-1.
is_currentTrue for the one version still in force. Exactly one per chain.
truncatedTrue when the chain was longer than the service walks. The returned steps are still the oldest ones.

What it is for

Two uses. Debugging: why a memory reads the way it does today. And letting an assistant look at its own history - a fact corrected three times is a different kind of fact from one written once, and only the chain shows that.

Won

Won is for whoever reads the memory.

Most of this API answers with memories. Won answers about them: how much a store has been revised, and how far it can be trusted. Read-only, free, and separate from search.

Wontopos is Won + Topos, one place where memory lives. Won is the part of that place that reports on the memory instead of returning it. These calls are free, read-only, and never touch retrieval: asking costs your user nothing and changes nothing about what is remembered.

What is on it today

One call today.

CallWhat it does
POST /won/revisionsHow much of this store has been altered since it was written. Two numbers and two sentences explaining them.

A worked example

Use the ratio, not the raw count. 3 of 40 and 30 of 40 need different handling.

r = mem.revisions()
# {"revised": 3, "total": 40, "counts": "…", "excludes": "…"}

if r["revised"] / r["total"] > 0.1:
    system += "Some of what you remember here has been corrected since."
const r = await mem.revisions();
// { revised: 3, total: 40, counts: "…", excludes: "…" }

if (r.revised / r.total > 0.1) {
  system += "Some of what you remember here has been corrected since.";
}
let r = mem.revisions(None).await?;
let (rev, tot) = (r["revised"].as_f64().unwrap_or(0.0),
                r["total"].as_f64().unwrap_or(1.0));
if rev / tot > 0.1 { /* say so in the system prompt */ }
curl -X POST https://api.wontopos.com/api/v1/won/revisions \
  -H "X-API-Key: $WOS_KEY" -H "Content-Type: application/json" \
  -d '{"user_id":"alice"}'

# → {"user_id":"alice","revised":3,"total":40,
#     "counts":"memories a transform has touched (supersede, update, retract, image removed)",
#     "excludes":"deletions — a deleted memory leaves nothing to count"}

counts and excludes are returned as sentences rather than flags, since the caller is often a model. Deletions are not counted.

Price and limits

RuleValue
PriceNone. Free calls skip the billing gates - no token charge, no per-request fee, and no usage recorded.
Per minute10 per minute, per account and per endpoint. Spending one endpoint's minute does not spend another's.
Per hour300 per hour, per account, shared by every free call. This one ignores the path, so adding free endpoints does not raise the total an account can spend.
Against paid trafficSeparate in both directions. These calls cannot slow your searches and your searches cannot exhaust these. Keys on one account share the buckets, so holding more keys does not multiply the allowance.

Both ceilings answer 429 with Retry-After in seconds and a message naming which one you hit.

429 rate_limit_error
Retry-After: 41

{ "error": { "type": "rate_limit_error",
    "message": "This endpoint is free and limited to 10 requests per
                minute, counted per endpoint. Retry in 41s." } }
The same call also answers at /api/v1/memory/revisions, for clients published before the Won surface existed. It is the same handler and the same budget, not a second allowance. New code should use the Won address.
Won · revisions

How much of a store has been rewritten

revisions answers revised out of total: how many memories in a store were altered after they were written. It is worth asking before leaning on memory for something that matters, or when a recalled fact does not fit what the user is saying now. A store where three in ten facts have been replaced deserves less confidence than one nobody has edited.

Supported on Tablet 2 and newer. Callable over the HTTP API, from the Python, TypeScript and Rust SDKs, and as an MCP tool. Older engines answer 501 and name the model that cannot serve it.

Counts

It counts what a transform touched - superseded, updated, retracted, and images removed.

mem.revisions()
# {"revised": 3, "unrevised": 37, "total": 40, …}
await mem.revisions();
mem.revisions(None).await?;
curl -X POST https://api.wontopos.com/api/v1/won/revisions \
  -H "X-API-Key: $WOS_KEY" -d '{"user_id":"alice"}'
FieldWhat it means
revisedMemories a transform has touched.
unrevisedMemories nothing has touched since they were written. revised + unrevised always equals total - it is derived, not counted separately, so a concurrent write cannot make the three disagree.
totalMemories in the store.
counts / excludesPlain sentences, not flags, spelling out what the numbers cover. The caller is often a model.

Reading the list

Pass include to get the memories themselves, not just how many. Omit it and you get counts only, which is the cheap call.

page = mem.revisions(include="revised", limit=20)
page["memories"], page["matched"], page["has_more"]
const page = await mem.revisions(undefined, { include: "revised", limit: 20 });
let page = mem.revisions_page(None, "revised", 20, None, None).await?;
curl -X POST https://api.wontopos.com/api/v1/won/revisions \
  -H "X-API-Key: $WOS_KEY" \
  -d '{"user_id":"alice","include":"revised","limit":20}'
FieldWhat it does
include"revised" or "unrevised". Any other value is refused with a 400 rather than falling back to counts - a typo that silently drops the list looks exactly like an empty store.
limit5 to 20, default 20. Out of range, or the wrong type, is refused rather than clamped.
matchedTotal rows behind this page, not the size of the page.
ordered_byThe service states its own ordering: newest stored first, not most recently edited.
next_beforeCursor for the next page, with next_skip_ids. Hand both back; ids accumulate across pages.
The list is ordered by when a memory was stored, not when it was changed. A caller who assumes "most recently edited first" reads the page wrong, which is why the response says which it is.
Deletions are not counted. A deleted memory leaves nothing to count, so a store that was heavily pruned still reports a low revised. This number tells you how much was rewritten, not how much is gone.
Scale

Beyond the context window.

WOS recalls from histories of 1.4M tokens - far larger than any LLM context window - and still hands back a tight ~1,470-token slice.

Your agent's memory isn't capped by what fits in a prompt. It keeps everything and retrieves only what matters, no matter how large the history grows.

Privacy

Private, and yours.

Your data stays in your store. We never train on it, view it, or reuse it - we only organize it so you can retrieve it.

  • BYOK. Your LLM key is sent per request and never stored.
  • Isolated. Memories are scoped per account, then per user_id.
  • GDPR delete & self-host. One call wipes a user; run the engine in your own environment if you prefer.