Integrate Loom into an agent
This guide covers what an agent sends to Loom and how it reads the answers: the client surface, the identity, what to write, and how to read. It assumes memory and identities and namespaces. A coding agent with a shipped plugin (Claude Code, Codex) gets most of this from the plugin.
The same rules, written for the agent rather than for you, are the loom Agent Skill
(skills/loom/). An agent with no plugin reads it from the server: the experimental
mentor.guide returns
the loop, where each kind of state goes and how to read answers (part: "skill"), or every
plane with its ops (part: "reference"). Put it in the agent's system prompt:
system_prompt = "You triage support tickets.\n\n" + await agent.guide()
Over MCP the primer names the call; an Agent-Skills client installs it with
npx skills add ClickHouse/loom --skill loom.
1. Choose the client surface
| Surface | Use it when | What you get |
|---|---|---|
MCP (/mcp) |
The agent is an LLM client that already speaks MCP (Claude Code, Codex, Cursor, a desktop app). | A curated tool listing per ?toolset=, tool descriptions written for the model, identity defaults from the connection. The model decides when to call. |
Python SDK (clickhouse-loom, from loom import Agent) |
The agent is your own Python process. | Typed clients per plane, retries and idempotency keys, agent.tools() for a model's tool loop, run scopes, framework adapters (LangGraph LoomStore, Strands LoomMemoryStore). |
TypeScript SDK (@clickhouse/loom) |
The agent is a Node or browser-side service. | Datapack / Agent, agent.call(op, body) typed from the generated op table, AgentTools, Session. |
HTTP (POST /v1/<op>) |
Any other language, or a one-off script. | Every op, the same JSON bodies; you handle retries and the Idempotency-Key header yourself. |
All four serve the same ops from one registry. Choose by who decides to call: MCP when the
model decides, an SDK when your code decides. Many agents use both: the SDK to load context at
the start and record the run at the end, MCP tools for what the model chooses to remember or
look up. Over MCP, pick the listing with ?toolset= (MCP toolsets);
an op the listing leaves out is still callable through loom_op.
2. Choose the identity
Every row carries the identity tuple (org, namespace, agent, user_id, session_id), plus
run_id. The token fixes org and the namespaces; the rest travels per call.
| Field | Set it to | Why it matters |
|---|---|---|
namespace |
One per agent (agent-<name>), or one shared by a team of agents |
Membership is the read boundary: a reader of the namespace reads every row in it, whichever agent wrote it. |
agent |
A stable name for the writing agent | Authorship, not isolation. Over MCP it defaults to the client's name (clientInfo.name, slugified). |
user_id |
The end user the agent is serving, when it serves many | With a token minted --read-scope own_user, one user's prompt cannot pull another user's memories. |
session_id |
The conversation or job | Scopes working memory and own_session reads; groups a transcript. |
run_id |
One execution of a task | Ties a run's writes, searches, checkpoints and transcript together (runs). |
- One agent, many users, one token: mint the token with
--read-scope own_user(or pin--read-user-id) and passuser_idon every call. A scoped read without thatuser_idanswers 400IDENTITY_INCOMPLETE; it is never widened. - Several agents sharing knowledge: one shared namespace they all read, each writing under
its own
agent; or a token that reads a shared namespace and writes its own (ns_write). See namespace access. - No secret or personal identifier in
agent: it is stamped on every ledger row.
3. Decide what to write
Pick the plane first. Durable memory is embedded, ranked, conflict-checked and versioned. Working memory is read by key and never ranked. Knowledge holds chunked documents.
| The agent has… | Write with | Not with |
|---|---|---|
| A fact still true next week that another session needs (a decision, a constraint, an owner, a preference) | memory.set: one fact per call, one key per fact |
working memory |
| Its current plan, open assumptions, a checklist for this task | working_memory.set / append, loaded by context.pack |
memory.set |
| A transcript, email or thread worth remembering | memory.set_from_messages: the server extracts short-keyed facts |
one giant memory.set |
| A document (spec, runbook, report, anything over about 2 KB) | knowledge.upsert as Markdown, plus one pointer paragraph in memory.set under the same key |
one memory.set holding the text |
| A table of facts with identity (PRs by number, services by id) | memory_schema.register once, then memory.record.write |
one memory row per batch |
| Coordination state (a lease, a freeze flag, "where are we") | working_memory.set by block_key |
memory.set + memory.get in a loop |
| A checkpoint to resume from | a working-memory block, or context.offload for a large blob |
a durable memory per step |
| A conversation to show and search later | conversation.create / conversation.append |
memory |
| Reasoning, greetings, intermediate tool output | nothing | anything |
Investigation agent memory applies this table to an agent that works cases; trace and resume covers checkpoints.
Shape each fact so a later session can trust it:
- Claim first. The value states the fact in plain text; the source line follows. Only the start of a value is embedded (the excerpt is clipped at 1,200 characters).
- Readable keys, shaped
<scope>/<kind>/<id>:sre/case/1234/evidence/dns-sidecar, not a hash. Facets go intags(case:1234,kind:evidence) andentities. - A revision rewrites the same key. Loom keeps the versions (
memory.history) and serves the current one; never mintdeploy_window_v2, never keep an index row naming the current key. - Say where it came from:
source_resource_idfor the file, URL or document the fact was read from. - Never launder an inference into a fact. Write "Inferred from A and B: C" with
confidencebelow 1.0. - Resolve dates ("on
", not "yesterday") and set valid_atto when the observation was made. - No secrets. For a class of thing the user never wants remembered,
blacklist.addthe pattern: matching writes are refused with 422BLACKLISTED.
A statement from the user ("we deploy Tuesdays") is the trigger to write. Do not search first; the server handles duplicates and conflicts.
4. Read the write's answer
A memory.set reply is a receipt. Read it:
| Field | Means |
|---|---|
memory_id, version, content_sha256 |
The write persisted. Without them it did not; never tell the user something was saved when it wasn't. |
conflict_verdict: conflict, peers |
The write collides with an existing fact (both rows are marked conflicted). Show both values and ask which is current; don't overwrite silently. |
409 PINNED_CONFLICT |
A person pinned the existing fact. Report it; do not retry under a new key. |
duplicate_of, merged_into_key, noop |
The fact was already known; nothing new was stored. |
advisories |
A shape warning: base64, double-encoded JSON, a value over LOOM_MEMORY_VALUE_ADVISORY_CHARS. |
Pass if_match_sha256 (from a memory.get) when two writers may race on one key; a stale
hash is refused instead of overwriting.
5. Read with one call per question
-
Load context once per task.
context.packreturns a token-budgeted bundle: profile sections, working memory, the memories relevant toquery, procedures, and optionally knowledge, conversations and recent runs (include_knowledge,include_conversations,include_runs). The plugins call it at session start.pack = await agent.context.pack("fix the flaky ingest test", token_budget=2000) system_prompt = base_prompt + "\n\n" + pack.rendered -
Search once per question. Over MCP,
loom_searchcovers memory, profile, knowledge and conversations in one call. In the SDK, useagent.memory.search(query), oragent.memory.ask(question)for a cited answer. Page withnext_pageonly when the answer needs more rows (a list, a count). - Treat an abstain as an answer.
abstain: truemeans nothing cleared the similarity gates; reply "not in memory", not with the nearest weak hit.abstain_codenames the gate (abstain codes). Whilepending_extractionis above 0, deferred extraction has not run, so an empty answer is not evidence of absence. - Respect provenance. A hit's
source_classsays how the content entered memory andverifiedwhether a live attestation covers it.memory.getreturnsverification(human,process,stalewhen the content changed after attestation, ornone).memory.askleaves declared-external, unattested rows out of its synthesis by default.
hits = await agent.memory.search("refund window for EU customers", top_k=5)
if hits.abstain:
reply = "I don't have that in memory."
else:
reply = hits[0].content_excerpt
6. Record runs
A run is one execution of a task: its goal, what it recalled and relied on, what it wrote and its outcome. Recorded runs let ranking learn which memories help (runs).
async with agent.run_scope(goal="Fix the flaky CI job") as run:
answer = await agent.memory.ask("what broke CI last week?")
run.used("ci/flaky/runner-image")
run.final_response = "Pinned the runner image."
The scope calls run.begin on entry and run.complete on exit, with status completed or
failed and, as memories_used, the keys its memory.ask answers cited plus those passed to
used(). Writes inside the block carry its run_id. Without the SDK, call run.begin and
run.complete(run_id, status=..., memories_used=[...]) yourself. memories_used is what the
agent relied on, not everything it was shown; recalled keys are tracked separately.
An agent outside the plugins should also hand the finished transcript to
memory.set_from_messages (or close an agent.session(...)), so the server extracts what was
durable.
7. Follow the memory skill's review
The plugins ship a loom-memory skill
(integrations/claude-code-plugin/skills/loom-memory/, generated into the Codex plugin)
that encodes this guide for a model. A coding agent on plain MCP uses the agent
instructions block, whose last memory
line is a short form of this review; to run the full review, paste the questions below
under the block. Any other agent copies the questions into its instructions. Before
reporting done, write only what earns a yes:
- Did I make or learn a decision, constraint or owner a future session needs?
- Did an existing memory turn out wrong or outdated? Update it, same key.
- Did the user correct me? Update the fact; if they rejected it,
memory.deleteit andblacklist.addthe pattern if it would recur. - Did I produce an artifact (a file, a PR, a dashboard) future work should know about?
- Did I discover something non-obvious that cost real effort?
- Is an inference I wrote now confirmed? Raise
confidenceand drop the "Inferred" prefix.
Then run namespace.validate and act on findings that name the keys you wrote. Say "saved"
only when the receipt carried memory_id and version, "verified" only when a person or
process attested it, and "not verified here" when the check could not run. Other shipped
skills cover code questions (loom-code, also at skills/loom-code/ for any client),
reworking a namespace (loom-optimize), session restore (loom-session) and client health
(loom-status).
Mistakes to avoid
namespace.audit detects each of these; optimize a namespace turns
its findings into a fix.
| Finding | The mistake | Instead |
|---|---|---|
double_encoded_values |
json.dumps(json.dumps(doc)): the embedded text starts with an escaped quote. |
Send text, or the object itself, as value. |
json_prefix_excerpts |
A JSON object longer than the embed clip; the vector is built from its prefix. | Claim first as plain text; documents to knowledge.upsert. |
hash_keys |
A sha as the row's identity: unreadable, ungroupable, and a revision becomes a new key. | <scope>/<kind>/<id>; revise under the same key. |
exact_key_store |
Reads by exact key dwarf searches: the namespace is an object store. | Recall with memory.search; leases and pointers in working memory. |
coordination_as_memory |
Leases, locks, freeze flags stored as memories and read in a loop. | working_memory.set / get: never embedded, ranked or conflict-checked. |
hand_rolled_index_rows |
A row listing other keys because the agent cannot find its own rows. | tags + memory.list prefix=; chain revisions with memory.link supersedes. |
oversized_values, unembedded_rows |
Documents as memories; past the embedder's cap a value is never embedded. | knowledge.upsert plus a one-paragraph pointer row. |
episodic_blobs, version_churn |
Run dumps as episodic rows, rewritten every step. | context.offload for the blob; one short episodic row per run; revise the claim, not the blob. |
Two more that no audit catches:
- One blob per turn. Five facts are five
memory.setcalls, each under its own key. - Retiring keys a live agent still reads. Its reads answer
found: false, a client may fall back to local state, and two stores disagree. Move withmemory.move(it leaves a redirect) and delete nothing until every reader has moved.