← Back to Guides

Giving Your Agent Memory#

The agent from the first two guides keeps a transcript: one chat, replayed from the top each turn, growing until it compacts. Start a new chat and it begins from nothing.

Memory is a different thing entirely. It is a store in your workspace, not a property of a conversation, and giving your agent access to it is two nodes.

What you will build#

Telegram Message ─> Recall Memory ──memory──> AI Conversation        (part one, two nodes)

Cron Trigger ─> Table ─> JavaScript ─> AI Agent ─> JavaScript ─> Memory (insert)
                                          ^
                          Structured Output Schema                  (part two)

Memory in Verdalia#

A memory is a short piece of text stored in your workspace together with an embedding of it. The embedding is what makes retrieval semantic: a query returns memories that are close to it in meaning rather than memories containing the same words. Asking "when should I schedule focus time?" finds Freja protects 09:00 to 12:00 for deep work without the word "focus" appearing in it.

Stored beside the text:

  • the namespace — which corpus the memory belongs to
  • properties — free-form metadata you can filter on
  • initial_relevancy — how good the signal was when it was written, from an authoritative observation down to a weak inference
  • half_life_seconds — how durable it is; relevancy decays over that half-life into current_relevancy
  • links — where it came from, and how it relates to other memories

There are three things you do with the store, and everything in this guide is one of them.

Break a source into memories and store them. Anything that contains facts can become memories: a chat log run through a model, a document, a meeting transcript, a news report. The Memory node writes them, either from text it chunks itself or from facts you hand it.

Recall against a query and give the result to a model. The Recall Memory node takes a query, returns the memories closest to it, and feeds the memory socket on AI Agent or AI Conversation. Any AI node in any workflow, not just a chat agent.

Operate on memories directly. The same Memory node does search, get, list, update, delete, and link management with no model involved. Memory is workspace data, and an ordinary workflow can read and write it like any other data.

Prerequisites#

Memory needs an embedding model, separate from the model your agent talks with. Add an OpenAI connection under Settings, then Connections, and use text-embedding-3-small.

Memories live in a namespace, and a new workspace has none. Open the Memory page, choose Namespaces, type assistant under New namespace, and Create it.

Note: every node that touches memory selects its own embedding model, and they must all match. The provider, model, and dimensions are recorded on each memory when it is written, and text embedded with one model cannot be searched with another. Pick one and use it everywhere.

Searching the node palette for "memory" also turns up Memory Cache Store and Memory Cache Get Or Compute. Those are a short-lived key-value cache for workflow runs and are unrelated.

Part one: recall in your agent#

1. Add recall. Add a Recall Memory node. Connect the Telegram Message node to its input socket, and set:

  • query to {{ input.text }} — the default, and the message you just sent
  • Namespaces to your assistant namespace
  • model to your embedding model

If you followed the second guide, the message text may come from a transcribed voice note. Wire the node that produces the joined text into input and set the query to {{ input }}.

2. Wire it into the agent. Connect Recall Memory's output socket to the AI Conversation node's memory socket.

That is the whole change. Recalled memories are formatted into context for that turn only. They never enter the transcript, so they cost nothing on later turns and cannot pollute the stored conversation.

Nothing will come back yet, because the store is empty. The rest of the guide is about filling it.

Part two: writing memories#

Every writer has the same shape: take a source, turn it into facts, insert them.

Letting the agent write its own#

The quickest route is to give the agent the tools and let it decide.

3. Add memory tools. Add a Memory Tools node, set Namespace to assistant, select the same embedding model, and turn on search and insert. Connect its tools output to the same Flatten node feeding the agent's tools socket.

Each enabled operation becomes one tool the model can call: memory_search, memory_insert, memory_get, memory_update, memory_delete, memory_link_add, memory_link_remove. They are individual toggles, so search without write is a valid configuration, and a good default — the agent can look things up on demand while something more deliberate decides what gets kept.

Set tool_name to give the tools a prefix, which matters when one agent has two memory tool nodes pointing at different namespaces.

A tool call cannot change the namespace, model, or connection. Those come from the node.

Distilling conversations on a schedule#

Nothing has to write memories during a conversation, and a scheduled job reading the day's transcripts produces better ones. This is also the general pattern for turning any source into memories.

4. Trigger it nightly. Add a Cron Trigger node. On the Simple tab set Run workflow to Once per day, Hour to 3, and Minute to 0, then pick the node's Timezone. The Advanced tab takes a raw cron expression if you need one.

5. Load the conversations. Add a Table node pointed at Agent Conversation Data, operation Query, Dynamic Filters off with no filters configured, and a limit high enough to cover your chats. Connect the Cron Trigger to it.

This is the one case where you want an unfiltered query: the job reads every chat rather than one.

6. Take only what is new. Add a JavaScript node connected to the Table node:

const rows = inputs.input;
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const lines = [];

for (const row of rows) {
  for (const entry of row.transcript) {
    const at = Date.parse(entry.timestamp);

    if (!Number.isFinite(at) || at < cutoff) {
      continue;
    }

    if (entry.kind.type === "user_message") {
      const text = entry.kind.content
        .filter(item => item.type === "text")
        .map(item => item.text)
        .join("\n");
      if (text.trim() !== "") lines.push(`user: ${text}`);
    }

    if (entry.kind.type === "assistant_message") {
      lines.push(`assistant: ${entry.kind.text}`);
    }
  }
}

return { count: lines.length, transcript: lines.join("\n") };

Only user_message and assistant_message entries are read. Reasoning, tool calls, and tool results are the agent's working notes rather than things it learned.

7. Describe what a memory looks like. Add a Structured Output Schema node, which forces the model to return parseable JSON instead of prose:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["memories"],
  "properties": {
    "memories": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["text", "category", "importance"],
        "properties": {
          "text": {
            "type": "string",
            "description": "One durable fact, written so it stands alone."
          },
          "category": { "type": "string" },
          "importance": { "type": "string", "enum": ["low", "medium", "high"] }
        }
      }
    }
  }
}

8. Extract the facts. Add an AI Agent node. Connect the JavaScript node to its input socket and the schema node to its schema socket. Set max_turns to 1 and the temperature low, around 0.2.

AI Agent is the other AI node in the palette, and it suits this job better than AI Conversation. Conversation takes a transcript and returns a transcript; Agent takes one input, a templated prompt, and returns a response — or, with a schema wired in, a parsed object on structured. Extraction is one shot, not a conversation.

Its system_prompt is where the quality lives:

You are the nightly memory organizer.

Read the transcript and extract only durable facts that will still be useful in a month.

Keep: preferences, plans, decisions, recurring problems, project context,
relationships, deadlines, and stable personal context.

Discard: greetings, small talk, transient status, vague intentions, anything
you inferred rather than read, and anything already obvious.

Write each memory as a standalone sentence that makes sense without the transcript.

And user_prompt passes the transcript through:

Conversations from the last day ({{ input.count }} messages):

{{ input.transcript }}

9. Shape them for storage. Add a JavaScript node fed by the agent's structured output:

return inputs.input.memories.map(memory => ({
  text: memory.text,
  initial_relevancy: memory.importance === "high" ? 0.95
    : memory.importance === "medium" ? 0.85
    : 0.7,
  half_life_seconds: 31536000,
  properties: {
    category: memory.category,
    importance: memory.importance,
    source: "nightly-distillation"
  }
}));

Importance becomes starting relevancy, so a passing remark fades within the year while a firm decision is still there. A half-life of 31536000 seconds is one year.

10. Store them. Add a Memory node, set the operation to Insert, pick your namespace, and select your embedding model. Connect the previous node to it. It embeds each memory and writes them into the namespace you picked.

Note: bulk insert takes a bare array of memory objects. A wrapper such as { "memories": [...] } is rejected. The objects carry no namespace of their own — one Insert node writes one namespace, chosen in its Namespace field.

Run it manually once with a day of conversation behind you, then open Memory and read what it decided to keep. Adjust the system prompt until you agree with it.

A refinement worth adding once it works: put a Recall Memory node in front of the extractor, querying the same namespace, and wire it into the AI Agent's memory socket. The model then sees what is already stored and stops proposing facts you have. That is the same recall node from part one, in a workflow with no chat in it.

Ingesting documents#

A long document does not need a model to become memories. The Memory node's Ingest operation chunks the text, embeds each chunk, and writes one memory per chunk:

{
  "text": "Long document text...",
  "properties": { "kind": "reference" },
  "initial_relevancy": 0.9,
  "half_life_seconds": 31536000,
  "links": [
    { "relation": "source", "source": { "kind": "canopy_document", "value": "01J..." } }
  ]
}

Chunk size, overlap, and sentence-aware splitting are node config, as is the namespace — an Ingest payload carries no namespace of its own either. { "documents": [...] } ingests several at once.

This is how a reference file, an article, or a meeting transcript becomes something the agent can quote. Distillation and ingestion are the same access point with different granularity: ingest keeps the source's own words, distillation keeps only the conclusions.

Prepare Memory does the chunking and embedding without writing anything, for when you want to route or filter chunks before they are stored.

Using memory without an agent#

Set the Memory node's operation to Search and it becomes an ordinary query node. It takes a namespace and a text query and returns hits with their scores:

{
  "namespace_id": "01J00000000000000000000001",
  "text": "invoices",
  "limit": 10,
  "min_semantic_score": 0.3,
  "min_current_relevancy": 0.5
}

Each hit is { memory, semantic_score, current_relevancy }. list filters by namespace, properties, or link without a query at all; get reads one memory by id; update and delete do what they say.

This is what makes memory more than an agent feature. A morning brief can recall what you decided yesterday, a triage job can check whether an issue is one you have seen before, and a report can pull the facts it needs, none of them involving a chat.

Corrections and provenance#

Every link is { relation, source }, and there are three relations:

  • source — where the memory came from: a document, a table row, a URL, or another memory. Never followed, only recorded.
  • related — an undirected association with another memory.
  • supersedes — this memory replaces an older one.

Insert and Ingest accept source links only, because the other two name a memory that does not exist yet at write time. Add those afterwards from the Memory page or with the agent's link tools; the Memory node's own operation list does not offer them.

supersedes is the one worth reaching for. Superseding replaces a memory rather than deleting it: the older one disappears from search and from recall, so a query returns the corrected version rather than both. It stays readable through get and list, and removing the link makes it active again.

A memory holds at most 32 links, related and supersedes must stay inside one namespace, and supersession is linear — one predecessor, one successor.

Tuning recall#

Recall Memory's defaults are usually right, and worth knowing when they are not:

  • final_limit — how many memories reach the model (12)
  • min_semantic_score — how closely a memory must match the query (0.3)
  • min_current_relevancy — how faded a memory may be and still count (0.5)
  • max_context_chars — a ceiling on how much text is handed over (12000)

The middle two are independent gates and easy to confuse. One asks whether the memory is about what you asked; the other asks whether the memory is still worth anything after decay. A memory has to clear both.

If the agent brings up irrelevant things, raise min_semantic_score first. Recall returns direct hits only: each hit carries its links as metadata, but a memory linked to a hit is not pulled in with it.

Namespaces must match#

A namespace is a thing you make, not a word you type. Create one from Namespaces on the Memory page, and every node that reads or writes memory picks it from a list. Nothing resolves a namespace by its name, so you can rename one whenever it stops describing what is in it and every workflow pointing at it carries on unchanged.

That removes the failure this section used to be about — a memory written to telegram-assistant sitting invisible to a recall over assistant, with neither node reporting a problem. What is left is the ordinary kind of mismatch: a recall pointed at the wrong corpus. If recall comes back empty when you expected results, open the Memory page, filter by the namespace you meant, and check the memories are actually in it.

Separate namespaces for conversation facts, ingested documents, and project notes let one workflow read a narrow slice while the agent reads several at once. Deleting one is refused while it still holds memories, so a corpus cannot vanish from under a workflow by accident.

Seeing what is stored#

The Memory page in the sidebar lists every memory with its namespace, properties, and current relevancy after decay.

Add a Knowledge Graph page to see them as a graph instead, with the links between memories and back to their sources. A memory distilled from a document keeps a source link to it, so you can follow any fact back to where it came from.

Switch the page to 3D to see the same memories as a star map arranged by meaning: memories about similar things sit together and are coloured by cluster, and links are drawn between them. Documents join the map by meaning once Document Embeddings is on under Assistant Preferences; until then they sit beside the memories they are linked to.

Where to go next#

The three access points compose in more ways than this guide uses. A workflow that ingests every new document into its own namespace gives the agent a library to quote from. A weekly job that searches for contradictions and adds supersedes links removes stale facts without deleting them. A report can recall against a question nobody typed into a chat.