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.
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 by | Use | How it works |
|---|---|---|
| Meaning: "how do they like to be contacted?" | Facts | Text is embedded when written; search ranks by similarity |
| Key: "order ORD-12345" | Collections | JSON 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.
| Scope | Holds | Fact cap |
|---|---|---|
project | What the agent knows in general, shared by every conversation | 10,000 (5,000 on Free) |
contact | What it knows about one person, across every thread with them | 2,000 per contact |
conversation | What it knows about one inbox thread | 1,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:
| Skill | What it does |
|---|---|
remember | Saves one fact about the person the agent is talking to |
recall | Searches what was remembered before answering |
save_record | Stores a JSON record under a key, such as an order or a reservation |
lookup_record | Reads a record back by its key |
How they behave, so you can predict what the agent will do:
rememberwrites to the contact scope only. If Zavu has not identified who is writing, it refuses instead of falling back to the shared project scope.recallsearches 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_recordandlookup_recorduse the contact scope by default. Set their scope toprojectonly 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.
TypeScriptimport { 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:
TypeScriptconst 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:
| Handle | Scope | Present |
|---|---|---|
ctx.memory | project | Always |
ctx.memory.contact | the contact being talked to | Only inside a conversation. Undefined in an event or cron handler |
ctx.memory.conversation | the current thread | Same as contact |
ctx.memory.forContact(id) | a contact you name | Always |
ctx.memory.forConversation(id) | a thread you name | Always |
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
TypeScriptconst 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:
terminalnpx zavudev memory collections create orders --index status
TypeScriptconst 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:
TypeScriptimport { 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.
TypeScriptimport 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.
terminalcurl -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>.
terminalnpx 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
minScorewhen 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.3is a reasonable floor. metadatais not searchable. Search matches the text; metadata comes back with each hit.- Give temporary facts a TTL.
ttlSecondstakes 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
| Limit | Value |
|---|---|
| Fact text | 8 KB |
| Fact metadata | 2 KB (keys up to 64 characters, values up to 512) |
| Search results | 5 by default, 20 at most |
| Collection document | 200 KB serialized, 32 levels deep |
| Indexes per collection | 2, fixed at creation |
| Batch operations | 25 per call, each reported on its own |
| TTL | 60 seconds to 2 years |
| Error | Means |
|---|---|
400 field_not_indexed | The field has no index, or the collection was created by a write and has none |
404 | Nothing under that id or key in that scope. Check you passed the scope it was written with |
409 revision_conflict | An If-Match write lost a race. Read again and retry |
413 document_too_large | Over 200 KB. Keep media in file storage and store its URL |
429 memory_scope_full | The scope reached its fact cap. Delete facts or use a narrower scope |
429 memory_limit_exceeded | The 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.