conduyt-mcp 4.102.0 → 4.104.0

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 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
- ## 372 Tools
5
+ ## 376 Tools
6
6
 
7
7
  ### Discovery
8
8
  | Tool | Description |
@@ -14,7 +14,8 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
14
14
  ### Contacts
15
15
  | Tool | Description |
16
16
  |------|-------------|
17
- | `conduyt_list_contacts` | List and search contacts with filters (search, exact `email`, `tag` names, `tagId` uuids: ANY-of, repeatable up to 20, `meta.total` counts the filtered set) |
17
+ | `conduyt_list_contacts` | List and search contacts with filters (search, exact `email`, `tag` names, `tagId` uuids: ANY-of, repeatable up to 20). `meta.total` can be null with `meta.total_deferred` true; `meta.has_more` is always exact |
18
+ | `conduyt_count_contacts` | The exact total of a contacts list filter (`GET /contacts/count`, the filters shared with the list, not the list-only deal-context filters: with those, page with `meta.has_more` instead; read scope): `{ total, age_ms?, total_capped?, pending?, timed_out? }`; `pending` = ask again in a few seconds, `timed_out` = narrow the filter |
18
19
  | `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact; `hasOutboundEmail` = the same for email; `duplicateProposals` and `heldIdentifiers` = the open possible-duplicate lines and held identifiers |
19
20
  | `conduyt_create_contact` | Create a new contact (a 409 with `existingId`, `rule`, `ruleVersion`, `matchedOn`, `reason` means the identity policy identified the incoming person with that record: confirm the identity (name, company) before updating it, a shared phone or email is not proof, and the server creates (with a passive duplicate proposal) a different person on a shared identifier; 409 `identifier_changed` / `owner_busy` and 503 `IDENTITY_INDEX_NOT_READY` are retryable, nothing written; with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
20
21
  | `conduyt_update_contact` | Update contact fields (incl. `assignedTo`; `keepPreviousOwnerAsCollaborator: true` with a reassignment keeps the previous owner as a read-only collaborator instead of releasing them, 400 without `assignedTo`); optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
@@ -149,7 +150,7 @@ No tool carries call audio. Fetch it with `GET {API base}/api/v1/dialer/call/{id
149
150
  ### Companies
150
151
  | Tool | Description |
151
152
  |------|-------------|
152
- | `conduyt_list_companies` | List and search companies |
153
+ | `conduyt_list_companies` | List and search companies (`count_mode: none` skips the total: `meta.total` null, `meta.has_more` exact) |
153
154
  | `conduyt_get_company` | Get company with contacts and deals |
154
155
  | `conduyt_create_company` | Create a new company record |
155
156
  | `conduyt_update_company` | Update company details |
@@ -243,6 +244,9 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
243
244
  | `conduyt_list_chat_channels` | `GET /chat/channels`: your channels; also CREATES your memberships in the auto channels the first time |
244
245
  | `conduyt_get_chat_unread` | `GET /chat/unread`: per channel `{ channelId, unread, soundUnread, newestSeq, latestSeq }`; may list an auto channel you have not opened yet (no membership row) |
245
246
  | `conduyt_list_chat_messages` | List a channel's messages, newest first; every message carries `seq` (commit-ordered position in the channel); does not move the read marker; on a 403 "Not a member of this channel" it calls `GET /chat/channels` once and retries once |
247
+ | `conduyt_send_chat_message` | Send a message through the canonical audited Team Chat writer; agent keys receive the six-turn human-handoff guard |
248
+ | `conduyt_reply_chat_message` | Reply to a top-level message in its thread through the same audited writer; an agent passes `idempotencyKey` (`reply:<source message id>`) so a retried turn returns the first reply (200, `idempotentReplay: true`) instead of posting twice |
249
+ | `conduyt_react_chat_message` | React to a Team Chat message as the authenticated user/agent |
246
250
  | `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) |
247
251
  | `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` |
248
252
  | `conduyt_get_chat_notifications` | `GET /chat/channels/{id}/notifications`: your level `{ level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }` |
@@ -251,7 +255,7 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
251
255
 
252
256
  `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.
253
257
 
254
- The rest of team chat (sending, edit/delete, reactions, pins, claim/handoff, typing, presence, uploads) is not wrapped yet. `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.
258
+ 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.
255
259
 
256
260
  #### What the API publishes (realtime events, not tools)
257
261
 
@@ -11,6 +11,7 @@ export const TOOL_ANNOTATIONS = {
11
11
  conduyt_search: ro("Search CRM Records"),
12
12
  conduyt_mcp_coverage: ro("Get MCP Coverage Matrix"),
13
13
  conduyt_list_contacts: ro("List Contacts"),
14
+ conduyt_count_contacts: ro("Count Contacts"),
14
15
  conduyt_get_contact: ro("Get Contact"),
15
16
  conduyt_create_contact: create("Create Contact"),
16
17
  conduyt_update_contact: update("Update Contact"),
@@ -173,6 +174,9 @@ export const TOOL_ANNOTATIONS = {
173
174
  conduyt_list_chat_channels: update("List Chat Channels"),
174
175
  conduyt_get_chat_unread: ro("Get Chat Unread Counts"),
175
176
  conduyt_list_chat_messages: update("List Chat Messages"),
177
+ conduyt_send_chat_message: create("Send Chat Message"),
178
+ conduyt_reply_chat_message: create("Reply to Chat Message"),
179
+ conduyt_react_chat_message: update("React to Chat Message"),
176
180
  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)
177
181
  conduyt_get_chat_realtime_token: ro("Get Chat Realtime Token"),
178
182
  conduyt_get_chat_notifications: update("Get Chat Notifications"),
@@ -5,7 +5,7 @@ import { formatResult } from "../client.js";
5
5
  * matching → Run Batch" action and the Run Batch page. Admins and owners only.
6
6
  */
7
7
  export function registerBatchOperationTools(server, client) {
8
- server.tool("conduyt_create_batch_operation", "Queue a Run Batch: ONE task applied to many contacts, logged with progress and cancel (no undo). Tasks: enroll (automationId of a published workflow), delete (the #73 engine: cleanup runs, contacts with an upcoming appointment are kept), add_tag / remove_tag (params.tagId), assign (params.userId — one rep) or a round-robin pool (params.userIds, 2–50 reps rotated in order; a rep who is no longer an active member is skipped), set_source (params.source — OVERWRITES the lead source), set_status (params.status — one of the account's lead statuses; a lead with a won deal keeps its status). Pick the contacts ONE way: contactIds (up to 50,000), filterPayload (universal filters), or filter + expectedTotal — the same filter you would give conduyt_list_contacts plus the meta.total it returned; the server re-counts and refuses with 409 expected_total_mismatch when the set moved, then freezes the matching ids (max 50,000). The run is processed in the background; poll conduyt_get_batch_operation. Requires the admin or owner role and an active subscription.", {
8
+ server.tool("conduyt_create_batch_operation", "Queue a Run Batch: ONE task applied to many contacts, logged with progress and cancel (no undo). Tasks: enroll (automationId of a published workflow), delete (the #73 engine: cleanup runs, contacts with an upcoming appointment are kept), add_tag / remove_tag (params.tagId), assign (params.userId - one rep) or a round-robin pool (params.userIds, 2–50 reps rotated in order; a rep who is no longer an active member is skipped), set_source (params.source - OVERWRITES the lead source), set_status (params.status - one of the account's lead statuses; a lead with a won deal keeps its status). Pick the contacts ONE way: contactIds (up to 50,000), filterPayload (universal filters), or filter + expectedTotal - the same filter you would give conduyt_list_contacts plus the meta.total it returned (when the list answers meta.total null with meta.total_deferred true and has_more, use the total from conduyt_count_contacts for the same SHARED filter; with a deal-context filter see below); the server re-counts and refuses with 409 expected_total_mismatch when the set moved, then freezes the matching ids (max 50,000). Deal-context filters (deal_context_owners, deal_context_stages, deal_context_statuses) exist only on the list, not on conduyt_count_contacts: for a list that uses one of them do not take a total from the count tool (it would count a broader set); page the list with has_more and tally the rows instead. The run is processed in the background; poll conduyt_get_batch_operation. Requires the admin or owner role and an active subscription.", {
9
9
  name: z.string().min(1).max(256).describe("A name for the run (shown on the Run Batch page)"),
10
10
  operation: z.enum(["enroll", "delete", "add_tag", "remove_tag", "assign", "set_source", "set_status"]).describe("The task"),
11
11
  automationId: z.string().uuid().optional().describe("enroll only: the published workflow"),
@@ -13,8 +13,8 @@ export function registerBatchOperationTools(server, client) {
13
13
  comment: z.string().max(2000).optional().describe("Why this run (stored with it)"),
14
14
  contactIds: z.array(z.string().uuid()).min(1).max(50_000).optional().describe("Explicit contacts"),
15
15
  filterPayload: z.array(z.record(z.string(), z.unknown())).min(1).optional().describe("Universal filter rules (the Smart View filter shape)"),
16
- filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("The contact list filter parameters, exactly as for conduyt_list_contacts; at least one non-empty value"),
17
- expectedTotal: z.number().int().positive().optional().describe("With filter: the total the list showed for it (meta.total)"),
16
+ filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("The contact list filter parameters as for conduyt_list_contacts (the filters shared with conduyt_count_contacts; the list-only deal_context_owners / deal_context_stages / deal_context_statuses cannot be counted there: page by has_more and tally rows); at least one non-empty value"),
17
+ expectedTotal: z.number().int().positive().optional().describe("With filter: the total the list showed for it (meta.total), or the total from conduyt_count_contacts when meta.total was null (total_deferred); not for a deal-context filter (list-only, the count tool cannot express it): tally rows by has_more"),
18
18
  }, async (params) => {
19
19
  const body = { name: params.name, operation: params.operation };
20
20
  if (params.automationId)
@@ -118,9 +118,9 @@ export function registerBulkTools(server, client) {
118
118
  const result = await client.post("/api/v1/bulk/contacts/delete", params);
119
119
  return formatResult(result);
120
120
  });
121
- server.tool("conduyt_bulk_delete_contacts_matching", "Soft-delete EVERY contact a list filter matches, across pages (#73, 2026-09-15) — the contacts page's \"Select all N matching\" action. FIRST CALL: pass the same filter you would give conduyt_list_contacts (search, tag, tags, source, reachability, company, company_id, assigned_to, created_from/to, updated_from/to, min_score, import_id, smart_view, smartListId, filters JSON, master_status, exclude_master_status, cf[key]) and expectedTotal = the meta.total that filter returned. The server re-counts and refuses when the set moved (409 expected_total_mismatch), refuses when some matching contacts are shared with you but not yours to delete (409 not_all_editable), then FREEZES the matching ids (max 50,000; 422 above) and deletes only those. Contacts with an upcoming appointment are kept (keptWithAppointments). If done=false the response carries remainingIds AND a signed continuation token: CALL AGAIN with ids = remainingIds and continuation = that token, verbatim (no filter, no expectedTotal) until done=true; busy=true means wait a moment first. The token is bound to you, this account and exactly those ids and expires after 2 hours (403 continuation_*); a continuation without it is refused. Empty filters are refused. Requires contacts:delete and an active subscription.", {
122
- filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("First call: the contact list filter parameters, exactly as for conduyt_list_contacts; at least one non-empty value"),
123
- expectedTotal: z.number().int().positive().optional().describe("First call: the total the list showed for this filter (meta.total)"),
121
+ server.tool("conduyt_bulk_delete_contacts_matching", "Soft-delete EVERY contact a list filter matches, across pages (#73, 2026-09-15) - the contacts page's \"Select all N matching\" action. FIRST CALL: pass the same filter you would give conduyt_list_contacts (search, tag, tags, source, reachability, company, company_id, assigned_to, created_from/to, updated_from/to, min_score, import_id, smart_view, smartListId, filters JSON, master_status, exclude_master_status, cf[key]) and expectedTotal = the meta.total that filter returned (when the list answers meta.total null with meta.total_deferred true and has_more, get the exact total from conduyt_count_contacts with the same SHARED filter; with a deal-context filter see below). Deal-context filters (deal_context_owners, deal_context_stages, deal_context_statuses) exist only on the list, not on conduyt_count_contacts: for a list that uses one of them do not take a total from the count tool (it would count a broader set); page the list with has_more and tally the rows instead. The server re-counts and refuses when the set moved (409 expected_total_mismatch), refuses when some matching contacts are shared with you but not yours to delete (409 not_all_editable), then FREEZES the matching ids (max 50,000; 422 above) and deletes only those. Contacts with an upcoming appointment are kept (keptWithAppointments). If done=false the response carries remainingIds AND a signed continuation token: CALL AGAIN with ids = remainingIds and continuation = that token, verbatim (no filter, no expectedTotal) until done=true; busy=true means wait a moment first. The token is bound to you, this account and exactly those ids and expires after 2 hours (403 continuation_*); a continuation without it is refused. Empty filters are refused. Requires contacts:delete and an active subscription.", {
122
+ filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("First call: the contact list filter parameters as for conduyt_list_contacts (the filters shared with conduyt_count_contacts; the list-only deal_context_owners / deal_context_stages / deal_context_statuses cannot be counted there: page by has_more and tally rows); at least one non-empty value"),
123
+ expectedTotal: z.number().int().positive().optional().describe("First call: the total the list showed for this filter (meta.total), or the total from conduyt_count_contacts when meta.total was null (total_deferred); not for a deal-context filter (list-only, the count tool cannot express it): tally rows by has_more"),
124
124
  ids: z.array(z.string().uuid()).min(1).max(50_000).optional().describe("Continuation: the remainingIds from the previous response, in the same order (send INSTEAD of filter/expectedTotal)"),
125
125
  continuation: z.string().max(2_000).optional().describe("Continuation: the `continuation` token from the previous response, verbatim (required with ids)"),
126
126
  }, async (params) => {
@@ -43,7 +43,7 @@ async function withMembershipBootstrap(client, read) {
43
43
  }
44
44
  /** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
45
45
  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). 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 () => {
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 () => {
47
47
  const result = await client.get("/api/v1/chat/channels");
48
48
  return formatResult(result);
49
49
  }));
@@ -69,6 +69,36 @@ export function registerChatTools(server, client) {
69
69
  const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
70
70
  return formatResult(result);
71
71
  }));
72
+ 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
+ channelId: z.string().uuid().describe("Channel UUID"),
74
+ body: z.string().trim().min(1).max(10_000).describe("Message text"),
75
+ 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."),
76
+ }, async ({ channelId, body, idempotencyKey }) => {
77
+ const result = await client.post(`/api/v1/chat/channels/${channelId}/messages`, { body, ...(idempotencyKey ? { idempotencyKey } : {}) });
78
+ return formatResult(result);
79
+ }));
80
+ strictInput(server.tool("conduyt_reply_chat_message", "Reply in a Team Chat thread as the API key's user (POST /api/v1/chat/channels/{id}/messages with parentId). parentId must name a live top-level message in that channel. The caller must be a member with communications:create. AI-agent keys are subject to the same six-consecutive-agent-turn cap as a top-level send, and to the same 409 agent_human_required / agents_muted_in_channel refusals (nothing written, do not retry; agents_muted_in_channel = an admin turned \"agents respond here\" off for the channel).", {
81
+ channelId: z.string().uuid().describe("Channel UUID"),
82
+ parentId: z.string().uuid().describe("Top-level message UUID to reply to"),
83
+ body: z.string().trim().min(1).max(10_000).describe("Reply text"),
84
+ 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."),
85
+ 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 }) => {
87
+ // (#599 r7) a reply to an inbox turn (idempotencyKey reply:<source message id>) always names its claim
88
+ 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
+ throw new Error("claimToken is required with an idempotencyKey reply:<source message id> (the turn prompt's reply claim token); nothing was posted");
90
+ }
91
+ const result = await client.post(`/api/v1/chat/channels/${channelId}/messages`, { body, parentId, ...(idempotencyKey ? { idempotencyKey } : {}), ...(claimToken ? { claimToken } : {}) });
92
+ return formatResult(result);
93
+ }));
94
+ 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.", {
95
+ channelId: z.string().uuid().describe("Channel UUID"),
96
+ messageId: z.string().uuid().describe("Message UUID"),
97
+ emoji: z.string().min(1).max(16).describe("One allowed Team Chat emoji"),
98
+ }, async ({ channelId, messageId, emoji }) => {
99
+ const result = await client.post(`/api/v1/chat/channels/${channelId}/messages/${messageId}/reactions`, { emoji });
100
+ return formatResult(result);
101
+ }));
72
102
  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.", {
73
103
  channelId: z.string().uuid().describe("Channel UUID"),
74
104
  readUpTo: z.string().uuid().describe("UUID of the newest message you have seen in this channel"),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
3
  export function registerCompanyTools(server, client) {
4
- server.tool("conduyt_list_companies", "List and search companies. Returns name, domain, industry, contact count, deal count, and total deal value.", {
4
+ server.tool("conduyt_list_companies", "List and search companies. Returns name, domain, industry, contact count, deal count, and total deal value. `count_mode: \"none\"` skips the total count (a picker that only lists names): meta.total is then null and meta.has_more says whether another page exists.", {
5
5
  search: z.string().optional().describe("Search by company name, domain, or phone"),
6
6
  industry: z.string().optional().describe("Filter by industry"),
7
7
  size: z.enum(["1-10", "11-50", "51-200", "201-500", "501-1000", "1001+"]).optional().describe("Filter by company size"),
@@ -9,7 +9,8 @@ export function registerCompanyTools(server, client) {
9
9
  order: z.enum(["asc", "desc"]).optional().describe("Sort order (default: desc)"),
10
10
  page: z.number().optional().describe("Page number (default 1)"),
11
11
  per_page: z.number().optional().describe("Results per page (default 50, max 200)"),
12
- }, async ({ search, industry, size, sort, order, page, per_page }) => {
12
+ count_mode: z.enum(["exact", "none"]).optional().describe("exact (default) counts every matching company into meta.total. none skips that count: meta.total is then null and meta.has_more says whether another page exists."),
13
+ }, async ({ search, industry, size, sort, order, page, per_page, count_mode }) => {
13
14
  const params = new URLSearchParams();
14
15
  if (search)
15
16
  params.set("search", search);
@@ -25,6 +26,8 @@ export function registerCompanyTools(server, client) {
25
26
  params.set("page", String(page));
26
27
  if (per_page)
27
28
  params.set("per_page", String(per_page));
29
+ if (count_mode)
30
+ params.set("count_mode", count_mode);
28
31
  const result = await client.get(`/api/v1/companies?${params}`);
29
32
  return formatResult(result);
30
33
  });
@@ -21,5 +21,7 @@ export declare const MERGE_PREVIEW_MAX = 50;
21
21
  export declare const MERGE_PREVIEW_TTL_MS: number;
22
22
  export declare const EMAIL_LOOKUP_NOTE = "Exact email lookup: one address, or up to 20 distinct addresses in an array. Each is trimmed and lower-cased and matches the contact's primary email OR any secondary email slot (email_2 and up); it is an exact match, not a substring search (use `search` for partial text). Combines with every other filter (AND) inside your account and visibility. An empty or invalid address, or more than 20 distinct addresses, is a 400. On a workspace whose identifier index is still being built, the lookup answers 422 when the stored spellings of the requested addresses exceed its stored-spelling bind budget (a count of spellings, not of contacts; other filters do not help); ask support to complete the identifier backfill. Send addresses once; duplicates collapse.";
23
23
  export declare const TAG_FILTER_NOTE = "Filter by tag NAME: one name, or up to 20 distinct names in an array (the contact carries ANY of them). Names match case-insensitively within your account. Cannot be combined with tagId (400). To filter by tag id use tagId.";
24
- export declare const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400). The returned total (meta.total) counts the filtered set, so tagId with per_page=1 is a cheap count of the contacts holding a tag.";
24
+ export declare const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400). meta.total counts the filtered set when present, but it can be null with total_deferred; per_page 1 is only an existence probe. Use conduyt_count_contacts with tagId for the exact total.";
25
+ /** (slow566b, app main 9aef66389) the contacts list may not count: how a caller gets the exact total */
26
+ export declare const LIST_TOTAL_NOTE = "`meta.total` can be null: then `meta.total_deferred` is true and the page was not counted (the unfiltered list and a Smart View answer a cached total, `meta.total_age_ms`, null until first counted; a filter without an index of its own is never counted here). `meta.has_more` is always exact, so page on by `has_more`, never by `total`. For the exact total call conduyt_count_contacts (GET /contacts/count), which takes the SHARED filters only: with dealContextOwners, dealContextStages or dealContextStatuses (list-only) the count route cannot answer (dropping them counts a broader set), so page with has_more instead. A search stops counting at 10,000 (`meta.total_capped`).";
25
27
  export declare function registerContactTools(server: McpServer, client: ConduytClient): void;
@@ -73,10 +73,13 @@ export const EMAIL_LOOKUP_NOTE = "Exact email lookup: one address, or up to 20 d
73
73
  /** (API ?tag= / ?tagId=, #506) both are repeatable and capped at 20 distinct values; the API reads every occurrence (ANY-of) and refuses tagId together with tag */
74
74
  const TAG_FILTER_MAX = 20;
75
75
  export const TAG_FILTER_NOTE = "Filter by tag NAME: one name, or up to 20 distinct names in an array (the contact carries ANY of them). Names match case-insensitively within your account. Cannot be combined with tagId (400). To filter by tag id use tagId.";
76
- export const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400). The returned total (meta.total) counts the filtered set, so tagId with per_page=1 is a cheap count of the contacts holding a tag.";
76
+ export const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400). meta.total counts the filtered set when present, but it can be null with total_deferred; per_page 1 is only an existence probe. Use conduyt_count_contacts with tagId for the exact total.";
77
+ /** (slow566b, app main 9aef66389) the contacts list may not count: how a caller gets the exact total */
78
+ export const LIST_TOTAL_NOTE = "`meta.total` can be null: then `meta.total_deferred` is true and the page was not counted (the unfiltered list and a Smart View answer a cached total, `meta.total_age_ms`, null until first counted; a filter without an index of its own is never counted here). `meta.has_more` is always exact, so page on by `has_more`, never by `total`. For the exact total call conduyt_count_contacts (GET /contacts/count), which takes the SHARED filters only: with dealContextOwners, dealContextStages or dealContextStatuses (list-only) the count route cannot answer (dropping them counts a broader set), so page with has_more instead. A search stops counting at 10,000 (`meta.total_capped`).";
77
79
  export function registerContactTools(server, client) {
78
80
  const mergePreviews = createMergePreviewStore();
79
- server.tool("conduyt_list_contacts", "List and search contacts with filtering. To find the contact(s) holding a known email address, pass `email` (exact match on the primary or any secondary email); `search` is a substring search. To list the contacts carrying a tag (or any of several tags), pass `tagId` (uuid) or `tag` (name); meta.total is the count of that filtered set. Returns paginated results with name, email, phone, tags, source, and assignee. Each row carries `revision` (integer save revision; pass it back as expectedRevision on conduyt_update_contact or the collaborator tools).", {
81
+ /** the filter parameters conduyt_list_contacts and conduyt_count_contacts share (GET /contacts and GET /contacts/count read the same ones) */
82
+ const contactFilterShape = {
80
83
  search: z.string().optional().describe("Search by name, email, or phone number"),
81
84
  email: z.union([EMAIL_LOOKUP_ONE, z.array(EMAIL_LOOKUP_ONE).min(1).max(20)]).optional().describe(EMAIL_LOOKUP_NOTE),
82
85
  tag: z.union([z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1).max(TAG_FILTER_MAX)]).optional().describe(TAG_FILTER_NOTE),
@@ -85,9 +88,25 @@ export function registerContactTools(server, client) {
85
88
  assignedTo: z.string().optional().describe("Filter by assigned user ID"),
86
89
  masterStatus: z.array(z.string().trim().min(1).max(40)).max(20).optional().describe("Only contacts whose LEAD (master) status is one of these: 'open', 'won', 'lost', 'abandoned', 'disqualified' or an account custom status. Unknown values are a 400."),
87
90
  excludeMasterStatus: z.array(z.string().trim().min(1).max(40)).max(20).optional().describe("Hide contacts whose lead status is one of these — e.g. ['won','lost','abandoned','disqualified'] is the 'Active' view. Naming a status in both lists is a 400."),
88
- page: z.number().optional().describe("Page number (default 1)"),
89
- per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
90
- }, async ({ search, email, tag, tagId, source, assignedTo, masterStatus, excludeMasterStatus, page, per_page }) => {
91
+ // the rest of the filters GET /contacts and GET /contacts/count both read (app main 9aef66389); each maps to the query param of the same meaning
92
+ company: z.string().optional().describe("Filter by company name (query param `company`)"),
93
+ companyId: z.string().optional().describe("Filter by company id (query param `company_id`)"),
94
+ createdFrom: z.string().optional().describe("Created on or after this date/time (query param `created_from`)"),
95
+ createdTo: z.string().optional().describe("Created on or before this date/time (query param `created_to`)"),
96
+ updatedFrom: z.string().optional().describe("Updated on or after this date/time (query param `updated_from`)"),
97
+ updatedTo: z.string().optional().describe("Updated on or before this date/time (query param `updated_to`)"),
98
+ importId: z.string().optional().describe("Only contacts from this import job (query param `import_id`)"),
99
+ minScore: z.number().int().optional().describe("Minimum lead score (query param `min_score`)"),
100
+ smartView: z.string().optional().describe("Smart View: a Smart View id, or a built-in view such as needs_follow_up, hot_leads, new_this_week, recently_won, untagged, missing_email, missing_phone (query param `smart_view`)"),
101
+ smartListId: z.string().optional().describe("Smart List id (uuid); a non-uuid is a 400 invalid_smart_list_id, an unknown list a 404 smart_list_not_found (query param `smartListId`)"),
102
+ tags: z.string().optional().describe("Comma-separated tag names (query param `tags`; the same set the Run Batch / bulk delete filters use). Prefer `tag` / `tagId` for new calls"),
103
+ filters: z.string().optional().describe("The filter builder's JSON, as a string (query param `filters`)"),
104
+ reachability: z.string().optional().describe("Reachability filter (query param `reachability`)"),
105
+ cf: z.string().optional().describe("Compact custom-field filters: fieldKey=value pairs (comma-separated) or a JSON object; a value can be exact, __notempty__ or __empty__ (query param `cf`)"),
106
+ customFieldFilters: z.record(z.string(), z.string()).optional().describe("Filter by contact custom fields: { fieldKey: exact value | '__notempty__' | '__empty__' }; each is sent as `cf[fieldKey]`. The key must be a contact custom-field of the account"),
107
+ };
108
+ const contactFilterParams = (f) => {
109
+ const { search, email, tag, tagId, source, assignedTo, masterStatus, excludeMasterStatus } = f;
91
110
  const params = new URLSearchParams();
92
111
  if (search)
93
112
  params.set("search", search);
@@ -107,6 +126,34 @@ export function registerContactTools(server, client) {
107
126
  params.set("master_status", masterStatus.join(","));
108
127
  if (excludeMasterStatus?.length)
109
128
  params.set("exclude_master_status", excludeMasterStatus.join(","));
129
+ const plain = [
130
+ ["company", f.company], ["company_id", f.companyId], ["created_from", f.createdFrom], ["created_to", f.createdTo],
131
+ ["updated_from", f.updatedFrom], ["updated_to", f.updatedTo], ["import_id", f.importId], ["min_score", f.minScore],
132
+ ["smart_view", f.smartView], ["smartListId", f.smartListId], ["tags", f.tags], ["filters", f.filters], ["reachability", f.reachability], ["cf", f.cf],
133
+ ];
134
+ for (const [name, value] of plain)
135
+ if (value !== undefined && value !== "")
136
+ params.set(name, String(value));
137
+ for (const [key, value] of Object.entries(f.customFieldFilters ?? {}))
138
+ params.set(`cf[${key}]`, value);
139
+ return params;
140
+ };
141
+ server.tool("conduyt_list_contacts", "List and search contacts with filtering. To find the contact(s) holding a known email address, pass `email` (exact match on the primary or any secondary email); `search` is a substring search. To list the contacts carrying a tag (or any of several tags), pass `tagId` (uuid) or `tag` (name). Returns paginated results with name, email, phone, tags, source, and assignee. Each row carries `revision` (integer save revision; pass it back as expectedRevision on conduyt_update_contact or the collaborator tools). " + LIST_TOTAL_NOTE, {
142
+ ...contactFilterShape,
143
+ // list-only filters (GET /contacts reads them; GET /contacts/count does not)
144
+ dealContextOwners: z.string().optional().describe("Deal-context filter: comma-separated owner ids (query param `deal_context_owners`; list only, the count route does not read it)"),
145
+ dealContextStages: z.string().optional().describe("Deal-context filter: comma-separated stage ids (query param `deal_context_stages`; list only)"),
146
+ dealContextStatuses: z.string().optional().describe("Deal-context filter: comma-separated deal statuses (query param `deal_context_statuses`; list only)"),
147
+ page: z.number().optional().describe("Page number (default 1)"),
148
+ per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
149
+ }, async ({ page, per_page, dealContextOwners, dealContextStages, dealContextStatuses, ...filter }) => {
150
+ const params = contactFilterParams(filter);
151
+ if (dealContextOwners)
152
+ params.set("deal_context_owners", dealContextOwners);
153
+ if (dealContextStages)
154
+ params.set("deal_context_stages", dealContextStages);
155
+ if (dealContextStatuses)
156
+ params.set("deal_context_statuses", dealContextStatuses);
110
157
  if (page)
111
158
  params.set("page", String(page));
112
159
  if (per_page)
@@ -114,6 +161,11 @@ export function registerContactTools(server, client) {
114
161
  const result = await client.get(`/api/v1/contacts?${params}`);
115
162
  return formatResult(result);
116
163
  });
164
+ server.tool("conduyt_count_contacts", "Count the contacts a list filter matches (GET /contacts/count): the exact total when conduyt_list_contacts answers `meta.total: null` with `meta.total_deferred: true`. Takes the SHARED subset of conduyt_list_contacts filters (no page, per_page or sort, and NOT dealContextOwners / dealContextStages / dealContextStatuses, which exist only on the list): with a deal-context filter the exact total is not available here (dropping it counts a broader set), so page the list with has_more instead. Answers `{ total, age_ms?, total_capped?, pending?, timed_out? }`: a cached list (the unfiltered list, or a Smart View alone) answers its cached exact count while under a minute old (`age_ms`), else recounts; `total: null` with `pending: true` = another request is recounting and nothing is cached yet, ask again in a few seconds; `total: null` with `timed_out: true` = the count could not finish within 30 s (narrow the filter); a `search` counts up to 10,000 (`total_capped: true` = more match). Use the total as `expectedTotal` for conduyt_bulk_delete_contacts_matching or conduyt_create_batch_operation.", contactFilterShape, async (filter) => {
165
+ const params = contactFilterParams(filter);
166
+ const result = await client.get(`/api/v1/contacts/count?${params}`);
167
+ return formatResult(result);
168
+ });
117
169
  server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free), each the FIRST page only, with duplicateProposalsTotal, duplicateProposalsNextCursor, duplicateProposalsMembershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor and heldIdentifiersMembershipRevision (page on with conduyt_list_duplicate_proposals, passing the next cursors as cursor / held_cursor); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
118
170
  id: z.string().describe("Contact UUID"),
119
171
  }, async ({ id }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.102.0",
3
+ "version": "4.104.0",
4
4
  "mcpName": "com.conduyt/mcp",
5
5
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
6
6
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  "claude",
40
40
  "llm-tools"
41
41
  ],
42
- "homepage": "https://conduyt.app",
42
+ "homepage": "https://conduyt.com",
43
43
  "repository": {
44
44
  "type": "git",
45
45
  "url": "git+https://github.com/ptaramona/conduyt-mcp-server.git"
@@ -130,11 +130,36 @@
130
130
  "status": "excluded",
131
131
  "reason": "UI-only deep-link helper (resolves a message to its channel/position for the app's router); not an agent surface"
132
132
  },
133
+ {
134
+ "match": "/api/v1/chat/agents/me/",
135
+ "status": "excluded",
136
+ "reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
137
+ },
138
+ {
139
+ "match": "POST /api/v1/chat/agents/pair",
140
+ "status": "excluded",
141
+ "reason": "(#599) the single-use pair-code exchange that MINTS the agent's key: the listener's first call (npm CLI `conduyt agent pair` / `listen --pair-code`), never made with an existing key"
142
+ },
143
+ {
144
+ "match": "PATCH /api/v1/chat/channels/:id/agents",
145
+ "status": "excluded",
146
+ "reason": "(#599) session-only (assertSessionOnly): a channel's agents-respond mode is an admin setting; every API key is refused"
147
+ },
148
+ {
149
+ "match": "/api/v1/chat/agents",
150
+ "status": "excluded",
151
+ "reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
152
+ },
133
153
  {
134
154
  "match": "/api/v1/chat",
135
155
  "status": "planned",
136
156
  "reason": "team chat read/post for agents"
137
157
  },
158
+ {
159
+ "match": "/api/v1/settings/ai-teammates",
160
+ "status": "excluded",
161
+ "reason": "(#614) session-only (assertSessionOnly): an admin turns AI teammates on or off in Settings > AI (Professional and above, the trial, comp); every API key is refused"
162
+ },
138
163
  {
139
164
  "match": "/api/v1/settings",
140
165
  "status": "planned",
@@ -455,6 +480,11 @@
455
480
  "status": "planned",
456
481
  "reason": "account readiness/onboarding surfaces"
457
482
  },
483
+ {
484
+ "match": "GET /api/v1/activity/export.csv",
485
+ "status": "planned",
486
+ "reason": "activity-log CSV export (small586, landed on app main after the 4.103.0 pin); a CSV download, tool deferred; classified here so the 4.104.0 snapshot refresh passes the coverage gate"
487
+ },
458
488
  {
459
489
  "match": "/api/v1/activities",
460
490
  "status": "planned",
@@ -1,10 +1,10 @@
1
1
  {
2
- "generatedAt": "2026-10-28T11:47:07.866Z",
3
- "toolCount": 372,
4
- "totalEndpoints": 1115,
2
+ "generatedAt": "2026-10-30T13:37:28.694Z",
3
+ "toolCount": 376,
4
+ "totalEndpoints": 1134,
5
5
  "counts": {
6
- "covered": 326,
7
- "excluded": 334,
6
+ "covered": 329,
7
+ "excluded": 350,
8
8
  "planned": 455,
9
9
  "uncategorized": 0
10
10
  },
@@ -301,6 +301,10 @@
301
301
  "status": "planned",
302
302
  "reason": "activity detail reads beyond the log tools"
303
303
  },
304
+ "GET /api/v1/activity/export.csv": {
305
+ "status": "planned",
306
+ "reason": "activity-log CSV export (small586, landed on app main after the 4.103.0 pin); a CSV download, tool deferred; classified here so the 4.104.0 snapshot refresh passes the coverage gate"
307
+ },
304
308
  "GET /api/v1/admin/accounts": {
305
309
  "status": "excluded",
306
310
  "reason": "super-admin platform operations (Conduyt staff only)"
@@ -609,6 +613,14 @@
609
613
  "status": "excluded",
610
614
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
611
615
  },
616
+ "GET /api/v1/auth/sso/confirm": {
617
+ "status": "excluded",
618
+ "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
619
+ },
620
+ "POST /api/v1/auth/sso/confirm": {
621
+ "status": "excluded",
622
+ "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
623
+ },
612
624
  "POST /api/v1/auth/switch-account": {
613
625
  "status": "excluded",
614
626
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
@@ -1261,6 +1273,46 @@
1261
1273
  "conduyt_voice_drop"
1262
1274
  ]
1263
1275
  },
1276
+ "GET /api/v1/chat/agents": {
1277
+ "status": "excluded",
1278
+ "reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
1279
+ },
1280
+ "POST /api/v1/chat/agents": {
1281
+ "status": "excluded",
1282
+ "reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
1283
+ },
1284
+ "PATCH /api/v1/chat/agents/:id": {
1285
+ "status": "excluded",
1286
+ "reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
1287
+ },
1288
+ "DELETE /api/v1/chat/agents/:id": {
1289
+ "status": "excluded",
1290
+ "reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
1291
+ },
1292
+ "POST /api/v1/chat/agents/:id/pair-code": {
1293
+ "status": "excluded",
1294
+ "reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
1295
+ },
1296
+ "POST /api/v1/chat/agents/me/heartbeat": {
1297
+ "status": "excluded",
1298
+ "reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
1299
+ },
1300
+ "GET /api/v1/chat/agents/me/inbox": {
1301
+ "status": "excluded",
1302
+ "reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
1303
+ },
1304
+ "POST /api/v1/chat/agents/me/inbox/ack": {
1305
+ "status": "excluded",
1306
+ "reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
1307
+ },
1308
+ "POST /api/v1/chat/agents/me/inbox/claim": {
1309
+ "status": "excluded",
1310
+ "reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
1311
+ },
1312
+ "POST /api/v1/chat/agents/pair": {
1313
+ "status": "excluded",
1314
+ "reason": "(#599) the single-use pair-code exchange that MINTS the agent's key: the listener's first call (npm CLI `conduyt agent pair` / `listen --pair-code`), never made with an existing key"
1315
+ },
1264
1316
  "POST /api/v1/chat/ask": {
1265
1317
  "status": "planned",
1266
1318
  "reason": "team chat read/post for agents"
@@ -1279,6 +1331,10 @@
1279
1331
  "status": "planned",
1280
1332
  "reason": "team chat read/post for agents"
1281
1333
  },
1334
+ "PATCH /api/v1/chat/channels/:id/agents": {
1335
+ "status": "excluded",
1336
+ "reason": "(#599) session-only (assertSessionOnly): a channel's agents-respond mode is an admin setting; every API key is refused"
1337
+ },
1282
1338
  "POST /api/v1/chat/channels/:id/members": {
1283
1339
  "status": "planned",
1284
1340
  "reason": "team chat read/post for agents"
@@ -1295,8 +1351,11 @@
1295
1351
  ]
1296
1352
  },
1297
1353
  "POST /api/v1/chat/channels/:id/messages": {
1298
- "status": "planned",
1299
- "reason": "team chat read/post for agents"
1354
+ "status": "covered",
1355
+ "tools": [
1356
+ "conduyt_send_chat_message",
1357
+ "conduyt_reply_chat_message"
1358
+ ]
1300
1359
  },
1301
1360
  "GET /api/v1/chat/channels/:id/messages/:messageId": {
1302
1361
  "status": "planned",
@@ -1323,8 +1382,10 @@
1323
1382
  "reason": "team chat read/post for agents"
1324
1383
  },
1325
1384
  "POST /api/v1/chat/channels/:id/messages/:messageId/reactions": {
1326
- "status": "planned",
1327
- "reason": "team chat read/post for agents"
1385
+ "status": "covered",
1386
+ "tools": [
1387
+ "conduyt_react_chat_message"
1388
+ ]
1328
1389
  },
1329
1390
  "DELETE /api/v1/chat/channels/:id/messages/:messageId/reactions": {
1330
1391
  "status": "planned",
@@ -1432,6 +1493,10 @@
1432
1493
  "status": "planned",
1433
1494
  "reason": "company subroutes beyond core CRUD"
1434
1495
  },
1496
+ "GET /api/v1/companies/:id/related": {
1497
+ "status": "planned",
1498
+ "reason": "company subroutes beyond core CRUD"
1499
+ },
1435
1500
  "GET /api/v1/companies/:id/tags": {
1436
1501
  "status": "planned",
1437
1502
  "reason": "company subroutes beyond core CRUD"
@@ -1603,6 +1668,12 @@
1603
1668
  "conduyt_ai_import_contacts"
1604
1669
  ]
1605
1670
  },
1671
+ "GET /api/v1/contacts/count": {
1672
+ "status": "covered",
1673
+ "tools": [
1674
+ "conduyt_count_contacts"
1675
+ ]
1676
+ },
1606
1677
  "GET /api/v1/contacts/dnc-status": {
1607
1678
  "status": "planned",
1608
1679
  "reason": "contact subroutes beyond the core CRUD/search tools"
@@ -1741,6 +1812,10 @@
1741
1812
  "status": "excluded",
1742
1813
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1743
1814
  },
1815
+ "GET /api/v1/cron/chat-webhooks": {
1816
+ "status": "excluded",
1817
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1818
+ },
1744
1819
  "GET /api/v1/cron/cleanup-audit-logs": {
1745
1820
  "status": "excluded",
1746
1821
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -4226,6 +4301,14 @@
4226
4301
  "status": "planned",
4227
4302
  "reason": "account settings read/update surfaces"
4228
4303
  },
4304
+ "GET /api/v1/settings/ai-teammates": {
4305
+ "status": "excluded",
4306
+ "reason": "(#614) session-only (assertSessionOnly): an admin turns AI teammates on or off in Settings > AI (Professional and above, the trial, comp); every API key is refused"
4307
+ },
4308
+ "PATCH /api/v1/settings/ai-teammates": {
4309
+ "status": "excluded",
4310
+ "reason": "(#614) session-only (assertSessionOnly): an admin turns AI teammates on or off in Settings > AI (Professional and above, the trial, comp); every API key is refused"
4311
+ },
4229
4312
  "PUT /api/v1/settings/ai/byo": {
4230
4313
  "status": "planned",
4231
4314
  "reason": "account settings read/update surfaces"