Skip to navigation

Migrating from v3

Every v3 SDK method mapped to its v4 replacement

Most v3 methods have a direct v4 replacement. This page maps all of them for Python, TypeScript, and Go, then covers the changes that need more than a rename.

The v3 column matches zep-cloud / @getzep/zep-cloud / zep-go v3.30.0.

UUID addressing, pagination, and asynchronous deletes account for most of the work in a port. Read Migration steps, Pagination, and Deletes first.

Let a coding agent do the migration

The Build with Zep plugin for Claude Code, Codex, and Cursor contains the migrating-zep-v3-to-v4 skill. The skill tells your coding agent how to migrate your application from v3 to v4 with this guide.

To use the skill, install the Build with Zep plugin. Then ask your agent to migrate your application to Zep v4.

Migration steps

v4 addresses every user, thread, and graph by a UUID that Zep generates. Your v3 user_id, thread_id, and graph_id values are not v4 addresses. The optional v4 user_id field supports identity-provider (IdP) matching. It does not change the user’s v4 address. This section explains the changes and gives the steps to migrate an application from v3 to v4.

What is true in v4:

  • The methods that took your identifier in v3, such as get, update, delete, and add_messages, take a UUID. If you pass the old identifier, the call fails with HTTP 400 and the error code invalid_identifier.
  • Zep generates the UUID of every user, thread, and graph. user.create accepts an optional user_id for IdP matching. This value is unique within the project, has a 500-character limit, and cannot change after creation. It is not the user’s address. thread.create rejects thread_id, graph.create rejects graph_id, and graph.clone rejects target_graph_id.
  • A v4 user response includes user_id when it has a value. A v4 create can set this field, and a v3-created user keeps its existing value. The field is read-only in v4. A v4-created thread or graph has no legacy ID.
  • A resource from v3 keeps its v3 identifier as read-only data in v4. This value is not a v4 address.
  • v3 and v4 serve the same project data at the same time. The v4 API addresses a v3 resource by its UUID. The v3 user.get method can find a v4-created user when the request uses the same user_id. The v3 API cannot address a v4-created thread or graph.
  • The lookup operations resolve a legacy name to a UUID: user.lookup, thread.lookup, graph.lookup, and lookup.batch. lookup will become deprecated when the v3 surface retires, and a later major version will remove it.

Why it changes: Each identifier is an opaque UUID. A UUID holds no tenant, no resource type, no order, and no customer data. A customer-supplied value can hold personal data, and a value in a URL path or a query string goes into access logs, proxy logs, referrer headers, and error reports. For the same reason, each lookup operation is a POST with the identifier in the request body.

The pattern after the migration: Your application stores each Zep UUID in its own database, next to its own identifier. It reads the UUID from that row and passes it on every v4 call. For example, use zep_user_uuid and zep_graph_uuid columns on your users table, and a zep_thread_uuid column on the table that holds conversations. Zep stores an optional user_id in the same field that v3 uses. Set it only when the IdP must match a user. Use the UUID to address the user in v4. If you need an application label on a Zep user, put it in the user metadata. A thread has no metadata field. A graph has a name and a description; these fields describe the graph.

How to migrate: Do the steps below in order. Your application stores the UUID of each new resource while it still uses v3. Then a backfill job gives the existing rows their UUIDs. The backfill is the only step that uses lookup. Because lookup will become deprecated, your application code must never call it, also not as a fallback. Then your application moves its reads and writes to v4. Move resource creation to v4 after your remaining v3 calls no longer need identifiers that v4 does not set. The v3 user.get method can find a v4-created user when user_id matches. The v3 API cannot address a v4-created thread or graph.

During steps 2 to 5, your application uses the v3 SDK and the v4 SDK. The Go SDKs have different module paths (github.com/getzep/zep-go/v3 and github.com/getzep/zep-go/v4), so one Go module can import both. In TypeScript, install the v3 SDK under an alias, for example npm install zep-cloud-v3@npm:@getzep/zep-cloud@^3.30.0, and install the v4 SDK as @getzep/zep-cloud. In Python, both SDKs are the zep-cloud package, so one Python environment cannot contain both. Run the v3 calls and the v4 calls in different processes, for example in two services.

Step 1: Add nullable UUID columns

Purpose: Give each of your records a place for the Zep UUID.

Add a nullable column for the Zep UUID to each table that refers to a Zep resource. For example:

  • Add zep_user_uuid and zep_graph_uuid to your users table.
  • Add zep_thread_uuid to the table that holds conversations.
  • Add zep_graph_uuid to each table that refers to a standalone graph.
  • Add zep_lookup_status to each table that the backfill reads. The backfill writes 'not_found' into this column and skips those rows later.

Keep your own identifier columns. They stay the key of your records.

The columns are nullable because the existing rows have no UUID yet. Step 3 fills them.

Finish this step when the columns exist in production and your application runs with the new schema.

Step 2: Store the UUID of each new resource on v3

Purpose: Stop the number of rows without a UUID from growing.

Keep the v3 create calls, and keep your v3 identifiers for now. Change each create call to write the UUID from the v3 response into the new column. The v3 response already contains the UUID that v4 uses, so no lookup is necessary. Use v3 SDK version 3.30.0 or later, which exposes the graph fields below.

v3 create callValue to storeColumn
user.adduuidzep_user_uuid
user.addgraph_uuidzep_graph_uuid on the user row
thread.createuuidzep_thread_uuid
graph.createcanonical_graph_uuidzep_graph_uuid

Do not store the v3 Graph.uuid. That value identifies an internal group record, and v4 does not accept it as a graph UUID. v4 addresses a graph by the value that v3 returns as canonical_graph_uuid. User and thread UUIDs have the same value in v3 and v4.

A v3 graph.clone response contains the identifiers of the target, not a UUID. After a clone into a standalone graph, call graph.get on v3 with the target graph_id, and store its canonical_graph_uuid. After a clone into a user graph, call user.get on v3 with the target user_id, and store its graph_uuid.

The graph fields are empty when the graph record does not exist yet. In that case, write NULL. The backfill in step 3 fills the value.

from zep_cloud.client import Zep # zep-cloud 3.30.0 or later (v3)
zep_v3 = Zep(api_key=ZEP_API_KEY)
def create_user(db, user_id: str, email: str) -> None:
user = zep_v3.user.add(user_id=user_id, email=email)
db.execute(
"UPDATE users SET zep_user_uuid = %s, zep_graph_uuid = %s WHERE id = %s",
(user.uuid_, user.graph_uuid, user_id),
)
def create_conversation(db, conversation_id: str, user_id: str) -> None:
thread = zep_v3.thread.create(thread_id=conversation_id, user_id=user_id)
db.execute(
"UPDATE conversations SET zep_thread_uuid = %s WHERE id = %s",
(thread.uuid_, conversation_id),
)
def create_team_graph(db, team_id: str) -> None:
graph = zep_v3.graph.create(graph_id=team_id, name="Team knowledge")
db.execute(
"UPDATE teams SET zep_graph_uuid = %s WHERE id = %s",
(graph.canonical_graph_uuid, team_id),
)

Finish this step when the new code runs on every instance of your application. From this point, each new row gets its UUID at creation time.

Step 3: Backfill the existing rows with lookup

Purpose: Give every row that existed before step 2 its UUID.

This step is the only step that uses lookup. lookup.batch resolves up to 1000 identifiers of users, threads, and graphs in one request. The response has one item for each identifier. An identifier that does not resolve returns found: false with no UUID.

Run a job outside your request path. The job uses the v4 SDK:

  1. Read up to 1000 rows that have no UUID. Order the rows by your own identifier, and continue after the last identifier of the previous page.
  2. Call lookup.batch with the identifiers of those rows. One request accepts up to 1000 identifiers in total across users, threads, and graphs. Do not put the same identifier two times in one list.
  3. For each item with found: true, write the uuid into the row. Write only when the column is still NULL, so that the job never changes a value that step 2 wrote.
  4. For each item with found: false, record the row as not found, for example in a zep_lookup_status column. The job does not read these rows again.
  5. Continue with the next page until no row remains.

lookup.batch returns only the UUID of each user. To fill zep_graph_uuid on the users table, call user.get on v4 for each user that has a zep_user_uuid and no zep_graph_uuid, and store the graph_uuid of the response. For graphs, lookup.batch returns the UUID that v4 uses.

The job is safe to run again. It reads only rows without a UUID and rows that are not marked as not found. It writes only to empty columns. If the job stops, start it again from the beginning.

An item with found: false means that the identifier does not resolve for the API key. The resource does not exist in the project of the key, the resource was deleted, or the key cannot read the resource. Examine each of these rows. Then create the resource again with the code from step 2, or remove the reference to Zep from the row.

Send one lookup request at a time. When Zep limits the request rate, a request fails with HTTP 429, the error code rate_limited, and a Retry-After header that gives the number of seconds to wait. The SDKs retry HTTP 429 automatically. If the error still reaches your job, wait the number of seconds in Retry-After, and send the same request again.

A request that fails with HTTP 403 permission_denied or HTTP 503 service_unavailable did not resolve any identifier. Do not mark rows as not found for this failure. Stop the job, correct the cause, and run the job again.

import time
from zep_cloud.client import Zep # v4
from zep_cloud.core.api_error import ApiError
zep = Zep(api_key=ZEP_API_KEY)
PAGE_SIZE = 1000
def backfill_users(db) -> None:
last_id = ""
while True:
rows = db.fetch_all(
"SELECT id FROM users"
" WHERE zep_user_uuid IS NULL AND zep_lookup_status IS NULL AND id > %s"
" ORDER BY id LIMIT %s",
(last_id, PAGE_SIZE),
)
if not rows:
return
ids = [row.id for row in rows]
result = lookup_with_retry(ids)
for item in result.items or []:
if item.found:
db.execute(
"UPDATE users SET zep_user_uuid = %s"
" WHERE id = %s AND zep_user_uuid IS NULL",
(item.uuid_, item.legacy_id),
)
else:
db.execute(
"UPDATE users SET zep_lookup_status = 'not_found' WHERE id = %s",
(item.legacy_id,),
)
last_id = ids[-1]
def lookup_with_retry(ids: list[str]):
while True:
try:
return zep.lookup.batch(users=ids)
except ApiError as err:
if err.status_code != 429:
raise # 403 and 503 stop the job. No row is marked.
time.sleep(int((err.headers or {}).get("retry-after", "1")))

The examples show the users table. Run the same job for your conversation table with threads, and for your graph tables with graphs.

Finish this step when the job finds no row without a UUID, and you resolved every row that is marked as not found.

Step 4: Move reads and writes to v4

Purpose: Remove every v3 call that reads or changes an existing resource.

Change each read and write call to v4, and pass the stored UUID. Use the method map for the v4 method, and read Changes that need more than a rename for the changes in behavior. Keep the v3 create calls from step 2.

Put the v4 code behind a feature flag, or roll it out to a part of your traffic at a time. v3 and v4 use the same data, so a message that v3 adds is available on v4, and the reverse is also true. You can move the traffic back to v3 during this step, because every resource still has its v3 identifier.

Start this step only after the step 3 job finds no more rows without a UUID. Before you start, resolve each row that the job marked as not found: create the resource again with the code from step 2, or remove the reference to Zep from the row. Step 2 continues to store the UUID of each new resource, so every row then has its UUID. The v4 code reads the UUID from your table and does not call lookup. Do not add a lookup fallback to your application, because lookup will become deprecated.

A new row can still have no UUID, for example because a write in step 2 failed. The v4 code cannot address that row. Serve the requests for that row with the v3 code, and run the step 3 job again to fill the UUID.

The example below moves one call to v4. The v4 code reads the thread UUID from your own table, and then passes it to thread.add_messages.

v3. thread.add_messages takes your thread_id.

from zep_cloud.types import Message
client.thread.add_messages(
thread_id="thread_1234",
messages=[
Message(role="user", name="Ada", content="I prefer window seats."),
],
)

v4. Read the thread UUID from your conversation record, and pass it.

from zep_cloud import AddMessage
row = db.fetch_one(
"SELECT zep_thread_uuid FROM conversations WHERE id = %s",
("thread_1234",),
)
client.thread.add_messages(
row.zep_thread_uuid,
messages=[
AddMessage(role="user", name="Ada", content="I prefer window seats."),
],
)

Finish this step when all of your traffic uses the v4 code for reads and writes, and the only remaining v3 calls are the create calls.

Step 5: Move resource creation and the remaining v3 code to v4

Purpose: Create new users, threads, and graphs on v4, and move the remaining v3 code to v4.

Change each create call to v4. Set user_id only when it must match an IdP claim. Do not send thread_id, graph_id, or target_graph_id. Read each UUID from the response and store it in the row:

v4 create callValue to storeColumn
user.createuuidzep_user_uuid
user.creategraph_uuidzep_graph_uuid on the user row
thread.create (takes the user_uuid)uuidzep_thread_uuid
graph.createuuidzep_graph_uuid
graph.clone (takes the source graph_uuid, no target)graph.uuidzep_graph_uuid of the copy

A create call is a network call, so do not keep a database transaction open while it runs. Use this order of operations:

  1. Insert your row with the Zep UUID column set to NULL.
  2. Call the v4 create with the profile fields, such as the email address.
  3. Write the UUID from the response into the row.

If the process stops after step 1 or step 2, the row has a NULL UUID. A repair task finds these rows and repeats steps 2 and 3.

The SDK sends an idempotency key on its own retries, so a retried request does not make a second resource. A create that your application repeats, such as the repair task after a stop between steps 2 and 3, makes a second user. To prevent that, you can pass an optional idempotency key that you store with the row. See Repeated creates.

This order prevents orphans. If you create the Zep resource before you insert your row, and the insert fails, no row refers to the Zep resource.

from zep_cloud.client import Zep # v4
zep = Zep(api_key=ZEP_API_KEY)
def create_user(db, user_id: str, email: str) -> None:
db.execute(
"INSERT INTO users (id, email) VALUES (%s, %s)",
(user_id, email),
)
provision_zep_user(db, user_id)
def provision_zep_user(db, user_id: str) -> None:
"""Called at creation time, and again by the repair task."""
row = db.fetch_one(
"SELECT email FROM users WHERE id = %s", (user_id,)
)
user = zep.user.create(email=row.email)
db.execute(
"UPDATE users SET zep_user_uuid = %s, zep_graph_uuid = %s"
" WHERE id = %s AND zep_user_uuid IS NULL",
(user.uuid_, user.graph_uuid, user_id),
)
def provision_zep_thread(db, conversation_id: str) -> None:
row = db.fetch_one(
"SELECT u.zep_user_uuid FROM conversations c"
" JOIN users u ON u.id = c.user_id WHERE c.id = %s",
(conversation_id,),
)
thread = zep.thread.create(user_uuid=row.zep_user_uuid)
db.execute(
"UPDATE conversations SET zep_thread_uuid = %s"
" WHERE id = %s AND zep_thread_uuid IS NULL",
(thread.uuid_, conversation_id),
)

Use the same pattern for graph.create and graph.clone. A clone request has no body fields: Zep creates the target graph, and the response contains the new graph and a task. The response contains the UUID of the copy at once, so store it before the task completes. See Cloning graphs.

In the same step, move every other v3 call that remains in your application to v4. Use the method map for the v4 method of each call, and read Changes that need more than a rename for the changes in behavior. After this step, your application sends no v3 call.

Do not move reads or writes back to v3 after this step starts. The v3 user.get method can find a v4-created user only when user_id matches. The v3 API cannot address a v4-created thread or graph.

When your application sends no v3 call, complete the migration:

  • Remove the v3 SDK package from your dependencies. In Python, also remove the separate process that sent the v3 calls.
  • Delete the backfill job from step 3. Your application does not need it after this step.
  • Keep the repair task from this step. It is part of the create pattern.

A v3-created user keeps its user_id as a read-only field in v4. V3-created threads and graphs also keep their v3 identifiers as read-only fields. These values are not v4 addresses. Read the UUID from your own record.

Finish this step when a search of your code finds no v3 SDK import and no lookup call, and the repair task finds no row that has a NULL UUID.

Method map

Use the language tab that matches your SDK. Each row is one v3 method.

TypeScript search and graph-list methods take one request object. Put pageSize, query, and filters on that object. Use { pageSize: 1 } for an unfiltered list and { pageSize: 1, query: "..." } for search. Do not nest these fields under body. Filter names use camelCase, such as connectedNodeUuids. The SDK ignores unknown filter keys, so a snake_case key has no effect.

Go search methods take request structs with top-level fields. For example, use &zep.SearchEdgesRequest{Query: "..."}. Graph artifact-list methods use resource-specific request structs. For example, use &graph.EdgeListRequest{Filters: &zep.EdgeListFilters{ConnectedNodeUUIDs: []string{...}}}. The episode list also takes Filters directly, for example &graph.EpisodeListRequest{Filters: &zep.EpisodeListFilters{MentionedNodeUUIDs: []string{...}}}.

Users

v3v4
user.adduser.create (optional user_id for IdP matching)
user.list_ordereduser.list
user.getuser.get (takes user_uuid)
user.updateuser.update (takes user_uuid)
user.deleteuser.delete (takes user_uuid; returns a task)
user.get_nodeuser.get_node (takes user_uuid)
user.get_threadsthread.list(user_uuid=)
user.warmgraph.warm(graph_uuid=user.uuid_)
user.list_user_summary_instructionsuser.get_summary_instructions, or project.get_user_summary_instructions
user.add_user_summary_instructionsuser.set_summary_instructions, or project.set_user_summary_instructions (writes the whole set)
user.delete_user_summary_instructionsuser.set_summary_instructions, or project.set_user_summary_instructions (writes the whole set)

Threads

v3v4
thread.createthread.create (requires user_uuid, no thread_id)
thread.deletethread.delete (takes thread_uuid; returns a task)
thread.list_allthread.list
thread.get (returns messages)thread.list_messages. thread.get in v4 returns the thread record.
thread.add_messagesthread.add_messages (takes thread_uuid)
thread.add_messages_batchbatch.create, then batch.add_items, then batch.process
thread.get_user_contextthread.get_context
thread.get_summarythread.get_summary
thread.get_episodesthread.list_episodes (takes thread_uuid)
thread.message.updatethread.message.update (takes thread_uuid and message_uuid)

Graphs and ingestion

v3v4
graph.creategraph.create (no graph_id)
graph.getgraph.get (takes graph_uuid)
graph.updategraph.update (takes graph_uuid)
graph.deletegraph.delete (takes graph_uuid; returns a task)
graph.list_allgraph.list
graph.warmgraph.warm (takes graph_uuid)
graph.clonegraph.clone (source graph_uuid, no target)
graph.addgraph.episode.add (returns an AddEpisodeResult; see Ingestion results)
graph.add_batchbatch.create, then batch.add_items, then batch.process
graph.add_nodesgraph.node.add
graph.add_fact_triplegraph.edge.add (takes an edges list; see Adding edges)
graph.get_episodes_for_documentgraph.episode.list_for_document (also requires graph_uuid)
graph.get_subgraphgraph.get_subgraph
graph.detect_patternsremoved

Search and context

v3v4
graph.search with scope edgesgraph.search_edges
graph.search with scope nodesgraph.search_nodes
graph.search with scope episodesgraph.search_episodes
graph.search with scope observationsgraph.search_observations
graph.search with scope thread_summariesgraph.search_thread_summaries
graph.search with scope autograph.get_context

Graph artifacts

v3v4
graph.edge.get_by_graph_idgraph.edge.list
graph.edge.get_by_user_idgraph.edge.list
graph.edge.getgraph.edge.get (also requires graph_uuid)
graph.edge.updategraph.edge.update (also requires graph_uuid)
graph.edge.deletegraph.edge.delete (also requires graph_uuid; returns a task)
graph.node.get_by_graph_idgraph.node.list
graph.node.get_by_user_idgraph.node.list
graph.node.getgraph.node.get (also requires graph_uuid)
graph.node.updategraph.node.update (also requires graph_uuid)
graph.node.deletegraph.node.delete (also requires graph_uuid; returns a task)
graph.node.get_edgesgraph.edge.list with filters.connected_node_uuids
graph.node.get_episodesgraph.episode.list with filters.mentioned_node_uuids
graph.node.get_neighborsgraph.node.list_neighbors
graph.episode.get_by_graph_idgraph.episode.list
graph.episode.list_by_graph_idgraph.episode.list
graph.episode.get_by_user_idgraph.episode.list
graph.episode.list_by_user_idgraph.episode.list
graph.episode.getgraph.episode.get (also requires graph_uuid)
graph.episode.updategraph.episode.update (also requires graph_uuid)
graph.episode.deletegraph.episode.delete (also requires graph_uuid; returns a task)
graph.episode.get_nodes_and_edgesgraph.node.list and graph.edge.list with filters.episode_uuids
graph.observation.get_by_graph_idgraph.observation.list
graph.observation.get_by_user_idgraph.observation.list
graph.observation.getgraph.observation.get (also requires graph_uuid)
graph.thread_summary.get_by_graph_idgraph.thread_summary.list
graph.thread_summary.get_by_user_idgraph.thread_summary.list
graph.document_summary.get_by_graph_idgraph.document_summary.list

Configuration

v3v4
graph.list_entity_typesgraph.get_ontology, or project.get_ontology
graph.set_entity_types_internalgraph.set_ontology, or project.set_ontology
graph.set_entity_typesgraph.set_ontology, or project.set_ontology
graph.set_ontologygraph.set_ontology, or project.set_ontology
zep_cloud.external_clients.ontology (EntityModel, EdgeModel, EntityText)zep_cloud.ontology (EntityModel, EdgeModel, EntityText, build_ontology); pass the entity_types and edge_types that build_ontology returns to set_ontology
graph.list_custom_instructionsgraph.get_instructions, or project.get_instructions
graph.add_custom_instructionsgraph.set_instructions, or project.set_instructions (writes the whole set)
graph.delete_custom_instructionsgraph.set_instructions, or project.set_instructions (writes the whole set)
project.getproject.get
project.updateproject.update
project.get_observation_steeringproject.get_observation_steering, or graph.get_observation_steering
project.set_observation_steeringproject.set_observation_steering, or graph.set_observation_steering
context.list_context_templatescontext.list_templates (filter with name, not search)
context.create_context_templatecontext.create_template
context.get_context_templatecontext.get_template
context.update_context_templatecontext.update_template
context.delete_context_templatecontext.delete_template

Batches, tasks, and user groups

v3v4
batch.listbatch.list
batch.createbatch.create
batch.getbatch.get
batch.deletebatch.delete
batch.list_itemsbatch.list_items
batch.addbatch.add_items
batch.processbatch.process
task.gettask.get
user_group.listuser_group.list
user_group.createuser_group.create
user_group.getuser_group.get
user_group.deleteuser_group.delete
user_group.updateuser_group.update
user_group.list_for_useruser_group.list_for_user
user_group.list_membersuser_group.list_members
user_group.add_membersuser_group.add_members
user_group.remove_membersuser_group.remove_members
user_group.remove_memberuser_group.remove_member
user_group.list_member_candidatesuser_group.list_member_candidates
user_group.list_policy_setsno SDK method. Manage policy sets in the Zep dashboard or with zepctl.
user_group.attach_policy_setno SDK method. Manage policy sets in the Zep dashboard or with zepctl.
user_group.detach_policy_setno SDK method. Manage policy sets in the Zep dashboard or with zepctl.

New in v4

These methods have no v3 counterpart.

  • user.lookup, thread.lookup, graph.lookup, lookup.batch
  • thread.get (thread record by UUID)
  • thread.message.get
  • task.list
  • debug_log.enable, graph.episode.get_debug_logs, graph.episode.list_ingestion_traces

graph.node.list also takes two new parameters. order_by sets the sort key: uuid (the default) or degree. order sets the direction: asc or desc (the default). When order_by is degree, each node in the result has a degree field. This field is the number of live entity edges that touch the node, in the two directions. The SDK parameters are order_by and order in Python, orderBy and order in TypeScript, and OrderBy and Order in Go. In Go, the values are enums, for example graph.NodeListRequestOrderByDegree.Ptr() and graph.NodeListRequestOrderAsc.Ptr().

Changes that need more than a rename

Pagination

Loops written against page_number or lastn, or against the Zep-Next-Cursor header, do not port. Replace them with the SDK pager. The pager walks an opaque cursor. Do not increment a page number.

v3. Advance page_number until a page is empty.

page_number = 1
while True:
page = client.user.list_ordered(page_number=page_number, page_size=50)
users = page.users or []
if not users:
break
for user in users:
print(user.user_id)
page_number += 1

v4. Iterate the pager. lastn episode reads become graph.episode.list.

for user in client.user.list(page_size=50):
print(user.user_id)

Deletes

A delete returns a task rather than a success envelope. The resource is not gone when the call returns. Poll the returned task until it reaches succeeded, partial, or failed. Bound the wait. Treat a missing status as not finished.

v3. The call returns a success envelope. The next line can assume the user is gone.

client.user.delete("user_1234")

v4. Delete by UUID, then poll the task.

import time
TERMINAL = {"succeeded", "partial", "failed"}
deadline = time.monotonic() + 600
result = client.user.delete(user.uuid_)
task = client.task.get(result.task.uuid_)
while task.status not in TERMINAL:
if time.monotonic() > deadline:
raise TimeoutError(f"task {result.task.uuid_} is still {task.status}")
time.sleep(1)
task = client.task.get(result.task.uuid_)

Instruction updates

v3 added and deleted individual instructions by name. v4 reads and writes the whole set. An update is a read, a change, and a write. A write that omits an existing name removes that instruction.

v3. Add or delete one instruction by name. Other instructions stay in place.

from zep_cloud import CustomInstruction
client.graph.add_custom_instructions(
instructions=[
CustomInstruction(name="tone", text="Prefer concise facts."),
]
)
client.graph.delete_custom_instructions(instruction_names=["tone"])

v4. Read the current list, change it, and write the full list back. project.set_instructions sets the project default. graph.set_instructions sets one graph.

from zep_cloud import CustomInstruction
current = client.project.get_instructions()
updated = [i for i in (current.instructions or []) if i.name != "tone"]
updated.append(CustomInstruction(name="tone", text="Prefer concise facts."))
client.project.set_instructions(instructions=updated)

Declaring entity and edge types in code

v3 took your model classes or structs and derived the payload inside the SDK call. v4 separates the two steps. You declare the types with the SDK ontology helpers, you build the payload, and you pass the payload to graph.set_ontology or project.set_ontology. The helper names changed with the step:

Languagev3v4
Pythonzep_cloud.external_clients.ontologyzep_cloud.ontology (EntityModel, EdgeModel, EntityText), and build_ontology
TypeScriptentityFieldsentityFields, and buildOntology
Gozep.BaseEntity, zep.BaseEdgezep.EntityBase, zep.EdgeBase, zep.Entities, zep.Edges, and zep.BuildOntology

The v3 Python module zep_cloud.external_clients.ontology does not exist in v4. Import the models and build_ontology from zep_cloud.ontology. The builder returns the payload. It does not call the API. build_ontology returns the entity_types list and the edge_types list. buildOntology and zep.BuildOntology return one Ontology object.

A type name is now a key of the builder input, not a struct tag or a class name. A property that tells two nodes of the same type apart is marked as an identity property in the declaration.

v3. graph.set_ontology took the model classes.

from pydantic import Field
from zep_cloud.external_clients.ontology import EntityModel, EntityText
class Traveler(EntityModel):
"""Someone who takes trips."""
home_city: EntityText = Field(description="The city they live in", default=None)
client.graph.set_ontology(entities={"Traveler": Traveler})

v4. Import the models from zep_cloud.ontology, call build_ontology, and pass the two lists it returns.

from pydantic import Field
from typing_extensions import Annotated
from zep_cloud.ontology import EntityModel, EntityText, Identity, build_ontology
class Traveler(EntityModel):
"""Someone who takes trips."""
home_city: Annotated[EntityText, Identity] = Field(
default=None, description="The city they live in"
)
entity_types, edge_types = build_ontology(entities={"Traveler": Traveler})
client.graph.set_ontology(
graph_uuid,
entity_types=entity_types,
edge_types=edge_types,
)

An edge type is declared the same way. Use EdgeModel in Python, an edge declaration with sourceTargets in TypeScript, and zep.EdgeBase with zep.Edges in Go. Give each edge type the entity type pairs it connects.

Ontology across many graphs

v3 applied one ontology to many graphs in a single call. v4 has one ontology operation per scope. Set the project ontology once for a shared default, or loop over the graphs that need an override.

v3. One call can target many user_ids and graph_ids.

client.graph.set_ontology(
user_ids=["user_1234", "user_5678"],
graph_ids=["support-kb", "sales-kb"],
entities=entities,
edges=edges,
)

v4. Set the project default, or call graph.set_ontology once per graph UUID.

client.project.set_ontology(entity_types=entity_types, edge_types=edge_types)
for graph_uuid in graph_uuids:
client.graph.set_ontology(
graph_uuid,
entity_types=entity_types,
edge_types=edge_types,
)

List filters

v3 had one artifact getter per scope, and a getter took no filter. v4 has one list per artifact type, and the list takes a filters object in the request body. The server applies the object, and combines each key with the scope of the list with a logical AND.

The SDKs give filters a type. The 5 artifact lists take SearchFilters, and graph.episode.list takes EpisodeListFilters. In Python, filters also accepts a dictionary. In TypeScript, the field names are camelCase, for example connectedNodeUuids, and the SDK removes a key that it does not know. The type does not show which keys a list accepts, so the server checks the keys. Three rules apply:

  • A key that no filter defines gets HTTP 400 with the code unsupported_filter and the name of the key.
  • A key that the result type cannot enforce gets the same error. graph.edge.list is the only list that takes connected_node_uuids, source_node_uuids, and target_node_uuids, because the other lists return no edges. graph.observation.list, graph.thread_summary.list, and graph.document_summary.list also reject episode_uuids, because an observation and a summary are never in the mention set of an episode.
  • metadata_filters selects results whose associated episodes’ stored metadata matches the filter. The episode_metadata_filters name from earlier revisions is rejected: rename the key.

graph.node.list takes episode_uuids, which selects the nodes that the listed episodes mention. graph.episode.list takes mentioned_node_uuids and the metadata filter, and rejects every other key.

The search methods, graph.get_subgraph, graph.node.list_neighbors, and graph.get_context take the same filters object. v4 does not accept the v3 search_filters name. The filter grammar also changes:

  • Operators are lowercase names: eq, ne, gt, lt, gte, lte, is_null, and is_not_null. property_filters and metadata_filters also take in. Only metadata_filters takes contains. v4 rejects uppercase and symbolic operators, such as EQ, =, IN, and CONTAINS.
  • Replace the v3 CONTAINS operator and its comma-separated value with in and an array value. The v4 contains operator is a case-sensitive substring match on metadata_filters.
  • date_filters replaces the v3 date arrays. Its any_of list holds groups, and each group has an all_of list of conditions. Each condition has a field (created_at, valid_at, invalid_at, or expired_at), an operator, and a value in RFC 3339 format. One group can contain conditions on more than one field. A request with more than one any_of group must use one field in every condition of every group. Otherwise, the request fails with HTTP 400 and the code unsupported_filter. In TypeScript, the names are dateFilters, anyOf, and allOf, and value is a Date. In Go, field and operator are enums, for example zep.DateFilterFieldCreatedAt.Ptr() and zep.DateFilterOperatorGte.Ptr(), and value takes zep.Time(...).
  • Each property_filters item has a property_name, an operator, and a value.
  • metadata_filters has a type of and or or, a filters list, and a groups list for nested groups.

An unknown key at the top level of filters fails with HTTP 400 and the code unsupported_filter. An unknown key in a nested filter object fails with HTTP 400 and the code invalid_request. See Searching the graph.

Searching for a name

The user, graph, and context template lists are POST .../list with the filter term in the body. A term you search by is customer data, and a query string is logged. Pass the term on the SDK list call. Do not put the term in a URL.

v3. graph.list_all sent search as a query parameter.

page = client.graph.list_all(search="EMEA support", page_number=1, page_size=20)

v4. graph.list puts search in the request body. Iterate the pager.

for graph in client.graph.list(search="EMEA support", page_size=20):
print(graph.uuid_, graph.name)

user.list and graph.list take the same search field. context.list_templates has no search field. It takes name, which selects the template with that exact name.

Reference times

v3 took the time of a message or an episode in a created_at field. v4 takes the same value in reference_time. The rename applies to these inputs:

  • Each message in thread.add_messages.
  • graph.episode.add.
  • Each item in batch.add_items.

The Message and Episode objects return the value in reference_time. A request that sets created_at on one of these inputs fails with HTTP 400 and the error code invalid_request. The SDK field is reference_time in Python, referenceTime in TypeScript, and ReferenceTime in Go.

Adding edges

v3 graph.add_fact_triple added one fact triple in each call. v4 graph.edge.add takes an edges list with 1 to 100 edges. Each edge has a fact, a fact_name, a source_node, and a target_node. An edge can also have valid_at, invalid_at, expired_at, attributes, and metadata.

A node reference contains the uuid of an existing node, or the name of a node to create. A reference with a name can also contain labels, a summary, and attributes.

The deduplicate field is false by default. The field applies to the nodes and to the edges:

  • When deduplicate is false, Zep does not compare the request with the graph. Each node name creates a new node. In one request, the node references that have the same name and labels use one node. Each edge is a new edge. The response gives the source_node_uuid and target_node_uuid of each edge.
  • When deduplicate is true, Zep matches each node name with an existing node in the graph. Zep can also merge a duplicate edge, and Zep can invalidate facts in the graph that the new fact contradicts. Deduplication adds LLM processing for each edge. The task result gives the node UUIDs.

v3 graph.add_fact_triple always matched each node name with an existing node, and it could invalidate contradicted facts. To keep this behavior in v4, set deduplicate to true.

These v3 checks also changed:

  • v3 rejected a node UUID that was not in the graph. v4 accepts the request, and the task fails with the node_not_found error.
  • v3 rejected an edge when the source node and the target node had the same name or the same UUID. v4 accepts this edge.

See Adding fact triplets.

Context assembly

graph.get_context replaces graph.search with scope auto. The Context Block from graph.get_context contains no episodes. To get episodes, call graph.search_episodes or graph.episode.list.

The v4 SDKs do not have the v3 context string helper: compose_context_string in Python, composeContextString in TypeScript, and ComposeContextString in Go. Use the Context Block from graph.get_context. If you make your own context string from the results of a search operation, you must write your own Context Block formatter.

Ingestion results

In v3, graph.add returned an Episode. In v4, graph.episode.add returns an AddEpisodeResult. Its episode field has the new episode, and its task field has the task that tracks graph extraction. Code that read episode.uuid_ in v3 must read result.episode.uuid_ in v4. In TypeScript, read result.episode?.uuid. In Go, read result.Episode.UUID.

Errors

Every v4 error response has an error object with these fields: code, message, param, details, and request_id. The code is a stable value that your code can compare. param names the request field that caused the error, when one field caused it. Every v4 response has an X-Zep-Request-ID header with the same value as request_id.

HTTP statusError codes
400invalid_request, invalid_cursor, unsupported_filter, invalid_identifier
401unauthenticated
403permission_denied
404not_found
409resource_already_exists, cursor_stale, conflict, idempotency_key_conflict
413payload_too_large
429rate_limited
500internal_error
503service_unavailable

Renamed fields

Each row is one field that v4 renames. The v3 column gives the v3 field, and the v4 column gives the field that replaces it.

Object or requestv3v4
Nodeepisodes, episodes_truncatedepisode_uuids, episode_uuids_truncated
Edgeepisodesepisode_uuids
Tasktask_id, paramsuuid, result
Context templatetemplate_iduuid (address) and name
Search and subgraph requestsearch_filtersfilters
Auto search and context requestreturn_raw_resultsinclude_results
List responsetotal_count, row_counttotal_size

The user, thread, graph, user group, task, batch, and context template lists return total_size. The graph artifact lists, the search operations, and the ingestion trace list return no total count. Use next_cursor to get the next page of these lists.

Document identifiers

v4 rejects a document_id that contains /, ?, #, or an ASCII control character, and rejects a whitespace-only value. v3 accepted any string of 1 to 100 characters.

Repeated creates

In v3, a retried create with the same identifier failed because the identifier was already in use. In v4, a repeated user.create with the same user_id returns HTTP 409 resource_already_exists. A create without user_id, or a thread or graph create, can make a new resource when you send it again with a new idempotency key. The SDK sends an idempotency key on its own retries, so a retry does not make a second resource. You can also pass an Idempotency-Key on a create that your application can repeat. See Step 5 for the pattern.

Defaults and caps

Search page_size defaults to 50, where v3 defaulted to 10. The maximum is 50 in both. A graph artifact list defaults to 50 with a maximum of 100. The other lists, such as user.list, thread.list, and task.list, default to 50 with a maximum of 200. lookup.batch accepts up to 1000 identifiers in one request.

What has no v4 equivalent

  • graph.detect_patterns / graph.detectPatterns / Graph.DetectPatterns. The experimental endpoint is removed.
  • User-group policy sets (list_policy_sets, attach_policy_set, detach_policy_set and the TypeScript / Go equivalents). The v4 SDKs have no method for these operations. Manage policy sets in the Zep dashboard or with zepctl.