mem0

mem0 is a long term memory service for AI workloads. Applications write facts to it and search them back later, so an agent can recall your preferences, projects, and past decisions across sessions.

It runs as three apps on smoll-harvester, each with its own hostname:

User

Connecting

🚨 NOTE 🚨:

  • mem0 requires connection to the network either through on-prem workloads like Coder or using Wireguard
  • The dashboard and API do not support OAuth. Like LiteLLM they have their own account system — an admin creates your account, then you mint your own API keys.
  • The MCP bridge does: it puts an OAuth layer of its own in front of mem0, so you log in with Vault in a browser and never handle a key.

Note: mem0 uses a GGL CA signed cert for TLS you need to add it to the trusted CA if you haven’t already see: Trusting Vault root CA

Getting an API key

The dashboard does all of this in a browser — log in and mint a key from the API keys view. The same thing over the API:

API=https://mem0-api.k8s.internal.galaxygridlabs.com
 
TOKEN=$(curl -sX POST $API/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@hul.to","password":"<password>"}' \
  | jq -r .access_token)
 
KEY=$(curl -sX POST $API/api-keys -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"label":"laptop"}' \
  | jq -r .key)

The key is returned once and stored hashed, so it cannot be recovered afterwards — revoke and reissue instead.

🚨 NOTE 🚨: a mem0 API key is instance-wide, not yours. mem0 scopes memories by the user_id you pass, and does not check that it is yours — see Authorization, or the lack of it. Use the MCP bridge for anything acting on one person’s behalf.

Connecting an MCP client

No API key and no mem0 account — the bridge does its own OAuth.

claude mcp add --transport http --scope user mem0 \
  https://mem0-mcp.k8s.internal.galaxygridlabs.com/mcp

Then /mcp in Claude Code to authenticate. Your client registers itself with the bridge, the bridge sends you to Vault to log in, and the token that comes back carries your email.

This gives the agent six tools: search_memories, add_memory, list_memories, get_memory, update_memory, delete_memory. None of them takes a user_id — it comes from your token, so every tool acts on your memories and refuses anyone else’s. Pass agent_id to keep a project’s memories separate from each other.

Memories land under the email Vault reports, lowercased. So a memory written through Claude Code and one written by hand with user_id=you@hul.to share a scope.

Access tokens last 8 hours and refresh tokens 30 days, both renewed silently by the client. You will be sent back to Vault only if you leave it alone for a month.

Using the API directly

The key authenticates the caller; user_id scopes the data. At least one of user_id / agent_id / run_id is required on write.

curl -sX POST $API/memories -H "X-API-Key: $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"I prefer Pulumi"}],
       "user_id":"you@hul.to"}'
 
curl -s "$API/memories?user_id=you@hul.to" -H "X-API-Key: $KEY"

On search, pass the scope inside filtersSearchRequest’s top level user_id / agent_id / run_id are deprecated:

curl -sX POST $API/search -H "X-API-Key: $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"query":"IaC preferences","filters":{"user_id":"you@hul.to"},
       "top_k":5}'

add and search are the only paths that reach the LLM, so they are also the only ones that fail when LiteLLM is down. Everything else — login, key management, listing — keeps working.

Note 📝 — mem0 decides per fact whether to ADD, UPDATE, DELETE or ignore. An empty results list on a write means it judged the text redundant, not that the call failed.

Admin

Auth

mem0 authenticates three ways, all of them locally managed credentials: session JWTs for the dashboard (HS256, 30 minute expiry), per-user X-API-Key keys (m0sk_* prefix, bcrypt hashed in Postgres) for applications, and a legacy ADMIN_API_KEY break-glass key.

There is no OIDC or OAuth support in the self-hosted server — verified against upstream v2.0.16, the tag we build. server/routers/auth.py has no /authorize, /token, or /callback, and server/auth.py does no JWKS or third party token validation. This is why the API and dashboard are not wired to the Vault IdP the way Coder is. Adding SSO would mean an authenticating proxy in front of the API, not a mem0 config change — which is what the MCP bridge below is, for the MCP surface only.

The one trap: mem0 does accept Authorization: Bearer <token>. That is its own self-issued session JWT from POST /auth/login, not an OAuth token — you cannot hand it a Vault issued one.

Authorization, or the lack of it

mem0 authenticates a key but does not authorize it. Memories are scoped by the user_id string passed on each call, and any valid key may pass any user_id — a per-user key from the dashboard reads everyone’s memories just as the admin key does. There is no per-key ownership check anywhere in the server.

So an API key is an instance-wide credential. Issue one only to something that is meant to see the whole instance. Anything acting for a single person should go through the MCP bridge, which is where the missing authorization actually lives.

Creating the first admin

Not automated — mem0 has no seed-from-env path, so the first admin is created by hand once per install. GET /auth/setup-status reports whether it has been done.

POST /auth/register is unauthenticated but only while setup-status reports needsSetup; a partial unique index caps the instance at one admin thereafter. Password must be at least 8 characters.

curl -sX POST $API/auth/register -H 'Content-Type: application/json' \
  -d '{"name":"Hulto","email":"hulto@hul.to","password":"<password>"}'

Locked out? Read the break-glass admin key off the Secret and send it as X-API-Key:

kubectl -n mem0 get secret mem0-secrets \
  -o jsonpath='{.data.adminApiKey}' | base64 -d

Deployment

The workload is a Fleet bundle (deployment.yaml) — Postgres with pgvector, the API server, the dashboard, and the MCP bridge. Fleet creates the namespace.

Secrets are pushed in by Pulumi instead (mem0.py) to keep them out of git: the Postgres password, the JWT signing secret, the bootstrap admin key, the LiteLLM key, the MCP bridge’s token signing key, and the bridge’s Vault OIDC client id and secret. Everything but the LiteLLM key is minted once at first pulumi up and never changes — replacing the JWT secret logs every dashboard session out, and replacing the MCP signing key logs every MCP client out.

The same module also ships the GGL root CA as a ConfigMap, since mem0 reaches LiteLLM over a cert the image’s certifi bundle doesn’t know.

LLM and embedder

mem0 calls LiteLLM for both, using Qwen3.8-27B-FP8 and gte-Qwen2-1.5B-instruct. The virtual key is a Pulumi config value:

pulumi -C src/2_app config set --secret ggl:mem0_llm_api_key <key>

It is optional so a stack without it still previews and deploys. Until it is a live key, writes and searches return provider_auth_failed while everything else works. A DeploymentPatch hashes the key into a pod annotation so the API restarts when it changes — secretKeyRef only resolves at container start, so without that the running pod would keep serving the old value.

Images

mem0 publishes no current image. docker.io/mem0/mem0-api-server was last pushed 2025-09-10, months before the self-hosted auth system landed upstream, and the dashboard has never been published at all. Both are mirror-built to ghcr.io by mem0.yml, the same way caddy and loki-mcp are. No registry credential is needed as long as those packages stay public.

To move to a newer mem0 release, run the workflow manually with a different ref input, then bump the tag in the Fleet bundle.

MCP bridge

mem0’s own MCP server (mem0ai/mem0-mcp) talks to the hosted platform through MemoryClient and takes one MEM0_API_KEY from its environment, so it can serve neither this cluster’s API nor a per-caller identity. Ours (mem0_mcp) does both, and supplies the authorization mem0 is missing.

It holds the break-glass admin key and never hands it out. Callers authenticate to the bridge over OAuth instead, and the email on their token is the only user_id they can reach — search and list are filtered to it, writes are pinned to it, and the tools that take a memory id fetch the memory and refuse one owned by anybody else. A memory whose owner mem0 does not report is refused rather than shown.

The bridge is its own authorization server, not just a resource server, because MCP clients provision themselves with dynamic client registration (RFC 7591) and Vault’s OIDC provider has no /register. So it serves /register, /authorize and /token itself and delegates the human’s login to the mem0_mcp Vault OIDC client (oidc_apps.py), verifying the returned id_token against Vault’s JWKS.

/.well-known/oauth-protected-resource/mcppoints clients at the bridge as its own authorization server
/.well-known/oauth-authorization-serverRFC 8414 metadata
/registerdynamic client registration
/authorizeparks the request in a signed state, redirects to Vault
/oauth/callbackVault returns here; the bridge issues the client its code
/tokenauthorization code and refresh grants, PKCE required

Everything it issues is an HS256 JWT it signs and re-verifies, so tokens survive a restart and nothing needs a session table. The trade-off is that a token expires rather than being revoked, which is why access tokens are short and revocation is not advertised. The one thing it cannot re-derive is the client registrations — the SDK mints those client_ids — so they sit on a 1Gi volume; losing it makes every client register and log in again.

Its /mcp transport runs stateless, so nothing survives between calls. The ingress disables proxy buffering and sets a 3600s timeout because MCP clients hold a GET /mcp open and a tool call blocks for as long as mem0 takes to reach the LLM.

The browser login lands on the bridge’s own hostname, which uses a GGL CA signed cert — so Trusting Vault root CA is a prerequisite for connecting, not just for the dashboard.