Who said it

Memory that knows who said it.

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

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

One team, three memories

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

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

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

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

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

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

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

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

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

Three rules to remember

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

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

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

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

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

Using it

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

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

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

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

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

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

Read one person's memories

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

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

List, browse and remove speakers

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