Memory
A memory is one claim an agent should find again: "deploys happen on Tuesdays", "the
payments team owns the billing service". Loom stores each claim as a row in
loom.memory, under a readable key, with who wrote it, when it was true, where it came
from and whether anyone vouched for it. This page covers the row, how a write is judged
against what is stored, and how rows change over time.
Retrieval and ranking covers how rows are found.
Rows in ClickHouse
Memories, document chunks, conversation turns, entity edges, code symbols, runs, audit
entries and erasure requests are all rows in ClickHouse, created by numbered migrations
the server applies on boot. Under the
Postgres memory authority,
Postgres holds live memory and knowledge state, and ClickHouse keeps the vectors, search
and history. Uploaded document originals are ClickHouse rows too
(loom.document_blobs), unless the deployment keeps their bytes in an S3-compatible
bucket. Outside ClickHouse, Postgres and that bucket, Loom keeps the code index's git
mirrors, a cache of the remotes ClickHouse records, and the rate-limit buckets on a
shared volume.
- The identity tuple leads the sort keys.
loom.memoryis ordered by(org, namespace, agent, user_id, session_id, memory_key), so scoping a read to a namespace is a primary-key range; a user narrows it fully only under a named agent, a session only under a named agent and user. Identities and namespaces covers the tuple. - One engine. Under the default ClickHouse authority, vector search, lexical search,
the graph walk, filters, audit and erasure are SQL over the same rows, with no second
store to keep in step. The cost is that every read is a query, not a key-value hit.
Under the Postgres authority, point reads (by key or id, outside an
as_ofreplay) are answered from Postgres. - A write is a new version. The memory tables are
ReplacingMergeTree(version)read withFINAL. A revision, tombstone, pin or restore inserts a new version row.
The write, derive, read loop
flowchart LR
W["Writer: memory.set,<br/>set_from_messages,<br/>knowledge.add"] --> G["Write checks:<br/>lock, pin, blacklist,<br/>precondition, conflict"]
G --> T[("ClickHouse rows:<br/>memory, embeddings,<br/>chunks, audit_log")]
T --> D["Workers derive:<br/>entities, deferred extraction,<br/>consolidation, calibration,<br/>usage folds"]
D --> T
R["Reader: memory.search,<br/>memory.ask, context.pack"] --> L["Retrieval legs:<br/>vector, lexical, graph,<br/>chunks, digests"]
T --> L
L --> K["Fuse and rank,<br/>gate or abstain"]
K --> A["Answer with provenance"]
A --> F["run.complete,<br/>memory.outcome"]
F --> T
- Write.
memory.setruns its checks (below), embeds the value inline, and inserts the memory row, its embedding row, verbatim chunks for long values and a hash-only audit row. If the inline embed fails, the call fails and nothing is written.memory.set_from_messageshas the LLM pick the durable facts in a transcript and writes through the same bulk path. - Derive. Workers add what is too slow for the write path: entity tags
(
entity_extractor), deferred extraction (extraction_queue), consolidated facts, gate recalibration, and usage folded into ranking signals (usage_fold). Derived rows carrysource_class=derived. - Read.
memory.searchruns several legs (vector, lexical, entity graph, verbatim chunks, session digests), fuses them, ranks, and returns hits or abstains.memory.askwrites a grounded answer;context.packbuilds a budgeted prompt block. - Feed back. A run that names the memories it relied on (
run.completememories_used, ormemory.outcome) credits them; a bad outcome records harm. This becomes a bounded, decaying ranking term. It never deletes a row or ranks it below its relevance.
The loop is eventually consistent. A read right after a write finds the fact through its inline embedding; entities and extracted facts arrive after the worker's next pass.
The loom.memory row
One row is one version of one memory.
| Group | Columns | Decides |
|---|---|---|
| Identity | org, namespace, agent, user_id, session_id, run_id |
Who may read it and who wrote it |
| Address | memory_key, memory_id |
How a caller names it (key) and how Loom names one row (id) |
| Content | value, memory_type, tags, entities, files, confidence |
What it says and how it is classified |
| Lifecycle | status, pinned, conflict_with, duplicate_of, superseded_by, version |
Whether it is served, and what replaced it |
| Time | created_at, updated_at, valid_at, valid_to, invalid_at, expires_at |
When it was written, when it was true, when it left service |
| Provenance | actor, source_class, content_sha256, verified_by, verified_sha256 |
Who wrote it, how Loom learned it, whether anyone attested it |
| Guards | lock_mode, lock_until, sensitivity, on_expire |
Which writes it refuses and how it is read |
The full column list is in the schema reference. A searchable
memory also has a row in loom.memory_embeddings: vectors for the value and the key,
lowercased, stemmed and lemmatised copies of the excerpt for lexical matching, and the
source_class and verified flags the ranker reads.
Keys
A key names one attribute of one subject, as a readable path <scope>/<kind>/<id>:
sre/case/1234/evidence/dns-sidecar
project/loom/deploy-window
user/preferences/editor
- One fact per key. Conflict checks, supersession and history work per key. A key holding a list of unrelated facts cannot be partly revised.
- A revision rewrites the same key. Loom keeps every version, so a numbered copy
(
…-v2) or a key made from a hash of the value only hides the revision. Hash-made keys are thehash_keysfinding ofnamespace.audit. - Facets go in
tagsandentities, not in the key.
The storage identity is (org, namespace, agent, user_id, session_id, memory_key): the
same key written by two agents or in two sessions is two rows. Under the namespace
policy newest_wins, the newest write for a key retires every older live row with that
key in the namespace, whoever wrote it. Extraction mints keys from the subject and
attribute, never from the value, so a later session that states the same attribute lands
on the same key. Under auto_reinforce and manual_review, the conflict resolver judges
the rows of one key that two sessions of the same user (not the anonymous user - or a
user_id sent empty, whose sessions are separate tasks) wrote, from a point write or a bulk
write, as it judges any close pair: it retires the older row of an update it is sure of
(except under manual_review), flags a contradiction for review and leaves two different
facts under a reused key live. Under append_only nothing is judged.
Memory types
memory_type is a free string, default semantic. A few values change how the row is
stored and found (server/memory/domain/memory_types.py):
| Type | Embedded | In unfiltered search | Use for |
|---|---|---|---|
semantic (default) |
yes | yes | Durable facts. |
preference |
yes | yes | Preferences that should rank in recall. |
procedural |
yes | yes | Procedures (procedure.*). |
reflection |
yes | no | Lessons from runs; read with memory_type="reflection". |
episodic |
no | no | Session events replayed by recency. |
profile |
no | no | Always-loaded context fetched by label (profile.*). |
any other (decision) |
yes | yes | Domain types are searchable by default. |
- Reflections are hidden from unfiltered search and
memory.ask: a lesson about a process is not a claim about the world. - A
proceduralwrite whose value is not a procedure body (a non-emptysituationand at least twosteps) is stored assemantic, taggedretyped_from:procedural, and the response says so inretyped_from. - Sensitivity. A row with
sensitivity="high"is never embedded, so no search leg finds it. It is readable by key only, and that read is audited. Withsensitivityunset the server detects credential shapes, card numbers, US Social Security numbers and stated PINs (high), and email, phone and IBAN shapes (medium) (LOOM_MEMORY_SENSITIVITY_AUTODETECT).
Status
| Status | Meaning | Served by default |
|---|---|---|
active |
The live belief. | yes |
conflicted |
Close to a peer but saying something different; linked by conflict_with. |
yes |
duplicate |
Near-identical to a canonical row (duplicate_of), which answers for it. |
no (include_duplicates) |
superseded |
A later row replaced it (superseded_by, retired at invalid_at). |
no (include_superseded) |
proposed |
A fact on trial, decided with memory.proposal_decide. |
no (include_proposed) |
rejected |
Marked wrong (memory.reject, or a rejected proposal). |
no |
tombstoned |
Deleted by its writer (memory.delete). |
no |
The served set is defined once, in server/memory/domain/temporal_sql.py (SERVED_STATUSES). A
conflicted row stays served: hiding one side of a contradiction would let the store
answer confidently with a fact it has reason to doubt.
How a write is judged
Five operations cover the memory lifecycle:
| Lens | Operation | Effect |
|---|---|---|
| Expand | memory.set |
Learn a fact under a key. |
| Revise | memory.update_by_id |
Correct one row by id (memory.update does the same by key). |
| Contract | memory.delete |
Tombstone a key. memory.restore revives it within 14 days of the delete. |
| Reinforce | memory.pin |
Store and pin: no later write that does not itself pin replaces it. |
| Refuse | blacklist.add |
Refuse every future write matching a substring or regex. |
A searchable memory.set runs these checks in order:
- Lock and pin guards on the row under the key.
memory.lock(read_onlyorno_delete) answers 423LOCKED. A changed value under a pinned key is 409PINNED_CONFLICT. A byte-identical restatement passes every guard. - Blacklist. A value matching a registered pattern is 422
BLACKLISTED; in a bulk write the entry is skipped. Replicas cache patterns and poll a one-row version table (loom.blacklist_version), so a new pattern reaches every replica within seconds. - Precondition.
if_match_sha256makes the write land only if the live row's hash still matches; otherwise 409PRECONDITION_FAILED. The current hash rides everymemory.get,memory.get_by_idandmemory.setresponse (content_sha256). - Restatement. A value byte-identical to the live row writes a new version that
carries the live row's
reinforcement_count + 1. The bulk path (memory.bulk_set, used byset_from_messages) treats any live row with the same hash in(org, namespace, agent, user_id)as the fact (LOOM_MEMORY_BULK_DEDUP_SCOPE=identity). - Peer search and verdict. Active rows of the same
memory_typewhose embedding is close to the new value are peers, compared closest first:- same canonical form (lowercased, punctuation stripped, whitespace collapsed) →
merge: nothing written, the peer is reinforced,merged_into_keynames it; - a close pinned peer →
rejected, 409PINNED_CONFLICT; - under
newest_wins, a peer that states the same subject and predicate with a different object →refines: the peer is retired, unless itsvalid_atis later than the new row's, which answersconflict; - cosine at or above the duplicate threshold with the same content words and no
differing value (a number, time, amount or unit; "27:10" / "26:30") →
duplicate; - cosine at or above the conflict threshold but different →
conflict, both rowsconflicted; - no close peer →
clear.
- same canonical form (lowercased, punctuation stripped, whitespace collapsed) →
- Insert into
loom.memoryandloom.memory_embeddings, supersession, entity projection and one hash-only row inloom.audit_log.
A non-searchable type or a high-sensitivity row skips the peer search: it has no
embedding to compare. dry_run=true runs every check and writes nothing, which answers
"new fact, restatement or contradiction?" before you commit to a key. The response
carries conflict_verdict, up to five peers, and a next hint.
- Thresholds. With
LOOM_ADAPTIVE_THRESHOLDSon and a real embedder, the conflict and duplicate thresholds come from the embedder's own similarity distribution at boot (server/memory/app/embedder_calibration.py). The scalarsLOOM_CONFLICT_THRESHOLD(0.85) andLOOM_DUPLICATE_THRESHOLD(0.97) are the fallback. A namespace or memory type can pin its own;calibration.getreports which is in force. - The conflict resolver. Cosine says two statements are about the same thing, not
whether they agree. A leader-only worker judges each write with a close peer after it
lands, one LLM call per row: it retires the older row of an update it is sure of under
the default
auto_reinforceand undernewest_wins(the old value stays readable withinclude_supersededandas_of), flags a contradiction, and clears a false flag (conflicts). No verdict on the write itself calls an LLM. - Run scope. A write under a
run_idwhose canonical match belongs to another run is stored as aduplicate, not merged, so a run-scoped read still finds it. - Namespace policy.
default_conflict_policydecides what a contradiction does (auto_reinforce,manual_review,newest_wins,append_only).
memory.update, memory.update_by_id and memory.bulk_set run the same check. Verdict
details, the policies, the Review queue and conflicts.resolve are in
conflicts and refusal.
Pins protect what the user said. A pinned row is never replaced by a write that does
not itself pin, by key or by a close value under another key. To change it, use
memory.update (keeps the pin) or memory.pin (re-pins). memory.for_file lists
pinned rows first; no other read orders by the pin.
Consolidation keeps lineage. memory.consolidate (and the consolidation worker, when
a namespace opts in) merges fragments about one subject into canonical rows with
source_class='derived', and writes one loom.memory_belief_sources row per source
(support_type='derived_from'). Deleting a source retires a consolidated row when none
of its sources survive, and downgrades it to conflicted when some do
(dependents_tombstoned, dependents_downgraded in the memory.delete response).
A memory's lifecycle
stateDiagram-v2
[*] --> active: memory.set (clear, refines)
[*] --> conflicted: memory.set (conflict)
[*] --> duplicate: memory.set (duplicate)
[*] --> proposed: memory.set(proposed=true)
active --> conflicted: a later close write
conflicted --> active: conflicts.resolve (revise, allow)
conflicted --> tombstoned: conflicts.resolve (contract, or peer revised)
proposed --> active: memory.proposal_decide (accept)
proposed --> conflicted: memory.proposal_decide (accept, contradicted on trial)
proposed --> rejected: memory.proposal_decide (reject)
active --> rejected: memory.reject
active --> superseded: newer write (newest_wins, refines, conflict resolver)
superseded --> active: memory.restore
active --> tombstoned: memory.delete, blacklist sweep, expiry
tombstoned --> active: memory.restore (within 14 days)
Every arrow is a new version; the old one stays in loom.memory_versions.
Tags, entities and files
tagsare free labels (case:1234,kind:evidence). A search filters on them (tags), boosts with them (tags_soft), or sinks them (demote_tags). A namespace'stag_registrycan attach retention, dedup and conflict policy to a tag. Tags keep the case they were written in:memory.listandknowledge.listmatch them in any case, and every other tag filter,memory.searchandmemory.applyincluded, matches them exactly.entitiesare the named things the value mentions, stored in the canonical form search reads them in (lowercased; articles, possessives and punctuation dropped; the head noun singular), soBilling Servicesandbilling serviceare one entity. A writer may pass them; otherwise theentity_extractorworker fills them. They drive the entity boost and the entity graph.filesare repository paths the memory is about;memory.for_fileserves them before an edit of those files.
memory.for_file renders one line per memory. A decision or a constraint prints whole,
up to the ceiling below, and does not count against the request's token_budget (300
tokens by default); the other rows are held to it, cut to one line of 220 characters. A
row that does not fit is left out whole while the rows after it still get their turn,
and a closing - … <n> more line, which counts against the budget too, says how many
were left out and how to list them. The header and the removal footer are always
printed, and the … more line whenever a row is left out, so a budget smaller than those
can be exceeded.
Decisions and constraints share one ceiling, memory_for_file_whole_tokens (1,500
tokens, about 6,000 characters), so a file governed by dozens of them cannot flood the
prompt. They print in the block's order; one that would pass the ceiling is left out,
and the ones after it that still fit print. A closing line counts those left out and
names the first five keys, each read with memory.get (in the namespace it names, for a
team row) or found with loom_search. A row left out with the newer row that supersedes
it is counted on the … more line instead, never named as one to read. The default
keeps the whole block under the 10,000 characters a coding-agent host passes from a hook
to the model as it is.
Each line:
- A memory from an extra namespace (the repository's code namespace) is headed
Team rule (by <author>, <age>), orDecision (by <author>, <age>)for a decision (memory_typedecisionor the tagdecision). - A line lists its
constraint,gotchaanddecisionkinds in one bracket ([constraint, decision]; a line headedDecisionleaves outdecision). (pinned)marks a pinned row. A pin is the row's own flag: a member of a code namespace can set it on a row they write there, so(pinned)on a team row says someone froze it, not that a maintainer did.- A decision nobody pinned, such as one
loom decisions extractwrote, carries no pin mark: it is served as it is. (outcome: failed)marks any decision whose recorded outcome failed, pinned or not.- A decision tagged
source-changed(its source document no longer says it; see extracting decisions) reads(source changed), pinned or not. - A note says when its linked code changed or went.
- A string value is printed as text, not as the JSON it is stored as, and a row other
than a decision or constraint ends in
…when the read or the line cut it; a cut object or list prints as stored. - A decision with a source carries
— source: <source_resource_id>after its value, whole, so aknowledge:citation keeps its#sectionand resolves throughknowledge.get. - A newer row the conflict check linked to an older one of its type is placed above it,
and a team line says which it supersedes (
supersedes decisionwhen the older is taggeddecision). Two typed decisions the check linked each keep their place in the block, with no such note, since each decision key is its own decision. When the budget or the ceiling leaves out any row that supersedes others, in any namespace, every row it supersedes is left out with it, so an older row never reads as current.
Memories linked to code
A memory about code is linked to the definitions and files it names, and the links
follow the code. The memory_code_links worker links each memory tagged repo:<id>
(the coding plugins tag their sessions) or written in a repository's code namespace
against that repository's trunk:
- each backticked name,
A::banda.b()call that resolves to exactly one definition; the memory'sfilespick between namesakes, and a name defined more than once is not linked; - a bare camelCase or snake_case word of the prose only to a definition in the memory's
files; - each file the memory names that holds none of those definitions.
A qualified name trunk does not define yet waits for memory_code_links_pending_days
(14), so a memory about unmerged code links once it merges.
A link records the definition's fingerprint: all its overloads and the conditions that
select them, as tokens, so whitespace, comments (not a Go //go: directive or a Python
# type: comment) and most of what formatters change (trailing commas and other optional
separators, a JavaScript ;, quote style and the escapes it needs, the case of a number
literal, a Python docstring's indentation, parentheses in Python, JavaScript and
TypeScript) leave it as it was. Joining adjacent string literals, and any other edit, does
not.
What a file defines is read from the file at the repository's indexed head in its bare
git mirror. On every indexed commit the worker follows the commit's renames and edits:
| Link state | When |
|---|---|
fresh |
The definition is where it was, with the text the memory was written against. |
moved |
Its file moved, or it was renamed with nothing else changed, or it moved to another file. |
changed |
Its text changed after the memory was written (changed_since names the commit), renamed or not. |
unresolved |
Its name is gone and a new definition in its place may be it, but none certainly is. Served, with a review signal. |
missing |
The code is gone: nothing new took its place. The link keeps its place, so a revert finds it again. |
A link follows its definition through file moves, renames (with an edit, when the new
name is related to the old), and its class's rename within the file. A namesake in
another class or namespace never inherits a link, and a missing link comes back only
when its own name does. A file the parser fails on keeps its links as they were until it
parses. When the fingerprint rules change, every link is re-hashed from the commits its
hashes were taken at. When its old text is gone from git, a link that matched it takes the
text the next check finds and a changed one stays changed; one whose old text no longer
parses keeps its last state, and the worker reports it.
Loom never writes the memory. The namespace's code_change_policy
(namespace.register) decides what reads make of a change:
| Policy | Removed code | Changed code |
|---|---|---|
retire_removed (default) |
When every link of a memory is missing (a pending or unresolved one counts as not), search (and memory.ask, context.pack) leaves it out before it picks its results; memory.get still shows it. It is back when the code is. |
Flagged. |
flag |
Flagged. | Flagged. |
revise |
As retire_removed. |
The claim is re-checked against the old and new code by the LLM: one that holds is re-anchored, a contradicted one gets a corrected claim as the link's suggestion. |
revise judges a changed link once per edit, within a per-pass budget
(memory_code_links_revise_per_pass). A memory labelled high sensitivity is never sent
to the model. A memory a person pinned, locked (until the lock lapses) or verified, and
one naming more code than the link cap, is only flagged. Links made for an earlier value
of a memory (its text, files, repository or user) are ignored by every read until it is
relinked.
Links are kept against the trunk. On another branch, a lesson compares the branch's own
definition, under any of the link's last 16 names, with the text the memory was written
against: a release branch that still has the old text reads fresh after trunk changed
it, and one that has code trunk removed still gets its lesson. Where the branch's files
cannot be read (a host with LOOM_GIT_HISTORY=none has no git mirror), a lesson carries
trunk's last state.
The links are read where the code is:
code.symbolattaches the linked memories aslessons, each with itscode_state. They come from the repository's team namespace and thelesson_namespacesthe call names (over MCP the caller's memory home when it names none), never from every namespace the caller can read.memory.for_fileserves memories linked to code in the file beside those whosefilesname it.memory.getreturns the links ascode(with anysuggestion);memory.searchhits read from a memory's own row carry its worst link ascode_state(a chunk, record or summary hit carries none and is never left out);memory.asknotes the memories whose code moved, changed or went.namespace.validatelists memories whose code changed, was not found again or is gone, in the repositories the caller may read.
Time
| Column | Axis | Meaning |
|---|---|---|
valid_at |
world | When the fact became true, as the writer stated it. NULL = unknown. |
valid_to |
world | When the fact stopped being true. A read drops a row past its valid_to. |
temporal_anchor |
world | The date the fact is about (an event date), set by extraction. |
created_at |
record | When Loom wrote the row. |
invalid_at |
record | When Loom retired the row (supersession). |
tombstoned_at |
record | When the writer deleted it. |
expires_at |
record | When the row leaves the served set, if its writer or the namespace owner set an expiry. |
An as_of read replays one axis. as_of_basis="valid_time" (the default) asks what was
true at T: rows whose best world date (valid_at, else temporal_anchor, else
created_at) is at or before T. as_of_basis="knowledge_time" asks what Loom had
learned by T: rows written by T and not retired, deleted or invalidated by T.
Under newest_wins, a templated substitution retires the peer only if the peer's
valid_at is not later than the new row's, so a backfill of old facts does not retire
current ones. This is valid time plus a retirement stamp, not a bi-temporal model:
supersession, not exclusion by date, decides which of two rows is current. A full
interval model would need every writer to state when each fact ended, which agents
rarely know.
Versions and history
Every change (value edit, tombstone, restore, lock, status flip) is a new row with a
higher nanosecond version and every other column carried forward. Merges drop old
versions, so loom.memory is the current state, not a history.
The history is loom.memory_versions, an append-only mirror filled by a materialized
view on every insert. memory.history reads every version of a key;
namespace.rollback returns every head in a namespace to its version at a point in time;
memory.redact scrubs a historical value and keeps its audit entry. content_sha256 on
each version is what if_match_sha256 compares and what an attestation binds to.
Provenance
The server stamps two columns on every write and never takes them from the body:
actor:human:<subject>orprocess:<subject>(a service account), from the token.source_class: how Loom came to hold the content, from the operation and the token.
source_class |
Means |
|---|---|
asserted |
A caller stated it directly (memory.set). |
extracted |
The extraction pipeline inferred it from a transcript. |
observed |
Loom recorded it from activity it saw. |
derived |
Computed from other memories (consolidation output, session digests). |
imported |
Loaded from another system by an importer. |
external |
Declared by the writer as outside content: a tool result, a page, a document. |
A request may only declare a downgrade (derived, external or imported); anything
else is a 422. Text an agent copies from its environment is what an attacker controls,
so memory.ask keeps unattested external rows out of its synthesis
(LOOM_MEMORY_ASK_EXCLUDE_UNVERIFIED_EXTERNAL, default on), and
LOOM_MEMORY_SEARCH_TRUST_WEIGHT can lift asserted and attested rows in ranking.
- Lineage. Consolidation writes
loom.memory_belief_sourcesrows; a session digest lists the keys it summarised insession_summaries.derived_from.memory.provenancesays where a row came from,memory.evidencereturns the conversation turns an extracted fact was drawn from, andmemory.lineagewalks how a shared memory was promoted. - Attestation.
memory.verifyrecords who vouched for the content, bound to its hash. Reads reportverification:humanorprocesswhile the content is unchanged,staleonce it changes,noneif never attested. An edit keeps the attestation and reads asstale; it never transfers trust to new content.
What goes where
| It is… | Plane | Why not memory |
|---|---|---|
| A fact still true next week that another session needs | Durable memory, memory.set |
It is memory. |
| A transcript the user wants remembered | memory.set_from_messages (extraction) |
One large row is findable only as a whole; extraction writes one short fact per key. |
| The current plan, a lease, a cursor, "where was I" | Working memory, working_memory.* |
Coordination state changes every step; ranking it pollutes recall. |
| A large tool result you may re-read | context.offload / context.fetch |
It is a blob to fetch by key, not a claim to rank. |
| The resumable state of one line of work | Session briefs, session.brief_* |
A brief is restored by choice, never ranked into another task's answer. |
| A document, spec or runbook; anything over a few kilobytes | Knowledge, knowledge.add / knowledge.upsert |
Documents are chunked by heading; a memory is embedded from an excerpt of at most 1,200 characters. |
| A chat turn log | Conversations, conversation.* |
Turns are evidence, not beliefs; memory.recall reads the turns an extraction used. |
| Data with identity and exact fields (a PR, a ticket) | Records, memory.record.* |
Records fold field by field and filter exactly. |
| Always-loaded persona or preferences | Profile, profile.* |
Fetched by label every time; ranking it against questions wastes the slot. |
For documents: store the document in knowledge and write one short memory under a
readable key that says what it is and points at it. context.pack loads working memory
ahead of the durable sections, so an agent sees its own state first.
Retention
Loom deletes only what its writer asked for. No memory table has a default TTL and no
sweep retires rows by age. A row leaves the served set when its writer deletes or
supersedes it, when its writer gave it an expiry (ttl_seconds, valid_to), or when the
namespace owner or operator configured a retention window. memory.restore revives a
tombstone within 14 days.
Retention and erasure covers the settings
and the erasure saga.
One fact over time
In March a user says "I live in Delhi"; in June, "I moved to Berlin."
- June write. Extraction mints the key from the subject and attribute, so both
statements land on the same key. The June row does not overwrite March: the Delhi
version stays in
loom.memory_versions, readable withmemory.history. - A misfiled fact. If Berlin lands under another key, the peer search finds the
Delhi row close but different and marks both
conflicted, linked byconflict_with. The namespace policy decides what follows (see how a write is judged). - "Where did they live in April?" An
as_ofread answers it, by when the fact was true or by what Loom knew at that date (time). - "Who said so?"
actorandsource_classrecord the writer and how the fact arrived;memory.evidencereturns the turns an extracted fact came from. - A bad import.
namespace.rollbackreturns every head in the namespace to its version at a point in time, and runs as a dry run unless told otherwise.
Example
Write a fact, probe a change before writing it, then update against the hash you read:
import asyncio
import os
from datetime import datetime, timezone
from loom import Datapack
async def main() -> None:
dp = Datapack(
org="acme",
namespace="eng-tooling",
base_url=os.environ["LOOM_BASE_URL"],
api_key=os.environ["LOOM_API_KEY"],
)
agent = dp.agent("release-bot")
key = "project/billing/deploy-window"
out = await agent.memory.set(
memory_key=key,
value="Billing deploys only on Tuesdays and Thursdays, never on Fridays.",
tags=["kind:constraint", "service:billing"],
entities=["billing"],
valid_at=datetime.now(timezone.utc),
)
print(out["conflict_verdict"], out["content_sha256"])
probe = await agent.memory.set(
memory_key="project/billing/deploy-days",
value="Billing deploys only on Thursdays.",
dry_run=True,
)
if probe["conflict_verdict"] == "conflict":
peer = probe["peers"][0] # closest peer, with its key
current = await agent.memory.get(memory_key=peer["memory_key"])
await agent.memory.update(
memory_key=peer["memory_key"],
value="Billing deploys only on Thursdays.",
if_match_sha256=current["content_sha256"],
)
asyncio.run(main())
A 409 PRECONDITION_FAILED on the update means another writer changed the row after the
read: read again and decide again. Over HTTP the write is POST /v1/memory.set; over MCP
it is the loom_memory_set tool.