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).
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
LiveA lean, fast, low-cost way to inscribe and recall memory - the foundation every model builds on.
Scroll
LiveAdds 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
NextOpens 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.
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.
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.25per 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.
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
"¿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.
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.
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.
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
| Item | What we do |
|---|---|
| Dataset | BEAM 1M - 35 conversations, 74,630 turns, 2.2 million memories, 700 questions |
| Judge | gpt-4.1-mini at temperature 0, running BEAM's own judging prompt - the default in the authors' repository, not a judge we picked |
| Runs | 5 independent runs, every score published, mean reported (σ 0.22%) |
| Reader | Fixed 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.
Earlier benchmark LongMemEval-S Cleared
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.
| Model | Input / 1M | Output / 1M | |
|---|---|---|---|
| Tablet | $2 | $3 | Live |
| Scroll | $4 | $8 | Live |
| 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.
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.
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.
Recall
recall() returns short-term + long-term + context in one call - a bounded, fixed-size context.
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.
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.
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.
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
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?"){"user_id": "alice", "status": "created"}{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}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.
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 storeStores - 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.
mem.create_store("alice") # create (idempotent)
mem.list_stores() # [{"user_id","created_at"}, ...]
mem.delete_store("alice") # delete the store + all its memories{ "user_id": "alice", "status": "created" } // "exists" if it already did{ "collections": [
{ "user_id": "default", "created_at": "2026-06-26T02:23:14Z" },
{ "user_id": "alice", "created_at": "2026-06-26T02:24:01Z" }
], "count": 2 }{ "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." } }"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 itRepeated 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.
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.
The full query is searched and cached: input at 2x (5-minute TTL).
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.
No engine call at all. Everything at 0.1x: the 90% discount.
The rates
| Operation | Token billing | What it means |
|---|---|---|
| Cache write - TTL 5 minutes | 2× | The first request. Its result is kept for 5 minutes, and every read slides the window forward. |
| Cache write - TTL 1 hour | 3× | The first request, kept for a full hour. |
| Cache read - hit or prefix hit | 0.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.
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"
){ "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.
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.
One team, three memories
A store keeps many voices apart. Register a person once, save each remark under its speaker, then ask by person.
The store now knows Bob. The 50-person limit is counted here, at registration; store calls never return a limit error.
The memory belongs to Bob now: every search that returns it says so.
Self-speech gets remembered too, and "me" never counts toward the speaker limit.
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"returns400 invalid_request_errorinstead of being silently coerced. - The limit lives at registration: 50 per store to start. Registering past it returns
400 invalid_request_errorwithspeaker_limit: 50in the error body. Storing with an unregistered name also returns400and stores nothing. Filtering search by an unregistered name returns404 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
Bobandbobare two different people. Names cap at 80 characters.
# 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{ "memories": [
{ "content": "Bob said the deadline moved to Tuesday",
"speaker": "Bob", ... } ] }{ "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"]| Field | What it does |
|---|---|
| memories | The memories, newest first. Same shape a search returns. |
| chunks | Sentence-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_before | Cursor 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 stayImages
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.
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 memorydata is required. A data:image/jpeg;base64, prefix and the line wrapping added by base64 and openssl are both stripped for you.
| Field | What it does |
|---|---|
| data | Base64 of the image. Required. The size ceiling is a server setting, not an SDK constant - /health reports it as memory.images.max_bytes. |
| reference | Where your own copy of the original lives. Stored as a string and never fetched by us. |
| taken_at | RFC3339, 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)| Call | What it does |
|---|---|
| get_image | The 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_images | One 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_image | Removes 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.
| Image | Counted at | Tokens |
|---|---|---|
| 700 × 700 | as sent | 881 |
| 1000 × 1000 | as sent | 1,797 |
| 1568 × 1568 | as sent | 4,417 |
| 1920 × 1080 | 1568 × 882 | 2,485 |
| 2500 × 1875 | 1568 × 1176 | 3,313 |
| 2500 × 2500 | 1568 × 1568 | 4,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.
How many come back
Default 1, maximum 5 images per response. Five images is close to 20,000 tokens.
| Field | What it does |
|---|---|
| max_images | 0 to 5. Images a single response may carry. Default 1. 0 returns text only. Out of range is rejected rather than clamped. |
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.
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)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
| Call | What it does |
|---|---|
| verify | 0 to 3. Additional passes permitted. Out of range is rejected with a 400 rather than quietly clamped. |
| verify_used | How 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.
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-mcpThe 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.
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.jsonWhat 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.
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.txtDrop 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.
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.
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-projectAdd 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=1registers 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
forgetin 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
The agent calls the remember tool. Stored durably in your store - the session ending changes nothing.
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" →
rememberstores it; the next session already knows. - "What error format did we settle on last week?" →
recallpulls the decision back into context. - "Actually, the deadline moved to Friday" → the agent sees it contradicts what it recalled and calls
updateto 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_memoriespages 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-personspeakerfilter — andfiltersto 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.
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.Claude Code
The flagship path: one command in your terminal, and every session starts with memory.
- Create an API key in the console. A key carries its workspace, so one key = one memory space.
- Register the server.
--scope usermakes it available in every project; without it, only the current project sees it. - Check it: run
/mcpinside Claude Code -wontoposshould be listed with nine tools. - 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-projectClaude 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" }
} } }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" }
} } }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" }
} } }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" }
} } }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-KeySame store, same memory: what ChatGPT stores through the Action, Claude Code recalls through MCP - and back.
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 - 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()[{"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
{"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")
{"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")
[{"content": "Bob said the deadline moved to Tuesday", "speaker": "Bob", ...}]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")
{"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")
{"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)
[{
"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"
}]| Field | Meaning |
|---|---|
| similarity | Raw embedding similarity to your query (0–1). |
| is_superseded | True if this fact was replaced by update(). |
| search_ms | Server-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")
{"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")
{"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")
{"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-...")
{"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}{"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)
{"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-...")
{"memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "deleted"}delete_all
Erase everything for one user - one call, GDPR-ready.
mem.delete_all("alice")
{"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 })
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}")
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 - 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();
[{"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
{"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");
{"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" });
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");
{"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");
{"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);
[{
"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"
}]| Field | Meaning |
|---|---|
| similarity | Raw embedding similarity to your query (0–1). |
| is_superseded | True if this fact was replaced by update(). |
| search_ms | Server-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");
{"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");
{"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");
{"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-...");
{"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 });
{"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-...");
{"memory_id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "deleted"}deleteAll
Erase everything for one user - one call, GDPR-ready.
await mem.deleteAll("alice");
{"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 });
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}` });
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 - 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?;
[{"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
{"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?;
{"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?;
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?;
{"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?;
{"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?;
[{
"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"
}]| Field | Meaning |
|---|---|
| similarity | Raw embedding similarity to your query (0–1). |
| is_superseded | True if this fact was replaced by update(). |
| search_ms | Server-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?;
{"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?;
{"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?;
{"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?;
{"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?;
{"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?;
{"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?;
{"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?;
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?;
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 - 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"}'
{"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!"}'
{"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"}'
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"}'
{"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"}'
{"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"}'
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}'
{"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"}}'
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
{"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?"}'
{"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
{"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, collectedlist_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
Callable recall tools your model can invoke - each one a different retrieval strategy over the same memory. Use one, or run several at once.
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.
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"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"Returns matches as exact records - precise elapsed time and absolute anchors, structured for a model to read straight off.
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 timetz 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:
{ "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" }
] }{ "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)" }
] }| Elapsed | Memoir | Archive |
|---|---|---|
| 3 min | a few minutes ago | 3 minutes ago |
| 14 min | about 15 minutes ago | 14 minutes ago |
| 30 min | half an hour ago | 30 minutes ago |
| 50 min | about an hour ago | 50 minutes ago |
| 2 hr | a couple hours ago | 2 hours ago, at 13:10 |
| 8 hr | this morning | 8 hours ago, at 07:10 |
| yesterday pm | yesterday afternoon | yesterday at 14:00 |
| last night | last night | 17 hours ago, at 22:00 |
| 2 days | a couple days ago | 2 days ago (Tue 15:10) |
| 6 days | several days ago | 6 days ago (Fri 15:10) |
| 9 days | about a week ago | last week (Jun 16) |
| 16 days | a couple weeks ago | 2 weeks ago (Jun 09) |
| 35 days | about a month ago | last month (May 21) |
| 60 days | a couple months ago | 2 months ago (Apr 2026) |
| 180 days | about half a year ago | 6 months ago (Dec 2025) |
| 380 days | about a year ago | last year (Jun 2025) |
| 800 days | a couple years ago | 2 years ago (Apr 2024) |
| 1500 days | about 4 years ago | 4 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.
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"){ "engram": "deep_recall", "hops": 2, "count": 12,
"memories": [ ... ],
"usage": { "input_tokens": 200, "output_tokens": 589 } }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.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"){ "engram": "timeline", "hops": 1, "count": 15,
"memories": [ ... ],
"usage": { "input_tokens": 100, "output_tokens": 736 } }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.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"){ "engram": "gather", "hops": 4, "count": 18,
"memories": [ ... ],
"usage": { "input_tokens": 400, "output_tokens": 637 } }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.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"){ "engram": "equilibrium", "hops": 3, "count": 12,
"memories": [ ... ],
"usage": { "input_tokens": 300, "output_tokens": 293 } }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.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"){ "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 } }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.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
| Header | What it does |
|---|---|
| X-API-Key | Required on every call. Your key, issued in the console. |
| X-WOS-Model | Optional. 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-Key | Optional, on writes. The same key with the same body replays the first response instead of storing again - see the note below. |
Endpoint
| Endpoint | Purpose | Body fields |
|---|---|---|
| POST /api/v1/memory/collection | create a store | user_id |
| GET /api/v1/memory/collections | list your stores | (none) |
| DELETE /api/v1/memory/collection | delete a store + its memories | user_id |
| /api/v1/memory/store | store one memory | user_id · content · metadata? (event_date · speaker) · image? |
| /api/v1/memory/store-turn | store a conversation turn | user_id · user_msg · assistant_msg |
| POST /api/v1/memory/speakers | register a speaker (explicit, up to 50) | user_id · speaker |
| GET /api/v1/memory/speakers | list registered speakers + memory counts | user_id |
| DELETE /api/v1/memory/speakers | unregister a speaker (memories stay) | user_id · speaker |
| /api/v1/memory/by-speaker | what one person said, newest first ("me" = the agent) | user_id · speaker · limit? · before? · skip_ids? |
| POST /api/v1/memory/image | the original bytes of an image memory | user_id · memory_id |
| DELETE /api/v1/memory/image | remove the image, keep the caption | user_id · memory_id · preview? |
| /api/v1/memory/images | a store's images, newest first (+ the store total) | user_id · limit? · before? · skip_ids? |
| /api/v1/memory/lineage | one memory's chain of edits, oldest first | user_id · memory_id |
| /api/v1/won/revisions | how much of a store has been rewritten. Free | user_id · include? · limit? · before? · skip_ids? |
| /api/v1/memory/revisions | the same call under the memory plane. Free | user_id · include? · limit? · before? · skip_ids? |
| /api/v1/memory/bulk-store | backfill a text blob | user_id · content · category? · timestamp? |
| /api/v1/memory/search | semantic search | user_id · query · max_results? · speaker? · cache_control? · filters? · verify? · max_images? |
| /api/v1/memory/recall | short + long + context | user_id · query · limit? · context_limit? |
| /api/v1/memory/get | one memory by id | user_id · memory_id |
| /api/v1/memory/list | page through a store | user_id · limit? · cursor? |
| /api/v1/memory/history | recent turns | user_id |
| /api/v1/memory/stats | memory counts | user_id |
| /api/v1/memory/supersede | replace a changed fact | user_id · old_memory_id · new_content |
| /api/v1/memory/forget | delete one (or all) | user_id · memory_id? (omit = delete all) |
| GET /api/v1/engram | engrams this model can run | (none) |
| POST /api/v1/engram/run | run one engram | name · user_id · query · form? · tz? |
| GET /api/v1/models | available models | (none) |
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?"}'
{"id": "576700aa-f0e0-4c26-99a0-10e2d5b0d624", "status": "stored (1 chunks)"}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 tier | Credit purchase | Monthly 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 - Enterprise | Talk to us | No 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.
| Tier | Requests per minute |
|---|---|
| Tier 1 | 150 |
| Tier 2 | 300 |
| Tier 3 | 600 |
| Tier 4 | 1,500 |
| Tier 5 | 3,000 |
| Tier 6 - Enterprise | Custom |
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.
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.
{"type": "error", "error": {
"type": "authentication_error",
"message": "Invalid or revoked API key.",
"request_id": "063f8b83-eee2-4383-a5cf-11e4bcd29d7c"
}}| HTTP | Meaning | What to do |
|---|---|---|
| 400 | Malformed body (missing/wrong-type field) | The message names the exact field - fix and retry. |
| 401 | Invalid or revoked API key | Check the key; issue a new one in the console. |
| 402 | Out of balance, no card on file, or a tier cap | Top up or add a card in the console. The response carries balance_cents and floor_cents, so you can tell which one stopped you. |
| 404 | No such memory, store, or image | Check the id. get_image also answers 404 when the memory exists but carries no image. |
| 409 | That name is already taken | Store and workspace names are unique within an account - pick another one. |
| 413 | Request body over 10MB | Base64 runs about 33% larger than the file it encodes, so resize the image before encoding it. |
| 429 | Rate limited | The 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. |
| 501 | This model's engine does not implement that endpoint | Images and revision history need a newer engine. GET /api/v1/models lists which models serve what. |
| 5xx | Server-side problem | Retry 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.
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.
lineage
The chain of edits behind one memory, oldest first. revisions says how much a store moved; this says what happened to one fact.
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"]){ "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 }
] }| Field | What it does |
|---|---|
| chain | The versions, oldest first. Each carries the same fields a memory does, plus the four below. |
| changed_at | When this version was superseded (RFC3339), or null while it is still in force. |
| action | What happened at this link - how the replacement related to this version. |
| confidence | How sure the engine was about that relation, 0-1. |
| is_current | True for the one version still in force. Exactly one per chain. |
| truncated | True 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 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.
| Call | What it does |
|---|---|
| POST /won/revisions | How 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."counts and excludes are returned as sentences rather than flags, since the caller is often a model. Deletions are not counted.
Price and limits
| Rule | Value |
|---|---|
| Price | None. Free calls skip the billing gates - no token charge, no per-request fee, and no usage recorded. |
| Per minute | 10 per minute, per account and per endpoint. Spending one endpoint's minute does not spend another's. |
| Per hour | 300 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 traffic | Separate 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.
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." } }/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.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.
Counts
It counts what a transform touched - superseded, updated, retracted, and images removed.
mem.revisions()
# {"revised": 3, "unrevised": 37, "total": 40, …}| Field | What it means |
|---|---|
| revised | Memories a transform has touched. |
| unrevised | Memories 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. |
| total | Memories in the store. |
| counts / excludes | Plain 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"]| Field | What 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. |
| limit | 5 to 20, default 20. Out of range, or the wrong type, is refused rather than clamped. |
| matched | Total rows behind this page, not the size of the page. |
| ordered_by | The service states its own ordering: newest stored first, not most recently edited. |
| next_before | Cursor for the next page, with next_skip_ids. Hand both back; ids accumulate across pages. |
revised. This number tells you how much was rewritten, not how much is gone.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.
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.