Skip to content

Memory

Give agents memory that outlives the conversation: facts recalled by meaning, JSON documents recalled by key, scoped to the project, a contact or a thread.

View .md

Memory is what an agent learns while it runs and needs again later: that this customer prefers WhatsApp, the order it looked up an hour ago, the address someone gave on Tuesday. It lives in Zavu, so an agent, a Function, your backend and the dashboard all read the same store.

It holds two shapes:

You recall it byUseHow it works
Meaning: "how do they like to be contacted?"FactsText is embedded when written; search ranks by similarity
Key: "order ORD-12345"CollectionsJSON documents, exact lookup by key, optional indexed fields

Scope: the one thing to get right

Every read and every write happens in exactly one scope, and scopes are isolated: a search in one never returns a fact from another.

ScopeHoldsFact cap
projectWhat the agent knows in general, shared by every conversation10,000 (5,000 on Free)
contactWhat it knows about one person, across every thread with them2,000 per contact
conversationWhat it knows about one inbox thread1,000 per thread

Everything defaults to project. That is the mistake to avoid: a fact about one customer written at project scope can be recalled in every other customer's conversation. File it against the person it is about.

Let the agent remember on its own

The fastest way to give an agent memory needs no code. On the dashboard's Memory screen, Add memory to an agent gives it remember and recall together: one without the other half-works, writing facts nothing reads or reading facts nothing writes. Each of these library skills can also be added on its own from the agent's Tools tab, under Library:

SkillWhat it does
rememberSaves one fact about the person the agent is talking to
recallSearches what was remembered before answering
save_recordStores a JSON record under a key, such as an order or a reservation
lookup_recordReads a record back by its key

How they behave, so you can predict what the agent will do:

  • remember writes to the contact scope only. If Zavu has not identified who is writing, it refuses instead of falling back to the shared project scope.
  • recall searches the contact first and then the project. In the project scope it skips facts that an agent wrote, so what one customer said is never read back to another. Facts you seed yourself at project scope (your business rules, opening hours) are recalled for everyone. Hits below a similarity of 0.3 are not returned, so "nothing on record" is a real answer.
  • save_record and lookup_record use the contact scope by default. Set their scope to project only for records that are not personal: anyone who talks to the agent can then ask for any record by its key.

The Memory screen in the dashboard shows what was stored, per scope, and lets you search and delete it.

In a Function: ctx.memory

In code, ctx.memory is available to every tool handler and event handler. It needs @zavudev/functions 0.3.0 or later.

TypeScript
import { defineTool } from "@zavudev/functions" defineTool({ name: "remember_preference", description: "Save something the customer told us about how they want to be served.", parameters: { type: "object", properties: { fact: { type: "string", description: "One fact, in plain language." } }, required: ["fact"], }, handler: async ({ fact }, ctx) => { // ctx.memory.contact is bound to whoever the agent is talking to. if (!ctx.memory.contact) return { ok: false, reason: "no contact on this call" } await ctx.memory.contact.add(fact, { metadata: { source: "customer" } }) return { ok: true } }, })

Recall before answering:

TypeScript
const hits = await ctx.memory.contact!.search("how they prefer to be contacted", { limit: 3, minScore: 0.3, }) // [{ id, text, score, metadata?, createdAt }]

The scope handles:

HandleScopePresent
ctx.memoryprojectAlways
ctx.memory.contactthe contact being talked toOnly inside a conversation. Undefined in an event or cron handler
ctx.memory.conversationthe current threadSame as contact
ctx.memory.forContact(id)a contact you nameAlways
ctx.memory.forConversation(id)a thread you nameAlways

contact and conversation are optional, so TypeScript makes you handle a missing one. ctx.memory.add(text, { scope: "contact" }) throws MemoryScopeError when there is no contact; that error means the scope could not be resolved, never that it is empty. An empty scope returns an empty array.

Collections

TypeScript
const orders = ctx.memory.contact!.collection("orders") await orders.set("ORD-12345", { status: "shipped", total: 42.5 }) const order = await orders.get<{ status: string }>("ORD-12345") // null if absent await orders.delete("ORD-12345") // resolves true even if absent const page = await orders.list({ prefix: "ORD-2026", limit: 50 }) // { items: [{ key, value, rev, createdAt, updatedAt }], nextCursor }

A collection is defined once per project, and its items are per scope: the same orders collection holds a separate set for the project, for each contact and for each thread.

Keys are 1 to 255 characters from A-Z a-z 0-9 . _ : @ -. A + is not allowed, so an E.164 phone number is not a valid key: use its digits (56940560201) or a prefix (phone:56940560201), and keep the E.164 inside the value.

Querying by a field needs an index, and indexes are declared when the collection is created. Writing to a collection that does not exist creates it with no indexes, and indexes cannot be added later. If you will ever look items up by a field, create the collection first:

terminal
npx zavudev memory collections create orders --index status
TypeScript
const shipped = await orders.query({ field: "status", op: "eq", value: "shipped" })

A collection takes up to 2 indexes. Querying a field without one fails with field_not_indexed.

Testing locally

createLocalContext() gives you a ctx.memory backed by an in-process stub, so a tool runs without network:

TypeScript
import { createLocalContext } from "@zavudev/functions" const { memory } = createLocalContext({ contactId: "test-contact" })

The stub is not a simulator. Its search matches substrings, not meaning, and scores every hit 1, so minScore does nothing locally. Its query answers any field, because it has no indexes. Use it to check that a tool calls the right scope and handles a missing contact; check retrieval against the real API with a test-mode key.

From your backend

The TypeScript SDK has client.memory, and every operation is also plain REST.

TypeScript
import Zavudev from "@zavudev/sdk" const zavu = new Zavudev({ apiKey: process.env.ZAVUDEV_API_KEY }) await zavu.memory.add({ text: "Prefers WhatsApp over email.", scope: "contact", contactId: "jd7x2k3m4n5p6q7r8s9t0abc", }) const { items } = await zavu.memory.search({ query: "how they prefer to be contacted", scope: "contact", contactId: "jd7x2k3m4n5p6q7r8s9t0abc", minScore: 0.3, }) await zavu.memory.collections.items.put("ORD-12345", { collection: "orders", scope: "contact:jd7x2k3m4n5p6q7r8s9t0abc", value: { status: "shipped", total: 42.5 }, })

The scope is passed differently on two endpoints. POST /v1/memory and POST /v1/memory/search take it in the body, as scope plus contactId or conversationId. Every other endpoint takes it as a scope query parameter: contact:<id>, conversation:<id>, or omitted for the project. Reading an item with a different scope than it was written with returns 404.

terminal
curl -X POST https://api.zavu.dev/v1/memory/search \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "contact preference", "scope": "contact", "contactId": "jd7x2k3m4n5p6q7r8s9t0abc", "minScore": 0.3}' curl "https://api.zavu.dev/v1/memory/collections/orders/items/ORD-12345?scope=contact:jd7x2k3m4n5p6q7r8s9t0abc" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"

To make a write conditional, send the item's rev from a previous read as If-Match: a write that lost a race answers 409 revision_conflict. Every endpoint is in the API reference.

From the CLI

Every command takes --scope project (the default), --scope contact:<id> or --scope conversation:<id>.

terminal
npx zavudev memory add "Orders before 2pm ship the same day." npx zavudev memory search "shipping cutoff" --limit 5 --min-score 0.3 npx zavudev memory list --scope contact:jd7x2k3m4n5p6q7r8s9t0abc npx zavudev memory wipe --scope conversation:<id> npx zavudev memory collections create orders --index status --default-ttl 2592000 npx zavudev memory collection set orders ORD-1 '{"status":"shipped"}' npx zavudev memory collection query orders --field status --eq shipped npx zavudev memory collection export orders > backup.ndjson npx zavudev memory usage

Writing facts an agent can find

  • One fact per write. A paragraph with five unrelated facts is embedded as an average of all five and found well by none of them.
  • Write it the way it will be asked for. "Prefers to be called in the morning" is found by "when should I call them". "pref: AM" is found by nothing.
  • Set minScore when your code branches on the result. Without it, a search always returns its closest facts however distant, so "nothing relevant" looks the same as "the right fact". 0.3 is a reasonable floor.
  • metadata is not searchable. Search matches the text; metadata comes back with each hit.
  • Give temporary facts a TTL. ttlSeconds takes 60 seconds to 2 years. A fact without one is kept until deleted and counts against its scope's cap.
  • Do not remember what you can look up. An order status belongs in a collection or in your own system, not in a fact.

Limits

LimitValue
Fact text8 KB
Fact metadata2 KB (keys up to 64 characters, values up to 512)
Search results5 by default, 20 at most
Collection document200 KB serialized, 32 levels deep
Indexes per collection2, fixed at creation
Batch operations25 per call, each reported on its own
TTL60 seconds to 2 years
ErrorMeans
400 field_not_indexedThe field has no index, or the collection was created by a write and has none
404Nothing under that id or key in that scope. Check you passed the scope it was written with
409 revision_conflictAn If-Match write lost a race. Read again and retry
413 document_too_largeOver 200 KB. Keep media in file storage and store its URL
429 memory_scope_fullThe scope reached its fact cap. Delete facts or use a narrower scope
429 memory_limit_exceededThe month's Memory units on Free are used up

Reads and deletes are never refused for quota reasons, only writes: what you stored stays readable and removable.

Test mode and billing

A test-mode API key writes to a separate partition that is invisible to live keys, deletes itself after 30 days, and is never counted or billed.

Live usage is measured in units, from the capacity each request consumes; a write costs 5 times a read. npx zavudev memory usage and GET /v1/memory/usage report the month's units, the bytes stored and whether a cap is reached.

  • Free includes 250,000 units a month, 0.5 GB, 10 collections and 5,000 project facts. Past the units, writes are refused until the next month.
  • Paid plans include more (Pro: 2,000,000 units, 5 GB, 100 collections). Units past the included amount are billed from your balance at $0.50 per 100,000. See pricing.

Next steps