conduyt-mcp 4.78.0 → 4.80.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 +21 -1
- package/dist/client.d.ts +14 -0
- package/dist/client.js +57 -24
- package/dist/tools/deals.js +29 -0
- package/dist/tools/form-intakes.d.ts +2 -1
- package/dist/tools/form-intakes.js +4 -3
- package/dist/tools/pipelines.js +4 -4
- package/dist/tools/tasks.js +2 -2
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +4 -2
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
27
27
|
|------|-------------|
|
|
28
28
|
| `conduyt_list_deals` | List deals by pipeline, stage, or assignee; rows carry `hasNextStep` and `nextAppointmentAt`. With `health_summary=true` it answers the Pipeline health strip for the same filtered set (`totalOpenDeals`, `atRiskValue`, `checks[]`, `deals`); `health_check`, `health_limit`, `health_offset`, `health_cursor` (the previous `nextCursor`, passed back unchanged) and `health_rows_only` page one check's deals (rows-only answers `{data:{deals:{check, offset, items, hasMore, nextCursor}}}`) |
|
|
29
29
|
| `conduyt_pipeline_health` | Pipeline health for one pipeline or all visible active pipelines (`GET /deals/inspection`): the six checks over open deals by money at risk; `check` adds that check's deals, paged by `cursor` (or `offset`). Cursor 400s carry `health_cursor_invalid` / `_context` / `_blank` / `_needs_check`, `health_filter_conflict` |
|
|
30
|
+
| `conduyt_pipeline_health_fix_deal` | Pipeline health quick fix for a deal row (`PATCH /deals/{id}` with `checkOrigin`): `stale` = `stageId` (+ `expectedStageId`), `unassigned` = `assignedTo`, `missing_close_date` = `expectedCloseDate`, `missing_contact` = `contactId`; `expectedUpdatedAt` required. Refusals verbatim, never retried: 409 `deal_changed` / `deal_check_cleared` (with the deal's current health row), uncoded 409s, 400 `health_fix_mismatch` |
|
|
31
|
+
| `conduyt_pipeline_health_fix_next_action` | Pipeline health "Set next action" (`POST /tasks` with `checkOrigin` `high_value_idle` / `needs_action`, `dealId`, `expectedDealUpdatedAt`, optional `idempotencyKey` sent as the `Idempotency-Key` header). At most one non-done quick-fix task per deal; refusals verbatim, never retried: 409 `deal_changed` / `deal_has_next_action` / `deal_check_cleared`, 400 `health_fix_mismatch`, idempotency 409/503 |
|
|
30
32
|
| `conduyt_get_stage_rules` / `conduyt_set_stage_rules` | Read / set one stage's rule (`requiredFields` incl. `custom:<fieldKey>`, `requiredDocuments` 0-20, lost-reason label and options, `lostReasonAllowOther`); other stages untouched; revision-aware, retried once on a 409 |
|
|
31
33
|
| `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
|
|
32
34
|
| `conduyt_get_settings_revision` | The account `settingsRevision` (plus stored stage rules and rot days): pass it as `expectedSettingsRevision` to `conduyt_archive_pipeline` / `conduyt_delete_stage` so a stale one is refused 409 `stale_settings_revision` |
|
|
@@ -55,7 +57,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
55
57
|
| `conduyt_delete_pipeline` | Delete a pipeline (move/remove its deals first) |
|
|
56
58
|
| `conduyt_create_stage` | Append a stage to a pipeline (e.g. 'On Hold', 'Declined/Dead') |
|
|
57
59
|
| `conduyt_update_stage` | Rename, recolor, reorder, or change won/lost flags on a stage |
|
|
58
|
-
| `conduyt_delete_stage` |
|
|
60
|
+
| `conduyt_delete_stage` | Soft-delete a stage: an EMPTY stage can always be deleted (history kept, hidden from every list, GET / PATCH then 404, no restore in v1, name reusable); `moveTo` re-homes its live deals first, `expectedSettingsRevision` refuses the delete when settings changed (409 `stale_settings_revision`; the answer then carries the new `settingsRevision`). Refusals are 409s whose message is surfaced verbatim: deals in the stage, `used by N active automation(s) (names)`, DPP status map, `pipeline_archived`, `intake_target`. A run parked before the delete fails at that step with a clear reason |
|
|
59
61
|
|
|
60
62
|
### Activities
|
|
61
63
|
| Tool | Description |
|
|
@@ -342,6 +344,24 @@ The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapp
|
|
|
342
344
|
| `conduyt_reply_capture_verify` | Check DNS and promote to live once the provider confirms inbound receiving |
|
|
343
345
|
| `conduyt_reply_capture_remove` | Remove capture (and finish an interrupted removal) — requires `confirm='REMOVE'`; replies to already-sent email are lost |
|
|
344
346
|
|
|
347
|
+
## Error text grammar
|
|
348
|
+
|
|
349
|
+
A refused call (HTTP 4xx/5xx) fails the tool with this text, stable across tools:
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried).
|
|
356
|
+
`<details>` appears only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try
|
|
357
|
+
each `{` from the left until the rest of the text parses as JSON (a server message may itself contain a brace). The object
|
|
358
|
+
carries scalars first (`code`, revision), then nested objects whole; `HealthFixRefusal.row` is always whole, any other nested
|
|
359
|
+
object too large for the bound is left out and named in `_omitted`. Every HTTP refusal takes this path, including the multipart call-flow audio upload; a tool that adds its own words (a missing-scope or busy note) puts them BEFORE this text. Example:
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
|
|
363
|
+
```
|
|
364
|
+
|
|
345
365
|
## Setup
|
|
346
366
|
|
|
347
367
|
```bash
|
package/dist/client.d.ts
CHANGED
|
@@ -28,7 +28,15 @@ export declare class ConduytApiError extends Error {
|
|
|
28
28
|
export interface RequestOptions {
|
|
29
29
|
timeoutMs?: number;
|
|
30
30
|
maxResponseBytes?: number;
|
|
31
|
+
/** Extra request headers (e.g. Idempotency-Key on POST /tasks); never overrides Authorization / Content-Type. */
|
|
32
|
+
headers?: Record<string, string>;
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
|
|
36
|
+
* long string is shortened inside its quotes), then nested objects whole while they fit ERROR_DETAILS_BOUND, plus the
|
|
37
|
+
* ALWAYS_WHOLE_KEYS; a nested object that does not fit is left out and named in `_omitted`. JSON is never cut mid-object.
|
|
38
|
+
*/
|
|
39
|
+
export declare function renderErrorDetails(body: Record<string, unknown>): string;
|
|
32
40
|
export declare class ConduytClient {
|
|
33
41
|
private baseUrl;
|
|
34
42
|
private apiKey;
|
|
@@ -42,7 +50,13 @@ export declare class ConduytClient {
|
|
|
42
50
|
* after (chunked responses carry no length header).
|
|
43
51
|
*/
|
|
44
52
|
private attempt;
|
|
53
|
+
/**
|
|
54
|
+
* Error grammar (stable): `Conduyt API <status>: <message>` + optionally ` [Retry-After: <N>s]` + optionally ` <details>`,
|
|
55
|
+
* where <details> is ONE JSON object and the FINAL token (see renderErrorDetails). Parse it by trying each `{` from the left
|
|
56
|
+
* until the rest of the text is valid JSON (a server message may itself contain a brace).
|
|
57
|
+
*/
|
|
45
58
|
private httpError;
|
|
59
|
+
private mkHttpError;
|
|
46
60
|
private retryDelayMs;
|
|
47
61
|
/**
|
|
48
62
|
* Retries are for idempotent GETs ONLY: transient statuses (429/502/503/
|
package/dist/client.js
CHANGED
|
@@ -32,6 +32,35 @@ const MAX_RETRY_AFTER_MS = 10_000;
|
|
|
32
32
|
const isAbort = (err) => err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
33
33
|
/** what a timeout of a MUTATION means: the server may have applied it */
|
|
34
34
|
const AMBIGUOUS = " The server may have applied it: read the current state before repeating it.";
|
|
35
|
+
/** Room for the nested objects of an error body (a 409 contact_changed's `contact`, a stage-requirements list). */
|
|
36
|
+
const ERROR_DETAILS_BOUND = 1000;
|
|
37
|
+
/** Error-body keys that are the actionable state itself and are always rendered WHOLE: HealthFixRefusal.row (the deal's
|
|
38
|
+
* current health row, which a caller needs to choose again). The response size cap bounds it. */
|
|
39
|
+
const ALWAYS_WHOLE_KEYS = new Set(["row"]);
|
|
40
|
+
/**
|
|
41
|
+
* Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
|
|
42
|
+
* long string is shortened inside its quotes), then nested objects whole while they fit ERROR_DETAILS_BOUND, plus the
|
|
43
|
+
* ALWAYS_WHOLE_KEYS; a nested object that does not fit is left out and named in `_omitted`. JSON is never cut mid-object.
|
|
44
|
+
*/
|
|
45
|
+
export function renderErrorDetails(body) {
|
|
46
|
+
const entries = Object.entries(body).filter(([k]) => k !== "error");
|
|
47
|
+
if (entries.length === 0)
|
|
48
|
+
return "";
|
|
49
|
+
const isScalar = (v) => v === null || typeof v !== "object";
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, v] of entries.filter(([, v]) => isScalar(v)))
|
|
52
|
+
out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
|
|
53
|
+
const omitted = [];
|
|
54
|
+
for (const [k, v] of entries.filter(([, v]) => !isScalar(v))) {
|
|
55
|
+
if (ALWAYS_WHOLE_KEYS.has(k) || JSON.stringify({ ...out, [k]: v }).length <= ERROR_DETAILS_BOUND)
|
|
56
|
+
out[k] = v;
|
|
57
|
+
else
|
|
58
|
+
omitted.push(k);
|
|
59
|
+
}
|
|
60
|
+
if (omitted.length > 0)
|
|
61
|
+
out._omitted = omitted;
|
|
62
|
+
return JSON.stringify(out);
|
|
63
|
+
}
|
|
35
64
|
export class ConduytClient {
|
|
36
65
|
baseUrl;
|
|
37
66
|
apiKey;
|
|
@@ -63,9 +92,10 @@ export class ConduytClient {
|
|
|
63
92
|
* from Content-Length before the body is read AND from the actual bytes
|
|
64
93
|
* after (chunked responses carry no length header).
|
|
65
94
|
*/
|
|
66
|
-
async attempt(method, path, body, timeoutMs, maxResponseBytes) {
|
|
95
|
+
async attempt(method, path, body, timeoutMs, maxResponseBytes, extraHeaders) {
|
|
67
96
|
const url = `${this.baseUrl}${path}`;
|
|
68
97
|
const headers = {
|
|
98
|
+
...(extraHeaders ?? {}),
|
|
69
99
|
Authorization: `Bearer ${this.apiKey}`,
|
|
70
100
|
"Content-Type": "application/json",
|
|
71
101
|
};
|
|
@@ -161,36 +191,40 @@ export class ConduytClient {
|
|
|
161
191
|
}
|
|
162
192
|
return { res, text };
|
|
163
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Error grammar (stable): `Conduyt API <status>: <message>` + optionally ` [Retry-After: <N>s]` + optionally ` <details>`,
|
|
196
|
+
* where <details> is ONE JSON object and the FINAL token (see renderErrorDetails). Parse it by trying each `{` from the left
|
|
197
|
+
* until the rest of the text is valid JSON (a server message may itself contain a brace).
|
|
198
|
+
*/
|
|
164
199
|
httpError(res, text) {
|
|
200
|
+
const retryAfterRaw = res.headers.get("retry-after");
|
|
201
|
+
const retryAfter = retryAfterRaw === null || retryAfterRaw.trim() === "" ? NaN : Number(retryAfterRaw);
|
|
202
|
+
const retryAfterOk = Number.isFinite(retryAfter) && retryAfter >= 0;
|
|
203
|
+
const raTag = retryAfterOk ? ` [Retry-After: ${retryAfter}s]` : "";
|
|
165
204
|
let msg = res.statusText;
|
|
166
205
|
try {
|
|
167
206
|
const json = JSON.parse(text);
|
|
168
207
|
if (json?.error)
|
|
169
208
|
msg = json.error;
|
|
170
|
-
// Structured error details (e.g. STAGE_REQUIREMENTS carries
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const details = Object.fromEntries([
|
|
179
|
-
...entries.filter(([, v]) => isScalar(v)),
|
|
180
|
-
...entries.filter(([, v]) => !isScalar(v)),
|
|
181
|
-
]);
|
|
182
|
-
if (Object.keys(details).length > 0) {
|
|
183
|
-
msg = `${msg} ${JSON.stringify(details).slice(0, 1000)}`;
|
|
184
|
-
}
|
|
209
|
+
// Structured error details (e.g. STAGE_REQUIREMENTS carries lostReasonOptions) must reach the agent, or the
|
|
210
|
+
// refusal is unactionable. Rendered by renderErrorDetails: JSON is never cut mid-object.
|
|
211
|
+
const details = renderErrorDetails(json ?? {});
|
|
212
|
+
if (details)
|
|
213
|
+
msg = `${msg}${raTag} ${details}`;
|
|
214
|
+
else
|
|
215
|
+
msg = `${msg}${raTag}`;
|
|
216
|
+
return this.mkHttpError(res, `Conduyt API ${res.status}: ${msg}`, retryAfter, retryAfterOk);
|
|
185
217
|
}
|
|
186
218
|
catch {
|
|
187
219
|
if (text)
|
|
188
220
|
msg = text.slice(0, 500);
|
|
221
|
+
msg = `${msg}${raTag}`;
|
|
189
222
|
}
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
return this.mkHttpError(res, `Conduyt API ${res.status}: ${msg}`, retryAfter, retryAfterOk);
|
|
224
|
+
}
|
|
225
|
+
mkHttpError(res, message, retryAfter, retryAfterOk) {
|
|
192
226
|
return new ConduytApiError({
|
|
193
|
-
message
|
|
227
|
+
message,
|
|
194
228
|
status: res.status,
|
|
195
229
|
code: "http",
|
|
196
230
|
retryable: RETRYABLE_STATUSES.has(res.status),
|
|
@@ -224,7 +258,7 @@ export class ConduytClient {
|
|
|
224
258
|
const remaining = totalBudgetMs - (Date.now() - startedAt);
|
|
225
259
|
let res = null;
|
|
226
260
|
try {
|
|
227
|
-
const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes);
|
|
261
|
+
const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes, opts?.headers);
|
|
228
262
|
res = out.res;
|
|
229
263
|
if (out.res.ok)
|
|
230
264
|
return out.text;
|
|
@@ -282,10 +316,9 @@ export class ConduytClient {
|
|
|
282
316
|
catch {
|
|
283
317
|
json = { raw: text };
|
|
284
318
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
throw
|
|
288
|
-
}
|
|
319
|
+
// every non-2xx goes through the shared formatter: same grammar, Retry-After placement and final JSON details object
|
|
320
|
+
if (!res.ok)
|
|
321
|
+
throw this.httpError(res, text);
|
|
289
322
|
return json;
|
|
290
323
|
}
|
|
291
324
|
post(path, body, opts) {
|
package/dist/tools/deals.js
CHANGED
|
@@ -112,6 +112,35 @@ export function registerDealTools(server, client) {
|
|
|
112
112
|
const result = qs ? await client.get("/api/v1/deals/inspection?" + qs) : await client.get("/api/v1/deals/inspection");
|
|
113
113
|
return formatResult(result);
|
|
114
114
|
}));
|
|
115
|
+
strictInput(server.tool("conduyt_pipeline_health_fix_deal", "Pipeline health QUICK FIX for a deal row (PATCH /api/v1/deals/{id} with checkOrigin). Pass the health check the row came from, the deal's updatedAt exactly as the health row carried it (expectedUpdatedAt, REQUIRED), and ONLY that check's fix: stale = stageId (+ optional expectedStageId, the stage the move was chosen against); unassigned = assignedTo; missing_close_date = expectedCloseDate; missing_contact = contactId. Each set to a value; any other combination is refused 400 health_fix_mismatch and nothing is read or written. For the next-action checks (high_value_idle, needs_action) use conduyt_pipeline_health_fix_next_action instead. Refusals reach you verbatim (status, server message, code, and the deal's CURRENT health row) and are NEVER retried. Error text grammar: `Conduyt API <status>: <message>[ [Retry-After: Ns]][ <one JSON object, the final token>]` (see README, Error text grammar). 409 deal_changed (the deal is no longer at expectedUpdatedAt; error text carries updatedAt and the current row: read it, choose again against row.updatedAt), 409 deal_check_cleared (the deal no longer fails that check; nothing written), a 409 without a code (a stage move by another writer, a stale expectedStageId, an in-flight publication), 400 health_fix_mismatch, 404 (no such deal, or your key cannot see it). The row in a refusal is null when your key can no longer see the deal. Ordinary edits (several fields, no health context) stay on conduyt_update_deal.", {
|
|
116
|
+
dealId: z.string().uuid().describe("Deal UUID"),
|
|
117
|
+
checkOrigin: z.enum(["stale", "unassigned", "missing_close_date", "missing_contact"]).describe("The health check this edit fixes"),
|
|
118
|
+
expectedUpdatedAt: z.string().datetime({ message: "expectedUpdatedAt must be the deal's updatedAt exactly as the health row carried it (UTC, trailing Z, e.g. 2026-10-06T23:19:00.000Z)" }).describe("REQUIRED: the deal's updatedAt as the health row carried it (PipelineHealthDeal.updatedAt). A deal at another version is refused 409 deal_changed."),
|
|
119
|
+
stageId: z.string().uuid().optional().describe("stale only: the stage to move the deal to"),
|
|
120
|
+
expectedStageId: z.string().uuid().optional().describe("stale only: the stage the move was chosen against; refused 409 (nothing written) when the deal is no longer in it"),
|
|
121
|
+
assignedTo: z.string().uuid().optional().describe("unassigned only: the user (owner) UUID to assign"),
|
|
122
|
+
expectedCloseDate: z.string().optional().describe("missing_close_date only: the expected close date (ISO 8601, e.g. '2026-11-30')"),
|
|
123
|
+
contactId: z.string().uuid().optional().describe("missing_contact only: the contact UUID to link"),
|
|
124
|
+
}, async ({ dealId, checkOrigin, expectedUpdatedAt, stageId, expectedStageId, assignedTo, expectedCloseDate, contactId }) => {
|
|
125
|
+
const result = await client.patch(`/api/v1/deals/${dealId}`, { checkOrigin, expectedUpdatedAt, stageId, expectedStageId, assignedTo, expectedCloseDate, contactId });
|
|
126
|
+
return formatResult(result);
|
|
127
|
+
}));
|
|
128
|
+
strictInput(server.tool("conduyt_pipeline_health_fix_next_action", "Pipeline health QUICK FIX \"Set next action\" (POST /api/v1/tasks with checkOrigin): creates a follow-up task on a deal from the High-value idle or Needs next action row. Pass checkOrigin, dealId and expectedDealUpdatedAt (the deal's updatedAt as the health row carried it); the task is created only while, under the deal's next-action lock, the deal is still at that version, still has no next action (no open task, no appointment booked from a day ago on) and still fails the check. Send idempotencyKey (1-255 printable ASCII) so a retry after a lost response replays the first task; reuse it ONLY for the same request. Refusals reach you verbatim (status, message, code, the deal's CURRENT health row) and are NEVER retried. Error text grammar: `Conduyt API <status>: <message>[ [Retry-After: Ns]][ <one JSON object, the final token>]` (see README, Error text grammar). 409 deal_changed (read the row, choose again against row.updatedAt), 409 deal_has_next_action (This deal already has a next action booked.), 409 deal_check_cleared, 400 health_fix_mismatch (checkOrigin not high_value_idle / needs_action, or without dealId / expectedDealUpdatedAt), a 409 without a code (the contact was merged or deleted mid-request: retry against the surviving record), or an idempotency 409/503 (see the idempotencyKey notes; verify the outcome before using a new key). The database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action); ordinary tasks (conduyt_create_task) are unconstrained and may coexist with or race a quick fix.", {
|
|
129
|
+
checkOrigin: z.enum(["high_value_idle", "needs_action"]).describe("The health check this task fixes"),
|
|
130
|
+
dealId: z.string().uuid().describe("Deal UUID the task is for"),
|
|
131
|
+
expectedDealUpdatedAt: z.string().datetime({ message: "expectedDealUpdatedAt must be the deal's updatedAt exactly as the health row carried it (UTC, trailing Z)" }).describe("REQUIRED: the deal's updatedAt as the health row carried it (PipelineHealthDeal.updatedAt)"),
|
|
132
|
+
title: z.string().min(1).max(500).describe("Task title (trimmed; blank is refused)"),
|
|
133
|
+
description: z.string().max(5000).optional().describe("Task description (max 5000)"),
|
|
134
|
+
dueDate: z.string().optional().describe("Due date: a date-only YYYY-MM-DD is stored as noon UTC that day; a date-time as given"),
|
|
135
|
+
priority: z.enum(["low", "medium", "high", "urgent"]).optional().describe("Task priority (default medium)"),
|
|
136
|
+
status: z.enum(["todo", "in_progress", "done"]).optional().describe("Task status (default todo)"),
|
|
137
|
+
assignedTo: z.string().nullable().optional().describe("A member's user UUID, '@me' or '@owner'; null or omitted = unassigned (assignment rules may still assign it). Assigning to someone else needs tasks.assign."),
|
|
138
|
+
contactId: z.string().uuid().nullable().optional().describe("Contact UUID to link"),
|
|
139
|
+
idempotencyKey: z.string().regex(/^[!-~](?:[ -~]{0,253}[!-~])?$/, "idempotencyKey must be 1-255 printable ASCII characters, not blank").optional().describe("Sent as the Idempotency-Key header: one key per creation attempt, reused only to retry the SAME request (same key + same body replays the first task; same key + a different body is 422). Two distinct 409s: the original attempt is still processing (wait Retry-After, retry with the SAME key), or code idempotency_key_spent (a prior attempt failed or has an unknown outcome: verify whether it took effect BEFORE a new key). 503 idempotency_claim_held: retry with a NEW key; 503 idempotency_outcome_unknown: verify the outcome first."),
|
|
140
|
+
}, async ({ idempotencyKey, checkOrigin, dealId, expectedDealUpdatedAt, title, description, dueDate, priority, status, assignedTo, contactId }) => {
|
|
141
|
+
const result = await client.post("/api/v1/tasks", { checkOrigin, dealId, expectedDealUpdatedAt, title, description, dueDate, priority, status, assignedTo, contactId }, idempotencyKey !== undefined ? { headers: { "Idempotency-Key": idempotencyKey } } : undefined);
|
|
142
|
+
return formatResult(result);
|
|
143
|
+
}));
|
|
115
144
|
server.tool("conduyt_get_deal", "Get a single deal with full details — value, currency, probability, stage, contact, expected close date, custom-field values, activities, tasks, and history. Use this to read structured custom-field data back out of a deal (e.g. for a P&L or balance-sheet model). Deal rows also carry hasNextStep and nextAppointmentAt (null unless your key can view the calendar) when the API returns them.", {
|
|
116
145
|
id: z.string().describe("Deal UUID"),
|
|
117
146
|
}, async ({ id }) => {
|
|
@@ -11,7 +11,8 @@ import { ConduytClient } from "../client.js";
|
|
|
11
11
|
/** What each tool's key must hold: the API names only the FIRST missing scope, so a 403 is surfaced with all of it. */
|
|
12
12
|
export declare const LIST_AND_CREATE_REQUIREMENT = "the form-intakes AND contacts scopes on a write or admin tier key (a member: settings.edit, contacts.create and contacts.edit)";
|
|
13
13
|
export declare const REVOKE_REQUIREMENT = "the form-intakes scope on an admin tier key (a member: settings.edit)";
|
|
14
|
-
/** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation).
|
|
14
|
+
/** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation). Annotations go BEFORE the
|
|
15
|
+
* client's message, so its details JSON object stays the final token (README, Error text grammar). */
|
|
15
16
|
export declare function intakeToolError(err: unknown, requirement: string): {
|
|
16
17
|
content: Array<{
|
|
17
18
|
type: "text";
|
|
@@ -11,13 +11,14 @@ import { ConduytApiError, formatResult, formatToolError } from "../client.js";
|
|
|
11
11
|
/** What each tool's key must hold: the API names only the FIRST missing scope, so a 403 is surfaced with all of it. */
|
|
12
12
|
export const LIST_AND_CREATE_REQUIREMENT = "the form-intakes AND contacts scopes on a write or admin tier key (a member: settings.edit, contacts.create and contacts.edit)";
|
|
13
13
|
export const REVOKE_REQUIREMENT = "the form-intakes scope on an admin tier key (a member: settings.edit)";
|
|
14
|
-
/** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation).
|
|
14
|
+
/** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation). Annotations go BEFORE the
|
|
15
|
+
* client's message, so its details JSON object stays the final token (README, Error text grammar). */
|
|
15
16
|
export function intakeToolError(err, requirement) {
|
|
16
17
|
if (err instanceof ConduytApiError) {
|
|
17
18
|
if (err.status === 403)
|
|
18
|
-
return formatToolError(
|
|
19
|
+
return formatToolError(`This tool needs ${requirement}. ${err.message}`);
|
|
19
20
|
if (err.status === 503 && err.retryAfterSeconds !== null) {
|
|
20
|
-
return formatToolError(`busy; retry after ${err.retryAfterSeconds} s
|
|
21
|
+
return formatToolError(`busy; retry after ${err.retryAfterSeconds} s: ${err.message}`);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
throw err;
|
package/dist/tools/pipelines.js
CHANGED
|
@@ -9,7 +9,7 @@ export const ARCHIVE_ERROR_CODES = "A refusal carries a machine-readable code in
|
|
|
9
9
|
/** above the archive route's 60 s transaction (up to 500 deal moves) plus post-commit events; the client default is 30 s */
|
|
10
10
|
export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
|
|
11
11
|
export function registerPipelineTools(server, client) {
|
|
12
|
-
server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages. Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
|
|
12
|
+
server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages (soft-deleted stages are never included). Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
|
|
13
13
|
const result = await client.get("/api/v1/pipelines");
|
|
14
14
|
return formatResult(result);
|
|
15
15
|
});
|
|
@@ -90,7 +90,7 @@ export function registerPipelineTools(server, client) {
|
|
|
90
90
|
const result = await client.post("/api/v1/pipelines/reorder", { order, expectedRevision });
|
|
91
91
|
return formatResult(result);
|
|
92
92
|
}));
|
|
93
|
-
server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead').", {
|
|
93
|
+
server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead'). A name may be reused after the old stage of that name was deleted (deleted stages are soft-deleted and hidden).", {
|
|
94
94
|
pipelineId: z.string().describe("Pipeline UUID to add the stage to"),
|
|
95
95
|
name: z.string().describe("Stage name"),
|
|
96
96
|
color: z.string().optional().describe("Hex color, e.g. '#3B82F6'"),
|
|
@@ -107,7 +107,7 @@ export function registerPipelineTools(server, client) {
|
|
|
107
107
|
});
|
|
108
108
|
return formatResult(result);
|
|
109
109
|
});
|
|
110
|
-
server.tool("conduyt_update_stage", "Update a stage within a pipeline — rename, recolor, reorder (sortOrder), or change its won/lost/system flags.", {
|
|
110
|
+
server.tool("conduyt_update_stage", "Update a stage within a pipeline — rename, recolor, reorder (sortOrder), or change its won/lost/system flags. A soft-deleted stage (conduyt_delete_stage) answers 404.", {
|
|
111
111
|
pipelineId: z.string().describe("Pipeline UUID"),
|
|
112
112
|
stageId: z.string().describe("Stage UUID to update"),
|
|
113
113
|
name: z.string().optional().describe("Updated stage name"),
|
|
@@ -128,7 +128,7 @@ export function registerPipelineTools(server, client) {
|
|
|
128
128
|
const result = await client.post(`/api/v1/pipelines/${pipelineId}/stages/reorder`, { order, expectedRevision });
|
|
129
129
|
return formatResult(result);
|
|
130
130
|
}));
|
|
131
|
-
strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline. Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted.
|
|
131
|
+
strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline (soft delete). Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted. An EMPTY stage can always be deleted, even if deals once used it: the row is kept so deal history, activities, audit rows and reports keep its name, but the stage is hidden from every pipeline and stage list, GET / PATCH on it answer 404, there is NO restore in v1, and its name may be reused by a new stage. Its stage rule, appointment-outcome mappings and status metadata are removed with it. With moveTo (another stage of the same pipeline, same won / lost setting, not a system stage) its live deals move there and the stage is deleted in one transaction. Pass expectedSettingsRevision (the settingsRevision from conduyt_get_settings_revision, read right before) to refuse the delete when settings changed since you read them; the answer is then { id, deleted: true, settingsRevision } with the revision after the delete. A refusal is a 409 whose server message is surfaced verbatim (never swallowed, never retried): deals currently in the stage and no moveTo (\"Cannot delete stage: N deal(s) are in this stage...\"), \"Cannot delete stage: it is used by N active automation(s) (names). Change their stage or pause them first.\" (a published automation references the stage in an action, trigger, condition or wait-until), the DPP status map selects it, plus the coded refusals stale_settings_revision, pipeline_archived and intake_target (enabled intake routes deals into this stage); a moveTo that is not a valid target is a 400. Accepted window: a run parked or in flight before the delete is not tracked; at the step that targets the deleted stage it fails or is skipped with a clear reason in the execution log.", {
|
|
132
132
|
pipelineId: z.string().describe("Pipeline UUID"),
|
|
133
133
|
stageId: z.string().describe("Stage UUID to delete"),
|
|
134
134
|
moveTo: z.string().uuid().optional().describe("Another stage of the same pipeline the live deals move to (sent as move_to)"),
|
package/dist/tools/tasks.js
CHANGED
|
@@ -49,7 +49,7 @@ export function registerTaskTools(server, client) {
|
|
|
49
49
|
const result = await client.get(`/api/v1/tasks?${params}`);
|
|
50
50
|
return formatResult(result);
|
|
51
51
|
});
|
|
52
|
-
server.tool("conduyt_create_task", "Create a new task. Can be linked to a contact or deal and assigned to a team member.", {
|
|
52
|
+
server.tool("conduyt_create_task", "Create a new task. Can be linked to a contact or deal and assigned to a team member. This is the ORDINARY task route: ordinary tasks are unconstrained and may coexist with or race a Pipeline health quick-fix task. The database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action); to create the quick fix itself (checkOrigin + expectedDealUpdatedAt, guarded against a deal that changed) use conduyt_pipeline_health_fix_next_action. A 409 without a code means the contact was merged or deleted mid-request: retry against the surviving record. Errors reach you verbatim and are never retried.", {
|
|
53
53
|
title: z.string().describe("Task title"),
|
|
54
54
|
description: z.string().optional().describe("Task description"),
|
|
55
55
|
dueDate: z.string().optional().describe("Due date (ISO 8601)"),
|
|
@@ -68,7 +68,7 @@ export function registerTaskTools(server, client) {
|
|
|
68
68
|
const result = await client.get(`/api/v1/tasks/${encodeURIComponent(id)}`);
|
|
69
69
|
return formatResult(result);
|
|
70
70
|
});
|
|
71
|
-
server.tool("conduyt_update_task", "Update an existing task's fields. Only the fields you provide are changed; omit a field to leave it as-is. To mark a task done, prefer conduyt_complete_task. Requires tasks:edit.", {
|
|
71
|
+
server.tool("conduyt_update_task", "Update an existing task's fields. Only the fields you provide are changed; omit a field to leave it as-is. To mark a task done, prefer conduyt_complete_task. Requires tasks:edit. A 409 means nothing was written, surfaced verbatim and never retried: code deal_has_next_action (\"This deal already has a next action booked.\": re-opening or re-linking a quick-fix task would give the deal a second open one), or no code (the task changed while saving: read it again and retry).", {
|
|
72
72
|
id: z.string().uuid().describe("Task UUID to update"),
|
|
73
73
|
title: z.string().optional().describe("Updated task title"),
|
|
74
74
|
description: z.string().optional().describe("Updated description (pass an empty string to clear it)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
2
|
+
"generatedAt": "2026-10-09T06:41:49.430Z",
|
|
3
|
+
"toolCount": 336,
|
|
4
4
|
"totalEndpoints": 1066,
|
|
5
5
|
"counts": {
|
|
6
6
|
"covered": 299,
|
|
@@ -2165,6 +2165,7 @@
|
|
|
2165
2165
|
"PATCH /api/v1/deals/:id": {
|
|
2166
2166
|
"status": "covered",
|
|
2167
2167
|
"tools": [
|
|
2168
|
+
"conduyt_pipeline_health_fix_deal",
|
|
2168
2169
|
"conduyt_update_deal"
|
|
2169
2170
|
]
|
|
2170
2171
|
},
|
|
@@ -4495,6 +4496,7 @@
|
|
|
4495
4496
|
"POST /api/v1/tasks": {
|
|
4496
4497
|
"status": "covered",
|
|
4497
4498
|
"tools": [
|
|
4499
|
+
"conduyt_pipeline_health_fix_next_action",
|
|
4498
4500
|
"conduyt_create_task"
|
|
4499
4501
|
]
|
|
4500
4502
|
},
|