conduyt-mcp 4.105.4 → 4.105.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/tool-annotations.js +3 -0
- package/dist/tools/chat.d.ts +23 -0
- package/dist/tools/chat.js +121 -8
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +16 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-accessible tools. Covers the core CRM surface (contacts, deals, pipelines, custom fields, automations, messaging, and more); use `conduyt_api_catalog` to discover the full set of 600+ REST endpoints.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 387 Tools
|
|
6
6
|
|
|
7
7
|
### Discovery
|
|
8
8
|
| Tool | Description |
|
|
@@ -258,6 +258,8 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
258
258
|
| `conduyt_react_chat_message` | React to a Team Chat message as the authenticated user/agent; an AI teammate passes `claimToken` (the turn prompt's reply claim token, plus `sourceMessageId` when it reacts to another message of the thread) and the reaction ENDS that turn (outcome reacted); 409 `reaction_stale_claim` / `turn_finished` write nothing |
|
|
259
259
|
| `conduyt_stay_silent` | (#802) the turn prompt's stay_silent: ends an AI teammate's turn with no response (`POST /chat/agents/me/inbox/claim` action `silent` for the turn's `messageId`, generation and listener id read from `claimToken`); 409 `turn_finished` when it already replied or reacted |
|
|
260
260
|
| `conduyt_get_chat_linked_record` | (#802) the turn prompt's linked_record: `GET /chat/channels/{id}/linked-record`, the channel's linked deal or contact as a read-only summary `{ kind, record }` under the caller's visibility; one 404 for not a member / no link / not visible |
|
|
261
|
+
| `conduyt_get_chat_thread` | (#684, thread view on) `GET /chat/channels/{id}/messages/{messageId}/thread`: one thread by its ROOT, `{ root, replies (oldest first), total, earlierCount, hasEarlier, earlierCursor, aiReactions, replying }`; `limit` 1-50 (default 20, the latest page), `before` = `earlierCursor`; an AI teammate reads the thread it answers in with the turn prompt's Reply parent id |
|
|
262
|
+
| `conduyt_list_chat_thread_files` | (#684, thread view on) `GET /chat/channels/{id}/messages/{messageId}/files`: the files shared in one thread, oldest first, `{ data, hasMore }` (200 per read) |
|
|
261
263
|
| `conduyt_mark_chat_read` | Acknowledge read up to one message (`readUpTo` = message UUID; the marker only moves forward, an older or foreign message is a no-op; same one-retry membership bootstrap) |
|
|
262
264
|
| `conduyt_get_chat_realtime_token` | `GET /realtime/token`: a signed Ably token request for the realtime pipe (`channelIds` up to 10, sent as repeated `channel=`); `{ enabled: false, reason }` when the account switch is off; returns `accountId` / `userId` |
|
|
263
265
|
| `conduyt_get_chat_notifications` | `GET /chat/channels/{id}/notifications`: your level `{ level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }` |
|
|
@@ -266,6 +268,8 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
266
268
|
|
|
267
269
|
`conduyt_list_chat_messages` also takes `afterSeq` (messages committed after that seq; once, a whole number, never with `cursor`) and every message carries `version` (a counter bumped on each update). `conduyt_get_chat_unread` rows carry `mentions`, `notify`, `updatedAt`, `membershipId` (null = no membership), `membershipSince`, `membershipSeq`, and the response `teamChatRealtime` and `membershipSeqHigh`; `conduyt_list_chat_channels` rows carry `mentionCount` and the same membership fields.
|
|
268
270
|
|
|
271
|
+
The thread view (#684): `conduyt_list_chat_messages` takes `topLevel: true` (top-level messages with their reply chip `thread`, and the replies shown in the channel with `threadReply` `{ reason: aiFirstAnswer | alsoInChannel, root }`; never with `afterSeq`) and `conduyt_reply_chat_message` takes `alsoInChannel: true` ("Also send to #channel" on a person's reply). Both are sent only when true. They and the two thread tools work only while the workspace's switch is on (`conduyt_list_chat_channels` then carries `teamChatThreads: true`); while it is off each answers an error result with `code: team_chat_threads_off` and the sentence "The thread view is not switched on for this workspace (account setting teamChatThreads)." Against a server that predates the thread view: the two thread tools answer a 404 error result that says so, `topLevel` is reported as not applied (an error result, never a page of every reply), and an `alsoInChannel` reply is posted in the thread only, with a note saying so (never re-sent).
|
|
272
|
+
|
|
269
273
|
AI teammate turns (#802, the app listener's #655 phase 1): a turn run through Codex (`conduyt agent listen --adapter codex`) carries the thread so far and, when the channel is linked, "This channel is linked to deal <id>" (read it with `conduyt_get_chat_linked_record`), and ends with exactly ONE of `conduyt_reply_chat_message` (with `idempotencyKey` + `claimToken`), `conduyt_react_chat_message` (with `claimToken`) or `conduyt_stay_silent`; a second, different one is refused 409 `turn_finished`. The inbox itself (`/chat/agents/me/*`: the event's `context.recent`, `channel.dealId` / `contactId` and the page's `budget`) is the listener's protocol and has no tool; `GET /chat/agents` (with each teammate's `turnsToday` / `dailyBudget`) is session-only, refused to every API key, so it has no tool either: the daily budget is visible in Settings > AI and in the listener's log.
|
|
270
274
|
|
|
271
275
|
The remaining Team Chat mutations (edit/delete, pins, claim/handoff and typing/presence writes) are not wrapped yet; sends, thread replies, reactions and direct uploads are wrapped. `GET /chat/messages/{messageId}/location` is a UI deep-link helper and deliberately not wrapped. `GET /cron/team-chat-digest` is a CRON_SECRET-gated job and not key-callable.
|
package/dist/tool-annotations.js
CHANGED
|
@@ -188,6 +188,9 @@ export const TOOL_ANNOTATIONS = {
|
|
|
188
188
|
// (#802) ends an AI teammate's turn with outcome silent; a repeat on the claim it completed answers the same (like an acknowledgement)
|
|
189
189
|
conduyt_stay_silent: update("Stay Silent on a Chat Turn"),
|
|
190
190
|
conduyt_get_chat_linked_record: ro("Get Chat Linked Record"),
|
|
191
|
+
// (#684) the thread reads recover a first-use 403 through GET /chat/channels, which creates auto-channel memberships (as conduyt_list_chat_messages)
|
|
192
|
+
conduyt_get_chat_thread: update("Get Chat Thread"),
|
|
193
|
+
conduyt_list_chat_thread_files: update("List Chat Thread Files"),
|
|
191
194
|
conduyt_mark_chat_read: row("Mark Chat Read", false, false, false), // every call re-announces the read receipt to the reader's other tabs and devices, even when the marker did not move (chat/channels/[id]/messages/route.ts:126)
|
|
192
195
|
conduyt_get_chat_realtime_token: ro("Get Chat Realtime Token"),
|
|
193
196
|
conduyt_get_chat_notifications: update("Get Chat Notifications"),
|
package/dist/tools/chat.d.ts
CHANGED
|
@@ -2,5 +2,28 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { ConduytClient } from "../client.js";
|
|
3
3
|
/** The messages route's membership refusal (403 "Not a member of this channel"); any other 403 (a permission) is NOT this. */
|
|
4
4
|
export declare function isMembershipRefusal(err: unknown): boolean;
|
|
5
|
+
/** the plain sentence for the 409 team_chat_threads_off every thread view route and parameter answers while the switch is off */
|
|
6
|
+
export declare const THREADS_OFF_SENTENCE = "The thread view is not switched on for this workspace (account setting teamChatThreads).";
|
|
7
|
+
export declare const THREAD_NOT_FOUND_SENTENCE = "Not found: the message is not a thread root you can see in this channel (a reply id, an unknown or hidden message, a root past the 90-day window with no reply inside it), or this Conduyt server predates the thread view.";
|
|
8
|
+
/**
|
|
9
|
+
* (#684) a thread view refusal as a plain-sentence error result: 409 team_chat_threads_off (offSentence) and, on the two new routes,
|
|
10
|
+
* a 404 (an older server has no such route, or the id is not a visible root). null for anything else, which the caller rethrows as is.
|
|
11
|
+
*/
|
|
12
|
+
export declare function threadsRefusal(err: unknown, opts: {
|
|
13
|
+
offSentence: string;
|
|
14
|
+
notFound?: boolean;
|
|
15
|
+
}): {
|
|
16
|
+
content: {
|
|
17
|
+
type: "text";
|
|
18
|
+
text: string;
|
|
19
|
+
}[];
|
|
20
|
+
isError: true;
|
|
21
|
+
} | null;
|
|
22
|
+
/** (#684) a topLevel=true page: every row carries `thread` (a root) or `threadReply` (a reply shown in the channel). A server that
|
|
23
|
+
* predates the thread view ignores the parameter and serves every message without either key. An empty page cannot tell. */
|
|
24
|
+
export declare function topLevelApplied(page: unknown): boolean;
|
|
25
|
+
/** (#684) after a reply sent with alsoInChannel: the note when the stored metadata does not carry the flag (an older server ignored it;
|
|
26
|
+
* the reply WAS posted and must not be sent again); null when it does or there is no metadata to judge by. */
|
|
27
|
+
export declare function alsoInChannelNote(posted: unknown): string | null;
|
|
5
28
|
/** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
|
|
6
29
|
export declare function registerChatTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/chat.js
CHANGED
|
@@ -41,9 +41,49 @@ async function withMembershipBootstrap(client, read) {
|
|
|
41
41
|
return read();
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
// ---- (#684) the Team Chat thread view (app lane A de0a86727 + lane D files, behind the account switch teamChatThreads) ----
|
|
45
|
+
/** the plain sentence for the 409 team_chat_threads_off every thread view route and parameter answers while the switch is off */
|
|
46
|
+
export const THREADS_OFF_SENTENCE = "The thread view is not switched on for this workspace (account setting teamChatThreads).";
|
|
47
|
+
export const THREAD_NOT_FOUND_SENTENCE = "Not found: the message is not a thread root you can see in this channel (a reply id, an unknown or hidden message, a root past the 90-day window with no reply inside it), or this Conduyt server predates the thread view.";
|
|
48
|
+
/** a structured, non-retried error result: { ok: false, status, code?, error: <plain sentence>, message: <the API's error line> } */
|
|
49
|
+
function threadsError(status, code, sentence, message) {
|
|
50
|
+
const base = formatToolError(sentence);
|
|
51
|
+
return { ...base, content: [{ type: "text", text: JSON.stringify({ ok: false, status, ...(code ? { code } : {}), error: sentence, message: message.slice(0, 500) }, null, 2) }] };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* (#684) a thread view refusal as a plain-sentence error result: 409 team_chat_threads_off (offSentence) and, on the two new routes,
|
|
55
|
+
* a 404 (an older server has no such route, or the id is not a visible root). null for anything else, which the caller rethrows as is.
|
|
56
|
+
*/
|
|
57
|
+
export function threadsRefusal(err, opts) {
|
|
58
|
+
if (!(err instanceof ConduytApiError) || err.code !== "http")
|
|
59
|
+
return null;
|
|
60
|
+
if (err.status === 409 && errorDetailsOf(err.message)?.code === "team_chat_threads_off")
|
|
61
|
+
return threadsError(409, "team_chat_threads_off", opts.offSentence, err.message);
|
|
62
|
+
if (err.status === 404 && opts.notFound)
|
|
63
|
+
return threadsError(404, null, THREAD_NOT_FOUND_SENTENCE, err.message);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** (#684) a topLevel=true page: every row carries `thread` (a root) or `threadReply` (a reply shown in the channel). A server that
|
|
67
|
+
* predates the thread view ignores the parameter and serves every message without either key. An empty page cannot tell. */
|
|
68
|
+
export function topLevelApplied(page) {
|
|
69
|
+
const inner = page && typeof page === "object" && "data" in page ? page.data : page;
|
|
70
|
+
const rows = inner && typeof inner === "object" && Array.isArray(inner.data) ? inner.data : null;
|
|
71
|
+
return rows !== null && rows.every((r) => r !== null && typeof r === "object" && ("thread" in r || "threadReply" in r));
|
|
72
|
+
}
|
|
73
|
+
/** (#684) after a reply sent with alsoInChannel: the note when the stored metadata does not carry the flag (an older server ignored it;
|
|
74
|
+
* the reply WAS posted and must not be sent again); null when it does or there is no metadata to judge by. */
|
|
75
|
+
export function alsoInChannelNote(posted) {
|
|
76
|
+
const row = posted && typeof posted === "object" && "data" in posted ? posted.data : posted;
|
|
77
|
+
const meta = row && typeof row === "object" ? row.metadata : undefined;
|
|
78
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta))
|
|
79
|
+
return null;
|
|
80
|
+
if (meta.alsoInChannel === true)
|
|
81
|
+
return null;
|
|
82
|
+
return "Note: the reply was posted in the thread only: this Conduyt server did not record alsoInChannel (it predates the thread view). Do not send it again.";
|
|
83
|
+
}
|
|
44
84
|
/** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
|
|
45
85
|
export function registerChatTools(server, client) {
|
|
46
|
-
strictInput(server.tool("conduyt_list_chat_channels", "List the team chat channels the API key's user belongs to (GET /api/v1/chat/channels): id, name, kind, members, last message, counts. Each row also carries the Team Chat 2.0 fields mentionCount (unread @mentions of you), notify (your stored notification level: all, mentions, mute, or null = the kind's default), updatedAt (when notify last changed, null = never), membershipId (your member row; changes after a removal and re-add), membershipSince (when this membership began) and membershipSeq (strictly increasing incarnation number). Each row also carries agentsRespond (boolean, #613 lane A: false = an admin muted AI teammates in that channel) and, ONLY while the account's AI teammates switch is on, aiTeammates: [{ userId, lastHeartbeatAt, replying }] (the paired AI teammates that are members of the channel; lastHeartbeatAt is their last listener heartbeat or null, replying is true while one is working a turn in that channel; derive the state like the app: heartbeat within 60 seconds = Listening, listening and replying = Replying, otherwise Away). The aiTeammates key is absent when the switch is off. SIDE EFFECT: it also creates the caller's memberships in the account's AUTO channels (needs-a-hand, new-leads, wins) the first time, so a user who never opened Team Chat can read them. conduyt_list_chat_messages and conduyt_mark_chat_read already do this once for you when a channel listed by conduyt_get_chat_unread answers 403 'Not a member of this channel'; call this tool directly to discover channel ids.", {}, async () => {
|
|
86
|
+
strictInput(server.tool("conduyt_list_chat_channels", "List the team chat channels the API key's user belongs to (GET /api/v1/chat/channels): id, name, kind, members, last message, counts. Each row also carries the Team Chat 2.0 fields mentionCount (unread @mentions of you), notify (your stored notification level: all, mentions, mute, or null = the kind's default), updatedAt (when notify last changed, null = never), membershipId (your member row; changes after a removal and re-add), membershipSince (when this membership began) and membershipSeq (strictly increasing incarnation number). Each row also carries agentsRespond (boolean, #613 lane A: false = an admin muted AI teammates in that channel) and, ONLY while the account's AI teammates switch is on, aiTeammates: [{ userId, lastHeartbeatAt, replying }] (the paired AI teammates that are members of the channel; lastHeartbeatAt is their last listener heartbeat or null, replying is true while one is working a turn in that channel; derive the state like the app: heartbeat within 60 seconds = Listening, listening and replying = Replying, otherwise Away). The aiTeammates key is absent when the switch is off. SIDE EFFECT: it also creates the caller's memberships in the account's AUTO channels (needs-a-hand, new-leads, wins) the first time, so a user who never opened Team Chat can read them. conduyt_list_chat_messages and conduyt_mark_chat_read already do this once for you when a channel listed by conduyt_get_chat_unread answers 403 'Not a member of this channel'; call this tool directly to discover channel ids. (#684) While the workspace's thread view is on, the answer also carries teamChatThreads: true next to data (the key is ABSENT when it is off): only then do conduyt_list_chat_messages topLevel, conduyt_get_chat_thread, conduyt_list_chat_thread_files and conduyt_reply_chat_message alsoInChannel work.", {}, async () => {
|
|
47
87
|
const result = await client.get("/api/v1/chat/channels");
|
|
48
88
|
return formatResult(result);
|
|
49
89
|
}));
|
|
@@ -51,14 +91,17 @@ export function registerChatTools(server, client) {
|
|
|
51
91
|
const result = await client.get("/api/v1/chat/unread");
|
|
52
92
|
return formatResult(result);
|
|
53
93
|
}));
|
|
54
|
-
strictInput(server.tool("conduyt_list_chat_messages", "List messages in a team chat channel (GET /api/v1/chat/channels/{id}/messages), newest first, paged by cursor. The caller must be a channel member (else 403). Every message carries seq, its commit-ordered position inside the channel (a total order: the list and the cursor follow it), and version, a server counter bumped on every update (edit, delete, reaction, pin, claim, handoff) so a client can tell which copy of a message is newer. afterSeq=N is the realtime catch-up read: the messages committed AFTER seq N (give it once, a whole number 0-999999999, never together with cursor: both are refused here before any request); the page is still newest first and hasMore says more follow it (then re-read the latest page). Read-only: this tool does not move the read marker. On a 403 'Not a member of this channel' it calls GET /chat/channels once (which creates the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is; acknowledge with conduyt_mark_chat_read.", {
|
|
94
|
+
strictInput(server.tool("conduyt_list_chat_messages", "List messages in a team chat channel (GET /api/v1/chat/channels/{id}/messages), newest first, paged by cursor. The caller must be a channel member (else 403). Every message carries seq, its commit-ordered position inside the channel (a total order: the list and the cursor follow it), and version, a server counter bumped on every update (edit, delete, reaction, pin, claim, handoff) so a client can tell which copy of a message is newer. afterSeq=N is the realtime catch-up read: the messages committed AFTER seq N (give it once, a whole number 0-999999999, never together with cursor: both are refused here before any request); the page is still newest first and hasMore says more follow it (then re-read the latest page). Read-only: this tool does not move the read marker. On a 403 'Not a member of this channel' it calls GET /chat/channels once (which creates the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is; acknowledge with conduyt_mark_chat_read. (#684) topLevel: true serves the thread view's channel (top-level messages with their reply chip `thread`, and the replies shown in the channel with `threadReply`); a server that predates the thread view, which ignores the parameter, is reported as an error instead of a page of every reply.", {
|
|
55
95
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
56
96
|
limit: z.number().int().min(1).max(100).optional().describe("Messages per page (1-100, default 50)"),
|
|
57
97
|
cursor: z.string().uuid().optional().describe("nextCursor from the previous page (a message UUID)"),
|
|
58
98
|
afterSeq: z.number().int().min(0).max(999_999_999).optional().describe("Only messages committed after this seq (the realtime catch-up read). Not combinable with cursor"),
|
|
59
|
-
|
|
99
|
+
topLevel: z.boolean().optional().describe("(#684, only while the workspace's thread view is on) true = the channel as the thread view shows it: every top-level message with `thread` (its reply chip { replyCount, lastReplyAt, repliers (up to 3), replierCount }, null when it has no reply), plus the replies shown in the channel with `threadReply` { reason: aiFirstAnswer | alsoInChannel, root: { id, excerpt, deleted } | null }; every other reply stays in its thread (conduyt_get_chat_thread). Sent only when true; never with afterSeq. 409 team_chat_threads_off while the thread view is off"),
|
|
100
|
+
}, async ({ channelId, limit, cursor, afterSeq, topLevel }) => {
|
|
60
101
|
if (afterSeq !== undefined && cursor !== undefined)
|
|
61
102
|
throw new Error("afterSeq and cursor cannot be combined");
|
|
103
|
+
if (topLevel === true && afterSeq !== undefined)
|
|
104
|
+
throw new Error("topLevel and afterSeq cannot be combined (the catch-up read serves every message); nothing was sent");
|
|
62
105
|
const params = new URLSearchParams();
|
|
63
106
|
if (limit !== undefined)
|
|
64
107
|
params.set("limit", String(limit));
|
|
@@ -66,8 +109,25 @@ export function registerChatTools(server, client) {
|
|
|
66
109
|
params.set("cursor", cursor);
|
|
67
110
|
if (afterSeq !== undefined)
|
|
68
111
|
params.set("afterSeq", String(afterSeq));
|
|
69
|
-
|
|
70
|
-
|
|
112
|
+
if (topLevel === true)
|
|
113
|
+
params.set("topLevel", "true");
|
|
114
|
+
if (topLevel !== true) {
|
|
115
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
|
|
116
|
+
return formatResult(result);
|
|
117
|
+
}
|
|
118
|
+
let page;
|
|
119
|
+
try {
|
|
120
|
+
page = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const refused = threadsRefusal(err, { offSentence: THREADS_OFF_SENTENCE });
|
|
124
|
+
if (refused)
|
|
125
|
+
return refused;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
if (!topLevelApplied(page))
|
|
129
|
+
return formatToolError("This Conduyt server did not apply topLevel: its messages carry no thread summaries (it predates the thread view). Read the channel without topLevel.");
|
|
130
|
+
return formatResult(page);
|
|
71
131
|
}));
|
|
72
132
|
strictInput(server.tool("conduyt_send_chat_message", "Send a Team Chat message as the API key's user (POST /api/v1/chat/channels/{id}/messages). The caller must be a channel member and have the communications:create permission. AI-agent keys are subject to the server's six-consecutive-agent-turn cap; a 409 agent_human_required means no message was sent (markerCreated: true on the refusal that inserted the one human-handoff line, false while the thread stays paused). A 409 agents_muted_in_channel means an admin turned \"agents respond here\" off for this channel: the post was refused, nothing was written, and it must not be retried (same shape as agent_human_required). body is plain Team Chat text (1-10,000 characters).", {
|
|
73
133
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
@@ -82,14 +142,30 @@ export function registerChatTools(server, client) {
|
|
|
82
142
|
parentId: z.string().uuid().describe("Top-level message UUID to reply to"),
|
|
83
143
|
body: z.string().trim().min(1).max(10_000).describe("Reply text"),
|
|
84
144
|
idempotencyKey: z.string().regex(/^[A-Za-z0-9:_.-]{1,128}$/).optional().describe("Paired AI agent keys only: a key unique per agent and source turn (the listener uses reply:<source message id>). The same key returns the reply already created (200, idempotentReplay: true) instead of posting a second one; a key used in another channel is a 409 idempotency_key_reused. A person's key gets 400."),
|
|
145
|
+
alsoInChannel: z.boolean().optional().describe("(#684, a person's reply, only while the workspace's thread view is on) true = \"Also send to #channel\": the ONE reply is also shown in the channel (topLevel reads list it with threadReply.reason alsoInChannel); never a second message or notification. Sent only when true. 409 team_chat_threads_off while the thread view is off (nothing posted); an AI teammate never sets it (its first answer already shows in the channel): refused here with claimToken, 400 from the server otherwise"),
|
|
85
146
|
claimToken: z.string().regex(/^[1-9][0-9]{0,8}:[A-Za-z0-9:_.-]{8,128}$/).optional().describe("Paired AI agent keys only; REQUIRED with idempotencyKey reply:<source message id> (refused otherwise, nothing posted): the listener's claim token <generation>:<listener id> from the turn prompt. The reply completes that turn only when posted in the source's channel and thread with the current generation's token; a reply:<source> reply anywhere else, or with a stale token, is refused 409 (reply_wrong_channel / reply_wrong_thread / reply_stale_claim) with nothing posted, so a corrected retry still completes the turn."),
|
|
86
|
-
}, async ({ channelId, parentId, body, idempotencyKey, claimToken }) => {
|
|
147
|
+
}, async ({ channelId, parentId, body, idempotencyKey, claimToken, alsoInChannel }) => {
|
|
87
148
|
// (#599 r7) a reply to an inbox turn (idempotencyKey reply:<source message id>) always names its claim
|
|
88
149
|
if (!claimToken && typeof idempotencyKey === "string" && /^reply:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idempotencyKey)) {
|
|
89
150
|
throw new Error("claimToken is required with an idempotencyKey reply:<source message id> (the turn prompt's reply claim token); nothing was posted");
|
|
90
151
|
}
|
|
91
|
-
|
|
92
|
-
|
|
152
|
+
// (#684) the server refuses alsoInChannel from an AI teammate (400): a turn reply (claim token) never sends it
|
|
153
|
+
if (alsoInChannel === true && claimToken)
|
|
154
|
+
throw new Error("alsoInChannel is for a person's reply: an AI teammate's first answer already shows in the channel; nothing was posted");
|
|
155
|
+
const also = alsoInChannel === true;
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await client.post(`/api/v1/chat/channels/${channelId}/messages`, { body, parentId, ...(idempotencyKey ? { idempotencyKey } : {}), ...(claimToken ? { claimToken } : {}), ...(also ? { alsoInChannel: true } : {}) });
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const refused = also ? threadsRefusal(err, { offSentence: `${THREADS_OFF_SENTENCE} Nothing was posted: send it again without alsoInChannel.` }) : null;
|
|
162
|
+
if (refused)
|
|
163
|
+
return refused;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
const note = also ? alsoInChannelNote(result) : null;
|
|
167
|
+
const out = formatResult(result);
|
|
168
|
+
return note ? { ...out, content: [...out.content, { type: "text", text: note }] } : out;
|
|
93
169
|
}));
|
|
94
170
|
strictInput(server.tool("conduyt_react_chat_message", "Add a reaction to a Team Chat message as the API key's user (POST /api/v1/chat/channels/{id}/messages/{messageId}/reactions). The server accepts only its Team Chat reaction set and returns the fully hydrated message. Repeating the same reaction is safe and leaves one reaction from this user. (#802, app #655 c) An AI teammate ENDS its turn with a reaction by passing claimToken (the turn prompt's reply claim token): the reaction then completes that turn (outcome reacted) in the same transaction, so the turn needs no reply. React to the turn's message or one in its thread; when it is another message of the thread, pass the turn's message id as sourceMessageId. Exactly one of a reply, a reaction or conduyt_stay_silent ends a turn: a token that is not the running holder's, or a message outside the turn's thread, is refused 409 reaction_stale_claim, and a turn that already ended (a reply, a silence, another reaction) 409 turn_finished with its outcome; nothing is written on either, do not retry. A person's key sending claimToken gets 400.", {
|
|
95
171
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
@@ -116,6 +192,43 @@ export function registerChatTools(server, client) {
|
|
|
116
192
|
const result = await client.get(`/api/v1/chat/channels/${channelId}/linked-record`);
|
|
117
193
|
return formatResult(result);
|
|
118
194
|
}));
|
|
195
|
+
strictInput(server.tool("conduyt_get_chat_thread", "(#684, only while the workspace's thread view is on) Read ONE Team Chat thread by its ROOT, the top-level message (GET /api/v1/chat/channels/{id}/messages/{messageId}/thread): { root, replies (oldest first), total (every counted reply), earlierCount, hasEarlier, earlierCursor (send as before for the page below), aiReactions: [{ messageId, agent, emoji, at }] (an AI teammate's turn that ended with a reaction instead of a reply: \"<name> reacted <emoji>. No reply needed.\" under messageId), replying: [{ userId }] (AI teammates whose turn on this thread is running) }. Without before: the LATEST limit replies (default 20, 1 to 50). Counted replies are not deleted, inside the 90-day window and under the caller's deal visibility. A deleted root is served as stored (deletedAt set, body \"Message deleted\"). An AI teammate reads the whole thread it is answering in with this: pass the turn prompt's Channel id and its Reply parent id (the thread's root), then page back with before while hasEarlier. Members only: a 403 'Not a member of this channel' is recovered once like the other reads. Refusals are error results with a plain sentence, never retried: 409 team_chat_threads_off (the thread view is off), 404 (a reply id, an unknown or hidden message, a root past the window with no reply inside it, or a server that predates the thread view), 400 limit / before.", {
|
|
196
|
+
channelId: z.string().uuid().describe("Channel UUID"),
|
|
197
|
+
messageId: z.string().uuid().describe("The thread's ROOT message UUID (a top-level message; for an AI teammate's turn: the Reply parent id)"),
|
|
198
|
+
limit: z.number().int().min(1).max(50).optional().describe("Replies per page, 1-50 (default 20)"),
|
|
199
|
+
before: z.number().int().min(0).max(999_999_999).optional().describe("earlierCursor from the previous page (a reply's seq): the replies below it"),
|
|
200
|
+
}, async ({ channelId, messageId, limit, before }) => {
|
|
201
|
+
const params = new URLSearchParams();
|
|
202
|
+
if (limit !== undefined)
|
|
203
|
+
params.set("limit", String(limit));
|
|
204
|
+
if (before !== undefined)
|
|
205
|
+
params.set("before", String(before));
|
|
206
|
+
try {
|
|
207
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages/${messageId}/thread?${params}`));
|
|
208
|
+
return formatResult(result);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const refused = threadsRefusal(err, { offSentence: THREADS_OFF_SENTENCE, notFound: true });
|
|
212
|
+
if (refused)
|
|
213
|
+
return refused;
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}));
|
|
217
|
+
strictInput(server.tool("conduyt_list_chat_thread_files", "(#684, only while the workspace's thread view is on) The files shared in ONE Team Chat thread (GET /api/v1/chat/channels/{id}/messages/{messageId}/files): the root's attachments and every reply's, oldest first, at most 200 per read: { data: [{ id (<message id>:<position>), messageId, name, type, size, url, fileId (the CRM file id, opened through GET /files/{id}/download, or null), sharedBy { id, name, isAgent }, sharedAt }], hasMore (true when the thread holds more than 200) }. A deleted message contributes nothing; only messages inside the 90-day window count. messageId is the ROOT. Members only (a first-use 403 is recovered once). Refusals are error results with a plain sentence, never retried: 409 team_chat_threads_off, 404 (a reply id, an unknown or hidden message, a root past the window with no reply inside it, or a server that predates the thread view).", {
|
|
218
|
+
channelId: z.string().uuid().describe("Channel UUID"),
|
|
219
|
+
messageId: z.string().uuid().describe("The thread's ROOT message UUID"),
|
|
220
|
+
}, async ({ channelId, messageId }) => {
|
|
221
|
+
try {
|
|
222
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages/${messageId}/files`));
|
|
223
|
+
return formatResult(result);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const refused = threadsRefusal(err, { offSentence: THREADS_OFF_SENTENCE, notFound: true });
|
|
227
|
+
if (refused)
|
|
228
|
+
return refused;
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}));
|
|
119
232
|
strictInput(server.tool("conduyt_mark_chat_read", "Acknowledge team chat as read up to one message (GET /api/v1/chat/channels/{id}/messages?readUpTo=<messageId>&limit=1; the API takes the acknowledgement as a query on the message list). readUpTo is the UUID of a message you were served (its seq becomes your read marker); take it from conduyt_list_chat_messages. The marker only moves FORWARD, never to 'now': an older (stale) message is a no-op, and a message that is not in this channel moves nothing (the call still succeeds, so re-check conduyt_get_chat_unread). A malformed id is a 400. On a 403 'Not a member of this channel' it calls GET /chat/channels once (creating the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is. The response is the newest message of the channel (limit 1), not a confirmation of the move.", {
|
|
120
233
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
121
234
|
readUpTo: z.string().uuid().describe("UUID of the newest message you have seen in this channel"),
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-11-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-11-06T12:12:53.647Z",
|
|
3
|
+
"toolCount": 387,
|
|
4
|
+
"totalEndpoints": 1152,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 341,
|
|
7
7
|
"excluded": 353,
|
|
8
8
|
"planned": 458,
|
|
9
9
|
"uncategorized": 0
|
|
@@ -1419,6 +1419,12 @@
|
|
|
1419
1419
|
"status": "planned",
|
|
1420
1420
|
"reason": "team chat read/post for agents"
|
|
1421
1421
|
},
|
|
1422
|
+
"GET /api/v1/chat/channels/:id/messages/:messageId/files": {
|
|
1423
|
+
"status": "covered",
|
|
1424
|
+
"tools": [
|
|
1425
|
+
"conduyt_list_chat_thread_files"
|
|
1426
|
+
]
|
|
1427
|
+
},
|
|
1422
1428
|
"POST /api/v1/chat/channels/:id/messages/:messageId/handoff": {
|
|
1423
1429
|
"status": "planned",
|
|
1424
1430
|
"reason": "team chat read/post for agents"
|
|
@@ -1437,6 +1443,12 @@
|
|
|
1437
1443
|
"status": "planned",
|
|
1438
1444
|
"reason": "team chat read/post for agents"
|
|
1439
1445
|
},
|
|
1446
|
+
"GET /api/v1/chat/channels/:id/messages/:messageId/thread": {
|
|
1447
|
+
"status": "covered",
|
|
1448
|
+
"tools": [
|
|
1449
|
+
"conduyt_get_chat_thread"
|
|
1450
|
+
]
|
|
1451
|
+
},
|
|
1440
1452
|
"GET /api/v1/chat/channels/:id/notifications": {
|
|
1441
1453
|
"status": "covered",
|
|
1442
1454
|
"tools": [
|