Agent frameworks and orchestrators
The SDKs ship adapters that present Loom through a framework's own memory or retrieval
interface. The LangChain and LlamaIndex adapters need their framework: install the SDK's
extra for it (pip install 'clickhouse-loom[langchain]', [llamaindex]), or, in
TypeScript, @langchain/core, an optional peer dependency. The LangGraph and Strands
adapters import without their framework. Identity comes from the Loom Agent the adapter
wraps, and isolation is Loom's namespace authorization. Every adapter calls your Loom
server through the SDK: the data is stored in your Loom deployment (ClickHouse, and
Postgres for live memory under the Postgres memory
authority), and no adapter adds or needs another
database, vector store or cache.
| Framework | Adapter | SDK | Framework interface |
|---|---|---|---|
| LangChain | LoomKnowledgeRetriever, LoomMemoryRetriever |
Python, loom.integrations.langchain |
BaseRetriever |
| LangChain.js | LoomKnowledgeRetriever, LoomMemoryRetriever |
TypeScript, @clickhouse/loom/integrations/langchain |
BaseRetriever from @langchain/core |
| LlamaIndex | LoomRetriever, LoomMemoryBlock |
Python, loom.integrations.llamaindex |
BaseRetriever, BaseMemoryBlock |
| LangGraph | LoomStore |
Python, loom.integrations.langgraph |
BaseStore |
| Strands Agents | LoomMemoryStore |
Python, loom.integrations.strands |
the MemoryStore protocol |
| Strands Agents | LoomMemoryStore |
TypeScript, @clickhouse/loom/integrations/strands |
the same protocol, structurally |
Any other framework that can call an HTTP endpoint or an MCP server uses Loom directly: see agent integration and MCP clients.
LangChain
LoomKnowledgeRetriever searches the namespace's documents (knowledge.search) and
LoomMemoryRetriever its memories (memory.search). Both are LangChain BaseRetrievers,
so they work with invoke, in a chain, and as an agent tool through
create_retriever_tool. Install with pip install 'clickhouse-loom[langchain]'
(langchain-core 1.x).
from loom import Agent, SyncAgent
from loom.integrations.langchain import LoomKnowledgeRetriever, LoomMemoryRetriever
with SyncAgent(org="acme", namespace="support") as agent:
docs = LoomKnowledgeRetriever(agent=agent, k=3).invoke("refund policy for annual plans")
async with Agent(org="acme", namespace="support") as agent:
memories = await LoomMemoryRetriever(agent=agent, user_id="alice").ainvoke("seat preference")
invoke needs a SyncAgent; ainvoke takes an Agent or a SyncAgent.
| Retriever | page_content |
id |
metadata |
|---|---|---|---|
LoomKnowledgeRetriever |
the chunk | <namespace>/<source_uri>#<chunk_idx> |
source_uri, chunk_idx, similarity, section, namespace, tags |
LoomMemoryRetriever |
the memory text | none: a key is unique per agent, user and session, and a search reads every agent's | namespace, memory_key, memory_type, similarity, source, valid_at when the row has one |
| Field | Retriever | Default | Effect |
|---|---|---|---|
k |
both | 4 | documents per query |
search_tags |
both | none | only rows carrying at least one of these Loom tags (tags is LangChain's own field) |
uri_prefix |
knowledge | none | only documents whose source_uri starts with one of these |
namespace_scope |
knowledge | this |
all_mine adds the caller's other namespaces, up to the server's fan-out cap |
search_kwargs |
knowledge | none | further knowledge.search fields, such as search_mode or rerank |
memory_type |
memory | every searchable type except reflection |
only memories of this type |
user_id, session_id, run_id, agent_id |
memory | none | only that user's, session's, run's or agent's memories |
honour_abstain |
memory | True |
leave out memories below Loom's floor; no documents when Loom abstains |
A LangChain agent built with create_agent runs on LangGraph: pass LoomStore (below)
as its store for long-term memory.
The TypeScript retrievers extend BaseRetriever from @langchain/core (an optional peer
dependency, 1.x) and take the same options in camelCase, with the memory retriever's
user, session, run and agent in one scope:
import {
LoomKnowledgeRetriever,
LoomMemoryRetriever,
} from "@clickhouse/loom/integrations/langchain";
const docs = await new LoomKnowledgeRetriever(agent, { k: 3 }).invoke("refund policy");
const memories = await new LoomMemoryRetriever(agent, { scope: { userId: "alice" } }).invoke(
"seat preference",
);
LlamaIndex
LoomRetriever searches the namespace's documents (knowledge.search) as a LlamaIndex
BaseRetriever, for a query engine, a chat engine or a retriever tool. LoomMemoryBlock
is a long-term memory block for LlamaIndex's Memory. Install with
pip install 'clickhouse-loom[llamaindex]' (llama-index-core 0.14).
from llama_index.core.memory import Memory
from loom import SyncAgent
from loom.integrations.llamaindex import LoomMemoryBlock, LoomRetriever, flush
with SyncAgent(org="acme", namespace="support") as agent:
nodes = LoomRetriever(agent, similarity_top_k=3).retrieve("refund policy")
memory = Memory.from_defaults(
session_id="s-42", memory_blocks=[LoomMemoryBlock(agent=agent, user_id="alice")]
)
... # the session's turns go through memory.put / memory.get
flush(memory) # at the session's end: hand the rest of the window to the blocks
retrieve (and a sync query engine) needs a SyncAgent; aretrieve takes an Agent or
a SyncAgent. A node's text is the chunk, its id <namespace>/<source_uri>#<chunk_idx>,
its score the similarity, and its metadata source_uri, chunk_idx, section (when
the chunk has one), namespace and tags, of which only source_uri and section reach
the text a synthesizer sends the model. A query engine takes the retriever with
RetrieverQueryEngine.from_args(LoomRetriever(agent), llm=...) and the LLM you use.
LoomRetriever argument |
Default | Effect |
|---|---|---|
similarity_top_k |
4 | nodes per query |
tags |
none | only chunks carrying any of these Loom tags |
uri_prefix |
none | only documents whose source_uri starts with one of these |
namespace_scope |
this |
all_mine adds the caller's other namespaces, up to the server's fan-out cap |
search_kwargs |
none | further knowledge.search fields, such as search_mode or rerank |
Memory keeps the recent messages itself and hands the block the ones that leave its
short-term window, which by default is 70% of a 30,000-token limit; a shorter session hands
it nothing. Call await aflush(memory) (flush(memory) from sync code), both from
loom.integrations.llamaindex, when a session ends: like Memory's own hand-over, it gives
what is still in the window to every block of that Memory and then archives it, so the
window starts empty. If a block fails, nothing is archived and the next flush hands the same
messages over again. The block's two calls:
aputsends the user and assistant turns tomemory.set_from_messagesunder theMemory's session id; Loom extracts the facts worth keeping and resolves duplicates and conflicts with what it already holds. Withdefer(the default) the server queues the extraction, so a flush is one request and an extraction failure does not fail the turn.agetsearches the block's namespace, across every agent writing to it, with the latest user message and returns the matching memories, one line each, whichMemoryplaces in the system message. It leaves out memories below Loom's floor, and returns nothing when Loom abstains.
LoomMemoryBlock field |
Default | Effect |
|---|---|---|
agent |
required | the Agent or SyncAgent the block reads and writes through |
top_k |
5 | memories per read |
tags |
none | stamped on extracted memories; reads keep memories carrying any of them |
user_id |
none | the end user the memories belong to; unset, reads every user's memories in the namespace |
honour_abstain |
True |
leave out memories below Loom's floor; nothing when Loom abstains |
defer |
True |
queue the extraction on the server; False extracts inline whatever the operator's defer list says |
name, priority |
LoomMemories, 0 |
LlamaIndex's block name and truncation priority (0: never truncated) |
Give the block a SyncAgent when Memory is used through its sync methods (get, put)
or flush is called, which may run a call on another event loop than the one an Agent
was opened on; an Agent serves aget and aput on its own loop. Memory keeps its
short-term messages in its own chat store (in process by default); only what the block
receives reaches Loom.
LangGraph
LoomStore (sdk/python/loom/integrations/langgraph.py) is a LangGraph BaseStore, so
every graph node reaches it through get_store().
from loom import Agent
from loom.integrations.langgraph import LoomStore
store = LoomStore(Agent(org="acme", namespace="support", agent="assistant"))
graph = builder.compile(store=store) # nodes: store = get_store()
# inside a node
store.put(("users", user_id), "prefs", {"seat": "aisle"})
store.search(("users", user_id), query="seating preference", limit=3)
store.close() # after the sync API: closes the agent and the loop the sync methods ran on
Use one store either through the sync methods or through the a* ones; the sync methods
run the agent on an event loop the store owns.
| LangGraph | Loom |
|---|---|
namespace ("users", "alice") + key prefs |
memory key lg/users/alice/prefs, tags lg:users and lg:users/alice (one per prefix, so a prefix search is one tag filter) |
put(value) |
memory.set; the JSON object is the memory's value |
put(..., index=False) |
memory_type="episodic": kept, not ranked |
put(..., ttl=minutes) |
ttl_seconds |
search(query=…) |
memory.search, then one memory.bulk_get for the values; SearchItem.score is the similarity |
search() without a query |
memory.list, by recency |
filter |
applied to the stored value with LangGraph's operators ($eq $ne $gt $gte $lt $lte) |
list_namespaces |
derived from the keys under lg/ |
LoomStore(agent, tags=[...], search_top_k_cap=50) also stamps extra tags on every
write and caps the rows one ranked search asks for.
Limits. A ranked search asks for at most search_top_k_cap (50) rows, so
offset + limit past 50 returns fewer items. A search without a query and
list_namespaces page through memory.list; filter applies to each page. Without
langgraph installed, the module still imports (structural twins of Item,
SearchItem and the op types).
Strands Agents
LoomMemoryStore implements the MemoryStore protocol (add / search) that the
Strands Memory Manager folds into the model input before each call.
from loom import Agent
from loom.integrations.strands import LoomMemoryStore
from strands import Agent as StrandsAgent
from strands.memory import MemoryManager
async with Agent(org="acme", namespace="support", agent="assistant") as loom_agent:
store = LoomMemoryStore(loom_agent, tags=["customer"])
agent = StrandsAgent(memory_manager=MemoryManager(stores=[store], add_tool_config=True))
agent("Which seat should I pick for my December trip?")
The TypeScript version behaves the same and does not depend on the Strands SDK:
import { Datapack } from "@clickhouse/loom";
import { LoomMemoryStore } from "@clickhouse/loom/integrations/strands";
const agent = new Datapack("acme", "support", { baseUrl, apiKey }).agent("assistant");
const store = new LoomMemoryStore(agent, { tags: ["customer"] });
// new MemoryManager({ stores: [store] })
addwritesmemory.setunder a content-addressed key,<key_prefix>/<first 20 hex of sha256(content)>, so the same fact added twice is one row and the server's supersession handles near-duplicates. Metadata values underkind,source,topicandcategory(strings up to 64 characters) becomekey:valuetags.searchrunsmemory.searchlimited to the store's tags and memory type. When Loom abstains (no hit above its calibrated floor), the store returns nothing rather than a weak match, so the Memory Manager injects no context at all; a hit below the floor in a response that does not abstain is left out too. Each entry's metadata carriesmemory_key,memory_type,similarity,sourceandvalid_at.
| Option (Python / TypeScript) | Default | Effect |
|---|---|---|
memory_type / memoryType |
semantic |
the type every write carries and every search is limited to |
tags |
none | stamped on every write; searches require them |
max_search_results / maxSearchResults |
3 | results per search when the caller does not say |
honour_abstain / honourAbstain |
true |
leave out hits below Loom's floor; nothing when Loom abstains |
key_prefix / keyPrefix |
strands |
key prefix |
— / scope |
none | TypeScript only: a per-store user, session or run scope |
Agent orchestrators
A harness that runs several agents gives them shared, durable memory by mounting Loom's MCP endpoint in each:
mcp_servers:
loom:
type: streamable-http # some clients call it `http`
url: ${LOOM_BASE_URL}/mcp
headers:
Authorization: Bearer ${LOOM_API_KEY}
- Agents in one namespace share everything. A sub-agent that should read a team
namespace but write only its own gets a
viewergrant on the team namespace (tokens and grants). - Checkpoint task state with
working_memory.*under arun_id; the next agent reloads it throughcontext.pack(trace and resume). - Hand work between agents with the task inbox.
With the TypeScript SDK, agentId in a call's Scope overrides the agent name, so one
client can speak for several agents.
Failure modes
- Abstain is not an error. An empty search means nothing cleared Loom's floor.
- Write verdicts are hidden by the framework interface. A
putoraddthat lands asconflictedorduplicatereturns normally; read the namespace's conflicts in the dashboard or withconflicts.list(conflicts and refusal). - One store per event loop for LangGraph's sync API: mixing sync and async calls on one store runs the agent on two loops.