Blog / How-to guides

Persistent memory for LangChain in Elixir, on your own machine

25 October 2026 · Semurg · 5 min read · Markdown

Last checked on r11.132.20261027.a274a28 (27 Oct 2026): every command below ran on a fresh node of that release. The current release is r11.135.20261028.2ff2348 (28 Oct 2026), and the commands have not been re-checked on it yet (how)

langchainelixirmcpagentsmemoryhow-to

Tested end to end with langchain 0.4.1, langchain_mcp 0.2.0, anubis_mcp 0.16.0 and plug 1.20.3. The release it ran on is named in the checked line at the top of this page.

An agent built with LangChain for Elixir forgets what it learned when its process ends. This guide gives it a memory that outlives the process: a Semurg node on your own machine, reached through the langchain_mcp adapter, where every fact stays on your disk and comes back cited to where it was said. Any other agent or framework that uses the same node and the same scope reads the same memory.

Your Elixir program stores a fact through LangChain's MCP adapter; your node keeps it in the scope you named, on your own disk; a later run, another agent or a plain HTTP call on the same scope reads it back, cited.Your Elixir programLangChain's MCPadapterYour scope, on yournodeA later run, anotheragent, or plain HTTPtoken and scoperememberthe same fact, cited

What you need

  • An Intel or AMD x86_64 machine with Ubuntu 24.04, on Linux directly or on Windows through WSL2. One node is free, with no licence key.
  • Elixir 1.18 with Erlang/OTP, installed the way you prefer. We test in the official elixir:1.18-slim container.

Step one: install a node

On Ubuntu 24.04 (x86_64), with sudo:

bash
curl -fsSL https://one.semurg.io/install | sudo bash

The install page lists what the installer needs and what it changes. The installer ends by printing your API token, and the node keeps a copy. Put it in a file only you can read, and have every new terminal load it:

bash
mkdir -p ~/.config/semurg
(umask 077; sudo grep '^SEMURG_API_TOKEN=' /etc/semurg/semurg.env | cut -d= -f2- > ~/.config/semurg/token)
echo 'export SEMURG_API_TOKEN="$(cat ~/.config/semurg/token)"' >> ~/.bashrc
export SEMURG_API_TOKEN="$(cat ~/.config/semurg/token)"

Step two: the packages

There is nothing to install by hand. The program's first line fetches and compiles LangChain, its MCP adapter and Plug on the first run (the adapter compiles a small test server that needs Plug):

elixir
Mix.install([{:langchain, "~> 0.4"}, {:langchain_mcp, "~> 0.2"}, {:plug, "~> 1.0"}])

On a fresh Elixir install, fetch the package tools once first, as our test setup does:

bash
mix local.hex --force
mix local.rebar --force

Step three: connect

Save the program below as snippet.exs. It is the program our tests run as written, shown here from the agents page rather than copied, so what you read is always the file that ran.

Elixir: LangChain (Elixir)
# Run: elixir snippet.exs   (SEMURG_API_TOKEN must be set in the environment)
Mix.install([{:langchain, "~> 0.4"}, {:langchain_mcp, "~> 0.2"}, {:plug, "~> 1.0"}])   # langchain_mcp compiles a test server that needs Plug.Router

Logger.configure(level: :warning)   # the MCP client logs every request at debug level

url = URI.parse(System.get_env("SEMURG_MCP_URL", "http://127.0.0.1:4000/v1/mcp"))
headers = %{
  "Authorization" => "Bearer " <> System.fetch_env!("SEMURG_API_TOKEN"),
  "X-Semurg-Scope" => System.get_env("SEMURG_MCP_SCOPE", "default")
}
facts = %{
  "entities" => [%{"name" => "Ada", "type" => "person"}, %{"name" => "Acme", "type" => "organisation"}],
  "relations" => [%{"source" => "Ada", "target" => "Acme", "type" => "WORKS_AT"}]
}

defmodule Semurg.MCP do
  use LangChain.MCP.Client, name: "semurg", version: "1.0.0", protocol_version: "2025-03-26"
end

{:ok, _} = Semurg.MCP.start_link(transport: {:streamable_http, base_url: "#{url.scheme}://#{url.host}:#{url.port}", mcp_path: url.path, headers: headers})
adapter = LangChain.MCP.Adapter.new(client: Semurg.MCP)

# the client does its MCP handshake in the background: ask until the memory tools are listed
wait = fn wait, tries ->
  tools = LangChain.MCP.Adapter.to_functions(adapter)
  cond do
    length(tools) >= 6 -> tools
    tries == 0 -> raise "the node listed no memory tools"
    true -> Process.sleep(500); wait.(wait, tries - 1)
  end
end

by_name = wait.(wait, 60) |> Map.new(&{&1.name, &1})
by_name["memory_remember"].function.(%{"facts" => facts}, %{})
IO.inspect(by_name["memory_recall"].function.(%{"subject" => "Ada"}, %{}), limit: :infinity, printable_limit: :infinity)
System.halt(0)

Ran: this exact file, end to end against a fresh node on release r11.132.20261027.a274a28 in elixir:1.18-slim, with langchain 0.4.1, langchain_mcp 0.2.0, anubis_mcp 0.16.0, plug 1.20.3. Install: Mix.install([{:langchain, "~> 0.4"}, {:langchain_mcp, "~> 0.2"}, {:plug, "~> 1.0"}]). Its run record and every other framework: /agents.

It reads your token from SEMURG_API_TOKEN, the node's address from SEMURG_MCP_URL (without it, http://127.0.0.1:4000/v1/mcp) and the scope from SEMURG_MCP_SCOPE (without it, the scope named default). It defines a client module with use LangChain.MCP.Client, starts it over the streamable HTTP transport with the token and the X-Semurg-Scope header, waits until the node's tools are listed, stores two facts with memory_remember (Ada works at Acme) and prints what memory_recall returns for Ada.

Step four: run it

Give the program a scope of its own, so it never writes into a memory you use for real work:

bash
export SEMURG_MCP_SCOPE=try-elixir-langchain
elixir snippet.exs

The first run is slower, because it fetches and compiles the packages. The program then inspects the recall result, which carries the node's answer for Ada: state set to answered and the WORKS_AT fact that links Ada to Acme, each item cited. Storing the same facts again changes nothing, so it is safe to run twice.

Now read the same fact from outside Elixir, over plain HTTP. What one client writes, any other client on the same node and scope reads:

bash
curl -sS http://127.0.0.1:4000/v1/memory/recall \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"try-elixir-langchain","subject":"Ada"}'

The memory tools in LangChain for Elixir

LangChain.MCP.Adapter.to_functions returns each memory tool as a LangChain function with the same name; the program calls its function directly. The same functions are what you add to a LangChain chain, and the model then chooses (our test calls them directly, without a model).

  • memory_status: reports whether shared memory is on for this node and which of your scopes hold facts.
  • memory_remember: stores entities, the relationships between them and the passages they came from, or plain text, with an optional source label and date.
  • memory_recall: recalls what is stored about one named entity, its own facts first, each item cited.
  • memory_relate: adds one typed relationship between two entities in a scope, creating an end that is not stored yet.
  • memory_context: answers a question in plain words with cited items and a verdict on whether the store covers it.
  • memory_document: takes a whole document (PDF with a text layer, Word, HTML or plain text) and stores it as cited passages in a scope.
  • memory_forget: deletes one thing per call: an entity and everything that names it, one fact, one document, every document under a source label, or the whole scope. It is unreachable at once and not yet wiped from the disk.
  • memory_export: reads everything a scope holds, one page per call: send each reply's next back as cursor until it is empty. The records are the entities, the facts with their dated sources and whether each is current or ended, and each stored document once; a forgotten passage is never exported.
  • memory_import: stores the records memory_export produced into any scope, through the same writes memory_remember makes. A page sent twice stores nothing twice, and the reply names every record it skipped.

Every answer says its state: answered, nothing_stored (nothing stored matched, so the agent should say it does not know) or could_not_judge.

Troubleshooting

  • An error that the environment variable SEMURG_API_TOKEN is not set: run the last line of step one, or open a new terminal.
  • Mix.install cannot fetch the packages on a fresh Elixir: run the two mix local lines of step two once.
  • the node listed no memory tools: the client never finished its handshake while the program waited. Check the token and the address first.
  • The program sets the log level to warning because the MCP client logs every request at debug level; remove that line to watch each request.
  • After the node restarts (an update restarts it), the first call can fail; in our October 2026 restart test, run on an earlier release, the client then reconnected by itself. For a node that is up but stalled, set the per-call timeout your framework offers.

Use 127.0.0.1 rather than localhost: on Ubuntu localhost can resolve to the IPv6 address first, and the node listens on IPv4 loopback only. To reach a node on another machine, set SEMURG_MCP_URL, or forward its port with ssh -N -L 4000:127.0.0.1:4000 you@your-node.

Next

This post was written with AI assistance. Every result in it is checked against a published release or record before the post goes out.

How these commands were checked

Our test farm ran each program shown from /agents, the very file our test farm ran end to end, against a fresh node of the published release r11.132.20261027.a274a28. A post is published only when every one of them passed there.

Related