conduyt-mcp 4.103.0 → 4.105.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 +9 -4
- package/dist/client.d.ts +9 -0
- package/dist/client.js +27 -0
- package/dist/tool-annotations.js +5 -0
- package/dist/tools/activities.js +43 -1
- package/dist/tools/activity-export.d.ts +62 -0
- package/dist/tools/activity-export.js +216 -0
- package/dist/tools/chat.js +31 -1
- package/dist/tools/companies.js +13 -1
- package/package.json +1 -1
- package/scripts/coverage-manifest.json +25 -0
- package/scripts/coverage-matrix.json +83 -10
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
|
+
## 378 Tools
|
|
6
6
|
|
|
7
7
|
### Discovery
|
|
8
8
|
| Tool | Description |
|
|
@@ -73,6 +73,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
73
73
|
| Tool | Description |
|
|
74
74
|
|------|-------------|
|
|
75
75
|
| `conduyt_list_activities` | List recent CRM activities |
|
|
76
|
+
| `conduyt_export_activities_csv` | Export the activities a filter matches (`type`, `search`, `contactId`, `dealId`, `fromDate`, `toDate`) to a CSV FILE at `out_path` (`GET /activity/export.csv`; absolute path; an existing file is refused unless `overwrite: true`). Writes the file on the machine running this server and returns only `{ path, rows, bytes, parts }`, never the CSV. The server streams the export in parts of up to 55 s; the tool follows each part's `continue` trailer by itself (same filters plus the cursor, no repeated header) and commits the file only on `complete`. All or nothing: 413 `export_too_large` (more than 100,000 rows; error, code and hint surfaced), a `too_large` trailer (50 MB), 400 `cursor_invalid`, or a part still cut after 3 attempts leaves no file; a 503 `export_count_timeout` is retried once after its Retry-After. For small reads use `conduyt_list_activities` |
|
|
76
77
|
| `conduyt_create_activity` | Log a call, meeting, or note |
|
|
77
78
|
|
|
78
79
|
### AI
|
|
@@ -151,7 +152,8 @@ No tool carries call audio. Fetch it with `GET {API base}/api/v1/dialer/call/{id
|
|
|
151
152
|
| Tool | Description |
|
|
152
153
|
|------|-------------|
|
|
153
154
|
| `conduyt_list_companies` | List and search companies (`count_mode: none` skips the total: `meta.total` null, `meta.has_more` exact) |
|
|
154
|
-
| `conduyt_get_company` | Get company with contacts
|
|
155
|
+
| `conduyt_get_company` | Get company with the first page of its contacts (100), deals (50) and child accounts (25), each with its total and next cursor |
|
|
156
|
+
| `conduyt_list_company_related` | The next pages of a company's contacts, deals or child accounts (`GET /companies/{id}/related`; `company_id`, `kind` contacts / deals / children, `cursor`): answers `{ kind, rows, total, nextCursor }`; pass `nextCursor` back until it is null. A caller who may not read deals gets an empty deals list with total 0 |
|
|
155
157
|
| `conduyt_create_company` | Create a new company record |
|
|
156
158
|
| `conduyt_update_company` | Update company details |
|
|
157
159
|
|
|
@@ -244,6 +246,9 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
244
246
|
| `conduyt_list_chat_channels` | `GET /chat/channels`: your channels; also CREATES your memberships in the auto channels the first time |
|
|
245
247
|
| `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) |
|
|
246
248
|
| `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 |
|
|
249
|
+
| `conduyt_send_chat_message` | Send a message through the canonical audited Team Chat writer; agent keys receive the six-turn human-handoff guard |
|
|
250
|
+
| `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 |
|
|
251
|
+
| `conduyt_react_chat_message` | React to a Team Chat message as the authenticated user/agent |
|
|
247
252
|
| `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) |
|
|
248
253
|
| `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` |
|
|
249
254
|
| `conduyt_get_chat_notifications` | `GET /chat/channels/{id}/notifications`: your level `{ level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }` |
|
|
@@ -252,7 +257,7 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
252
257
|
|
|
253
258
|
`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.
|
|
254
259
|
|
|
255
|
-
The
|
|
260
|
+
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.
|
|
256
261
|
|
|
257
262
|
#### What the API publishes (realtime events, not tools)
|
|
258
263
|
|
|
@@ -399,7 +404,7 @@ A refused call (HTTP 4xx/5xx) fails the tool with this text, stable across tools
|
|
|
399
404
|
Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
|
|
400
405
|
```
|
|
401
406
|
|
|
402
|
-
`[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried; the one exception is the finalize POST of the upload convenience tools `conduyt_upload_local_file` and `conduyt_upload_chat_attachment`: on a 409 `upload_in_progress` only, at most 4 total attempts, the Retry-After is honoured (capped at 10 s, 5 s when absent or unreadable); finalize is idempotent on the server for the same upload id, so a repeat cannot double-finalize).
|
|
407
|
+
`[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried; the one exception is the finalize POST of the upload convenience tools `conduyt_upload_local_file` and `conduyt_upload_chat_attachment`: on a 409 `upload_in_progress` only, at most 4 total attempts, the Retry-After is honoured (capped at 10 s, 5 s when absent or unreadable); finalize is idempotent on the server for the same upload id, so a repeat cannot double-finalize; the other is `conduyt_export_activities_csv`, a read: on a 503 `export_count_timeout` it waits the Retry-After (capped at 30 s, 5 s when absent) and asks once more).
|
|
403
408
|
`<details>` appears only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try
|
|
404
409
|
each `{` from the left until the rest of the text parses as JSON (a server message may itself contain a brace). The object
|
|
405
410
|
carries scalars first (`code`, revision), then nested objects whole; `HealthFixRefusal.row` is always whole, any other nested
|
package/dist/client.d.ts
CHANGED
|
@@ -76,6 +76,15 @@ export declare class ConduytClient {
|
|
|
76
76
|
request(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<unknown>;
|
|
77
77
|
requestText(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<string>;
|
|
78
78
|
get(path: string, opts?: RequestOptions): Promise<unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* (#624) A GET whose 2xx body is handed back UNREAD as a stream (the activity CSV export, text/csv): sent once, never retried, never
|
|
81
|
+
* buffered and never parsed as JSON. The deadline covers the body as well. A non-2xx is read and thrown through the shared error
|
|
82
|
+
* grammar (status, code, Retry-After and the details object intact).
|
|
83
|
+
*/
|
|
84
|
+
getStream(path: string, opts?: {
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
accept?: string;
|
|
87
|
+
}): Promise<Response>;
|
|
79
88
|
/** Multipart upload (call-flow greeting audio): same auth, timeouts and
|
|
80
89
|
* response bounds as JSON calls; the body is a FormData the caller built. */
|
|
81
90
|
postMultipart(path: string, form: FormData, opts?: RequestOptions): Promise<unknown>;
|
package/dist/client.js
CHANGED
|
@@ -391,6 +391,33 @@ export class ConduytClient {
|
|
|
391
391
|
get(path, opts) {
|
|
392
392
|
return this.request("GET", path, undefined, opts);
|
|
393
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* (#624) A GET whose 2xx body is handed back UNREAD as a stream (the activity CSV export, text/csv): sent once, never retried, never
|
|
396
|
+
* buffered and never parsed as JSON. The deadline covers the body as well. A non-2xx is read and thrown through the shared error
|
|
397
|
+
* grammar (status, code, Retry-After and the details object intact).
|
|
398
|
+
*/
|
|
399
|
+
async getStream(path, opts = {}) {
|
|
400
|
+
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
401
|
+
let res;
|
|
402
|
+
try {
|
|
403
|
+
res = await fetch(`${this.baseUrl}${path}`, {
|
|
404
|
+
method: "GET",
|
|
405
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, Accept: opts.accept ?? "text/csv" },
|
|
406
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
throw new ConduytApiError({
|
|
411
|
+
message: isAbort(err) ? `Conduyt API timeout: no response within ${timeoutMs}ms for GET ${path}` : `Conduyt API network error for GET ${path}: ${err?.message ?? String(err)}`,
|
|
412
|
+
code: isAbort(err) ? "timeout" : "network",
|
|
413
|
+
retryable: true,
|
|
414
|
+
cause: err,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
if (!res.ok)
|
|
418
|
+
throw this.httpError(res, (await res.text().catch(() => "")).slice(0, this.maxResponseBytes));
|
|
419
|
+
return res;
|
|
420
|
+
}
|
|
394
421
|
/** Multipart upload (call-flow greeting audio): same auth, timeouts and
|
|
395
422
|
* response bounds as JSON calls; the body is a FormData the caller built. */
|
|
396
423
|
async postMultipart(path, form, opts = {}) {
|
package/dist/tool-annotations.js
CHANGED
|
@@ -55,6 +55,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
55
55
|
conduyt_reorder_stages: update("Reorder Stages"),
|
|
56
56
|
conduyt_delete_stage: destroy("Delete Stage"),
|
|
57
57
|
conduyt_list_activities: ro("List Activities"),
|
|
58
|
+
conduyt_export_activities_csv: row("Export Activities to CSV File", false, true, false), // writes a LOCAL file, so not read-only; destructive because overwrite: true replaces an existing regular file (MCP: destructiveHint false is additive-only); a repeat writes a new export, so not idempotent
|
|
58
59
|
conduyt_create_activity: create("Create Activity"),
|
|
59
60
|
conduyt_ai_insights: create("Query AI Insights"),
|
|
60
61
|
conduyt_list_automations: ro("List Automations"),
|
|
@@ -112,6 +113,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
112
113
|
conduyt_update_call_recording_settings: update("Update Call Recording Settings"),
|
|
113
114
|
conduyt_list_companies: ro("List Companies"),
|
|
114
115
|
conduyt_get_company: ro("Get Company"),
|
|
116
|
+
conduyt_list_company_related: ro("List Company Related Records"),
|
|
115
117
|
conduyt_create_company: create("Create Company"),
|
|
116
118
|
conduyt_update_company: row("Update Company", false, false, false), // every identical PATCH creates a new company_updated Activity and re-dispatches the update fan-out (companies/[id]/route.ts:362-375)
|
|
117
119
|
conduyt_list_tags: ro("List Tags"),
|
|
@@ -174,6 +176,9 @@ export const TOOL_ANNOTATIONS = {
|
|
|
174
176
|
conduyt_list_chat_channels: update("List Chat Channels"),
|
|
175
177
|
conduyt_get_chat_unread: ro("Get Chat Unread Counts"),
|
|
176
178
|
conduyt_list_chat_messages: update("List Chat Messages"),
|
|
179
|
+
conduyt_send_chat_message: create("Send Chat Message"),
|
|
180
|
+
conduyt_reply_chat_message: create("Reply to Chat Message"),
|
|
181
|
+
conduyt_react_chat_message: update("React to Chat Message"),
|
|
177
182
|
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)
|
|
178
183
|
conduyt_get_chat_realtime_token: ro("Get Chat Realtime Token"),
|
|
179
184
|
conduyt_get_chat_notifications: update("Get Chat Notifications"),
|
package/dist/tools/activities.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatResult } from "../client.js";
|
|
2
|
+
import { formatResult, formatToolError } from "../client.js";
|
|
3
|
+
import { EXPORT_PART_TIMEOUT_MS, runActivityExport } from "./activity-export.js";
|
|
3
4
|
export function registerActivityTools(server, client) {
|
|
4
5
|
server.tool("conduyt_list_activities", "List recent activities across the CRM — calls, emails, notes, meetings, deal changes, and system events.", {
|
|
5
6
|
type: z.string().optional().describe("Filter by activity type (e.g. 'call', 'email', 'note', 'meeting', 'deal_stage_changed')"),
|
|
@@ -25,6 +26,47 @@ export function registerActivityTools(server, client) {
|
|
|
25
26
|
const result = await client.get(`/api/v1/activities?${params}`);
|
|
26
27
|
return formatResult(result);
|
|
27
28
|
});
|
|
29
|
+
server.tool("conduyt_export_activities_csv", "Export the activities a filter matches to a CSV FILE on this machine (GET /activity/export.csv, permission activities.view): the same rows and columns as the Activity page's Export (id, type, description, user, contact, deal, timestamp), newest first. Writes `out_path` (an absolute path; an existing file is refused unless `overwrite: true`) and returns only { path, rows, bytes, parts }: the CSV content is NEVER returned to you (it can be 50 MB). For a small read use conduyt_list_activities instead. The server streams the export in parts of up to 55 s; this tool follows each part's continue trailer by itself (same filters plus the cursor) and commits the file only when the last part says complete: a refusal, a too_large trailer or a part that stays cut leaves no file. Limits: 100,000 rows and 50 MB (413 export_too_large before any row, with maxRows, maxBytes, matchedAtLeast and a hint to narrow the date range or filters). A 503 export_count_timeout is retried once after its Retry-After.", {
|
|
30
|
+
out_path: z.string().describe("Absolute path of the CSV file to write on the machine running this MCP server (a relative path is refused)"),
|
|
31
|
+
overwrite: z.boolean().optional().describe("Replace an existing file at out_path (default false: an existing file is refused before any request)"),
|
|
32
|
+
type: z.string().optional().describe("Filter by activity type, as conduyt_list_activities"),
|
|
33
|
+
search: z.string().optional().describe("Search activity titles/descriptions"),
|
|
34
|
+
contactId: z.string().uuid().optional().describe("Only this contact's activities (UUID)"),
|
|
35
|
+
dealId: z.string().uuid().optional().describe("Only this deal's activities (UUID)"),
|
|
36
|
+
fromDate: z.string().optional().describe("From this day, YYYY-MM-DD in the account's time zone"),
|
|
37
|
+
toDate: z.string().optional().describe("Up to this day, YYYY-MM-DD in the account's time zone"),
|
|
38
|
+
}, async ({ out_path, overwrite, type, search, contactId, dealId, fromDate, toDate }) => {
|
|
39
|
+
const params = new URLSearchParams();
|
|
40
|
+
if (type)
|
|
41
|
+
params.set("type", type);
|
|
42
|
+
if (search)
|
|
43
|
+
params.set("search", search);
|
|
44
|
+
if (contactId)
|
|
45
|
+
params.set("contact_id", contactId);
|
|
46
|
+
if (dealId)
|
|
47
|
+
params.set("deal_id", dealId);
|
|
48
|
+
if (fromDate)
|
|
49
|
+
params.set("fromDate", fromDate);
|
|
50
|
+
if (toDate)
|
|
51
|
+
params.set("toDate", toDate);
|
|
52
|
+
try {
|
|
53
|
+
const result = await runActivityExport({
|
|
54
|
+
outPath: out_path,
|
|
55
|
+
overwrite: overwrite === true,
|
|
56
|
+
// every part carries the SAME filters; a resumed part adds the cursor of the previous part's continue trailer
|
|
57
|
+
fetchPart: (cursor) => {
|
|
58
|
+
const qs = new URLSearchParams(params);
|
|
59
|
+
if (cursor !== null)
|
|
60
|
+
qs.set("cursor", cursor);
|
|
61
|
+
return client.getStream(`/api/v1/activity/export.csv?${qs}`, { timeoutMs: EXPORT_PART_TIMEOUT_MS, accept: "text/csv" });
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return formatResult(result);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return formatToolError(err instanceof Error ? err.message : String(err));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
28
70
|
server.tool("conduyt_create_activity", "Log a new activity — a call, meeting, note, or custom event. Can be linked to a contact.", {
|
|
29
71
|
type: z.string().describe("Activity type: 'call', 'email', 'note', 'meeting', or custom"),
|
|
30
72
|
title: z.string().describe("Activity title/summary"),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type FileHandle } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* (#624) The part loop of GET /api/v1/activity/export.csv, the same protocol as the app's browser download (lib/activity-export.ts
|
|
4
|
+
* downloadActivityExport): request a part, stream it, read its LAST CSV record as the trailer, and on `continue` request the next part with the
|
|
5
|
+
* SAME filters plus the cursor (a resumed part has no header). All or nothing: the rows go to a temporary file next to out_path, which is
|
|
6
|
+
* committed only on `complete`; any refusal, a too_large trailer, a part that is still cut after 3 attempts, or more bytes than the cap
|
|
7
|
+
* leaves no file. The CSV is never returned and never held whole in memory: only the part's final CSV RECORD is held back until the part ends
|
|
8
|
+
* (it can span physical lines inside quotes; it is bounded by the remaining 50 MB budget plus the trailer room).
|
|
9
|
+
*/
|
|
10
|
+
export declare const EXPORT_TRAILER: RegExp;
|
|
11
|
+
/** the app's ceiling (lib/activity-export.ts ACTIVITY_EXPORT_MAX_BYTES; 100,000 rows are counted by the server) */
|
|
12
|
+
export declare const ACTIVITY_EXPORT_MAX_BYTES: number;
|
|
13
|
+
/** the app's part bound (downloadActivityExport maxParts) */
|
|
14
|
+
export declare const EXPORT_MAX_PARTS = 200;
|
|
15
|
+
/** one part runs at most 55 s on the server (maxDuration 60); the client allows 120 s for the whole part, body included */
|
|
16
|
+
export declare const EXPORT_PART_TIMEOUT_MS = 120000;
|
|
17
|
+
/** a 200 part without a trailer (a cut stream) is requested again from the same cursor: this many attempts in all */
|
|
18
|
+
export declare const EXPORT_CUT_ATTEMPTS = 3;
|
|
19
|
+
/** the one 503 export_count_timeout retry waits its Retry-After (5 s from the route), capped here; `sleep` is module state for tests */
|
|
20
|
+
export declare const exportRetry: {
|
|
21
|
+
maxWaitMs: number;
|
|
22
|
+
defaultWaitMs: number;
|
|
23
|
+
sleep: (ms: number) => Promise<unknown>;
|
|
24
|
+
};
|
|
25
|
+
export type ActivityExportResult = {
|
|
26
|
+
path: string;
|
|
27
|
+
rows: number;
|
|
28
|
+
bytes: number;
|
|
29
|
+
parts: number;
|
|
30
|
+
};
|
|
31
|
+
type PartEnd = {
|
|
32
|
+
kind: "complete" | "continue" | "too_large";
|
|
33
|
+
value: string;
|
|
34
|
+
end: number;
|
|
35
|
+
} | {
|
|
36
|
+
cut: true;
|
|
37
|
+
} | {
|
|
38
|
+
over: true;
|
|
39
|
+
};
|
|
40
|
+
/** (r4) a write that made no progress: the export fails (never retried as a cut part, never published) */
|
|
41
|
+
export declare class ExportWriteError extends Error {
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Write all `length` bytes of `buf` from `offset` at file position `position`: FileHandle.write may write fewer bytes than asked (a short
|
|
45
|
+
* write), so loop with offset and position advanced by each bytesWritten; an iteration that writes 0 bytes throws ExportWriteError naming
|
|
46
|
+
* the part and the position. Answers the bytes written (always `length` when it returns).
|
|
47
|
+
*/
|
|
48
|
+
export declare function writeAll(fh: Pick<FileHandle, "write">, buf: Buffer, offset: number, length: number, position: number, part: number): Promise<number>;
|
|
49
|
+
/**
|
|
50
|
+
* Stream one part's body into the file from `start`; the trailer is never written. RFC 4180 quote state is tracked across chunks (a double
|
|
51
|
+
* quote toggles in-quote, so the escaped "" inside a quoted field toggles twice), and only a newline OUTSIDE quotes ends a record. The
|
|
52
|
+
* trailer must be the whole LAST record, starting at a record boundary and ending the body outside quotes: a marker line inside a quoted
|
|
53
|
+
* multi-line description is field text, never a trailer, and a body that ends inside quotes was cut.
|
|
54
|
+
*/
|
|
55
|
+
export declare function readPart(res: Response, fh: Pick<FileHandle, "write">, start: number, room: number, part?: number): Promise<PartEnd>;
|
|
56
|
+
export declare function runActivityExport(o: {
|
|
57
|
+
outPath: string;
|
|
58
|
+
overwrite: boolean;
|
|
59
|
+
fetchPart: (cursor: string | null) => Promise<Response>;
|
|
60
|
+
maxBytes?: number;
|
|
61
|
+
}): Promise<ActivityExportResult>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { linkSync, lstatSync, renameSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { ConduytApiError } from "../client.js";
|
|
6
|
+
/**
|
|
7
|
+
* (#624) The part loop of GET /api/v1/activity/export.csv, the same protocol as the app's browser download (lib/activity-export.ts
|
|
8
|
+
* downloadActivityExport): request a part, stream it, read its LAST CSV record as the trailer, and on `continue` request the next part with the
|
|
9
|
+
* SAME filters plus the cursor (a resumed part has no header). All or nothing: the rows go to a temporary file next to out_path, which is
|
|
10
|
+
* committed only on `complete`; any refusal, a too_large trailer, a part that is still cut after 3 attempts, or more bytes than the cap
|
|
11
|
+
* leaves no file. The CSV is never returned and never held whole in memory: only the part's final CSV RECORD is held back until the part ends
|
|
12
|
+
* (it can span physical lines inside quotes; it is bounded by the remaining 50 MB budget plus the trailer room).
|
|
13
|
+
*/
|
|
14
|
+
export const EXPORT_TRAILER = /^#conduyt-export,(complete|continue|too_large),([^,"\r\n]*)$/;
|
|
15
|
+
/** the app's ceiling (lib/activity-export.ts ACTIVITY_EXPORT_MAX_BYTES; 100,000 rows are counted by the server) */
|
|
16
|
+
export const ACTIVITY_EXPORT_MAX_BYTES = 50 * 1024 * 1024;
|
|
17
|
+
/** a trailer line's bytes at most (the app's TRAILER_ROOM; a signed cursor is ~200 bytes) */
|
|
18
|
+
const TRAILER_ROOM = 1024;
|
|
19
|
+
/** the app's part bound (downloadActivityExport maxParts) */
|
|
20
|
+
export const EXPORT_MAX_PARTS = 200;
|
|
21
|
+
/** one part runs at most 55 s on the server (maxDuration 60); the client allows 120 s for the whole part, body included */
|
|
22
|
+
export const EXPORT_PART_TIMEOUT_MS = 120_000;
|
|
23
|
+
/** a 200 part without a trailer (a cut stream) is requested again from the same cursor: this many attempts in all */
|
|
24
|
+
export const EXPORT_CUT_ATTEMPTS = 3;
|
|
25
|
+
/** the one 503 export_count_timeout retry waits its Retry-After (5 s from the route), capped here; `sleep` is module state for tests */
|
|
26
|
+
export const exportRetry = { maxWaitMs: 30_000, defaultWaitMs: 5_000, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
|
|
27
|
+
const LIMIT_COPY = "An export holds up to 100,000 activities and 50 MB: narrow the date range or filters. No file was made.";
|
|
28
|
+
/** (r4) a write that made no progress: the export fails (never retried as a cut part, never published) */
|
|
29
|
+
export class ExportWriteError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write all `length` bytes of `buf` from `offset` at file position `position`: FileHandle.write may write fewer bytes than asked (a short
|
|
33
|
+
* write), so loop with offset and position advanced by each bytesWritten; an iteration that writes 0 bytes throws ExportWriteError naming
|
|
34
|
+
* the part and the position. Answers the bytes written (always `length` when it returns).
|
|
35
|
+
*/
|
|
36
|
+
export async function writeAll(fh, buf, offset, length, position, part) {
|
|
37
|
+
let done = 0;
|
|
38
|
+
while (done < length) {
|
|
39
|
+
const { bytesWritten } = await fh.write(buf, offset + done, length - done, position + done);
|
|
40
|
+
if (!bytesWritten)
|
|
41
|
+
throw new ExportWriteError(`Writing part ${part} of the export made no progress at byte ${position + done} of the temporary file (${length - done} bytes left). No file was made.`);
|
|
42
|
+
done += bytesWritten;
|
|
43
|
+
}
|
|
44
|
+
return done;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Stream one part's body into the file from `start`; the trailer is never written. RFC 4180 quote state is tracked across chunks (a double
|
|
48
|
+
* quote toggles in-quote, so the escaped "" inside a quoted field toggles twice), and only a newline OUTSIDE quotes ends a record. The
|
|
49
|
+
* trailer must be the whole LAST record, starting at a record boundary and ending the body outside quotes: a marker line inside a quoted
|
|
50
|
+
* multi-line description is field text, never a trailer, and a body that ends inside quotes was cut.
|
|
51
|
+
*/
|
|
52
|
+
export async function readPart(res, fh, start, room, part = 1) {
|
|
53
|
+
let pos = start;
|
|
54
|
+
let read = 0;
|
|
55
|
+
let pending = Buffer.alloc(0);
|
|
56
|
+
let inQuote = false;
|
|
57
|
+
// record starts inside `pending`: lastB = after the latest unquoted newline, prevB = the one before (0 = pending starts a record)
|
|
58
|
+
let lastB = 0, prevB = 0;
|
|
59
|
+
const take = async (chunk) => {
|
|
60
|
+
const from = pending.length;
|
|
61
|
+
pending = from ? Buffer.concat([pending, chunk]) : chunk;
|
|
62
|
+
for (let i = from; i < pending.length; i++) {
|
|
63
|
+
const c = pending[i];
|
|
64
|
+
if (c === 0x22)
|
|
65
|
+
inQuote = !inQuote;
|
|
66
|
+
else if (c === 0x0a && !inQuote) {
|
|
67
|
+
prevB = lastB;
|
|
68
|
+
lastB = i + 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// hold back the last record: the complete one when the bytes end on a boundary, else the unfinished tail
|
|
72
|
+
const k = lastB === pending.length ? prevB : lastB;
|
|
73
|
+
if (k > 0) {
|
|
74
|
+
pos += await writeAll(fh, pending, 0, k, pos, part);
|
|
75
|
+
pending = pending.subarray(k);
|
|
76
|
+
lastB -= k;
|
|
77
|
+
prevB = Math.max(0, prevB - k);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const reader = res.body?.getReader();
|
|
81
|
+
try {
|
|
82
|
+
if (!reader) {
|
|
83
|
+
const all = Buffer.from(await res.text(), "utf8");
|
|
84
|
+
if (all.length > room)
|
|
85
|
+
return { over: true };
|
|
86
|
+
await take(all);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
for (;;) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done)
|
|
92
|
+
break;
|
|
93
|
+
read += value.byteLength;
|
|
94
|
+
if (read > room) {
|
|
95
|
+
await reader.cancel().catch(() => { });
|
|
96
|
+
return { over: true };
|
|
97
|
+
}
|
|
98
|
+
await take(Buffer.from(value));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof ExportWriteError)
|
|
104
|
+
throw err;
|
|
105
|
+
return { cut: true };
|
|
106
|
+
}
|
|
107
|
+
if (inQuote)
|
|
108
|
+
return { cut: true };
|
|
109
|
+
// `pending` is now exactly the last record (one final newline at most): it is the trailer or the part was cut
|
|
110
|
+
const text = pending.toString("utf8");
|
|
111
|
+
const m = EXPORT_TRAILER.exec(text.endsWith("\n") ? text.slice(0, -1) : text);
|
|
112
|
+
if (!m)
|
|
113
|
+
return { cut: true };
|
|
114
|
+
return { kind: m[1], value: m[2], end: pos };
|
|
115
|
+
}
|
|
116
|
+
const isCountTimeout = (err) => err instanceof ConduytApiError && err.status === 503 && /export_count_timeout/.test(err.message);
|
|
117
|
+
export async function runActivityExport(o) {
|
|
118
|
+
const maxBytes = o.maxBytes ?? ACTIVITY_EXPORT_MAX_BYTES;
|
|
119
|
+
if (!isAbsolute(o.outPath))
|
|
120
|
+
throw new Error(`out_path must be an absolute path (got "${o.outPath}"). Nothing was requested.`);
|
|
121
|
+
// lstat (never follows a link): a symlink (dangling or not) or a directory is refused whatever overwrite says; a regular file needs overwrite
|
|
122
|
+
let existing = null;
|
|
123
|
+
try {
|
|
124
|
+
existing = lstatSync(o.outPath);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err.code !== "ENOENT")
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
if (existing?.isSymbolicLink())
|
|
131
|
+
throw new Error(`${o.outPath} is a symbolic link; out_path must name a regular file. Nothing was requested.`);
|
|
132
|
+
if (existing?.isDirectory())
|
|
133
|
+
throw new Error(`${o.outPath} is a directory; out_path must name a file. Nothing was requested.`);
|
|
134
|
+
if (existing && !existing.isFile())
|
|
135
|
+
throw new Error(`${o.outPath} is not a regular file. Nothing was requested.`);
|
|
136
|
+
if (existing && !o.overwrite)
|
|
137
|
+
throw new Error(`${o.outPath} already exists; pass overwrite: true to replace it, or another out_path. Nothing was requested.`);
|
|
138
|
+
const tmp = join(dirname(o.outPath), `.${basename(o.outPath)}.${process.pid}.${randomBytes(6).toString("hex")}.part`);
|
|
139
|
+
let fh = await open(tmp, "wx", 0o600);
|
|
140
|
+
const drop = async () => {
|
|
141
|
+
if (fh) {
|
|
142
|
+
await fh.close().catch(() => { });
|
|
143
|
+
fh = null;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(tmp);
|
|
147
|
+
}
|
|
148
|
+
catch { /* already gone */ }
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
let cursor = null;
|
|
152
|
+
// every cursor this export has requested: any repeat (A, A or A, B, A) would append pages already written
|
|
153
|
+
const seenCursors = new Set();
|
|
154
|
+
let committed = 0;
|
|
155
|
+
let countRetried = false;
|
|
156
|
+
for (let part = 1; part <= EXPORT_MAX_PARTS; part++) {
|
|
157
|
+
let end = null;
|
|
158
|
+
for (let attempt = 1;;) {
|
|
159
|
+
let res;
|
|
160
|
+
try {
|
|
161
|
+
res = await o.fetchPart(cursor);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
// 503 export_count_timeout: wait its Retry-After and ask ONCE more; every other refusal ends the export as the server said it
|
|
165
|
+
if (isCountTimeout(err) && !countRetried) {
|
|
166
|
+
countRetried = true;
|
|
167
|
+
const ra = err.retryAfterSeconds;
|
|
168
|
+
await exportRetry.sleep(Math.min(ra !== null ? ra * 1000 : exportRetry.defaultWaitMs, exportRetry.maxWaitMs));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
end = await readPart(res, fh, committed, maxBytes - committed + TRAILER_ROOM, part);
|
|
174
|
+
if (!("cut" in end))
|
|
175
|
+
break;
|
|
176
|
+
// a cut part: drop what it wrote and ask for THAT part again from the same cursor
|
|
177
|
+
await fh.truncate(committed);
|
|
178
|
+
if (++attempt > EXPORT_CUT_ATTEMPTS)
|
|
179
|
+
throw new Error(`Part ${part} of the export was cut before its end ${EXPORT_CUT_ATTEMPTS} times (no trailer). No file was made.`);
|
|
180
|
+
}
|
|
181
|
+
if ("over" in end || ("kind" in end && end.end > maxBytes))
|
|
182
|
+
throw new Error(`The export passed 50 MB. ${LIMIT_COPY}`);
|
|
183
|
+
if (end.kind === "too_large")
|
|
184
|
+
throw new Error(`The export reached its ${end.value === "bytes" ? "50 MB" : "100,000 row"} limit while streaming (too_large,${end.value}). ${LIMIT_COPY}`);
|
|
185
|
+
if (end.kind === "complete") {
|
|
186
|
+
await fh.sync();
|
|
187
|
+
await fh.close();
|
|
188
|
+
fh = null;
|
|
189
|
+
if (o.overwrite)
|
|
190
|
+
renameSync(tmp, o.outPath);
|
|
191
|
+
else {
|
|
192
|
+
try {
|
|
193
|
+
linkSync(tmp, o.outPath);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (err.code === "EEXIST")
|
|
197
|
+
throw new Error(`${o.outPath} appeared while the export ran; it was not replaced. No file was made.`);
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
unlinkSync(tmp);
|
|
201
|
+
}
|
|
202
|
+
return { path: o.outPath, rows: Number(end.value), bytes: end.end, parts: part };
|
|
203
|
+
}
|
|
204
|
+
if (!end.value || seenCursors.has(end.value))
|
|
205
|
+
throw new Error("The export did not advance (the continue trailer repeated a cursor already used). No file was made.");
|
|
206
|
+
seenCursors.add(end.value);
|
|
207
|
+
cursor = end.value;
|
|
208
|
+
committed = end.end;
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`The export did not complete within ${EXPORT_MAX_PARTS} parts. No file was made.`);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
await drop();
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
package/dist/tools/chat.js
CHANGED
|
@@ -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"),
|
package/dist/tools/companies.js
CHANGED
|
@@ -31,12 +31,24 @@ export function registerCompanyTools(server, client) {
|
|
|
31
31
|
const result = await client.get(`/api/v1/companies?${params}`);
|
|
32
32
|
return formatResult(result);
|
|
33
33
|
});
|
|
34
|
-
server.tool("conduyt_get_company", "Get a single company with full details
|
|
34
|
+
server.tool("conduyt_get_company", "Get a single company with full details and aggregated deal value. Carries the FIRST page of each related list: contacts (100, total contactCount, contactsNextCursor), deals (50, dealsNextCursor) and child accounts (childCompanies, 25, total childCount, childNextCursor). A non-null next cursor means more rows: read them with conduyt_list_company_related.", {
|
|
35
35
|
id: z.string().describe("Company UUID"),
|
|
36
36
|
}, async ({ id }) => {
|
|
37
37
|
const result = await client.get(`/api/v1/companies/${id}`);
|
|
38
38
|
return formatResult(result);
|
|
39
39
|
});
|
|
40
|
+
server.tool("conduyt_list_company_related", "List the NEXT pages of a company's related records (GET /companies/{id}/related, permission companies.view). `conduyt_get_company` already carries the FIRST page of each kind (contacts, deals, children) with its total and next cursor (contactsNextCursor, dealsNextCursor, childNextCursor): pass that cursor here as `cursor` with the same `kind`, then pass each answer's `nextCursor` back until it is null (keyset: strictly after the last row shown, none repeated or skipped). Answers { kind, rows, total, nextCursor }. kind contacts = the company's own and associated contacts, newest first; deals = newest first; children = child accounts oldest first (by creation time, then id). A caller who may not read deals (or a key without the deals scope) gets an empty deals list with total 0. 400 for an unknown kind or a cursor this list did not issue; 404 for an unknown company.", {
|
|
41
|
+
company_id: z.string().uuid().describe("Company UUID"),
|
|
42
|
+
kind: z.enum(["contacts", "deals", "children"]).describe("Which related list: contacts, deals or children (child accounts)"),
|
|
43
|
+
cursor: z.string().optional().describe("The nextCursor of the previous page (from conduyt_get_company or this tool), passed back unchanged; omit it to read the first page"),
|
|
44
|
+
}, async ({ company_id, kind, cursor }) => {
|
|
45
|
+
const params = new URLSearchParams();
|
|
46
|
+
params.set("kind", kind);
|
|
47
|
+
if (cursor !== undefined)
|
|
48
|
+
params.set("cursor", cursor);
|
|
49
|
+
const result = await client.get(`/api/v1/companies/${encodeURIComponent(company_id)}/related?${params}`);
|
|
50
|
+
return formatResult(result);
|
|
51
|
+
});
|
|
40
52
|
server.tool("conduyt_create_company", "Create a new company record with business details.", {
|
|
41
53
|
name: z.string().describe("Company name (required)"),
|
|
42
54
|
domain: z.string().optional().describe("Company domain (e.g. 'acme.com')"),
|
package/package.json
CHANGED
|
@@ -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",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-30T13:37:28.694Z",
|
|
3
|
+
"toolCount": 378,
|
|
4
|
+
"totalEndpoints": 1134,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
7
|
-
"excluded":
|
|
8
|
-
"planned":
|
|
6
|
+
"covered": 331,
|
|
7
|
+
"excluded": 350,
|
|
8
|
+
"planned": 453,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -301,6 +301,12 @@
|
|
|
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": "covered",
|
|
306
|
+
"tools": [
|
|
307
|
+
"conduyt_export_activities_csv"
|
|
308
|
+
]
|
|
309
|
+
},
|
|
304
310
|
"GET /api/v1/admin/accounts": {
|
|
305
311
|
"status": "excluded",
|
|
306
312
|
"reason": "super-admin platform operations (Conduyt staff only)"
|
|
@@ -1269,6 +1275,46 @@
|
|
|
1269
1275
|
"conduyt_voice_drop"
|
|
1270
1276
|
]
|
|
1271
1277
|
},
|
|
1278
|
+
"GET /api/v1/chat/agents": {
|
|
1279
|
+
"status": "excluded",
|
|
1280
|
+
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1281
|
+
},
|
|
1282
|
+
"POST /api/v1/chat/agents": {
|
|
1283
|
+
"status": "excluded",
|
|
1284
|
+
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1285
|
+
},
|
|
1286
|
+
"PATCH /api/v1/chat/agents/:id": {
|
|
1287
|
+
"status": "excluded",
|
|
1288
|
+
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1289
|
+
},
|
|
1290
|
+
"DELETE /api/v1/chat/agents/:id": {
|
|
1291
|
+
"status": "excluded",
|
|
1292
|
+
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1293
|
+
},
|
|
1294
|
+
"POST /api/v1/chat/agents/:id/pair-code": {
|
|
1295
|
+
"status": "excluded",
|
|
1296
|
+
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1297
|
+
},
|
|
1298
|
+
"POST /api/v1/chat/agents/me/heartbeat": {
|
|
1299
|
+
"status": "excluded",
|
|
1300
|
+
"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"
|
|
1301
|
+
},
|
|
1302
|
+
"GET /api/v1/chat/agents/me/inbox": {
|
|
1303
|
+
"status": "excluded",
|
|
1304
|
+
"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"
|
|
1305
|
+
},
|
|
1306
|
+
"POST /api/v1/chat/agents/me/inbox/ack": {
|
|
1307
|
+
"status": "excluded",
|
|
1308
|
+
"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"
|
|
1309
|
+
},
|
|
1310
|
+
"POST /api/v1/chat/agents/me/inbox/claim": {
|
|
1311
|
+
"status": "excluded",
|
|
1312
|
+
"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"
|
|
1313
|
+
},
|
|
1314
|
+
"POST /api/v1/chat/agents/pair": {
|
|
1315
|
+
"status": "excluded",
|
|
1316
|
+
"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"
|
|
1317
|
+
},
|
|
1272
1318
|
"POST /api/v1/chat/ask": {
|
|
1273
1319
|
"status": "planned",
|
|
1274
1320
|
"reason": "team chat read/post for agents"
|
|
@@ -1287,6 +1333,10 @@
|
|
|
1287
1333
|
"status": "planned",
|
|
1288
1334
|
"reason": "team chat read/post for agents"
|
|
1289
1335
|
},
|
|
1336
|
+
"PATCH /api/v1/chat/channels/:id/agents": {
|
|
1337
|
+
"status": "excluded",
|
|
1338
|
+
"reason": "(#599) session-only (assertSessionOnly): a channel's agents-respond mode is an admin setting; every API key is refused"
|
|
1339
|
+
},
|
|
1290
1340
|
"POST /api/v1/chat/channels/:id/members": {
|
|
1291
1341
|
"status": "planned",
|
|
1292
1342
|
"reason": "team chat read/post for agents"
|
|
@@ -1303,8 +1353,11 @@
|
|
|
1303
1353
|
]
|
|
1304
1354
|
},
|
|
1305
1355
|
"POST /api/v1/chat/channels/:id/messages": {
|
|
1306
|
-
"status": "
|
|
1307
|
-
"
|
|
1356
|
+
"status": "covered",
|
|
1357
|
+
"tools": [
|
|
1358
|
+
"conduyt_send_chat_message",
|
|
1359
|
+
"conduyt_reply_chat_message"
|
|
1360
|
+
]
|
|
1308
1361
|
},
|
|
1309
1362
|
"GET /api/v1/chat/channels/:id/messages/:messageId": {
|
|
1310
1363
|
"status": "planned",
|
|
@@ -1331,8 +1384,10 @@
|
|
|
1331
1384
|
"reason": "team chat read/post for agents"
|
|
1332
1385
|
},
|
|
1333
1386
|
"POST /api/v1/chat/channels/:id/messages/:messageId/reactions": {
|
|
1334
|
-
"status": "
|
|
1335
|
-
"
|
|
1387
|
+
"status": "covered",
|
|
1388
|
+
"tools": [
|
|
1389
|
+
"conduyt_react_chat_message"
|
|
1390
|
+
]
|
|
1336
1391
|
},
|
|
1337
1392
|
"DELETE /api/v1/chat/channels/:id/messages/:messageId/reactions": {
|
|
1338
1393
|
"status": "planned",
|
|
@@ -1440,6 +1495,12 @@
|
|
|
1440
1495
|
"status": "planned",
|
|
1441
1496
|
"reason": "company subroutes beyond core CRUD"
|
|
1442
1497
|
},
|
|
1498
|
+
"GET /api/v1/companies/:id/related": {
|
|
1499
|
+
"status": "covered",
|
|
1500
|
+
"tools": [
|
|
1501
|
+
"conduyt_list_company_related"
|
|
1502
|
+
]
|
|
1503
|
+
},
|
|
1443
1504
|
"GET /api/v1/companies/:id/tags": {
|
|
1444
1505
|
"status": "planned",
|
|
1445
1506
|
"reason": "company subroutes beyond core CRUD"
|
|
@@ -1755,6 +1816,10 @@
|
|
|
1755
1816
|
"status": "excluded",
|
|
1756
1817
|
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
1757
1818
|
},
|
|
1819
|
+
"GET /api/v1/cron/chat-webhooks": {
|
|
1820
|
+
"status": "excluded",
|
|
1821
|
+
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
1822
|
+
},
|
|
1758
1823
|
"GET /api/v1/cron/cleanup-audit-logs": {
|
|
1759
1824
|
"status": "excluded",
|
|
1760
1825
|
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
@@ -4240,6 +4305,14 @@
|
|
|
4240
4305
|
"status": "planned",
|
|
4241
4306
|
"reason": "account settings read/update surfaces"
|
|
4242
4307
|
},
|
|
4308
|
+
"GET /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
|
+
},
|
|
4312
|
+
"PATCH /api/v1/settings/ai-teammates": {
|
|
4313
|
+
"status": "excluded",
|
|
4314
|
+
"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"
|
|
4315
|
+
},
|
|
4243
4316
|
"PUT /api/v1/settings/ai/byo": {
|
|
4244
4317
|
"status": "planned",
|
|
4245
4318
|
"reason": "account settings read/update surfaces"
|