Writing memory
A memory is one claim stored under a key in a namespace. Each write is judged against the namespace's existing facts before it lands, and the response carries the verdict: clear, merge, duplicate, conflict or rejected. The verdicts and conflict policies are in conflicts and refusal; the model is in memory concepts; the server internals are in memory architecture.
Choosing the operation
| You want to | Operation | Notes |
|---|---|---|
| Save a fact | memory.set |
The default. Idempotent on (identity tuple, memory_key). |
| Save many facts in one call | memory.bulk_set |
Up to 1,000 entries; per-entry verdicts. Not an MCP tool. |
| Let the server decide what a transcript taught | memory.set_from_messages |
See extraction. |
| Save a standing fact that later writes cannot replace | memory.pin |
Same input as memory.set. |
| Correct a fact you found through search or ask | memory.update |
Resolves the key namespace-wide; keeps a pin. |
| Correct one exact row by id | memory.update_by_id |
Addressed by (org, memory_id). |
| Change only the expiry | memory.touch |
One key or up to 1,000 memory_keys; value untouched. |
| Retire a fact | memory.delete |
Tombstone; memory.restore revives it within 14 days. |
| Rename a key and keep the old one readable | memory.move |
keep_redirect (default true) leaves a redirect. |
| Guard a row against edits or deletion | memory.lock / memory.unlock |
Unlock is admin. |
| Apply one change to many existing rows | memory.apply |
Dry run by default. |
| Clear an agent's or a user's rows | memory.clear |
Admin; needs confirm_subject. |
| Import a curated Markdown file section by section | memory.import_curated |
One memory per heading at heading_level (default 2). |
| Import facts another memory system already distilled | memory.import |
Up to 200 entries; see importing. |
| Mark a candidate fact as wrong | memory.reject |
Keeps it from being re-extracted. |
| Try a fact out before it is served | memory.set(proposed=true) |
See proposals. |
| Store a row with exact typed fields | memory.record.write |
See typed records. |
| Store a document | knowledge.add |
Not a memory; see knowledge. |
| Park scratch state (a cursor, a lease) | working_memory.set |
Not embedded or ranked; see working memory. |
| Learn when a namespace's memory changes | webhook.register |
Admin; see change webhooks. |
Each op is POST /v1/<op>, an SDK method, and, where exposed, the MCP tool
loom_<op with dots as underscores>. The operations reference
lists roles and MCP toolsets.
Keys and values
- One fact per key, and a readable key. Shape it
<scope>/<kind>/<id>(deploy/window/prod). The key is how a later write revises the fact and howmemory.getreads it. Search also uses key words as a tie-break (unlessLOOM_MEMORY_SEARCH_KEY_MATCH_MODE=off).namespace.auditreports hash or UUID keys (hash_keys). - A revision rewrites the same key. The prior version stays in history. A new value under a fresh key leaves two live claims for the conflict check to flag.
- The value is the claim, in plain text.
valuetakes any JSON value, but recall embeds only the first 1,200 characters of its text. Put facets intagsand named things inentities.
The write succeeds whatever the shape. advisories names a shape recall cannot reach:
| Advisory | Cause | Fix |
|---|---|---|
| base64 | The value is base64-encoded bytes. | Send the text. |
| double-encoded | A JSON object serialized inside a JSON string (json.dumps twice). |
Send the object, or a plain-text claim with facets in tags. |
| oversized | The serialized value is longer than LOOM_MEMORY_VALUE_ADVISORY_CHARS (default 30,000). |
Write the claim as the value and put the document in knowledge.add. |
Documents go to knowledge. A value past a couple of kilobytes is usually a document.
Long values are split into verbatim chunks (chunks_written): any value whose stored JSON
runs past the 1,200-character excerpt it is embedded by, less its (#<key>) suffix, which
a non-ASCII text reaches at about 200 characters, since each such character is stored as a
six-character escape. A knowledge document is chunked on its headings and searched with its
section. Store the document in knowledge and one short claim in memory.
Size limits. No fixed value cap. The request body is capped by LOOM_MAX_REQUEST_BYTES
(32 MiB, 413 PAYLOAD_TOO_LARGE). A caller may set char_limit on a row (422
CHAR_LIMIT_EXCEEDED); it persists for later writes and is not offered to MCP clients.
Memory types. memory_type (default semantic) decides whether a row is embedded and
searched. episodic and profile rows are not embedded and never appear in
memory.search. A procedural write that is not a procedure body is stored as
semantic (retyped_from). A sensitivity="high" row, or one detected as high (a
credential, card number, SSN or stated PIN), is never embedded and is readable by key
only. The full table is in memory concepts.
What a write returns
memory.set returns memory_id, version, content_sha256 and status, plus:
| Field | Meaning |
|---|---|
conflict_verdict |
The verdict (table). Empty when no check ran. |
peers |
Rows the verdict was reached against, closest first, at most five: key, 200-character excerpt, similarity. |
next |
A hint naming the call to make, such as memory.update(memory_key=...) on a conflict. |
noop |
True when nothing was written: a merge or a dry run. |
merged_into_key |
On a merge, the key that already holds the fact. Your key holds no row. |
advisories |
Value-shape advice. |
degraded |
Providers that fell back during this write. |
dry_run=trueruns the full check and writes nothing: no row, embedding or audit hash.- A write under a
run_idthat canonically matches a peer from another run is stored as aduplicate, not merged, so run-scoped reads find it. memory.bulk_setreports per entry inwritten[]and in the listsmerged,duplicates,conflicted,rejected_pinned,skipped_pinnedandskipped_locked. A blacklisted entry is skipped. Entries pastLOOM_BULK_CONFLICT_CHECK_MAX_ENTRIES(200) are written unchecked and counted inunchecked.
The checks, in order
The first refusal ends the call.
- Unknown arguments. An unknown key is named in
ignored_args(withdid_you_mean). On a write, a close match to a real field is refused with 422UNKNOWN_ARGUMENT, sotagfortagsdoes not store an untagged row. - Guards on the existing row. A byte-identical value passes and reinforces the row.
A changed value meets the lock (423
LOCKED) and then the pin (409PINNED_CONFLICT). - Value gates.
char_limit(422CHAR_LIMIT_EXCEEDED), then the blacklist (422BLACKLISTED, see refusal). - Content-hash precondition.
if_match_sha256(409PRECONDITION_FAILED). - Conflict verdict against active peers of the same type.
Every write that creates or changes memory leaves one hash-only row in the audit log (key, id, content hash, actor, source class, verdict; never the value). See audit.
Racing writers: if_match_sha256
memory.set, memory.get and memory.get_by_id return content_sha256. Pass it back as
if_match_sha256 and the write succeeds only if the live row still has that content.
from loom import Agent
from loom.errors import PreconditionFailed
async with Agent(org="acme", namespace="support", agent="triage-bot") as agent:
row = await agent.memory.get("customer/4812/plan")
try:
await agent.memory.set(
"customer/4812/plan",
"Customer 4812 is on the Enterprise plan since the March renewal.",
tags=["customer", "billing"],
if_match_sha256=row["content_sha256"],
)
except PreconditionFailed:
... # re-read (the error carries current_sha256), decide, retry
memory.update, memory.update_by_id and memory.move (source row) accept the same
precondition. The Python SDK coalesces a burst of set calls from one identity into one
memory.bulk_set (from the third set inside 200 ms); a coalesced result carries
coalesced: true and no content_sha256. A set with if_match_sha256 is never
coalesced.
A memory.set or memory.update that carries the precondition holds a lock on its key
from the check to the insert, so of two writers holding the same hash only one succeeds.
The lock is a KeeperMap key when the server has a Keeper, and per process otherwise.
Example
Against the stock stack from the quickstart (drop
the Authorization header in single-user mode):
curl -sX POST http://localhost:7777/v1/memory.set \
-H "Authorization: Bearer $LOOM_API_KEY" -H 'Content-Type: application/json' \
-d '{"org":"dev","namespace":"local","agent":"ops-bot",
"memory_key":"deploy/window/prod",
"value":"Production deploys run Tuesday and Thursday only; never Friday.",
"tags":["deploy","policy"],
"entities":["production"]}' \
| jq '{conflict_verdict, noop, memory_key, content_sha256, next}'
With a real embedder, writing "Production deploys run Monday to Thursday." to another
key then answers conflict_verdict: "conflict", peers[0].memory_key:
"deploy/window/prod" and a next naming memory.update(memory_key='deploy/window/prod',
...). Over MCP the same write is the loom_memory_set tool.
Changing and retiring facts
| Op | Behaviour |
|---|---|
memory.update |
Resolves memory_key across the namespace when no identity is given ("-" counts as none, and "*" for agent). One live row: updated. None: updated=false, reason="not_found". Several: 409 AMBIGUOUS_KEY with candidates. Runs the conflict check and keeps a pin. An expired row answers 409 EXPIRED unless revive=true. |
memory.update_by_id |
One row by (org, memory_id). The namespace is resolved and checked before the read; a refusal answers updated=false, not 403. |
memory.delete |
Writes a tombstone, looking past the caller's identity for the key (found=false when no live row exists). memory.delete_version instead reads only the caller's exact identity (a concrete agent) and, under the key's write lock, deletes only the version it names, else tombstoned=false, reason="version_changed". Consolidated summaries lose the row: retired when no source survives, else conflicted. memory.restore revives the key within 14 days. |
memory.move |
Renames a key, carrying value, type, tags, entities, validity and provenance. The source becomes a redirect (canonical_key, follow=true) or, with keep_redirect=false, a tombstone. See optimize a namespace. |
memory.lock |
read_only refuses value, expiry and deletion changes; no_delete refuses tombstone, redact, clear and consolidation. Both refuse memory.reject (423 LOCKED); a negative-outcome quarantine leaves a locked row as it is, while its negatives keep counting. A writer may lock or tighten (until, ttl_seconds); memory.unlock is admin. locked=false, reason="not_found" is not a lock. |
memory.apply |
A target (keys, a filter or a search; filter and search need limit ≤ 1,000), an action (delete, expire, lock, label, demote), a reason, dry_run on by default. Each row goes through the single-row path; refusals come back as refused rows. Refuses agent="*". |
Provenance fields
The server stamps actor (human:<subject> or process:<subject>, from the token) and
source_class on every row. A request may only downgrade source_class to external,
imported or derived; any other value is a 422. external matters: memory.ask keeps
unattested external rows out of synthesis. source_resource_id records the primary
source (a path, document id or URL). The values are in
memory concepts; reads are in
history and provenance.
Validity time: valid_at and valid_to
created_at is when Loom recorded the row, valid_at when the fact became true, and
valid_to when it stopped. Default reads drop a row past its valid_to. Extraction fills
valid_at for past-tense state changes; most writers leave it unset.
as_of on memory.get, memory.search and memory.ask replays one of two axes:
as_of_basis |
Returns |
|---|---|
valid_time (default) |
What was true then: valid_at, else the event date the row carries, else created_at, at or before as_of. |
knowledge_time |
What Loom knew then: created_at at or before as_of, and not retired (invalid_at) or deleted (tombstoned_at) by then. |
memory.timeline orders by coalesce(valid_at, created_at).
Tags, entities and files
tagsfilter recall (OR semantics) and carry per-tag policy from the namespace'stag_registry.namespace.taxonomylists the namespace's top tags and entities.entitiesare stored lowercased, soentity_filter, the entity graph andmemory.timelinefind the row at once. Without them theentity_extractorworker fills them later.filesnames up to 32 repository files the fact is about;memory.for_fileserves it before an edit. Unset, files are detected from the value.
Expiry on a write
A row expires only when its writer or the namespace owner asks: ttl_seconds,
valid_to, or a configured retention window. on_expire is tombstone (default),
demote or anonymize. clear_expiry=true overrides type and namespace defaults. An
edit to other fields keeps a live expiry. See
retention and erasure.
Importing from another memory system
memory.import writes up to 200 facts another memory system already distilled. Each
entry is the fact's text plus, optionally, tags, valid_at (when the source first knew
it), source_trace (its id or path there), category and sensitivity. The write is a
memory.bulk_set underneath, so each entry meets the same checks: identity dedup, the
lock and pin guards, the blacklist, the sensitivity rule, inline embedding and the
conflict verdict. Every entry gets the conflict check, whatever
LOOM_BULK_CONFLICT_CHECK_MAX_ENTRIES caps a memory.bulk_set at. In a namespace on
newest_wins that verdict can retire a live memory the imported item updates, as any write
there does; such an entry answers written (refines). The value-update linker of the
extraction path (LOOM_MEMORY_EXTRACT_VALUE_UPDATES) never runs for an import.
- The key comes from the text:
import:and the first 24 hex characters of the SHA-256 of the text as given, so the same text always lands on the same key. The key is a digest of the text, so anyone who can see a key can confirm a guessed text against it: do not import a secret as an item. - Re-runs write nothing new: an entry whose key holds its text comes back
skipped(unchanged) in any session the token may read (anown_sessiontoken reads only its own, see below), and the stored row keeps the tags,valid_at,source_traceand sensitivity it was written with; an entry naming anothersensitivityanswersskipped(sensitivity_kept), andmemory.setchanges the stored level. The import reinforces nothing, unlike a restatement throughmemory.setormemory.bulk_set, so re-runs never raise a memory's reinforcement; a fact already held under another key comes backheld. - An item edited in the source has new text and so a new key: the import adds it and leaves the old fact as it is (the conflict check may flag the pair), so delete or revise the old one yourself.
- The text is embedded and indexed alone: a key that is only the digest (
import:and 24 lowercase hex characters, nothing else) puts no(#key)suffix on the excerpt a row is embedded and lexically indexed by, and builds no key-axis vector, so an imported fact relates to other memories through its text, as a hand-written one does. A latermemory.setormemory.bulk_setof the same key follows the same rule, so its vector stays comparable with the imported one; a key of any other shape keeps its suffix. Only the first 1,200 characters of an item's JSON encoding are embedded and indexed (a non- ASCII character takes six of them, one outside the Basic Multilingual Plane twelve), so split a longer item into several. - A key that was deleted, retired or rejected stays so: before the write, the import
reads each key's newest version in every session of the identity that the token may read
(only its own session for an
own_sessiontoken), tombstones and rows of every status included; the newest version across those sessions decides, whichever session holds it. A key its owner deleted comes backskipped(deleted), one a newer fact retiredskipped(superseded) and one marked wrong (memory.reject)skipped(rejected); none is written again. A key holding a proposal comes backskipped(proposed): onlymemory.proposal_decidemoves a fact out of its trial. A live key holding other text was corrected (memory.set,memory.update_by_id, a pin) or anonymized by an expiry (on_expire=anonymize) and comes backskipped(revised), before the bulk write's own pin and lock guards. A status the import does not recognise leaves the key as it is too, asskipped(unknown). The read and the write are not atomic: a delete that lands between them is written over. Text deleted, retired or rejected under another key is imported again under itsimport:key: the bulk write's dedup sees live rows only. - A tombstone has no way back through the import:
memory.delete,memory.clearand an expiry withon_expire=tombstoneall write one, so clearing an agent and importing from the source again answersskipped(deleted) for every such item. Bring the keys back withmemory.restorewithin 14 days of the delete (a re-import then answersunchanged), or remove the scope's rows witherasure.requestandpurge=true: a key with no rows left is imported as new. - Provenance: every row is
source_class=imported,valid_atis the source's time andcreated_atthe import's. An entry with nosource_traceis traced by its key. - Categories: a
categorythe namespace registered (namespace.register_category) is linked to the key once the key holds the fact, asmemory.assign_categorylinks one. A link already stored is left as it is, so a re-run does not bring back a link the owner rescinded, unless the rescind lands while the import runs. When the category is not linked, the result'snotesays why:category_unregistered(the namespace has no such category),category_refused(a token bounded to one user or one session links none, since a link has neither axis; it is refusedmemory.assign_categorytoo),category_not_linked(the key does not hold the fact and has no link) orcategory_rescinded(a link the owner rescinded, left so). The note never changes the entry's outcome. A live link already stored for the key answers no note, and an entry its own request superseded (newest_wins) is linked like any written one. What an erasure leaves of the links: retention and erasure. dry_run=truemakes every check and writes no memory row, embedding, category link, audit row or usage event. It still pays for the embedding of each entry it sends to the bulk write, since the conflict check reads the vectors (and those vectors land in the embedding cache,loom.embedding_cache), and counts a blacklist pattern's hit. Aconflictedorrefinesentry is previewed before a real write settles its peers, so it may land without the flag or the retirement if its peers change first.
Each result carries the entry's index, its key, an outcome and, when the entry was
not plainly written, a reason:
outcome |
reason |
|---|---|
written |
Empty, duplicate or conflicted (the status the conflict check stored the row with), or refines: under newest_wins the item retired the live memories it updates. |
held |
merge (the same fact after canonicalisation) or identity_duplicate (the same text under another key, named in held_by); nothing is written or reinforced. |
skipped |
unchanged, sensitivity_kept, deleted, superseded, rejected, proposed, revised (the key holds other text: a correction, or an expiry anonymized it), repeated (an earlier entry of the request with the same text was written) or unknown (logged: a status the import does not recognise). |
refused |
blacklist, pinned or locked (only when the key is pinned or locked between the import's read and its write) or pinned_peer (too close to a pinned memory). |
Under newest_wins, two entries of one request that state the same attribute differently
keep only the newer: the later valid_at (an undated entry counts as said at the time of
the import), then the later in the request. The other still answers written, and its row
is stored superseded.
An entry with the same text as an earlier one must be identical to it (otherwise the
request is a 422 naming both indexes), and answers as that entry did, except after a
write, when it is skipped (repeated).
totals counts the outcomes, and dry_run echoes whether the request was a dry run. A request
with more than 200 entries is refused with 422, and each call draws from its own
rate-limit class, memory.import. The op is
listed on no MCP toolset: an MCP client calls loom_memory_import by name. Fields:
operations reference.
Proposals
memory.set(proposed=true) writes a fact on trial (status='proposed'). It is served only
to reads that pass include_proposed, and never retires, supersedes or flags an established
fact. The relations it names stay out of the fact graph, and accepting it does not add
them: state them again once it is established. A key that already holds a fact answers 409
KEY_HOLDS_FACT. memory.proposals lists proposals with the graded runs that relied on
each. A run in the proposal's namespace counts toward its trial when it names the key in
run.complete(memories_used=…) and is graded with run.grade. Loom decides only when the
owner set proposal_policy.mode to auto (min_passes, max_fails,
reject_after_fails). See runs.
Any writer, the proposing agent's own token included, accepts or rejects a proposal under
either policy mode, with memory.proposal_decide or on the Proposals tab of the
Review queue at /admin/review_queue. The admin drawer never lists the Review queue for
a proposal (dashboard): open /admin/review_queue
directly. A decision names a key and decides its newest row, so the tab offers no decision
for a key that more than one listed proposal holds. Accepting a proposal that recorded a
contradiction while on trial stores it conflicted, and it waits on the Conflicts tab.
Learnings have a review of their own:
learnings review queue.
Typed records
Records are rows with exact fields, for data that has identity (a ticket, a customer). They answer predicate questions without parsing text.
| Operation | Role | Does |
|---|---|---|
memory_schema.register |
admin | Register a schema: up to 64 fields. |
memory_schema.propose |
writer | Suggest a schema (status='proposed') for an admin to accept. |
memory_schema.approve |
admin | decision approve or reject. |
memory_schema.list |
viewer | Schemas of the namespace. |
memory.record.write |
writer | Write one record; constraint violations are rejected. |
memory.record.query |
viewer | Filter by field values and typed predicates, optionally as_of; rows carry status. |
memory.record.history |
viewer | Every version of one record with its status, newest first. |
memory.record.conflicts |
viewer | Field values that changed when a record superseded another. |
memory.aggregate |
viewer | count, sum, avg, min, max over records, with group_by. |
- A field has a
type(string,number,integer,boolean) and may berequired, anidentityfield, an enum (enum_values), or constrained (pattern,min_length,max_length,min_value,max_value). - Identity fields derive the key when none is given (
key_source="identity"), so two writes of one entity land on one record. A name-like field (name,title,label,*_name,*_title,*_label) keys on its words without case, a leading article or a possessive; it drops accents and the other combining marks (the Japanese voicing mark, the virama, Thai tone marks; Indic vowel signs stay), soかぎandかきshare a name key. It is refused only when nothing is left once separators (spaces,_,-), combining marks, a leading article and a possessive are dropped. Any other field keys on its letters and digits a-z, 0-9 after case folding and compatibility decomposition with accents stripped, soINV-2024/001andinv 2024 001agree. A value with none of those (Cyrillic, CJK or Arabic text) keys on every letter, digit and combining mark it holds, after NFKC normalisation and case folding. No mark is stripped there: Indic vowel signs, Thai tone marks, the Japanese voicing mark and Arabic and Hebrew points tell words apart, soかぎandかきget two keys, as do a word written with and without its Arabic vowel points. A value with any a-z or 0-9 after compatibility decomposition (30 дней,2024年度,№,℃) keys on those characters alone, so two values that differ only in their other characters share one key. Any other field with no letter or digit (punctuation, symbols or marks alone) is refused with 400MISSING_IDENTITY_FIELD. - Each field value carries a
state(value,unknown,rejected,absent). memory.set_from_messageswithrecord_schemaandmemory_keyextracts one record.
Change webhooks
A product built on Loom (a chat service that shows a user what the assistant remembers,
say) can be told when memory in a namespace changes instead of polling for it. A
namespace admin registers a URL with webhook.register (or in the dashboard's Settings →
Memory → Webhooks); Loom then POSTs one signed event per change. The event carries ids,
the memory key and the identity tuple, never a value, tags or evidence: the receiver
reads the memory back with its own token
(memory.get_by_id), so what it may read is decided when it reads, as for any other read.
A key reaches every registered receiver, including one whose token may not read the row,
so a secret or a fact never goes in a key. Copies into the namespace (memory.copy_to,
memory.cross_promote) and namespace.restore from a snapshot send no event: after one,
the receiver reads the namespace again. The full list of writes that send none is below
the table.
| Event | Sent after |
|---|---|
memory.written |
memory.set or memory.bulk_set writes a key that held no live row, or a proposal is accepted |
memory.revised |
a new value of a live key (for memory.set, also a new type, tags, expiry, confidence, valid_at, valid_to, sensitivity, files or source): memory.set, memory.bulk_set, memory.update, memory.update_by_id, namespace.rollback, the source of memory.move with keep_redirect, an expiry that demotes or anonymizes, a completed run's retention (demote or expire), the sensitivity sweep's new label |
memory.tombstoned |
memory.delete, memory.delete_version, memory.clear (one per row it clears), conflicts.resolve and memory.dedup_resolve for the side they tombstone, blacklist.add and blacklist.sweep for each live row the sweep tombstones, namespace.rollback back to a tombstone or removing a key born later, the source of memory.move without keep_redirect, an expiry that tombstones |
memory.restored |
memory.restore, or namespace.rollback reviving a key that is tombstoned now |
memory.redacted |
memory.redact scrubs a historical version, and once for each profile key with versions built from that memory, naming its newest scrubbed version |
memory.erased |
an erasure saga completes (erasure.request, collection.delete, code.repo.remove) |
Ops that write through memory.set (procedures, profiles, learnings, decisions) send its
events. A memory.set of the same value with the same type, tags, expiry, confidence,
valid_at, valid_to, sensitivity, files, source_resource_id and source_class (a
restatement, which only reinforces the row)
sends none; the same value with any of those changed is memory.revised. A retry with the
same fields is a restatement and sends nothing; a retry that recomputes a relative expiry
(ttl_seconds, a type's retention, the sensitive-data retention) moves expires_at and
is memory.revised. memory.bulk_set skips any entry whose value equals the live row's:
it writes no version and sends no event, even when the entry's tags, expiry or confidence
differ. In one memory.bulk_set, a key that an earlier entry of the batch wrote is
memory.revised; if the batch's lookup of live keys fails, its revisions are announced as
memory.written. A merge (merged_into_key) writes no version at your key and sends no
event; neither does a dry run or a refused write.
These writes send no event:
- the summaries
memory.deleteretires with the row they were built from memory.consolidateand the consolidation worker- observations and their distillation
- period digests
memory.touch,memory.pinandmemory.unpin,memory.lockandmemory.unlock,memory.verifyandmemory.applymemory.reject, and the quarantinememory.outcomemakes after repeated negative outcomes. Both write the row again with statusrejected, which takes it out of search: a receiver that mirrors what Loom serves reads the key again after one.memory.resolve_orphan_duplicates, which re-activates rowsmemory.dedup_collapse, which changes only the search mirrormemory.promoteandmemory.demote, which change the namespace's shared copy, not the memory row- the counters of
memory.outcomeandmemory.outcome_run memory.assign_category,memory.linkandmemory.reindex- a proposal (
memory.setwithproposed, which is not served until it is accepted) and its rejection - the status change
conflicts.resolveandmemory.dedup_resolvemake on the rows they keep - the supersessions a write makes of other rows: the older same-key rows the
newest_winsconflict policy retires, and the peers arefinesverdict supersedes. The new row's own event is sent. - what the conflict resolver lands later: the rows it retires (
superseded, under another key too), and theconflictedflags it sets or clears. The write's own event was sent before. - the
conflictedandduplicatestatus the write's own check lands. The write's own event is sent. - the undo of an automatic supersession through
memory.restore - copies into another namespace (
memory.copy_to,memory.cross_promote) namespace.restorefrom a snapshot- entity backfills
- typed records (
memory.record.*) and saved chat messages (conversation.*). Neither is a memory row.
A receiver that mirrors what Loom serves reads memory.auto_retirements for the rows the
supersessions above retired: a write's own and the conflict resolver's.
Accepting a proposal, by memory.proposal_decide or the namespace's auto proposal
policy, sends memory.written.
{
"type": "memory.revised",
"event_id": "5f0c…",
"org": "acme",
"namespace": "chat",
"agent": "assistant",
"user_id": "u-42",
"session_id": "-",
"memory_key": "user/pref/tea",
"memory_id": "0b7e…",
"version": 1790000000000000000,
"actor": "human:alice",
"request_id": "r-91c2…",
"erasure_id": "",
"occurred_at": "2026-09-20T12:00:00.000Z"
}
memory.erased carries the erasure_id the request returned and no key, user,
identity or requester (actor is -, request_id empty): a namespace erasure goes to that namespace's webhooks, a user's erasure to
every webhook of the org that takes the event, including namespaces that never held
the user. erasure.status of a user's erasure answers only a flat org admin or
platform admin whose token names the org, so a receiver's token cannot read the user
id from it.
| Operation | Role | What it does |
|---|---|---|
webhook.register |
admin | Registers url, secret_ref and events (default all six); the same URL again replaces it. At most LOOM_MEMORY_WEBHOOK_MAX_PER_NAMESPACE per namespace (10 by default): two registrations of one new URL at the same moment can make two webhooks, one past the cap; remove either. |
webhook.list |
admin | The namespace's webhooks, with secret_present (whether the replica that answered holds a usable secret). |
webhook.remove |
admin | Removes one by webhook_id; none of its events is sent after the call, save a POST already in flight. |
webhook.deliveries |
admin | The last 3 days of events by state: pending, delivered, dead, with the last HTTP status or error. |
Signing. Each request carries the Standard Webhooks headers the task inbox webhook
uses: webhook-id (the event id; drop a repeat), webhook-timestamp (unix seconds;
refuse one more than a few minutes old to bound replay) and
webhook-signature: v1,<base64 HMAC-SHA256> over "{id}.{timestamp}.{body}". The secret
lives only in the server's environment: secret_ref names the variable, one of the
org's own, LOOM_WEBHOOK_SECRET_<ORG>__<NAME>, encoded as for
task inbox webhooks; a whsec_ prefix marks a base64 key,
whose line breaks and other whitespace are dropped.
A secret belongs to the org, not to one webhook: every webhook that names the same
secret_ref signs with it, so a receiver checks the signed body's org and namespace
before acting on an event.
URLs. https only, unless LOOM_MEMORY_WEBHOOK_ALLOW_HTTP is set (a local receiver
in development), and with no user name or password in it. The host must resolve to
public addresses: loopback, private, link-local (cloud metadata) and multicast targets
are refused at registration and at every delivery, unless
LOOM_MEMORY_WEBHOOK_PRIVATE_HOSTS names the host. Each delivery connects to the address
it checked and follows no redirect.
Delivery. The write leaves one row per event and webhook in
loom.memory_webhook_outbox and makes no network call; the memory_webhook_deliver
worker sends it. Any 2xx within 10 s (LOOM_MEMORY_WEBHOOK_ACK_TIMEOUT_SECONDS)
acknowledges it; the 10 s cover the name lookup, the connection and the status line, and
the answer's body is never read. Anything else is retried after 5 s, 30 s, 2 min, 10 min
and 30 min (LOOM_MEMORY_WEBHOOK_BACKOFF_STEPS_SECONDS) and then hourly
(LOOM_MEMORY_WEBHOOK_MAX_BACKOFF_SECONDS, which also caps the earlier waits), until
LOOM_MEMORY_WEBHOOK_MAX_ATTEMPTS attempts (60 by default, about 2.3 days of retries)
mark it dead, and any event a receiver has not taken 2.75 days after it happened is
marked dead with "not delivered in time". actor is the principal that
made the change, or worker:<name> for a change a background pass made (the expiry
sweep, the delete_version check).
- Fairness. A pass takes events in turns: every webhook's oldest due event, then
every webhook's second, at most 10 of one webhook
(
LOOM_MEMORY_WEBHOOK_POSTS_PER_WEBHOOK), sent one after another, so a receiver that answers in 1 s takes about 10 events per pass with the defaults. A pass sends at most 40 in all (LOOM_MEMORY_WEBHOOK_MAX_POSTS_PER_PASS), 4 at a time (LOOM_MEMORY_WEBHOOK_DELIVER_CONCURRENCY) and one at a time per webhook, so a slow receiver holds at most one of the 4. While fewer than 40 webhooks have work, every webhook with work gets its first POST of a pass before any webhook gets a second. A webhook whose last 5 POSTs failed (LOOM_MEMORY_WEBHOOK_FAILURES_BEFORE_COOLDOWN) is passed over for a cooldown that starts at a minute (LOOM_MEMORY_WEBHOOK_COOLDOWN_SECONDS) and doubles up to an hour (LOOM_MEMORY_WEBHOOK_MAX_COOLDOWN_SECONDS), checked before each POST; a 2xx ends it. The cooldown is kept per server process, so a restart or a new worker leader starts it afresh. A pass POSTs until every POST could have waited its whole ack deadline, for the larger of an even share of the pass (40 / 4 = 10 POSTs per slot) and one webhook's whole turn (10 POSTs one after another): max(40 / 4, 10) × 10 s = 100 s after the pass starts, and at the latest untilLOOM_MEMORY_WEBHOOK_DELIVER_RESERVE_SECONDS(20 s) before its deadline (LOOM_MEMORY_WEBHOOK_DELIVER_MAX_PASS_SECONDS, 120 s); the reserve covers the one insert after the POSTs. At that point the pass cancels its POSTs still in flight: a receiver may already have handled one, but no outcome is recorded, so its event keeps the lease and is sent again under the samewebhook-idonce the lease ends. The reads before the POSTs come out of the POST budget, which counts from the start of the pass, so when the due-event read grows with the outbox backlog, raiseLOOM_MEMORY_WEBHOOK_DELIVER_MAX_PASS_SECONDS. What a pass did not send goes next pass. With fast receivers the ceiling is about 40 events per pass, a pass every 5 s while there is work: some 8 events a second per deployment with the defaults. A deployment that needs more raises the per-pass and concurrency settings, and the pass deadline with them. - Waiting and dead events. While the variable
secret_refnames is unset or not usable (awhsec_value that is empty or not base64), the webhook's events wait, and the wait counts as no attempt; after 2.5 days they are markeddeadwith "no usable secret". An event of a working webhook that a receiver has not taken after 2.75 days (a receiver that is down, or a backlog that never reached its turn) is markeddeadwith "not delivered in time". A pass checks before each POST that the webhook was not removed since it began, so afterwebhook.removeonly a POST already in flight goes out. The events of a removed webhook are markeddeadby the next delivery pass that runs within about 30 minutes of the removal (LOOM_MEMORY_WEBHOOK_REMOVAL_WINDOW_LEASESwidens it); after that they stay listed aspendinguntil they expire. Registering the same URL again creates a new webhook that does not inherit them. An event whose memory was erased before it went out is markeddeadand not sent: the key's history is read just before each POST, so only an erasure that lands during that POST can let one out. - Repeats. An event is sent until a receiver takes it or it is marked dead (see
above), and a delivered event can arrive more than once, in no order; a change can also
go unannounced (a failed outbox insert, a registration not yet seen by the serving
process, a rolling upgrade), so a receiver that mirrors memory re-reads the namespace
from time to time. Order events by
version, and read the row back rather than trusting the event's age. Two events can carry the sameversion: amemory.cleartombstone does not write a new version, so itsmemory.tombstonedevent carries the version it tombstoned; on a tie,memory.tombstonedcomes aftermemory.writtenormemory.revised. Amemory.redactedevent'sversionnames the historical version it scrubbed, not a new head, so it does not move the key's order; for a profile key scrubbed with the memory it built from, that version can be the head, so read the key back. A repeat shares itswebhook-id: a retry, two overlapping passes, and the same version of a memory announced twice to the same webhook (adelete_versioncheck that runs again) all carry one id, so drop an id you have seen. - Keeping. An outbox insert that fails is logged and that event is not sent; the
write still stands. A webhook registered on one server process applies to writes on the
others within
LOOM_MEMORY_WEBHOOK_REFRESH_SECONDS. Events and their delivery record are read for 3 days and leave the disk within about 4: each day's events are dropped together once the day's last one is 3 days old. An erasure deletes the events in its scope; their delivery records name no key or identity and expire with the rest.
Settings
| Setting | Default | Effect |
|---|---|---|
LOOM_MEMORY_VALUE_ADVISORY_CHARS |
30000 | Size past which a value earns the oversized advisory. 0 disables. |
LOOM_MEMORY_SENSITIVITY_AUTODETECT |
true | Detect credential, card, SSN and PIN shapes (high) and email, phone and IBAN shapes (medium) when sensitivity is unset. |
LOOM_MAX_REQUEST_BYTES |
32 MiB | Request-body cap. |
Change webhook settings (LOOM_MEMORY_WEBHOOK_*, including
LOOM_MEMORY_WEBHOOK_DELIVER_ENABLED, which stops delivery) are in
configuration: MemoryWebhook.
Conflict and duplicate settings are in
conflicts and refusal. Per namespace,
namespace.register sets conflict_threshold, default_conflict_policy and
proposal_policy. See the configuration reference.
Limits
- Writes are eventually consistent and not transactional across tables. A bulk write is not atomic.
- A merge writes nothing at your key. Read
merged_into_key. - A
memory.setthat names nosensitivitydrops the stored label. Re-writing ahighrow without it stores the new version with only the detected level, embedded unless that ishigh, so restatesensitivityon a re-write. Amemory.bulk_setentry that names none and repeats the stored value keeps its label. - Fake providers give string-similarity verdicts. Judge verdicts only with real providers.
- A misspelled argument is refused (422
UNKNOWN_ARGUMENT); an unknown one with no close match is served and named inignored_args. - Error codes are in the errors reference.
Related
- Search and ask
- Conflicts and refusal
- Extraction
- History and provenance
- Investigation agent memory: a worked key and value shape.