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 serde_json cargo add tokio --features full
use wontopos::Client; use serde_json::json; let mem = Client::new("wos-live-...");
Choose a model
The API key picks which memory (your workspace); 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-..."); // default engine: tablet-2 mem.recall("...", "alice").await?; // tablet-2 mem.with_model("tablet-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. No LLM call on the way in - you pay the write rate and nothing else.
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 refuses a metadata key that spells a store id or the idempotency key, such as userId, store_id or idempotencyKey, in any case and with _, - or . anywhere. It returns Err with ErrorKind::BadRequest and sends nothing. This applies to add, add_with and the _idempotent variants. The service keeps four metadata keys, speaker, event_date, category and conversation_id, and drops any other; the SDK warns once for each key it drops.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. Split and indexed server-side - ideal for importing existing history.
mem.add_bulk("Alice moved to Brooklyn in March...", "alice", "general").await?; mem.add_bulk_with(history, "alice", "general", json!({"timestamp": "2024-03-01T10:00:00Z"})).await?; // date a backfill
general. add_bulk_with takes extra body fields such as a timestamp, so a backfill can be dated.{"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. Any language finds any memory, whichever language it was written in. The SDK returns one list: the memories, then the agent's own words (speaker "me"), then any image rows the answer carried, with no id repeated. limit (5 to 20) sets how many memories come back. The other rows come on top, so size your prompt on what you receive. search_full keeps memories, self_memories and images apart, with the whole body in raw.
let r = mem.search("what does she drink?", "alice", 5).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 | How close this memory is to your query (0–1). |
| is_superseded | True if this fact was replaced by update(). |
| search_ms | Server-side retrieval time. It is in the HTTP body and in the raw field of search_full. |
recall
One round-trip returns everything your LLM needs - paste the result straight into your prompt: the recent turns, at most limit memories and at most context_limit rows of the surrounding conversation, however 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."}ctx.turns(), ctx.memories() and ctx.context_rows() read the three parts as types; a row of another kind is skipped. Each row from context_rows() starts with [before] , [match] or [after] .
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"}usage
What this key has spent and what is left. Free, and it skips the balance gate — an account at zero must still be able to find out why.
mem.usage(7).await?;
{"window_days": 7, "key": {"requests": 128, "cost_cents": 41.9, "since": "lifetime"}, "stores": [{"store": "alice", "cost_cents": 12.4}], "balance_cents": 1840}get
Fetch one memory by id - the id that add or list_memories returned. The original text plus its metadata, and nothing internal. An id from another store, or one that was deleted, 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}get needs SDK 2.2.40 or later. Earlier versions returned an empty result there.list_memories
List a store's memories - the original text you stored plus its metadata, and nothing internal. 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}
]}export_memories · export_images
Page through every memory with no cursor bookkeeping, or pull the whole store at once.
let all = mem.export_memories("alice").await?; // every page, collected let images = mem.export_images("alice", None).await?; // every image row, collected let page = mem.list_images("alice", 20, None, None).await?; // one page
list_all_memories is the same call as export_memories. Both export calls collect every row before returning, and so does iter_images, an older name for export_images. To page through images, call list_images and pass back both cursors it returned: next_before as before and next_skip_ids as skip_ids.
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) if e.is_gone() => { /* the model retired: pick one from list_models() */ } Err(e) => return Err(e), }
Err before anything is sent, with kind() equal to ErrorKind::BadRequest (status 400). An exhausted with_deadline budget is ErrorKind::Connection (status 0). Before 2.2.39 an argument out of range was ErrorKind::Other. RecallResponse is #[non_exhaustive]: destructure it with .., and read fields this version does not name from extra.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. A narrow filter can return fewer than limit even when the store holds more inside it.
mem.search_with("what did we decide", "alice", 10, json!({"filters": { "categories": ["work"], "event_from": "2026-01-01" // when it HAPPENED }})).await?;
YYYY-MM-DD, and a plain end date covers that whole day. Filters apply to the memories, not to the agent's own words (self_memories). The service drops a key it does not know instead of refusing it, so a typo silently widens the search. The SDKs print a warning the first time they see an unknown filter key.search_with sends every field as written, so the service drops a misspelled option without an error. For verify and max_images, use search_opts with SearchOpts, which the compiler checks.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.
Every memory write already carries a key: when you pass none, the client makes one for the call, so its own retries of add, add_turn, add_bulk and update cannot store twice. Pass your own when the retry is yours. The service keeps a key for 24 hours. A repeat that arrives while the first request is still running is told to wait and is sent again; one whose first request did not come back with an answer is refused with a 409, and you check before sending it again under a new key.
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._:-].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.add_with_idempotent(text, None, json!({}), extra, key)
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_with(text, None, cat, extra) // bulk + timestamp
mem.add_bulk_idempotent(text, None, cat, key)
mem.add_bulk_with_idempotent(text, None, cat, extra, key)
mem.revisions_page(None, "revised", 20, None, None)
mem.search_full_with(q, None, 10, &opts, extra) // full answer + filters, speaker, any field
mem.export_images(None, None) // every image row, as one Vecexport_images returns every image row in a store as one list, as export_images does in Python and exportImages in TypeScript. iter_images and list_all_images are older names for the same call, and none of them stream. To page, call list_images and pass back both cursors it returned: next_before as before and next_skip_ids as skip_ids.
with_timeout / with_retries / with_deadline
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.
timeout bounds one attempt, so a call that retries can outlive it: at the defaults a single call can hold a connection for 30s, back off, try again, and again. deadline bounds the whole call instead - every attempt is capped at what is left, and a backoff is never slept past the budget. Set it when the caller has a real limit, like a request handler with five seconds. When a retry would not fit in the deadline, the call raises the error of the answer it got instead of a deadline error.
By default a failed call is tried again up to twice. A 429 is retried on every call, after Retry-After. 408, 502, 503 and 504 are retried on calls that only read, on the DELETE calls and on the memory writes, such as search, recall, get, list_memories, ping, list_stores, usage, delete_store, add and update: a memory write carries an idempotency key, so a repeat is answered with the first result. The other writes sent as POST (create_store, add_speaker, delete, delete_all) get those statuses back on the first failure. A connection that could not be opened is retried on every call. A 409 for a write already in flight, or for a key whose first request is still running, is retried on every call, after the wait the service asks for. A Retry-After over 30 seconds is not waited out: the call raises at once. A timeout after the request went out is not retried.
mem.with_timeout(120).add_bulk(big_blob, "alice", "general").await?; mem.with_retries(0).add("...", "alice", json!({})).await?; mem.with_deadline(Duration::from_secs(5)).recall("...", "alice").await?; // 5s for the whole call