Agent memory
What this gives you: the patterns for giving an agent memory on skein, and the traps that are not obvious until they bite. There is no @skein-js/memory package to install — memory is something you build in your graph out of pieces that already exist, and this page is the map of which pieces, and why.
That is a deliberate choice. Memory is agent behaviour, and skein's job is durable persistence, the queue, the adapters and the CLI. Everything below is ~100 lines in your own graph, portable to LangGraph Platform because it uses getStore() and nothing skein-specific. Where skein does contribute — durable storage, semantic search, schedules — it is called out. Per-owner isolation is not on that list: it is a policy your @auth.on.store handler decides, and Multi-tenant memory is how.
What you already have
| Need | Use | From |
|---|---|---|
| Durable thread state | the checkpointer — PostgresSaver in prod, MemorySaver in dev | skein wires it (storage.md) |
| Trimming a long conversation | trimMessages, filterMessages, RemoveMessage | @langchain/core |
| Summarizing a long conversation | summarizationMiddleware, contextEditingMiddleware | the langchain v1 package |
| Cross-thread memory store | getStore() — namespace/key items, vector search | skein injects it into every run |
| Semantic search over memories | store.index (pgvector, HNSW optional) | skein (storage.md) |
| Per-owner isolation | an @auth.on.store handler that roots the namespace | you (agent-protocol.md) |
| Scheduled background extraction | a cron firing an extractor graph | skein (crons.md) |
| Memory shapes, tools, dedup | this page | you, in ~100 lines |
Short-term memory is already solved
Worth stating plainly, because "durable short-term memory" sounds like a gap and is not one.
Durability is the checkpointer. PostgresSaver persists a thread's state and history, so a conversation survives restarts and resumes from an interrupt; thread TTL collects the ones that go stale. skein selects it for you — nothing to build.
Context management — keeping a long thread inside the model's window — is upstream too:
import { createAgent, summarizationMiddleware } from "langchain";
const agent = createAgent({
llm,
tools,
middleware: [summarizationMiddleware({ model: llm, trigger: { tokens: 4000 }, keep: 20 })],
});Do not write your own trimming loop. If you keep a running summary yourself, keep it in graph state rather than the store: state is checkpointed, thread-scoped, and rolls back with the checkpoint, none of which is true of a store item.
Long-term memory: profile and collection
Cross-thread memory is getStore() — namespace/key JSON documents, the same store LangGraph Platform auto-provides. Two shapes cover almost everything, and the choice is really "one document or many":
Profile — one continuously-updated document. Good for stable facts with one current value: timezone, tone, home airport. Read it whole, merge a patch, write it back.
Collection — many documents accumulated over time. Good for open-ended facts: "prefers morning meetings", "allergic to shellfish". Append, and retrieve by relevance rather than reading all of them.
const NAMESPACE = (userId: string) => [userId, "memories"] as const;
// Profile: read-modify-write one document.
async function mergeProfile(store: BaseStore, userId: string, patch: Record<string, unknown>) {
const current = (await store.get([userId], "profile"))?.value ?? {};
await store.put([userId], "profile", { ...current, ...patch });
}Pick one merge rule and write it down. The rule matters less than having one; without it the profile becomes unpredictable, which is the failure LangChain's own memory guide warns about. A rule that works: shallow, patch wins, arrays unioned, null deletes, nested objects replaced. Replace rather than deep-merge — deep merge makes deletion inexpressible.
Two things to know:
- There is no compare-and-swap.
BaseStorehas none, so read-modify-write is last-writer-wins. On skein, runs on one thread are serialized by the execution lock, so concurrent merges only arise across threads for the same user. Do not build an optimistic-retry counter — without CAS it does not close the race and implies a guarantee that is not there. - Keep collection fields top-level and scalar.
search'sfilterreads the top-level keys of an item'svalueand takes scalars, so{ content, kind, createdAt }filters and{ meta: { kind } }does not.tags: string[]is storable but not filterable ($inasks the opposite question). Full operator semantics in storage.md.
The dedup trap
The most important paragraph on this page. Without dedup, an agent that saves a memory each turn accumulates the same fact dozens of times and recall degrades into noise. The obvious fix — semantic search for a near-duplicate before writing — inverts on most substrates:
| Substrate | score on a text search |
|---|---|
@skein-js/storage-memory (dev) | 1 for every hit |
@skein-js/storage-postgres without store.index | 1 for every hit |
@skein-js/storage-postgres with store.index | real cosine similarity |
LangGraph InMemoryStore without an index | no score field at all |
So score >= 0.9 means duplicate classifies everything as a duplicate on two of three substrates, and your agent silently stops recording memories. It will not error. You will notice weeks later.
Two rules:
1. Dedup on content, not similarity. A content-addressed key makes exact dedup free, with no read at all — saving the same sentence twice upserts one row instead of appending a second:
async function memoryKey(content: string): Promise<string> {
const normalized = content.trim().toLowerCase().replace(/\s+/g, " ");
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(normalized));
return `mem-${[...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}Use globalThis.crypto.subtle, not node:crypto — it is present on Node ≥20, Bun, Deno and workers, so the same graph still runs on an edge runtime.
2. Treat semantic dedup as opt-in, requiring store.index. And check the scores are meaningful before trusting them — at least one candidate scoring strictly below 1 — falling back to exact dedup otherwise. State the safe direction out loud: dedup errs toward writing. A duplicate is recoverable; a dropped memory is not.
One more, on cost: on Postgres without store.index, a query search is an unbounded full-table read filtered in JS. Semantic dedup on every write there reads the whole store_items table each time.
Recall
Retrieval is store.search with the namespace and filter already right:
const relevant = await store.search([userId, "memories"], { query: latestUserMessage, limit: 5 });When to recall is your graph's decision, and skein has no opinion. Two patterns, both fine:
- Auto-inject — fetch relevant memories before each model call and fold them into the system message. Personalization does not depend on the model remembering to ask. This is what
examples/chat-appdoes. - A recall tool — the model calls
search_memorywhen it decides it needs to. Fewer tokens per turn, but it only works when the model chooses well.
If you expose memory as a tool, bind the namespace server-side and give the model no namespace parameter at all:
// The model may choose *which collection*, from an enum — never which namespace.
const userId = config.configurable?.langgraph_auth_user_id; // never model outputThat is the pattern people get wrong: a namespace the model can name is a namespace the model can escape.
Multi-tenant memory
Memory is per-user by definition, so this matters more here than anywhere else in skein.
prefix, suffix and filter are request parameters, not access control. An omitted prefix matches every namespace. The control is an @auth.on.store handler that rewrites value.namespace to root the caller — LangGraph's own idiom, and skein honours it. See agent-protocol.md.
It covers the HTTP surface. getStore() inside a graph has no request and no principal, so nothing guards it for you. Build its namespace from config.configurable.langgraph_auth_user_id (server-injected and unspoofable), never from model output, use the same encoding your handler uses — and encode it: an identity containing . splits into two namespace segments on GET /store/items, and PostgresStore rejects ., %, _ and \ in a label outright.
Writing memories in the background
Extracting memories inline costs the user's turn a model call. Two ways to move it off the hot path, both working today with no new skein surface:
A cron. Point a schedule at an extractor graph — an ordinary graph you write, with your model and your prompt — and it runs on a cadence, durably, across restarts and instances:
{ "graphs": { "agent": "./src/agent.ts:graph", "extract-memories": "./src/extract.ts:graph" } }A debounced follow-up run. Closer to real-time: when a turn finishes, create a stateless background run of the extractor with after_seconds, and cancel this thread's pending one first so extraction fires once when the conversation pauses rather than once per turn. after_seconds is backed by the queue, so a pending extraction is a row in the store and survives the process that scheduled it — which is the part an in-process scheduler (Python langmem's ReflectionExecutor, say) cannot give you without a server behind it.
// Cancel the pending extractor for this thread, then schedule a fresh one.
const inflight = await client.runs.list(threadId);
for (const run of inflight.filter((r) => r.metadata?.extractor && r.status === "pending")) {
await client.runs.cancel(threadId, run.run_id);
}
await client.runs.create(null, "extract-memories", {
afterSeconds: 30,
input: { thread_id: threadId },
metadata: { extractor: true },
});The one thing neither expresses: a graph node cannot know its own run settled, so "extract only from successful runs, reliably even on error and cancellation paths" is not reachable from inside the graph. A server-side settled-run trigger would cover it; it is deferred, because everything above works without it and the gap is narrow.
See also
- storage.md — the store itself: drivers,
getStore(), filters, TTL, scoping, BYO store - crons.md — schedules, for the cron extraction pattern
examples/chat-app— a working research assistant with memory and recall- LangChain's memory guide — the profile/collection framing this page builds on