About conversations
A skill for building inboxes and related tools with Zavu’s conversation REST API. It explains thread identity, message retrieval, search, read state, replies, and webhook updates across channels.
Key features
- 01Cross-channel conversation threads
- 02Thread listing and identity search
- 03Message retrieval and read-state handling
- 04Replies using the thread sender
- 05Webhook-based live updates
Use cases
- 01Build a shared inbox thread list
- 02Show a contact’s conversation history
- 03Reply through the sender used by a thread
- 04Keep inbox views updated from webhooks
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ zavudev/ zavu-skills --skill conversationsAfter installing, type/conversationsin Claude Code to confirm it loaded.
Details
From the project READMEConversations
When to Use
Use this skill when building anything that reads an inbox: a shared inbox UI, a helpdesk sync, a "show me this customer's history" tool, or an agent that needs the thread before it answers.
GET /v1/messages returns a flat log with no thread to hang it on. Conversations are the thread.
What a conversation is
One thread with one contact, spanning every channel. A contact who writes on WhatsApp and later by email stays in the same conversation, and channels lists what it has carried.
| Field | Meaning |
|---|---|
id | Thread ID. Same value as conversationId on messages, conversation.new and message.inbound. |
contactIdentifier | The key the thread is filed under. Not always a phone number — see below. |
contactId | The contact, when resolved. Absent on group threads. |
channels | Every channel this thread has carried. |
lastMessage | Denormalized preview (id, text, channel, direction, at) so a thread list needs no extra fetch. |
senderId | Sender that last handled the thread. Pass it back as Zavu-Sender when replying. |
unreadCount | Inbound messages not yet marked read. |
whatsapp | bsuid + username, when the contact adopted a WhatsApp username. |
contactIdentifier is not a phone number
It holds whichever identifier keys the thread: an E.164 phone, a WhatsApp BSUID (US.13491208655302741918), or a numeric chat ID (Telegram/Instagram/Messenger). Parsing it as a phone number breaks on every non-SMS channel. Use channels to decide how to render it.
Endpoints
These are not in the generated SDKs yet. Use REST.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/conversations | List threads, most recently active first. |
GET | /v1/conversations/{conversationId} | One thread. |
GET | /v1/conversations/{conversationId}/messages | Messages in the thread, newest first. |
POST | /v1/conversations/{conversationId}/read | Reset unreadCount to zero. |
List threads
curl "https://api.zavu.dev/v1/conversations?limit=25" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
Filters: senderId scopes to one number, channel keeps only threads that have carried that channel.
curl "https://api.zavu.dev/v1/conversations?senderId=sender_12345&channel=whatsapp" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
TypeScript:
const res = await fetch("https://api.zavu.dev/v1/conversations?limit=25", {
headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` },
});
const { items, nextCursor } = await res.json();
Search threads
search finds a thread by who it is with: phone number, email address, WhatsApp username, or BSUID. Phone formatting does not matter — +1 (555) 123-4567 and 15551234567 both match the same thread.
curl "https://api.zavu.dev/v1/conversations?search=%2B56912345678" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
# by email, by its local part, or by group name
curl "https://api.zavu.dev/v1/conversations?search=maria" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
Matching is by whole word with prefix matching on the last term: mar finds [email protected], and +1555 finds +15551234567. A fragment from the middle or the end of a number (4567) does not match — search the full number or a prefix.
Three things to know before you build on it:
- It does not search message bodies.
searchmatches the thread's identity, never what was said inside it. - Results are ranked by relevance, not recency. The usual "most recently active first" ordering does not apply while
searchis set. - An empty
searchreturns nothing, not everything. Drop the parameter to list all threads.
search combines with senderId and channel, and paginates with cursor like any other list.
const params = new URLSearchParams({ search: "+56912345678", channel: "whatsapp" });
const res = await fetch(`https://api.zavu.dev/v1/conversations?${params}`, {
headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` },
});
const { items } = await res.json();
Paginate
nextCursor is opaque. Pass it back verbatim as cursor; never build one by hand. nextCursor is null on the last page.
async function allConversations() {
const out = [];
let cursor: string | undefined;
do {
const url = new URL("https://api.zavu.dev/v1/conversations");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}` },
});
const page = await res.json();
out.push(...page.items);
cursor = page.nextCursor ?? undefined;
} while (cursor);
return out;
}
The channel filter is applied to each page after it is fetched, so a filtered page can come back shorter than limit — even empty — while nextCursor is still set. Keep paginating until nextCursor is null; do not stop on a short page.
Telling the two sides apart
Every message carries direction (inbound or outbound). Use it — status cannot tell them apart, because an inbound message is stored as delivered too. Deriving it by comparing to against contactIdentifier works for one-to-one threads and breaks on groups, where from is the participant rather than the thread key.
for (const m of items) {
const mine = m.direction === "outbound";
console.log(`${mine ? "→" : "←"} ${m.text}`);
}
Read a thread
curl "https://api.zavu.dev/v1/conversations/$CONV_ID/messages?limit=50" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
Returns the same Message objects as GET /v1/messages, newest first, across every channel in the thread.
Mark read
curl -X POST "https://api.zavu.dev/v1/conversations/$CONV_ID/read" \
-H "Authorization: Bearer $ZAVUDEV_API_KEY"
This clears the counter in your inbox. It does not send a read receipt to the contact. To show a WhatsApp read receipt plus a typing indicator, use POST /v1/messages/{messageId}/typing on the inbound message instead.
Replying in a thread
Send with POST /v1/messages, addressing the thread's identifier and passing its senderId as Zavu-Sender so the reply leaves from the number the contact already knows.
const conv = await getConversation(conversationId);
await zavu.messages.send({
to: conv.contactIdentifier,
channel: conv.lastMessage.channel,
text: "Thanks — shipping today.",
"Zavu-Sender": conv.senderId,
});
The sender override is a header param inside the send params object, not a second argument.
Two constraints still apply when replying:
- WhatsApp 24-hour window. Outside it, free-form text is rejected with
whatsapp_window_closed; send an approved template instead. See thewhatsapp-templatesskill. - Channel availability. Reply on a channel the sender actually has. Trust
channelsonGET /v1/senders/{senderId}, not the presence of a phone number.
Getting a conversation from a webhook
message.inbound carries data.conversationId on every channel except reactions. It is always set: the webhook is sent once the message has been filed in its thread, so the first message of a new thread carries the same id as conversation.new, and the WhatsApp message that ties a BSUID to a phone number names the thread that remains. GET /v1/messages/{messageId} returns the same id.
Do not fall back to listing conversations and matching on the phone number: on a busy project the thread may not be at the top yet, and the identifier may be a BSUID rather than the number you expected.
Building an inbox
The API gives you threads, messages, and read state. It does not store assignment, open/done status, internal notes, or saved replies — that workspace state belongs to your app. Key it by conversation.id, which is stable across channels and survives WhatsApp identity re-keying (a BSUID-only thread that later gains a phone number keeps its id).
For live updates, subscribe a webhook to message.inbound, message.sent, message.delivered, message.read, message.failed, and conversation.new, then patch your local view. Polling GET /v1/conversations is the fallback when you cannot receive webhooks. See the webhook-setup skill.
Gotchas
| Trap | Reality |
|---|---|
Treating contactIdentifier as a phone | It can be a BSUID or a chat ID. |
| Building a cursor | Cursors are opaque; only echo nextCursor back. |
| Stopping pagination on a short page | The channel filter shrinks pages. Stop on nextCursor === null. |
Expecting contactId everywhere | Absent on group threads and unresolved contacts. |
Expecting /read to notify the contact | It only clears your own counter. |