Skip to navigation

Eve

Add long-term agent memory to Vercel Eve agents with the Zep SDK

Eve is Vercel’s framework for building production agents. This guide shows how to wire Zep into an Eve agent with the Zep TypeScript SDK. The pattern uses hooks for persistence and authored tools for retrieval.

Keep retrieved context out of privileged instructions

Zep context can include content that your users, documents, or tools supplied. A system or developer message gives that content higher instruction priority than ordinary input. Some convenience integrations use system-message injection. Use direct SDK retrieval or an actual retrieval tool call unless all stored content is application-authored and trusted. Follow Memory security best practices for provider-specific placement.

Build an agent with Zep tools

To build an agent that plans its retrieval and uses several Zep tools, read Build an Agent with Zep. The guide shows how to add domain knowledge, design tools, and evaluate the agent.

Why this pattern

Eve hooks are observe-only. They can persist side effects, but they cannot add model input. Authored tools preserve the distinction between application instructions and retrieved data.

ConcernEve primitiveZep API
Persist turnsHook (message.received / message.completed)thread.addMessages
Warm cacheHook (session.started)graph.warm (fire-and-forget)
RecallAuthored toolgraph.getContext (query = current tool query)

Do not use Eve dynamic instructions or channel context for retrieved memory. Dynamic instructions enter a privileged channel. Channel context enters durable session history and accumulates.

Identity mapping stays outside the model. Zep generates the UUID of each user and each thread, and a create call accepts no identifier from your application. Keep the Zep UUIDs in your own storage, next to the Eve identifiers:

  • Eve session auth principal (or your app’s user id) → the Zep userUuid and graphUuid that user.create returned
  • Eve session.id → the Zep threadUuid that thread.create returned

Never accept a user, thread, or graph identifier from the model.

Architecture

Eve HTTP message
│
├─ session.started → read or create the Zep user/thread, fire-and-forget warm
├─ message.received / message.completed → thread.addMessages
└─ authored tools → graph.getContext on the user graph or a shared Context Graph

An authored tool passes its current query directly to graph.getContext. Pin the graph UUID from authenticated session state.

Setup

npm install @getzep/zep-cloud@preview eve

Requires Node.js 24+ (Eve), @getzep/zep-cloud on the preview dist-tag (the v4 SDK is a pre-release), and a Zep Cloud API key from app.getzep.com.

export ZEP_API_KEY="your-zep-api-key"

Provision the Zep user and thread one time with your own helper, shown here as ensureZepUserAndThread. The helper reads the Zep UUIDs that your application stored for the user and the Eve session. If a UUID is missing, the helper calls user.create or thread.create with no identifier and stores the UUID from the response. The stored UUIDs keep a repeated call from making a second resource. After the helper returns, warm the user graph as fire-and-forget:

const { graphUuid } = await ensureZepUserAndThread({ userId, userName, sessionId });
void zep.graph.warm(graphUuid).catch(() => {});

One way to write the two helpers. ensureZepUserAndThread reads and fills the Zep UUID columns of your own records, and resolveZepIdentity reads the same columns on each hook call:

interface ZepIdentity {
userId: string; // your application's user id
sessionId: string; // the Eve session id
userName: string;
userUuid: string; // stored on your user record
threadUuid: string; // stored on your session record
graphUuid: string; // stored on your user record
}
async function ensureZepUserAndThread(identity: {
userId: string;
userName: string;
sessionId: string;
}): Promise<ZepIdentity> {
// Read the UUIDs that an earlier call stored.
const userRow = await db.user.findUnique({ where: { id: identity.userId } });
const sessionRow = await db.session.findUnique({ where: { id: identity.sessionId } });
if (userRow?.zepUserUuid && sessionRow?.zepThreadUuid && userRow.zepGraphUuid) {
return {
...identity,
userUuid: userRow.zepUserUuid,
threadUuid: sessionRow.zepThreadUuid,
graphUuid: userRow.zepGraphUuid,
};
}
// First call for this user or session: create the resources and store the UUIDs.
const user = await zep.user.create({ firstName: identity.userName });
const thread = await zep.thread.create({ userUuid: user.uuid! });
await db.user.update({
where: { id: identity.userId },
data: { zepUserUuid: user.uuid, zepGraphUuid: user.graphUuid },
});
await db.session.update({
where: { id: identity.sessionId },
data: { zepThreadUuid: thread.uuid },
});
return {
...identity,
userUuid: user.uuid!,
threadUuid: thread.uuid!,
graphUuid: user.graphUuid!,
};
}
// Called inside each hook: returns the stored UUIDs for this session.
function resolveZepIdentity(ctx: HookContext): {
userId: string;
sessionId: string;
userName: string;
userUuid: string;
threadUuid: string;
graphUuid: string;
} {
return {
userId: ctx.session.user.id,
sessionId: ctx.session.id,
userName: ctx.session.user.name,
userUuid: ctx.session.user.zepUserUuid,
threadUuid: ctx.session.zepThreadUuid,
graphUuid: ctx.session.user.zepGraphUuid,
};
}

Automatic message capture

Persist each turn with a hook. Skip interim narration before tool calls (finishReason === "tool-calls"); persist other completions (stop, length, and similar):

export default defineHook({
events: {
async "session.started"(_event, ctx) {
const identity = resolveZepIdentity(ctx);
const { graphUuid } = await ensureZepUserAndThread(identity);
void zep.graph.warm(graphUuid).catch(() => {});
},
async "message.received"(event, ctx) {
const text = event.data.message?.trim()?.slice(0, 4000);
if (!text) return;
const { threadUuid, userName } = resolveZepIdentity(ctx);
await zep.thread.addMessages(threadUuid, {
messages: [{ role: "user", name: userName, content: text }],
});
},
async "message.completed"(event, ctx) {
if (event.data.finishReason === "tool-calls") return;
const text = event.data.message?.trim()?.slice(0, 4000);
if (!text) return;
const { threadUuid } = resolveZepIdentity(ctx);
await zep.thread.addMessages(threadUuid, {
messages: [{ role: "assistant", name: "Eve Agent", content: text }],
});
},
},
});

Wrap each Zep call in try/catch so a Zep outage never fails the Eve turn.

Zep indexes knowledge asynchronously. Facts from a turn are not reliably searchable until processing finishes — often tens of seconds. Confirm facts in the Zep app before expecting preference recall in a new session.

On-demand search tools

Expose graph.getContext as authored tools. Pin the graph UUID from your own records or config — never from the model:

// User graph
await zep.graph.getContext(graphUuid, {
query: query.slice(0, 400), // Keep the search query short
maxCharacters: 4000,
});
// Shared company Context Graph (seed once outside Eve, then search)
await zep.graph.getContext(process.env.ZEP_COMPANY_GRAPH_UUID!, {
query: query.slice(0, 400), // Keep the search query short
maxCharacters: 4000,
});

For shared organization knowledge, create and seed a Context Graph with the Zep SDK. Wait for episodes to process before the tool searches the graph. Store the UUID that graph.create returns in your configuration, for example in ZEP_COMPANY_GRAPH_UUID.

Production notes

  • Replace demo identity — resolve userId from real auth in multi-tenant production.
  • Safe retries — a v4 create has no identifier, so a repeated create makes a second user or thread. Store the UUIDs after the first create. An idempotency key is optional.
  • Message size — Zep rejects thread messages over 4,096 characters; truncate to ~4,000 before thread.addMessages.
  • Search query size — The example truncates the graph.getContext query to 400 characters, because a long query increases latency.
  • Async indexing — do not expect read-after-write within the same turn.

Learn more