Conversations and sessions
Loom keeps three kinds of session state, each in its own plane:
| Plane | Holds | Embedded, searched | Written by |
|---|---|---|---|
| Conversation plane | The verbatim turns of a thread (loom.conversations) |
User and assistant turns and compaction summaries, by conversation.search |
conversation.append, the plugins' capture hooks, loom ingest-transcript |
| Session briefs | The condensed, resumable state of one line of work (loom.session_briefs) |
No | session.brief_save, the coding plugins at compaction and session end |
| Memory | Durable facts extracted from what was said | Yes, by memory.search |
memory.set, memory.set_from_messages |
Storing a turn does not create a memory; to turn a conversation into facts, post it to
memory.set_from_messages. An owner can switch transcript recording off without losing
extracted memory, and a brief never competes with facts in recall.
Conversations
Recording is the owner's choice
conversation.append stores a turn only when the namespace has conversation logging
on. The switch (conversation_logging_enabled on loom.namespaces) is off by default
and fails closed: a namespace with no row is not
recorded. The owner turns it on with conversation.set_logging(enabled=true) (admin) or
the Record conversations switch on the dashboard's Conversations page.
When logging is off the append is accepted and nothing is kept: the response says
stored: false, reason: "logging_disabled". A client cannot override the owner's choice.
If the turn is removed while its embedding runs, append skips the late vector and
answers stored: false, reason: "deleted".
Writing turns
conversation.append takes thread_id, role (user, assistant, system or
tool), content, and optionally msg_id, reasoning (the model's reasoning kept in
its own column), user_id, run_id, metadata and tokens. The thread is created on
the first append; conversation.create only attaches a title, metadata or run_id
up front.
- Idempotent on
msg_id. A turn whose(thread_id, msg_id)already landed is not written again (reason: "duplicate"). Without amsg_idthe server derives one from a hash of the thread, role and content, so a client that re-logs a turn does not double the transcript. - User and assistant turns are embedded, up to their first 2,048 characters,
which the embedding row keeps: a re-embed starts from the text the vector was built
from. Rows written by earlier builds keep 240 characters, and a re-embed works from
those.
systemandtoolturns stay in the transcript unindexed, except a compaction's summary. - The embed is fail-open. A failed embed still stores the turn, with the error recorded on its embedding row. Nothing retries it: the turn stays out of vector search and is found only by the substring fallback.
- A turn that reads as high is not embedded. A turn whose text holds a high shape (a
pasted API key, a
password=assignment, a card number, an SSN or a stated PIN) is stored as written but gets no embedding row, and its text is never sent to the embedder.conversation.searchnever returns it, by vector or by the substring fallback, andconversation.threadslists such a last turn with an empty excerpt (and a title that reads as high as empty); it checks the 240 characters its excerpt shows and 1,024 past them. The titler never sends such a turn to its model. A thread whose first eight user and assistant turns all read as high, or whose generated title reads as high, stays untitled, markedtitle_refusedin its metadata so the titler does not ask again; one with fewer such turns waits, unmarked, for a plain turn. Newmetadatafromconversation.updateorconversation.createreplaces the mark, and the titler then asks once more and marks the thread again.conversation.getandconversation.liststill serve the turn in its thread (sensitivity).
Saving single messages
A chat client can let a user save one message for later search without recording the
namespace's transcripts. conversation.save_message (writer) is that opt-in, and
conversation.search_saved_messages (viewer) is its search. A saved message is a
conversation row, never a document in the user's personal collection
(sharing).
Clients save messages only when every replica preserves saved state during compaction. See the rolling-upgrade rules.
They differ from conversation.append and conversation.search in six ways:
- The Record conversations switch does not apply. The save is stored even when the
switch (or
conversation.set_logging) has turned recording off, and saving does not turn recording on. - The message is flagged, not re-labelled. The transcript row and its embedding row
carry
is_savedandsaved_at, the time the user saved the message. A message the transcript already holds is flagged in place: itsrun_idand content stay as recorded. - The search is per user.
conversation.search_saved_messagesreturns only flagged messages owned byuser_id, in the one namespace named.conversation.searchranks every recorded turn and fans out over the caller's namespaces by default. - The search is hybrid. The vector leg embeds a message's first 2,048 characters;
a BM25 leg reads its whole text, so an exact term (a ticket id, a table name) or a
phrase deep in a long saved answer still ranks.
search_mode="hybrid"(default) fuses the two by reciprocal rank,semanticandlexicalrun one leg, andlexical_weight(0–1) scales the BM25 leg. A BM25 hit's excerpt is centred on the matched terms. The corpus is one user's saved messages, so its statistics are computed per query. - A token pinned to one user works here. An integration that serves many users through
one credential mints it with
--read-scope own_user(and--read-user-idfor one user). The saved-message ops then act as that user only: a body naming anotheruser_idis refused with 403, and every statement carries the token's user. An end-user token calls these ops withnamespace: "@me"(sharing). The conversation tables have nosession_id, so anown_sessiontoken is refused. Every other conversation op (append,get,list,search,threads, ...) stays refused under such a token, because a thread mixes users and has no user axis to bound. - Ownership, deletion and role are checked on every save. Saving a message stored
under another
user_idanswersindexed: false, reason: "owner_mismatch". A tombstoned message answersreason: "deleted", and a stored system or tool turn, which is never embedded, answersreason: "unsupported_role". A message whose text reads as high is never embedded either: it answersreason: "high_sensitivity", and a new one is not stored. One an earlier build saved is never a vector or keyword hit ofconversation.search_saved_messages, andconversation.list_saved_messageslists it with an empty excerpt (it checks theconversation_search_excerpt_charscharacters it shows and 1,024 past them, asconversation.threadsdoes). The checks also apply to a message that is already saved.
Save and unsave share a renewable thread lease with append embedding writes, compaction retirement and thread deletion. Append and compaction finish provider work before taking the lease.
The transcript row decides who owns a saved message. A save claims only rows that no
user owns or that the caller owns, in the mutation itself, so of two users saving one
unowned message the first claims it and the second is answered owner_mismatch. The
embedding row is flagged only after the transcript row reads back as the caller's.
A save can be retried safely. Two concurrent saves of a new message store one transcript
row: its insert carries a deduplication token derived from the message identity. Each
call re-reads the message and repairs what an interrupted or concurrent write left
behind: it writes a missing embedding row, embeds again when the last embed failed, and
flags any row still unflagged. A save whose embed fails stores the message saved and
answers indexed: false, reason: "embed_failed"; saving it again retries the embed. A
transcript append whose embedding lands after a concurrent save flags its own embedding
row.
conversation.list_saved_messages (viewer) pages through one user's saved messages with
no query, most recently saved first (limit up to 200, an opaque cursor, and an
optional conversation_id). Its ts is when the user saved the message, independent
of the transcript's message time. Rows written without a save timestamp use their
message timestamp. It is the first view of a "Saved" panel; the search
narrows it once the user types. conversation.saved_status (viewer) takes one
conversation_id and up to 500 message_ids and answers which of them this user has
saved, so a client draws every message's saved state when it opens a conversation in
one call.
conversation.unsave_message (writer) withdraws a saved message from its owner's
search. Where the namespace records transcripts, the turn stays in the transcript with
the flag cleared, because the owner chose to record it. Where it does not, the turn is
removed with a lightweight delete, because it existed only because of the save
(removed: true). The embedding keeps a tombstone so older versions cannot reappear.
A message
the caller's user never saved, or one another user saved, answers found: false; it
never says which. Unsaving is idempotent, and saving again restores the message. Erasure
by user removes saved messages with the rest of that user's conversation rows.
Reading and searching
| Operation | Role | Does |
|---|---|---|
conversation.threads |
viewer | Recent threads of a namespace, newest first (window_days, limit, offset up to 100,000; agent="*" for every agent). |
conversation.get |
viewer | One thread's metadata; include_messages=true pages its turns (order="desc" for the tail). |
conversation.list |
viewer | A thread's turns, cursor-paginated, optionally by run_id. |
conversation.search |
viewer | Vector search over embedded turns, optionally by thread, run or role. |
conversation.update |
writer | Change a thread's title, metadata or run_id. |
conversation.search ranks turns by cosine similarity, drops any below
LOOM_CONVERSATION_SEARCH_MIN_SIMILARITY (0.30) and fans out over the caller's namespaces by default
(namespace_scope="all_mine"). When the embedder is unavailable it falls back to a
substring match so the op still answers. Hits show the first
LOOM_CONVERSATION_SEARCH_EXCERPT_CHARS characters of a turn (240 by default); read the turn
with conversation.get or conversation.list. The op is hidden from MCP;
agents get the conversation section of loom_search.
memory.recall is a different read: it searches the turns an extraction read (kept in
loom.memory_evidence), which exist whether or not the conversation plane records. See
search and ask.
Compacting and deleting
conversation.compact(writer, needs an LLM provider) summarises the oldest turns before the lastkeep_recent(default 10) that fit the summarizer's prompt, writes the summary as one embeddedsystemturn whosemetadata.compacted_fromlists the summarisedmsg_ids, and tombstones the originals and their embeddings:conversation.searchthen finds the summary in their place. A turn another agent of the thread still holds live with the same text, as a turn search indexes, keeps its embedding (see delete below). A window of fewer thanmin_to_compact(default 4) new turns is left alone. An earlier summary counts toward neithermin_to_compactnormessages_summarized. Summaries at the start of the window fold into the prompt from a separate allowance; one that doesn't fit what is left of it stays live, and one a failed run left (its turns all still live and in the window, at the first one'sts) is replaced unread. A summary further into the window takes prompt budget like any turn and is replaced with it. A turn that reads as high is left out of the window: it stays live and is never sent to the summarizer, so the summary, placed at the window's first turn, can come before it in the thread. A turn saved withconversation.save_messageis never summarised or tombstoned either; it stays verbatim between the summary and the recent turns. A summary that cannot be embedded compacts nothing (reason: "embed-failed"), and neither does one that reads as high anyway (reason: "summary-high-sensitivity"), since it is never embedded. Unlikeembed-failed, that refusal is not worth a retry: the window stays the same while its turns are live, so a retry pays for the same summarizer call and most likely gets the same summary. A window that holds only saved turns or turns that read as high answersreason: "nothing-to-compact".conversation.delete(admin) tombstones one thread's turns, thread row and embeddings;confirm_thread_idmust equalthread_id. A turn another agent also logged in the thread keeps its embedding while that agent holds a live copy with the same text that search indexes (a user or assistant turn, or a summary), since the two copies share one embedding row; the row takes that copy's agent, role, run and user, so the deleting agent's own search drops it (compaction does the same). A row of exactly 240 characters (an earlier build's cut, or a turn of that length) is kept only for a copy whose first 2,048 characters match its agent's turn, and is tombstoned when no message row is left to check that against. A delete also finishes rows of the thread whose own agent no longer holds a live message for the turn: one it deleted or compacted (earlier builds left some), or one the retention TTL expired. For a person's or a namespace's data everywhere, useerasure.request.
Tombstoned turns are filtered from every read but erasure.export, which returns them
unless asked with include_tombstoned: false. They are removed from disk only if the
operator set a table TTL (LOOM_RETENTION_CONVERSATIONS_DAYS, 0 = keep forever). That
TTL covers loom.conversations only: a turn's embedding row, which keeps up to 2,048
characters of its text, has no TTL. Once its message has expired, a whole-thread
delete in its thread passes it to a live copy of the turn or tombstones it; an erasure
request tombstones it, and removes it only on a namespace_list erasure with purge.
erasure.export does not return embedding rows, so the excerpt of a turn the TTL
expired is erased with the subject but is not in their export.
Thread titles
The conversation_titler worker gives untitled threads (built by appends with no
conversation.create) a short LLM-written title, which the dashboard's Conversations
page leads with. It covers namespaces that record conversations, threads active in the
last LOOM_CONVERSATION_TITLER_WINDOW_HOURS (168) with at least
LOOM_CONVERSATION_TITLER_MIN_TURNS (4) turns, at most
LOOM_CONVERSATION_TITLER_MAX_THREADS_PER_PASS (10) per pass. It is on by default
(LOOM_CONVERSATION_TITLER_ENABLED) and does nothing with the fake LLM.
Capturing sessions from clients
| Client | How turns reach Loom |
|---|---|
| Claude Code plugin | The Stop hook (hooks/loom-conversation.mjs) appends each new turn from the session transcript, msg_id = the transcript uuid; LOOM_CAPTURE_CONVERSATIONS=false turns it off. It caches the namespace's recording verdict and skips appends while recording is off. |
| Codex plugin | The same capture, see Codex. |
| Cursor | integrations/cursor/cursor-session-hook.sh, run by Cursor's after-conversation hook, hands the transcript to loom ingest-transcript. |
| Other agents | integrations/generic/ingest.sh on the exported transcript. |
| Any MCP client | The default core listing does not carry conversation.append, and nothing there prompts the model to log turns: capture with a transcript hook, or mount ?toolset=all, which lists loom_conversation_append. |
| Your own code | conversation.append, or the Python SDK's Session, below. |
loom ingest-transcript --namespace NS --file session.md reads a Markdown file with
alternating # user / # assistant sections, or a JSON array of {role, content},
and appends each turn (--dry-run counts them; --thread-id sets a stable id).
The plugins also hand the session's turns to memory.set_from_messages(defer=true) at
session end, so facts are extracted whether or not the transcript is recorded, and
record tool actions as episodic observations through observe.record /
observe.crystallize (see extraction). The hook-by-hook
wiring is in Claude Code.
Session briefs
A session brief is the resumable state of one line of work: goal, status, decisions, dead ends, what is done, open
questions, next steps, key files, constraints and pointers. It is read after a compaction, the next day, in another
client or by a teammate. It is never embedded or returned by memory.search. Any agent that can read the namespace
can resume it.
| Operation | Role | Does |
|---|---|---|
session.brief_save |
writer | Revise (or start) a brief from events (what happened since the last save), from sections the agent wrote, or both. |
session.brief_get |
viewer | Load a brief by brief_key, else the newest one a session wrote (from_session), else the newest for a project. |
session.brief_list |
viewer | The namespace's briefs, newest first, one line each (mine_only for the caller's). |
session.brief_delete |
writer | Delete a brief when its writer asks for it to be forgotten. |
Addressing. An empty brief_key becomes auto/<project>/<branch>, so every session
on the same line of work revises the same brief, or session/<session_id> when neither
is known. Keys match ^[A-Za-z0-9][A-Za-z0-9_.:/@+-]{0,159}$. A token bounded to one
user (or session) stores its briefs as ~<user>~<key> (or ~<user>~<session>~<key>) and
sees them as <key>; a namespace-wide token lists them with that prefix and can get or
delete them by it. A bounded token can't send a ~ key.
Revising. A save with events (kinds user, assistant, edit, command,
error, read, search, tool, note, summary; up to 4,000 per call) is condensed
server-side. With LOOM_SESSION_BRIEF_LLM on (default) the model named by
LOOM_SESSION_BRIEF_LLM_MODEL revises the brief once per window of
LOOM_SESSION_BRIEF_WINDOW_CHARS characters, at most LOOM_SESSION_BRIEF_MAX_WINDOWS
windows per save; without that model's key the deployment's LLM writes it, and with no
LLM the deterministic builder writes goal, status, files, errors and pointers (no
decisions or dead ends). author in the response says llm, deterministic or
model (the calling agent wrote the sections). Under an operator's
extraction policy the builder never
runs and no client summary is taken from the events. A window whose model call failed
is skipped while the rest are revised. A save of events alone with no model to call (none
configured, use_llm: false, or the org's model calls switched off before its first
window was revised) writes nothing and answers changed: false, reason: "not-revised";
an org switched off after some windows were revised keeps those windows and revises no
more. A save of events alone whose every model call failed, was held by an unread switch
or did not parse is refused with 503 for the client to send again. A save that also
carries the agent's sections writes them over the brief as it was in both cases, and
takes nothing from the events.
State sections are replaced on each revision; decisions, dead ends and done items
accumulate. Every revision is kept in loom.session_brief_revisions; revision=N on
brief_get reads one. A save that repeats the previous save's session and
event_cursor is a retry and writes nothing; if_revision makes a save
compare-and-set (409 on a mismatch).
Restoring is the person's choice. The coding plugins save briefs at compaction and
session end with zero model tokens on the client, and the session-start primer only
names the saved briefs. Loading one happens when the person asks, through the
loom-session skill or the briefs section of the dashboard's Conversations page.
brief_get(full=true) appends the client's own last compaction summary verbatim below
the brief. Briefs never load on their own: only the person knows which line of work they are resuming.
Example
The Python SDK's Session binds these ops to one (user, session) scope: append
writes turns to the transcript and, every flush_every turns, to
memory.set_from_messages; resume is context.pack for the scope plus the transcript
tail.
from loom import Agent
async with Agent("dev", "local", "support-bot", base_url="http://localhost:7777") as bot:
s = bot.session("thread-42", user_id="u1")
resumed = await s.resume(query="resume the billing migration")
await s.append(
{"role": "user", "content": "We moved invoices to the new ledger."},
{"role": "assistant", "content": "Noted; I'll update the export job."},
)
print(s.transcript_stored) # False when the namespace does not record
await s.close() # flushes the buffered turns to extraction
await bot.briefs.save(
project="billing",
branch="ledger-move",
sections={
"goal": "Move invoices to the new ledger",
"next_steps": ["Update the export job"],
},
)
Extraction on close() needs a real LLM provider to find facts; with the stock fake
provider the turns are still recorded.
Settings
| Setting | Effect |
|---|---|
LOOM_CONVERSATION_SEARCH_MIN_SIMILARITY |
The cosine floor of conversation.search (Knowledge → Conversations). |
LOOM_CONVERSATION_SEARCH_EXCERPT_CHARS |
How much of a turn a search hit or a saved-message listing shows, live (Knowledge → Conversations). |
LOOM_CONVERSATION_TITLER_* |
The titler worker's switch, cadence, window and per-pass cap (Knowledge → Conversations). |
LOOM_RETENTION_CONVERSATIONS_DAYS, LOOM_TIERING_CONVERSATIONS_WARM_DAYS |
Opt-in TTL and warm-volume move for transcripts (Retention, storage tiering). |
LOOM_SESSION_BRIEF_LLM, …_LLM_MODEL, …_WINDOW_CHARS, …_MAX_WINDOWS |
How briefs are condensed (Context). |
LOOM_MCP_SESSION_RUN_IDLE_SECONDS |
Closes an MCP session's implicit run after this long without a call (Mcp). |
Limits
- "I appended but nothing is there." Recording is off for the namespace; the append
answered
stored: false. Turn it on withconversation.set_logging. - Transcripts are not mined. Recorded turns never become memories on their own.
conversation.searchis vector-first. Exact wording that the embedding does not capture may rank low;memory.recalldoes lexical and time-window reads over the turns extraction read.- Briefs are bounded. A rendered brief stays under about 12,000 characters; each list section has a cap (for example 20 decisions, 12 dead ends, 8 next steps), so old items fall off.
- A brief is not a lock. Two sessions on the same branch revise the same brief;
pass
if_revisionwhen that must not interleave.
Related
- Extraction — turning a transcript into memories.
- Working memory and profiles — the "where was I" scratchpad block the plugins also write.
- Runs — the run ledger a session's writes and observations join.
- Unified search — the conversation section of
loom_search. - Trace and resume — resuming a long run.
- Memory architecture — the conversation and brief tables from the inside.