Skip to main content
REST surface for the shared memory tier: search the 2-tier store (declarative facts + versioned procedures), save a fact or a procedure, browse what is stored, read the graph and record feedback, and inspect and repair the embeddings behind vector/hybrid search and similarity links. This is the UI-facing half of the memory dual surface; the model-facing half is the Memory MCP server. Both halves share one SqliteMemoryStore over the same SQLite file, so a fact saved here is searchable from a runtime’s Memory tool and vice versa.
Memory is always on; these routes are not flag-gated. The store is FTS5 (full-text) plus an optional vector index. Vector and hybrid search require a reachable embedding provider; when none resolves, they degrade to FTS automatically. See GET /api/memory/provider to inspect the active provider.
The POST routes read a JSON body parsed by express.json({ limit: '2mb' }). The GET routes read their inputs from the query string, then validate them against the same zod schemas the save body uses, so an out-of-range limit or empty query is a 400.

Routes

Save scrubs secrets at the write boundary: a fact’s title/content and a procedure’s content are passed through a secret scrubber before they are embedded and inserted. A credential can never land in a durable, searchable, or auto-injectable fact regardless of who wrote it.

GET /api/memory

Searches stored facts. The handler reads query, mode, limit, teamId, and agentId from the query string, assembles a { query, mode, limit, scope: { teamId, agentId } } object, and validates it with the same schema the MCP memory_search tool uses. Each result is a fact annotated with a 0..1 score and a matchedVia field recording how it matched.
  • Query params
  • Request body: none.
Scope is inclusive. A scoped query (a teamId and/or agentId) also sees globally-scoped facts (rows with a null scope), so global memory is always visible to a scoped search. A tenantId scope, if supplied, is strict; but tenantId is a dormant multi-tenant seam (a single implicit tenant today).

Responses

200 OK: the (possibly empty) result list:
matchedVia reflects the mode that actually ran, not the mode requested: a vector/hybrid request with no embedding provider runs as fts and reports matchedVia: 'fts'. In hybrid mode the score blends the cosine similarity (60%) and an FTS-hit signal (40%). 400 Bad Request: the assembled query object failed validation (e.g. blank query, limit out of 1..100, or an unknown mode):
500 Internal Server Error: any failure constructing the store or running the search:

Example


POST /api/memory

Saves a memory entry. The body is a discriminated union: a fact (the default, when kind is absent or "fact") or a procedure (kind: "procedure"). A fact is a durable declarative statement (“User prefers concise responses”); a procedure is a versioned, SKILL-style “how” kept out of the fact store. Saving a procedure under a name+scope that already exists creates a new version (the prior max version +1) rather than overwriting.
  • Path/query params: none.
  • Request body: one of:
Fact (default):
Procedure:

Responses

200 OK: a fact was saved (it is embedded if a provider was available, but the embedding is best-effort and never blocks the write):
200 OK: a procedure was saved (kind: 'procedure'):
400 Bad Request: the body failed the discriminated-union validation (e.g. an over-length title/content, too many tags, or a procedure missing name):
500 Internal Server Error: any failure constructing the store or writing the row:

Example


GET /api/memory/browse

Lists the most recent facts and procedures (facts newest-first by updatedAt), scoped the same inclusive way as search. The handler reads limit, teamId, and agentId from the query string, validates them, then fetches facts and procedures in parallel.
  • Query params
  • Request body: none.

Responses

200 OK: facts and procedures side by side:
400 Bad Request: limit out of the 1..200 range:
500 Internal Server Error: any failure constructing the store or reading:

Example


GET /api/memory/graph

The store projected as a graph: facts and procedures as nodes, with similarity, shared-tag and version edges, grouped into communities. Every edge maps to something in the store.
  • Query params: limit (1 to 500 facts, newest first), teamId, agentId (scope, as for search).
200 OK:
400 Bad Request: { "error": "invalid query", "details": { … } }.

POST /api/memory/feedback

Record how a fact fared, the signal behind the learning pills.
  • Body: { factId: string; outcome: 'useful' | 'dead_end' | 'corrected'; note?: string; scope?: { teamId?, agentId? } }. corrected requires a note.
200 OK: { ok: true, outcome: MemoryOutcome, learning: LearningEntry }, the recorded outcome and the fact’s updated learning entry. 400 Bad Request: { "error": "invalid body", "details": { … } }, or { "error": "corrected requires a note" }. 404 Not Found: { "error": "unknown fact" }.

GET /api/memory/outcomes

A fact’s full outcome trail, newest first.
  • Query params: factId (required), limit (1 to 200).
200 OK: { ok: true, factId: string, outcomes: MemoryOutcome[] }, where each outcome is { id, factId, outcome, note, agentId, teamId, taskId, runtime, createdAt }. 400 Bad Request: { "error": "invalid query", "details": { … } }. 404 Not Found: { "error": "unknown fact" }.

GET /api/memory/provider

Reports the embedding provider behind vector/hybrid search and the graph’s similarity links, and why it is what it is. The resolution order is: an Ollama at http://localhost:11434 that has nomic-embed-text installed, then an OpenAI key (OPENAI_API_KEY in the server’s environment, or one stored through Providers; OpenClaw’s ~/.openclaw/.env is deliberately not consulted), then none. Two rules sit on top of that order:
  • A reachable Ollama is not enough on its own: without the model every embedding call fails. With no OpenAI key that is reported as ollama-model-missing; with one, OpenAI serves and missingModel says a local install would move embeddings onto this machine.
  • Once any fact holds an Ollama vector, the store is local-first: an OpenAI key is not used automatically, and an Ollama that stops answering is reported as ollama-unreachable rather than silently replaced. POST /api/memory/embedding/reindex with allowRemote: true records the choice to use OpenAI for the current outage. It is withdrawn the next time any clawboo process (the dashboard, or the stdio Memory bin) finds Ollama serving, when the OpenAI key is disconnected under Providers or Runtimes, and when a resolution finds no OpenAI key at all (a vault that merely failed to read does not count); a switch still owed is dropped with it.
CLAWBOO_DISABLE_EMBEDDINGS=1 turns embeddings off: provider is null and status.state is disabled. The server re-checks the answer on its own timer, whether or not anything reads this route: every 30 seconds while no provider can serve or OpenAI is standing in, every 10 minutes while Ollama serves, and straight away after an embedding call fails or a provider key is connected or disconnected. A key that is disconnected stops being used at once, including by MCP sessions that were already open: a Memory session asks for the current provider on every call. Reading this route while facts are waiting to be indexed also starts indexing them, unless the previous attempt failed within the last minute.
  • Path/query params: none.
  • Request body: none.

Responses

200 OK: provider keeps its original shape (and is null whenever status.state is not ready); status explains it:
With a remote provider, pending counts only facts that have no vector at all: an automatic pass never re-uploads facts another provider already indexed. The exception is a switch the user asked for (reembedAll) that has not finished: until it has, pending also counts the facts still carrying another provider’s vectors, and automatic retries carry on with them. 500 Internal Server Error: an unexpected failure:

Example


POST /api/memory/embedding/reindex

Re-checks the provider straight away, sends it one short fixed string (never fact text) in the background to confirm it answers, so a provider that has recovered stops reporting its last failure on the next status read, and starts indexing every fact it has no vector for, including facts the provider turned down earlier. Indexing runs in the background, newest facts first, and never changes a fact’s updatedAt. Rate-limited on the sensitive tier.
  • Request body (optional): { allowRemote?: boolean; reembedAll?: boolean }. allowRemote: true records the choice to use an OpenAI key even though the store was indexed locally; the choice lasts until Ollama is found serving again, the key is disconnected, or no key is available. reembedAll: true also replaces vectors another provider produced, which an automatic pass does not do for a remote provider. It is owed to the provider this request resolved until that provider has converged the store: it survives a pass that fails partway and a server restart, and is dropped once another provider takes over.

Responses

202 Accepted: indexing was started (or there was nothing to do); poll GET /api/memory/provider for progress:

Example


POST /api/memory/embedding/install

Installs the embedding model through the local Ollama’s own /api/pull and streams its progress as server-sent events. The model is fixed server-side (nomic-embed-text); any request body is ignored. On success the provider is re-checked, which starts indexing the store. Closing the connection cancels the download. Rate-limited on the sensitive tier, since it downloads over the network.
  • Request body: none.

Responses

409 Conflict (JSON, not a stream): there is nothing to install, because status.missingModel is null (Ollama is not running, or already has the model):
200 OK (text/event-stream): one data: frame per event:
message is a plain phrase (Preparing the download, Downloading, Verifying, Finishing). Byte counts follow the largest layer only, so the percentage does not restart for each small file after the model. A stream that ends without Ollama reporting success is an error, never a complete. IN_PROGRESS means another install is already running.

Example

Error envelope

Errors on these routes use the standard envelope { error: string }. The validating routes (search, save, browse, graph, feedback, outcomes) add a details field carrying the zod flatten() output on a 400, e.g. { "error": "invalid query", "details": { … } }. The install route’s 409 carries detail and state instead, since nothing was malformed.

See also

Last modified on September 26, 2026