Custom eve memory providers connect a memory slot to any store that can read and write under a partition key, such as a Postgres table, vector index, key-value store, or an HTTP API. Teams often need agent memory to live in infrastructure they already operate, follow their own retention rules, or use retrieval tuned to their data.
eve handles slot naming, scope resolution, lifecycle timing, and tenant isolation, so a custom provider only implements storage and retrieval. You create a provider with defineMemoryProvider() from eve/memory by supplying a required recall handler and optional capture and tools handlers.
Copy link to headingOverview
In this guide, you'll learn how to:
- Recall stored context into the model's context before each turn
- Capture completed turns into your store without duplicate writes
- Expose model-facing memory tools that stay locked to the caller's scope
- Bind the provider to a memory slot scoped to authenticated callers
- Handle the failure behavior and limits that eve enforces
Copy link to headingPrerequisites
- Your own eve project with an
agent/directory. If you don't have one yet, create it withnpx eve init my-agentor follow the first agent tutorial. - Your store's client, with methods to search, write, and delete records.
- Authentication on your agent's channel (e.g., Slack) if you scope memory per caller. The
byPrincipalscope disables memory for anonymous callers.
Copy link to headingHow eve memory providers work
Every eve memory provider is an object with three surfaces that eve calls at fixed points in the agent lifecycle. eve owns the slot, scope, and timing of every call, and the provider owns storage, retrieval, and the tools it offers to the model.
| Surface | Handlers | Responsibility |
|---|---|---|
recall | turn.started andㅤcompaction.completed | Return messages to place in the model context |
capture | turn.completed andㅤcompaction.requested | Observe settled history and write to the data store |
tools | One function | Return model-facing operations bound to the locked scope |
Every handler receives a MemoryOperationContext with these fields:
| Field | Meaning |
|---|---|
memory.scope.key | Opaque, versioned digest of the namespace and scope. Use it as the partition key for every read and write. |
memory.scope.namespace | The resolved namespace string |
memory.scope.value | The resolved scope string or tuple |
memory.slot | The slot name, derived from the file path |
messages | Projected conversation history for the current phase |
operationId | Stable per session, turn, step, phase, and slot. Use it as an idempotency key. |
abortSignal | Cancellation signal for the operation |
session | Session ID, auth, and other SessionContext fields |
eve calls each handler during a specific lifecycle phase, and both the contents of messages and the extra context fields depend on that phase:
turn.started- Handler:
recall["turn.started"] messagescontains: History before recall. The new delivery is inturn.input.- Extra context fields:
turn, with the turn ID, input messages, and sequence.
- Handler:
turn.completed- Handler:
capture["turn.completed"] messagescontains: Settled history after a successful turn.- Extra context fields:
turn, with the turn ID, input messages, and sequence.
- Handler:
compaction.requested- Handler:
capture["compaction.requested"] messagescontains: History before the checkpoint changes.- Extra context fields:
compaction.modelIdandcompaction.usageInputTokens.turnisnullfor standalone compaction.
- Handler:
compaction.completed- Handler:
recall["compaction.completed"] messagescontains: The checkpoint plus the retained recalled messages.- Extra context fields:
compaction.modelId.turnmay benull.
- Handler:
Copy link to headingSteps
Copy link to heading1. Connect your store
The examples in this guide use a notes client exported from agent/lib/notes-db.ts.
Replace it with the client for your own database or service. The provider calls three methods on that client, and each method takes a partition argument that you map to your store's tenant, namespace, or partition field.
Each notes client method is called from a different stage of the turn:
notes.search({ partition, query, limit })is called fromrecalland returns the most relevant records for the scope.notes.ingest({ partition, idempotencyKey, messages })is called fromcaptureand writes a completed turn once per operation.notes.delete({ partition, id })is called fromtoolsand removes one record by ID within the scope.
The partition value is always ctx.memory.scope.key. Every read and write in your store must be constrained by that key, which is how eve keeps one tenant's or caller's memory separate from another's.
Copy link to heading2. Recall context before each turn
The recall["turn.started"] handler is the only required handler. eve calls it before the model call, after resolving and locking the scope for every active memory slot.
Recall handlers return { messages }, null, or undefined. Each message has a non-blank content string and an optional id, and eve rejects any other keys. eve adds each message to model context as a user-role message attributed to the slot, and never promotes provider content to system instructions.
Give replaceable facts a stable id. eve uses the ID to decide whether a recalled message replaces an earlier one in the same slot, namespace, and scope:
| Recalled message | Result in the session |
|---|---|
Same id as before, new content | Supersedes the earlier message |
Same id as before, same content | No change |
No id | Accumulates, even when the content repeats |
Earlier id left out of a result | The earlier message stays, because recall can't retract |
Implement recall["compaction.completed"] when your provider should refresh its context after eve compacts the session. eve calls it against the new checkpoint, and ctx.turn may be null for that phase, so don't read ctx.turn.input there without a fallback.
Copy link to heading3. Capture completed turns
Add a capture["turn.completed"] handler to write each successful turn to your store. Skip capture if your provider relies on the model to save memories through tools, as the built-in fileMemory() provider does.
Pass ctx.operationId as the idempotency key because eve may replay a handler with the same operation ID. If your store client accepts a cancellation signal, pass ctx.abortSignal to it as well.
Implement capture["compaction.requested"] when you want to persist history before eve replaces it with a compaction checkpoint. ctx.messages contains the history as it was before the checkpoint changes.
Copy link to heading4. Expose model-facing tools
Add a tools function when the model should be able to act on memory directly, for example to search, save, or delete records. The function returns a map of defineTool() values, or null to offer no tools.
The model sees the forget tool in a slot named notes as notes__forget, because eve qualifies each tool key as <slot>__<key>. The qualified name must start with a letter, contain only letters, digits, underscores, or dashes, and be at most 64 characters long. Schemas, approval, outputSchema, and toModelOutput work the same way they do for authored eve tools. Use approval to require confirmation before the model deletes or saves memories.
Each provider tool closes over the locked scope for the current turn, which prevents the model from redirecting a tool to another tenant or caller. eve also keeps tool callbacks replayable after a process restart or redeployment by calling tools() again with the captured memory, turn, and session context. After a mid-turn restart, ctx.messages in that replay also includes the turn's earlier tool results.
Copy link to heading5. Bind the provider to a memory slot
Memory slots bind your provider to a scope.
eve prepends the slot description to every provider tool description, which tells the model what belongs in the notes slot.
The byPrincipal scope from eve/memory/scope scopes memory to the authenticated principal, disables memory for anonymous and runtime principals, and returns a shared local-dev scope during local development.
For an application with multiple tenants, return a tenant and caller tuple from a scope resolver instead. Resolve both values from verified authentication, never from raw user or model input:
Returning null disables the slot for that operation. eve skips the provider and its tools and never falls back to a shared scope.
Copy link to heading6. Tell the model how to treat recalled memory
Recalled messages are user-controlled data, so state the trust policy in your agent's instructions. Add guidance like this to agent/instructions.md:
Copy link to heading7. Run the agent locally
Start the local development server to open eve’s terminal UI.
Ask the agent to remember a fact, start a new session, and confirm that the fact appears in the agent's answer. Then ask the agent to forget it and check that the record is gone from your store under the partition key.
During local development, byPrincipal resolves every session to the shared local-dev scope, which means all local sessions read and write the same partition.
If a scope or namespace resolver returns null, eve dev logs a diagnostic instead of calling your provider. The log includes the slot name and which resolver disabled it, but never the resolved scope value:
Copy link to headingProvider requirements and limits
Every eve memory provider must meet four requirements:
- Partition by scope key: Use
memory.scope.keyin every read and write. For semantic retrieval, include the key in the query itself rather than filtering the results of a global search. - Deduplicate by operation ID: Treat
operationIdas an idempotency key. eve may replay a handler with the same ID, and replaying a recall with a different result is an error. - Own size and retention: Enforce your own size limits and retention policies. eve doesn't truncate or expire provider content.
- Treat recalled content as untrusted: Recalled content is user-controlled data, even when your provider wrote it.
eve enforces these limits on the values it passes to and receives from a provider:
| Value | Limit |
|---|---|
| Namespace | 1,024 UTF-8 bytes |
| Each scope component | 1,024 UTF-8 bytes |
| Scope tuple | 16 components |
| Combined canonical namespace and scope | 4,096 bytes |
Recall message id | 1,024 UTF-8 bytes |
Calling clear() on a session removes its history, recalled messages, locked scopes, and replay bookkeeping, but it doesn't touch your provider's store. The next turn recalls the same data again.
Copy link to headingTroubleshooting
Copy link to headingWhat happens when a provider handler fails
The effect of a handler failure depends on when the handler runs:
| Handler | When it throws or returns an invalid result |
|---|---|
recall["turn.started"] | The turn fails before the model call and commits no recalled messages. eve treats the error as cancellation and runs any queued follow-up when the turn's abortSignal is already aborted. |
capture["turn.completed"] | eve logs the error after the response and doesn't rewrite the completed turn. |
capture["compaction.requested"] | Session history stays unchanged. |
recall["compaction.completed"] | Automatic compaction fails the turn, while standalone compaction logs the error and returns the session to waiting, because the checkpoint is already written. |
tools() | eve logs the error and omits the provider's tools. |
Copy link to headingThe same recalled fact appears more than once
Recalled messages without an id accumulate in the session. Return a stable id for each record, like a unique primary key, so that later recalls supersede the earlier message.
Copy link to headingDeleted records still appear in the current session
Recall can supersede earlier messages but can't retract them. When you delete a record from your store, its recalled message stays in the current session's context, although later sessions won't recall it.
To update the current session, recall a message with the same id and placeholder content. eve rejects blank content, so an empty string fails the turn instead of clearing the message. File memory follows this pattern by recalling "No memories are saved." when its document is empty.
Copy link to headingThe turn fails with a recall validation error
eve validates every recall result before committing it, and an invalid result fails the turn before the model call. Check that the result only contains a messages array, that each message only has content and id keys, that content isn't blank, and that no two messages in the same result share an id.
Copy link to headingThe provider's tools don't reach the model
eve omits a provider's tools for the turn when tools() throws or returns an invalid result, and logs the error. Check the logs, then confirm that every qualified name (<slot>__<key>) starts with a letter, uses only letters, digits, underscores, or dashes, and stays within 64 characters.
Copy link to headingMemory from one caller appears for another
Cross-scope leaks come from a store query that isn't constrained by memory.scope.key. Make sure every search, write, and delete passes the scope key into the query itself. Running a global vector search and filtering the results afterward can surface another scope's records.
Copy link to headingNext steps
- Read the eve memory overview for slots, scope, namespace, and visibility.
- Review the full provider contract in Build a memory provider.
- Study the built-in File memory provider as a reference implementation.
- Scope memory by tenant and caller with the Multi-tenant memory pattern.
- Learn how provider tools run in Dynamic capabilities.
- See how compaction treats recalled memory in the Default harness.