LangGraph
One command writes memory into a LangGraph or LangGraph.js project as code you own - nodes that recall and remember, and tools the model calls - with the end user set by your code, never the model.
twinmemory-cli adds memory to a LangGraph project the way shadcn adds a component: it writes one file into
your project, and from then on that file is yours to read and edit. It is on PyPI and npm Soon: this page
describes version 0.1.0, and until it is published, two plain nodes do the job.
uvx twinmemory-cli init langgraph # a LangGraph project you have
uvx twinmemory-cli create langgraph my-agent # a new one, with memory wired in
uvx twinmemory-cli doctor # is it all set up?LangGraph 1.0 or newer. The file uses the Python SDK.
What init does
Before it writes anything, init checks four things, and any of them can stop it with nothing changed:
- it is in a Python project (or a JavaScript one for
npx); - the project uses LangGraph. If it does not,
initasks before adding it, and--yesadds it without asking; - the installed LangGraph and LangChain are 1.0 or newer. If not, it says what to upgrade;
- which graph the file is for: the one
langgraph.jsonnames, asking when there are several (or--graph NAME).
Then it:
- writes
memory.py(ormemory.ts) beside that graph. An existing one is replaced only with--force; - adds what the file imports, with your project's own tool: uv, poetry, pdm or
requirements.txt; npm, pnpm, yarn or bun; - sets up your key. Pass
--key tm_..., or paste it when asked. Enter skips, and an emptyTWIN_MEMORY_API_KEY=waits in.envfor later. A key it is given is checked, and a key that only reads is called out, since saving needs one that writes.
| Flag | What it does |
|---|---|
--nodes-only, --tools-only | the file holds only the nodes, or only the tools |
--fast | searches skip the model filter: free and quicker |
--async | Python: async nodes and tools, for ainvoke |
--key, --base-url | your key, and memory's address, written into .env |
--skip-key | do not ask for a key |
--dir, --file, --graph | where the file goes, and what it is called |
--force | replace a file already there |
--no-install | print the install command instead of running it |
--dry-run | make every check, say what would happen, change nothing |
--yes | ask nothing |
What it writes
| Python | TypeScript | What it is |
|---|---|---|
recall | recall | a node: searches the user's memory for their latest message, into the state's memory |
remember | remember | a node: saves the turn - the user's message and the final reply - under their LangGraph ids |
memory_prompt(state) | memoryPrompt(state) | what recall found, as a block for your system prompt |
MemoryState, MemoryContext | MemoryState, MemoryContext | the state with a memory field, and the context with user_id |
MEMORY_TOOLS | MEMORY_TOOLS | memory_search and memory_read, for a model that looks things up itself |
MEMORY_WRITE_TOOLS | MEMORY_WRITE_TOOLS | the same and memory_add, to let the model save as well |
Its settings are at the top of the file: FAST, STRICT, MAX_CHARS and TIMEOUT.
Wire it in
Use the nodes, the tools, or both.
from langgraph.graph import START, StateGraph
from app.memory import MemoryContext, MemoryState, memory_prompt, recall, remember
def agent(state: MemoryState) -> dict:
system = "You are a helpful assistant.\n\n" + memory_prompt(state)
return {"messages": [model.invoke([SystemMessage(system), *state["messages"]])]}
builder = StateGraph(MemoryState, context_schema=MemoryContext)
builder.add_node("recall", recall)
builder.add_node("agent", agent)
builder.add_node("remember", remember)
builder.add_edge(START, "recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "remember")
graph = builder.compile()
graph.invoke(
{"messages": [HumanMessage("Where do I live?")]},
config={"configurable": {"thread_id": "t-1"}},
context={"user_id": "dana"},
)Or give the model the tools: create_agent(model, tools=[*MEMORY_TOOLS], context_schema=MemoryContext).
- Whose memory comes from the run's
context(orconfigurable.user_id), set by your code. The tools read it from the run's config, which LangGraph fills in and the model never sees, so a model cannot ask for anybody else's memory. See End users. - A LangGraph thread is a memory session:
remembersaves each turn under itsthread_id. - Saving needs a key that writes.
recalland the read tools work with a read key.
When memory is down
The file fails open: with STRICT = False, the default, memory being unreachable, slow or failing costs a turn
its memory, not the turn. recall comes back empty, remember skips the save, the tools tell the model memory
is unavailable, and each is logged. A wrong key still raises - that is a mistake to fix. Set STRICT = True to
raise on everything.
create
create langgraph <name> makes a new project with memory already wired in: a graph, a terminal chat,
langgraph.json for langgraph dev, and the memory file. It takes init's presets and key flags, and
--model, the model by LangChain's init_chat_model name (default anthropic:claude-sonnet-5-5).
uvx twinmemory-cli create langgraph my-agent --key tm_...
cd my-agent
uv run python -m my_agent.chat --user dana # talk to it
uv run langgraph dev # or open it in LangGraph StudioIn LangGraph Studio, pass {"user_id": "dana"} as the run's context. For a Python graph, Studio shows a
user_id field for it. For a LangGraph.js graph it shows none, since the LangGraph.js dev server does not
describe a graph's context; send the context with the run instead, and a run without it is refused.
doctor
doctor checks the memory file, the dependencies and their installed versions, the key (with one free request),
and that .env is not committed. Each failure says how to fix it.
Until it is published
Two plain nodes over the REST API do the same job: one searches before the model, one adds the turn after.
import os
import requests
TWIN = "https://api.twinmemory.dev/v1/memory"
def twin(user_id: str, route: str, body: dict) -> dict | None:
r = requests.post(
f"{TWIN}/{route}",
headers={"Authorization": f"Bearer {os.environ['TWIN_MEMORY_API_KEY']}", "X-Twin-Account": user_id},
json=body,
timeout=60,
)
if r.status_code == 404: # no memory for this end user yet
return None
r.raise_for_status()
return r.json()
def recall(state, runtime):
asked = state["messages"][-1].text
brief = twin(runtime.context["user_id"], "search", {"query": asked}) or {}
lines = [*brief.get("known", []), *(f["text"] for f in brief.get("facts", []))]
lines += [h["text"] for h in brief.get("hits", [])]
return {"memory": "\n".join(lines)}
def remember(state, runtime, config):
session = config["configurable"]["thread_id"]
asked, answered = state["messages"][-2], state["messages"][-1]
twin(runtime.context["user_id"], "add", {"items": [
{"id": asked.id, "session": session, "role": "user", "text": asked.text},
{"id": answered.id, "session": session, "role": "assistant", "text": answered.text},
]})
return {}Wire them in as above, with a memory: str key in your state.
Checked against docs.langchain.com/oss/python/langgraph and docs.langchain.com/oss/javascript/langgraph on 2026-10-23.