Skip to Content
Relevan Beta is here 🎉
GuidesBuild agentic memory with Relevan

Build agentic memory with Relevan

This guide shows how to give an agent a long-term memory in a Relevan index. The agent keeps facts about a user between conversations. A reply never waits for the memory.

The worked example is Haru , an open-source voice assistant. The memory design of Haru comes from plast-mem , an open-source memory layer by moeru-ai.

Agentic memory

Agentic memory is information that an agent keeps between sessions and finds again when it is relevant. A memory store must do two jobs:

  • Find the correct memories for each turn, fast enough for a live conversation.
  • Keep the memories correct when the facts change.

A search index does both jobs. Full-text search finds a memory from the words of a turn. A filter removes the memories that are no longer true. Feedback tells the index which memories helped, so the index gets relevance signal from real use.

Ideas from plast-mem

plast-mem uses these ideas, and this guide uses a small version of each:

  • Two layers. Episodic memory keeps what happened in a conversation. Semantic memory keeps durable facts, such as a name or a preference. This guide keeps semantic facts only.
  • Consolidation off the hot path. A background worker reads the conversation in batches and extracts facts later. The live conversation does not wait for it.
  • Actions, not rewrites. The model returns actions for the facts: new, reinforce, update, and invalidate. An invalid fact gets a timestamp, and plast-mem does not delete it.
  • Review after retrieval. plast-mem records each memory that a search found. Later, a model rates whether each memory helped.
Agentic memory on Relevan. The app preloads memories and searches the index with a deadline, then writes a turn log line. A separate writer process reads the log, asks an LLM what to add, update, or retire, writes the documents, and sends llm_judgment feedback.APP · LIVE CONVERSATIONHOT PATHSession startsfirst user inputPreload active memoriesbackground, into a session cacheUser turnwhat the user saidSearch memorieswith a deadline, else the cacheAgent replymemories in the system promptA late search stillfills the cache.Turn log linetext, memory ids, queryIdAppend-only JSONL,the only link.MEMORY WRITER · OWN PROCESSOFF THE HOT PATHSession is duequiet period, or N new turnsLLM extractionadd · update · retire · relevantWrite documentsvalidated: add, update, retirellm_judgment feedbackfor memories that helpedPolls the logon a timer.Sees relatedmemories by id.Checkpointed persession.Tied to queryIdand position.RELEVAN INDEXlistsearchwritefeedback
The app reads memories with a deadline. A separate writer process reads the turn log later, and changes it into index writes and search feedback.

Before you start

You need:

  • A write API key for Relevan, in the RELEVAN_API_KEY environment variable.
  • The id or slug of your organization, as {orgId}. The Getting started guide shows how to find it.
  • An LLM that can return JSON, for the extraction step.

1. Create the index and the mapping

Create an index for the memories:

curl -X POST https://api.relevan.dev/v1/{orgId}/indexes \ -H "x-api-key: $RELEVAN_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "memories" }'

Then deploy a mapping for the memory documents:

curl -X POST https://api.relevan.dev/v1/{orgId}/indexes/memories/mappings \ -H "x-api-key: $RELEVAN_API_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "text": { "use": ["searchable"], "kind": "text" }, "subject": { "use": ["searchable"], "kind": "text" }, "tags": { "use": ["searchable"], "kind": "array" }, "kind": { "use": ["filterable"], "kind": "id" }, "importance": { "use": ["filterable", "sortable"], "kind": "number" }, "status": { "use": ["filterable"], "kind": "id" }, "created_at": { "use": ["filterable", "sortable"], "kind": "date" }, "updated_at": { "use": ["filterable", "sortable"], "kind": "date" } } }'
  • text is the memory: one short sentence.
  • subject is the topic in a few words, such as wife birthday. A later query matches it.
  • kind is the type of the memory, such as fact, preference, event, or plan.
  • importance is a number from 1 (trivia) to 5 (core identity, such as a name).
  • status is active or retired. To retire a memory is a soft delete.

2. Write memories

Each memory is one document. Upload a maximum of 1000 documents in each request:

curl -X POST https://api.relevan.dev/v1/{orgId}/indexes/memories/documents/_bulk \ -H "x-api-key: $RELEVAN_API_KEY" \ -H "content-type: application/json" \ -d '{ "documents": [ { "id": "mem-3f2a9c", "content": { "text": "The user'"'"'s wife'"'"'s birthday is June 3.", "subject": "wife birthday", "tags": ["wife", "birthday"], "kind": "event", "importance": 4, "status": "active", "created_at": "2026-09-30T19:12:04Z", "updated_at": "2026-09-30T19:12:04Z" } } ] }'

Use these rules for the text of a memory:

  • Write one standalone sentence in the third person. The sentence must be correct in a prompt months later.
  • Give each memory a specific subject. Do not use user as the subject, because it matches every memory.
  • Put a date in the text of a fact that can change, such as “As of September 30, 2026, the user is learning Rust.”

3. Recall with a status filter and a deadline

When a session starts, list the memories into a local cache. This call is a list, not a search. It gets a maximum of 100 documents for each page, newest first. Request the next page until you have total documents:

curl "https://api.relevan.dev/v1/{orgId}/indexes/memories/documents?page=1&limit=100" \ -H "x-api-key: $RELEVAN_API_KEY"

The list also returns retired memories. Keep only the documents with content.status active in the cache.

On each turn, search the index. The filter removes the retired memories:

curl -X POST https://api.relevan.dev/v1/{orgId}/indexes/memories/search \ -H "x-api-key: $RELEVAN_API_KEY" \ -H "content-type: application/json" \ -d '{ "query": "wife birthday", "size": 10, "customerId": "household", "filters": [{ "field": "status", "value": "active" }] }'

Search on the content words of the turn. Remove filler words, such as “hey” and “please”, before you send the query. In Haru, a full spoken question scored 0, and the word “snack” alone scored 3.5.

Give the search a deadline. If the search does not complete in time, rank the cache by word overlap. Haru gives memory 250 ms of the 1.7 s between the end of speech and the reply:

search = asyncio.create_task(search_memories(query)) done, _ = await asyncio.wait({search}, timeout=0.25) if search in done and search.exception() is None: hits, query_id = search.result() else: hits, query_id = local_match(cache, query), None

Let a late search complete, and put its results in the cache for the next turn. Put the memories in the system prompt with the date of each memory, so the model can see old facts.

4. Log what each turn found

Write one log line for each turn. Keep the ids of the memories that the turn found, and the queryId and sessionId from searchContext in the search response:

{ "session": "6c1e...", "role": "user", "text": "When is my wife's birthday?", "memories": ["mem-3f2a9c"], "relevan": { "queryId": "<queryId>", "sessionId": "<sessionId>" } }

The log is the only connection between the live conversation and the writer in step 5.

5. Extract memories off the hot path

Run the extraction in a separate process. The process reads the log, and it starts a batch when a session is quiet or has many new turns. Haru waits for 2 quiet minutes or 20 new turns.

For each batch, give the LLM the new turns and the related memories with their ids. Ask for JSON operations:

{ "add": [{ "text": "...", "subject": "...", "kind": "plan", "importance": 4 }], "update": [{ "id": "mem-...", "text": "..." }], "retire": [{ "id": "mem-...", "reason": "..." }], "relevant": ["mem-..."] }

Tell the model what not to remember: small talk, passing states such as “I am hungry”, and speech that is not to the agent. Then do these steps:

  1. Validate each operation. Ignore an id that the model did not get, and limit the length of each memory.
  2. Send each add to the _bulk endpoint from step 2.
  3. Send each update and retire as a PUT. A PUT replaces all of the content, so send each field again.
  4. Record how many log lines the batch processed, so that a restart does not do the same lines again.

This call retires the memory from step 2. It sends each field again, and it changes only status and updated_at:

curl -X PUT https://api.relevan.dev/v1/{orgId}/indexes/memories/documents/mem-3f2a9c \ -H "x-api-key: $RELEVAN_API_KEY" \ -H "content-type: application/json" \ -d '{ "content": { "text": "The user'"'"'s wife'"'"'s birthday is June 3.", "subject": "wife birthday", "tags": ["wife", "birthday"], "kind": "event", "importance": 4, "status": "retired", "created_at": "2026-09-30T19:12:04Z", "updated_at": "2026-11-09T12:00:00Z" } }'

6. Send feedback after the fact

At search time, the agent does not know which memories will help. The writer knows, because it sees the full conversation. For each memory in relevant, send an llm_judgment event for each search that found it:

curl -X POST https://api.relevan.dev/v1-beta/{orgId}/indexes/memories/feedback \ -H "x-api-key: $RELEVAN_API_KEY" \ -H "content-type: application/json" \ -d '{ "actionName": "llm_judgment", "queryId": "<queryId>", "sessionId": "<sessionId>", "objectId": "mem-3f2a9c", "position": 0, "customerId": "household" }'

position is the rank of the memory in that search response. Feedback gives the index relevance signal. The feedback skill gives each event type.

Limits of this design

  • The preload lists the full index. This is correct for one household, but a large index needs a smaller preload, such as the most important memories.
  • The writer finds related memories by word overlap. A search would also find a paraphrase, such as “spouse” for “wife”.
  • The log holds each turn of the conversation. Set a retention limit, and give the user a way to see and delete the memories.

Next steps

Last updated on