Teams on Zoho's suite keep their support history in Zoho Desk: customer problems, agent replies, internal comments and knowledge-base articles. Zoho CRM had a cognee issue of its own, but Desk is a separate product with its own API, and nobody had proposed a connector for it. So I wrote the issue, tested the API on a trial account, and built it for Mergetober.
- Pull request: topoteretes/cognee-community#588
- Issue: topoteretes/cognee#6034
The demo: a deleted article the API still calls "Published"
I set up a small support case in Zoho Desk: a ticket from a customer whose password reset email never arrived, with an internal comment from the agent ("our mail provider was flagging reset emails as spam, fixed the SPF record"), and a knowledge-base article saying reset links stay valid for 15 minutes. Then I synced it into cognee (OpenAI as the LLM) and asked one question that needs both:
How long does a password reset link stay valid, and why were customers not receiving password reset emails?
Validity: Password reset links stay valid for 15 minutes. After that, request a new link from the sign-in page.
Why emails weren't received: The mail provider/server was flagging reset emails as spam due to an SPF record issue. The SPF record was fixed.
The 15 minutes came from the article, and the spam fix from the ticket's internal comment. Here's the article in Zoho Desk:
Then I deleted the article. This is what the Zoho Desk API said about it right afterwards:
It isn't in the Recycle Bin, where deleted tickets go. It's gone from the article listing. But looked up by id it still answers 200, with status: Published. Only isTrashed: true says it's been deleted. A connector that checks the status, or one that waits for the article to show up in the Recycle Bin, would keep it in memory for good.
The next sync forgot it (deleted 1), and the same question now gets:
Validity: The context does not specify how long a password reset link remains valid.
Why emails weren't received: The mail provider/server flagged the reset emails as spam due to an SPF record issue. The SPF record was fixed.
The article is gone from memory, and the ticket, which nobody touched, still answers its half of the question.
Setting it up
The one-time setup, in the API console for your data center (api-console.zoho.com, .eu, .in, …):
- Add Client → Self Client, and note the Client ID and Client Secret.
-
Generate Code with these read-only scopes:
Desk.tickets.READ,Desk.basic.READ,Desk.search.READ,Desk.articles.READ,Desk.contacts.READ,Desk.settings.READThat's three more than I planned on the issue:searchfor the incremental sync,contactsfor customer names, andsettingsfor the Recycle Bin. - Exchange the code for a refresh token once (the README has the exact request). The refresh token doesn't expire until you revoke it, so keep it like a password.
Zoho's Self Client gives you a refresh token straight from the API console, with no redirect server. The scopes are read-only, and the data center (com, eu, in, …) picks the domains:
import cognee
from cognee_community_connector_zoho_desk import zoho_desk_source
await cognee.remember(
zoho_desk_source(
data_center="com", client_id=client_id, client_secret=client_secret,
refresh_token=refresh_token,
),
dataset_name="zoho_desk",
primary_key="id",
write_disposition="merge",
max_rows_per_table=0,
)
Each ticket becomes one document: number, subject, department, status, priority, channel, assignee and customer name, then every thread and comment in time order, labelled Customer, Agent, Internal thread, Internal comment or Comment. Each published article becomes one document too. The department name is the node set, and departments=[...] limits the sync. Documents carry names only, never email addresses or phone numbers.
How it finds changes
The first run reads every ticket. Later runs ask the Search API for tickets modified since the cursor (modifiedTimeRange). A new reply or comment moves a ticket's modifiedTime, so a changed ticket is read again with its whole conversation. The search index can lag a little behind a change, so each window starts 10 minutes before the cursor, and tickets whose modifiedTime was already synced are skipped. The cursor comes from Zoho's own Date header, not from the machine running the sync.
Where deleted things hide
My issue assumed deletes were simple: Zoho Desk's Recycle Bin keeps deleted tickets and articles for 60 days, and the API lists them. Testing on a live account showed two surprises.
1. A just-deleted ticket is still in search. Right after I moved a ticket to the trash, the Search API still returned it, and reading its conversation answered 403. A connector that reads search results first would fail the run on a ticket that no longer exists. So each run reads the Recycle Bin first and doesn't read tickets that are in it.
2. Deleted articles don't go to the Recycle Bin at all. The first time I tested this, I published an article, deleted it, and the sync said nothing had changed. A small probe showed why (the demo above shows the same thing): a deleted article goes to the Knowledge Base's own trash. It drops out of /articles, and GET /articles/{id} still answers 200, with status: "Published" and isTrashed: true. So the article listing is read in full every run, and a stored article that's missing from it is looked up by id and forgotten when it's trashed, unpublished or gone.
Live run
On a Zoho Desk account (.in data center), through a real dlt merge:
| Step | Result |
|---|---|
| First sync (full read) | the test tickets stored with their description, no email addresses |
| Internal comment added on A | found through the Search API: scanned: 1, the comment labelled |
| B moved to the trash |
deleted: 1, A kept |
| Nothing changed | scanned: 0 |
| Article published in the Knowledge Base |
scanned: 1, the article stored |
| Article deleted (Knowledge Base trash) |
deleted: 1, the article forgotten |
Checking my own work
After opening the pull request, I reviewed it once more and found a gap the live run hadn't hit: a ticket deleted between the Recycle Bin read and the ticket's own read answers 403, and that failed the whole run. Now it's skipped, and the next sync forgets it through the Recycle Bin. A full read also no longer looks up tickets that are already in the bin. Both fixes have tests that fail on the old code.
Two things from making the demo:
- My demo script couldn't find the new article at first. Zoho Desk's listings count from 1, and asking for
from=0answers 422. The connector already handles that (it retries from 1), which is why the sync found the article when my script didn't. My script now does the same. - In one earlier demo run, a sync a few seconds after the delete still kept the article. That run didn't measure the listing, so I can't say why. In the run above, the listing dropped the article immediately and the sync forgot it. If you see a delete take one extra sync to land, that's the case.
Credits, not just rate limits
Zoho Desk meters API calls in credits per day, and every answer reports how many are left (X-Rate-Limit-Remaining-v3). When fewer than 50 remain, the run stops cleanly, keeps what it read, and leaves the cursor for the next run, so a sync never uses up the credits a team's other integrations need.
A sync more than 50 days after the last full read lists every ticket again. The Recycle Bin keeps deletes for 60 days, so a long gap can't hide one.
What I learned
Don't trust the field that sounds right. A deleted article says status: Published, and the place called "Recycle Bin" never sees it. The only honest signal was a flag I only found by deleting something and reading the whole response. For deletes, I now look at what the API returns after a delete, not at what its fields are called.
The setup steps are in the README in the pull request, and examples/example.py is the script to try it.




Top comments (0)