twin memory

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, init asks before adding it, and --yes adds 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.json names, asking when there are several (or --graph NAME).

Then it:

  • writes memory.py (or memory.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 empty TWIN_MEMORY_API_KEY= waits in .env for later. A key it is given is checked, and a key that only reads is called out, since saving needs one that writes.
FlagWhat it does
--nodes-only, --tools-onlythe file holds only the nodes, or only the tools
--fastsearches skip the model filter: free and quicker
--asyncPython: async nodes and tools, for ainvoke
--key, --base-urlyour key, and memory's address, written into .env
--skip-keydo not ask for a key
--dir, --file, --graphwhere the file goes, and what it is called
--forcereplace a file already there
--no-installprint the install command instead of running it
--dry-runmake every check, say what would happen, change nothing
--yesask nothing

What it writes

PythonTypeScriptWhat it is
recallrecalla node: searches the user's memory for their latest message, into the state's memory
rememberremembera 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, MemoryContextMemoryState, MemoryContextthe state with a memory field, and the context with user_id
MEMORY_TOOLSMEMORY_TOOLSmemory_search and memory_read, for a model that looks things up itself
MEMORY_WRITE_TOOLSMEMORY_WRITE_TOOLSthe 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.

graph.py
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 (or configurable.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: remember saves each turn under its thread_id.
  • Saving needs a key that writes. recall and 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 Studio

In 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.

graph.py
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.

On this page