conduyt-mcp 4.90.0 → 4.92.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
|
@@ -15,10 +15,13 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
15
15
|
| Tool | Description |
|
|
16
16
|
|------|-------------|
|
|
17
17
|
| `conduyt_list_contacts` | List and search contacts with filters |
|
|
18
|
-
| `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact; `hasOutboundEmail` = the same for email |
|
|
19
|
-
| `conduyt_create_contact` | Create a new contact (with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
|
|
18
|
+
| `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
|
+
| `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
20
|
| `conduyt_update_contact` | Update contact fields; optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
|
|
21
21
|
| `conduyt_merge_contacts` | DESTRUCTIVE: merge duplicates into a survivor (`POST /contacts/merge`, owner/admin); the losers are deleted. Two steps: without `confirmImpact` a preview (`counts`, `guardCounts`, `identifierOverflow`, `blockers`); with `confirmImpact: true` plus the preview's `expectedCounts`, `expectedGuardCounts` (and `expectedIdentifiers`) the merge; the confirm is sent only against a preview this server returned in the last 10 minutes (else refused locally); the server pins the record counts, guard counts and identifier plan, not other field edits made after the preview; 75 s timeout. Moved counts 409, 503 `merge_contacts_busy` / `merge_budget_exhausted` + Retry-After surfaced verbatim, never retried |
|
|
22
|
+
| `conduyt_list_duplicate_proposals` | List a contact's possible duplicates (`GET /contacts/{id}/duplicate-proposals`): open and not-sure proposals (`id`, `status`, `rule`, `other`, `raisedHere`) and the identifiers held from it (value-free); a record you cannot view has no id; keyset paged with `cursor` / `limit` (default 50) and `held_cursor` / `held_limit` (default 20), answers `total`, `nextCursor`, the held twins and membership revisions (re-read page one when a later page answers another revision) |
|
|
23
|
+
| `conduyt_list_import_duplicate_proposals` | The duplicate proposals one import raised on the records it created (`GET /imports/{id}/duplicate-proposals`, contacts:view): every status, oldest first, `page` / `per_page` (default 50), `{ rows, meta }`; a record you cannot view is "A contact you cannot view" with no id |
|
|
24
|
+
| `conduyt_decide_duplicate_proposal` | Decide one proposal (`POST /contacts/{id}/duplicate-proposals/{proposalId}`, contacts:edit on both records): `not_sure` keeps both visible, `different` is final (never shown again), `merge` records a pair ALREADY merged (this tool never merges: use `conduyt_merge_contacts` first, else 409 `not_merged`); 409 `already_decided`, 403 `other_record_not_editable`; answers the list's `membershipRevision` and `total` after the decision and `previousMembershipRevision`; never retried |
|
|
22
25
|
| `conduyt_add_contact_collaborator` | Add a team member as a collaborator (`POST /contacts/{id}/collaborators`); optional `expectedRevision`, 409 `contact_changed` |
|
|
23
26
|
| `conduyt_remove_contact_collaborator` | Remove a collaborator (`DELETE /contacts/{id}/collaborators`, body); optional `expectedRevision`, 409 `contact_changed`, 404 when not a collaborator |
|
|
24
27
|
| `conduyt_add_tags` | Add tags to a contact |
|
package/dist/tools/bulk.js
CHANGED
|
@@ -4,7 +4,7 @@ import { INVALID_NUMBER_REFUSALS } from "./contacts.js";
|
|
|
4
4
|
/** (#136, Codex r1 m2) POST /bulk/contacts/update is NOT atomic and answers per row: an impossible phone is a per-row success:false inside HTTP 200 (no code) while other rows commit; the route takes no country */
|
|
5
5
|
export const BULK_UPDATE_REFUSALS = " Results are PER ROW inside an HTTP 200 (not atomic: some rows can be saved while others fail): read data.summary { total, succeeded, failed } and data.results, each { id, success, error? }. An impossible US/Canada number (area code or exchange starting 0 or 1) fails only that row, success: false with the error text (no phone_invalid code, no top-level 400), and nothing is saved for that row. This route does not take country: a country-only row fails as a row, and a country sent beside another field is ignored while that field is applied (use conduyt_bulk_edit_contacts for a country; it refuses a null or blank country 400 country_invalid).";
|
|
6
6
|
export function registerBulkTools(server, client) {
|
|
7
|
-
server.tool("conduyt_ai_import_contacts", "AI-powered bulk contact import. Accepts up to 1000 contacts with optional auto-tagging, smart list creation, and automation enrollment. Deduplicates by email, phone, or both.", {
|
|
7
|
+
server.tool("conduyt_ai_import_contacts", "AI-powered bulk contact import. Accepts up to 1000 contacts with optional auto-tagging, smart list creation, and automation enrollment. Deduplicates by email, phone, or both. A workspace whose identifier index is still being built answers 503 `IDENTITY_INDEX_NOT_READY` (nothing imported; retry later).", {
|
|
8
8
|
contacts: z.array(z.object({
|
|
9
9
|
firstName: z.string().optional(),
|
|
10
10
|
lastName: z.string().optional(),
|
|
@@ -152,7 +152,7 @@ export function registerBulkTools(server, client) {
|
|
|
152
152
|
const result = await client.post("/api/v1/bulk/deals/update", params);
|
|
153
153
|
return formatResult(result);
|
|
154
154
|
});
|
|
155
|
-
server.tool("conduyt_import_preflight", "Pre-check a contact import BEFORE running it — returns criticalWarnings (phone-less list bound for SMS, heavy create-only duplicate skips, double-mapped columns), duplicate/existing-match estimates, invalid emails, DNC/litigator hits, SMS-provider readiness, and plan-limit impact. Same engine the import wizard uses; call this before conduyt_ai_import_contacts on any sizeable list.", {
|
|
155
|
+
server.tool("conduyt_import_preflight", "Pre-check a contact import BEFORE running it — returns criticalWarnings (phone-less list bound for SMS, heavy create-only duplicate skips, double-mapped columns), duplicate/existing-match estimates, invalid emails, DNC/litigator hits, SMS-provider readiness, and plan-limit impact. Same engine the import wizard uses; call this before conduyt_ai_import_contacts on any sizeable list. A workspace whose identifier index is still being built answers 503 `IDENTITY_INDEX_NOT_READY` (nothing checked; retry later).", {
|
|
156
156
|
objectType: z.enum(["contacts", "opportunities", "companies"]).default("contacts").describe("What the import creates"),
|
|
157
157
|
importMode: z.enum(["create_and_update", "create_only", "update_only"]).optional().describe("Duplicate handling mode the import will use"),
|
|
158
158
|
totalRows: z.number().describe("Total rows in the file/payload"),
|
|
@@ -160,6 +160,8 @@ export function registerBulkTools(server, client) {
|
|
|
160
160
|
phones: z.array(z.string()).optional().describe("All phone values from the file (order-free)"),
|
|
161
161
|
mappedFields: z.array(z.string()).optional().describe("The Conduyt field names your columns map to, e.g. ['firstName','phone']"),
|
|
162
162
|
removeDuplicates: z.boolean().optional().describe("Whether explicit dedupe is on"),
|
|
163
|
+
ambiguousMatchMode: z.enum(["proposal", "hold", "create_new"]).nullish().describe("What the import does with a row whose email matches one record and phone another (identity v2): 'proposal' (the platform default: the row is created and carries a duplicate proposal naming the similar records) or 'hold' (held for review). 'create_new' is RETIRED for new imports (the server answers 400); it is accepted here only so a preview of an old job still validates."),
|
|
164
|
+
enrollAdditionalCampaign: z.boolean().optional().describe("true = the import's Add mode: a contested email or phone slot value is stripped and the row is still created (estimated the way the import will run it); every other mode skips such a row as secondary_identifier_conflict"),
|
|
163
165
|
duplicateCheckFields: z.array(z.enum(["email", "phone"])).optional().describe("Identifiers explicit dedupe checks"),
|
|
164
166
|
rowIdentifiers: z.array(z.object({
|
|
165
167
|
email: z.string().nullable().optional(),
|
package/dist/tools/contacts.js
CHANGED
|
@@ -6,6 +6,15 @@ import { strictInput } from "./strict.js";
|
|
|
6
6
|
* create that carries callId must not carry either field. Refused here, after every field is merged, with the rule named. */
|
|
7
7
|
export const CALL_DERIVED_REFUSAL = "callId cannot be combined with source or assignedTo: for a lead created from a call the server derives both (source is 'Inbound Call', the owner is the rep who took the call). Omit them.";
|
|
8
8
|
export const callDerivedFieldsConflict = (v) => v.callId !== undefined && (v.source !== undefined || v.assignedTo !== undefined);
|
|
9
|
+
/** Identity rules v2 (board #69, app main 61bf61876): what a create answers when the email or phone already belongs to a record. */
|
|
10
|
+
const IDENTITY_409_MEANING = "A 409 with { existingId, rule, ruleVersion, matchedOn, reason } means the server's identity policy identified the incoming person WITH the record existingId (rule and matchedOn say how). It is not proof by itself: the policy deliberately allows two different people on one shared identifier when their full names conflict (rule R6; shared phone lines exist), and that case is NOT refused: the server creates the record and shows a passive duplicate proposal (conduyt_list_duplicate_proposals). The server itself refuses creates it identifies as the same person, so never create a copy. Before you update existingId, CONFIRM the identity (name, company); never treat a shared phone or email as proof that it is the same person.";
|
|
11
|
+
const IDENTITY_CREATE_ANSWERS = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` or `owner_busy` = another save for the same email, phone or owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later. A possible duplicate that is not an exact match is created and shown as a duplicate proposal on the record (conduyt_list_duplicate_proposals).";
|
|
12
|
+
const IDENTITY_SAVE_RETRY = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` = another save for this email or phone was in progress (or the record's email or phone changed while this saved), nothing was written: reload the contact and send the change again (retryable). 409 with code `owner_busy` = another save for the same owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later.";
|
|
13
|
+
const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
|
|
14
|
+
/** Duplicate proposals look (board #95, app f2d638db6): paging bounds of the proposal lists. */
|
|
15
|
+
const pageLimit = (max, dflt, name) => z.number().int().min(1).max(max).optional().describe(`${name}: integer 1-${max} (default ${dflt}); a value above ${max} is refused, not capped`);
|
|
16
|
+
const opaqueCursor = (name, from) => z.string().min(1).max(2048).optional().describe(`${name}: the ${from} of the previous page's answer, unchanged (opaque). Omit for the first page.`);
|
|
17
|
+
const MEMBERSHIP_REVISION_NOTE = "membershipRevision and heldIdentifiersMembershipRevision are opaque digests of each list's whole shown set (every page answers the same value while the list is unchanged): keep page one's revision and, when a later page answers another, discard that page and read page one again.";
|
|
9
18
|
export const CONTACT_REVISION_MAX = 2147483647;
|
|
10
19
|
const CONTACT_CHANGED = "A stale pin is refused with 409 and code contact_changed (nothing written): the error text carries `code`, the contact's CURRENT `revision` and the current `contact`; reload, decide, and call again with that revision (never retry the same pin). A malformed expectedRevision is a 400; only an omitted field means unpinned.";
|
|
11
20
|
const expectedRevision = () => z.number().int().min(0).max(CONTACT_REVISION_MAX).optional().describe("Optimistic-concurrency pin: the contact `revision` as you last read it (conduyt_get_contact / conduyt_list_contacts / a previous change). Omit for last-write-wins per field.");
|
|
@@ -95,13 +104,13 @@ export function registerContactTools(server, client) {
|
|
|
95
104
|
const result = await client.get(`/api/v1/contacts?${params}`);
|
|
96
105
|
return formatResult(result);
|
|
97
106
|
});
|
|
98
|
-
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.", {
|
|
107
|
+
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.", {
|
|
99
108
|
id: z.string().describe("Contact UUID"),
|
|
100
109
|
}, async ({ id }) => {
|
|
101
110
|
const result = await client.get(`/api/v1/contacts/${id}`);
|
|
102
111
|
return formatResult(result);
|
|
103
112
|
});
|
|
104
|
-
const createContactTool = server.tool("conduyt_create_contact", "Create a new contact in the CRM. Email or phone is recommended but not strictly required. With callId (a lead made from a contact-less inbound call) you must NOT send source or assignedTo: the server derives both from the call, and the request is refused if either is present." + INVALID_NUMBER_REFUSALS, {
|
|
113
|
+
const createContactTool = server.tool("conduyt_create_contact", "Create a new contact in the CRM. Email or phone is recommended but not strictly required. With callId (a lead made from a contact-less inbound call) you must NOT send source or assignedTo: the server derives both from the call, and the request is refused if either is present." + INVALID_NUMBER_REFUSALS + " " + IDENTITY_CREATE_ANSWERS, {
|
|
105
114
|
email: z.string().optional().describe("Contact email address"),
|
|
106
115
|
firstName: z.string().optional().describe("First name"),
|
|
107
116
|
lastName: z.string().optional().describe("Last name"),
|
|
@@ -131,7 +140,7 @@ export function registerContactTools(server, client) {
|
|
|
131
140
|
created.inputSchema = Object.assign(base.superRefine((v, ctx) => { if (callDerivedFieldsConflict(v))
|
|
132
141
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: CALL_DERIVED_REFUSAL, path: ["callId"] }); }), { shape: base.shape });
|
|
133
142
|
}
|
|
134
|
-
strictInput(server.tool("conduyt_update_contact", "Update an existing contact's fields — name, email, phone, company, custom fields. customFields merge into existing values (omit a key to leave it unchanged). Pass expectedRevision (the contact's `revision` as you read it) to refuse the save when the contact changed since." + INVALID_NUMBER_REFUSALS + " " + CONTACT_CHANGED, {
|
|
143
|
+
strictInput(server.tool("conduyt_update_contact", "Update an existing contact's fields — name, email, phone, company, custom fields. customFields merge into existing values (omit a key to leave it unchanged). Pass expectedRevision (the contact's `revision` as you read it) to refuse the save when the contact changed since." + INVALID_NUMBER_REFUSALS + " " + CONTACT_CHANGED + " " + IDENTITY_SAVE_RETRY, {
|
|
135
144
|
id: z.string().describe("Contact UUID"),
|
|
136
145
|
email: z.string().optional().describe("Updated email"),
|
|
137
146
|
firstName: z.string().optional().describe("Updated first name"),
|
|
@@ -235,4 +244,46 @@ export function registerContactTools(server, client) {
|
|
|
235
244
|
mergePreviews.drop(key);
|
|
236
245
|
return formatResult(result);
|
|
237
246
|
}));
|
|
247
|
+
strictInput(server.tool("conduyt_list_duplicate_proposals", "List the possible duplicates of one contact (GET /api/v1/contacts/{contactId}/duplicate-proposals, identity rules v2; requires contacts:view). Answers { data: { proposals, heldIdentifiers, total, nextCursor, membershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor, heldIdentifiersMembershipRevision } }. Each proposal: { id (the proposalId for conduyt_decide_duplicate_proposal), status ('open' or 'not_sure': the only two shown), rule, ruleVersion, source, createdAt, decidedAt, raisedHere (true = this record is the newer arrival the proposal was raised on), other: { id, name, createdAt, visible, editable } }. When your key cannot view the other record it is described only as 'a contact you cannot view' with id and createdAt null and visible false: you cannot decide that pair. heldIdentifiers are the emails or phones this record was NOT given because another record owns them (rule 4), value-free: { id, kind ('email' or 'phone'), rule, source, createdAt, heldBy: [{ id, name, visible }] }. Paging: both lists are keyset paged, newest first. cursor / limit (1-100, default 50) page the proposals; held_cursor / held_limit (1-100, default 20) page the held identifiers; a list sent without its cursor answers its first page. total and heldIdentifiersTotal count every line of each list (all pages); nextCursor and heldIdentifiersNextCursor are the cursors of the next pages, null on the last page. " + MEMBERSHIP_REVISION_NOTE + " Read-only, nothing is changed. Refusals: 400 code `invalid_cursor` (a cursor this list did not issue, a held cursor sent as cursor or the reverse), 400 naming limit or held_limit, 404 = no such contact for you. conduyt_get_contact carries the first page of the same two lists.", {
|
|
248
|
+
contactId: z.string().uuid().describe("Contact UUID whose possible duplicates to list"),
|
|
249
|
+
cursor: opaqueCursor("cursor", "nextCursor"),
|
|
250
|
+
limit: pageLimit(100, 50, "limit (proposals per page)"),
|
|
251
|
+
held_cursor: opaqueCursor("held_cursor", "heldIdentifiersNextCursor"),
|
|
252
|
+
held_limit: pageLimit(100, 20, "held_limit (held identifiers per page)"),
|
|
253
|
+
}, async ({ contactId, cursor, limit, held_cursor, held_limit }) => {
|
|
254
|
+
const qp = new URLSearchParams();
|
|
255
|
+
if (cursor !== undefined)
|
|
256
|
+
qp.set("cursor", cursor);
|
|
257
|
+
if (limit !== undefined)
|
|
258
|
+
qp.set("limit", String(limit));
|
|
259
|
+
if (held_cursor !== undefined)
|
|
260
|
+
qp.set("held_cursor", held_cursor);
|
|
261
|
+
if (held_limit !== undefined)
|
|
262
|
+
qp.set("held_limit", String(held_limit));
|
|
263
|
+
const qs = qp.toString();
|
|
264
|
+
const result = await client.get(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals` + (qs ? `?${qs}` : ""));
|
|
265
|
+
return formatResult(result);
|
|
266
|
+
}));
|
|
267
|
+
strictInput(server.tool("conduyt_decide_duplicate_proposal", "Record a decision on one possible-duplicate pair shown on a contact (POST /api/v1/contacts/{contactId}/duplicate-proposals/{proposalId} with { decision }; requires contacts:edit on BOTH records of the pair). Get proposalId from conduyt_list_duplicate_proposals. decision, exactly one of: 'not_sure' = keep both records visible, the pair stays listed and who looked is recorded (it can be decided again later); 'different' = the two records are different people, the pair is never shown again and the decision is FINAL; 'merge' = record that the pair is ALREADY merged: this tool NEVER merges. Merge first with conduyt_merge_contacts (preview, then confirm), then send 'merge' here; before the merge exists it is refused with 409 code `not_merged`. A pair already decided 'different' or merged is final: 409 code `already_decided` (also when someone decided it just now). Other refusals, nothing written: 404 (no such proposal on this contact: for EVERY decision; or the other record is gone: for 'not_sure' and 'different' only, while 'merge' still succeeds after the other record has been merged away), 403 code `other_record_not_editable` (you cannot edit the other record), 403 (you can view this contact but not edit it), 400 (decision missing or not one of the three), 402 billing. Answers { data: { id, status, decidedAt, previousMembershipRevision, membershipRevision, total } } with status 'not_sure', 'different' or 'merged'. membershipRevision and total are this contact's proposals list AFTER the decision (the same digest and count conduyt_list_duplicate_proposals answers); previousMembershipRevision is that digest just BEFORE the write (equal to membershipRevision on an idempotent repeat): a client holding loaded pages adopts the after values only when previousMembershipRevision equals the revision it holds, otherwise it reads page one again. Never retried automatically.", {
|
|
268
|
+
contactId: z.string().uuid().describe("Contact UUID the proposal is shown on"),
|
|
269
|
+
proposalId: z.string().uuid().describe("The proposal id from conduyt_list_duplicate_proposals"),
|
|
270
|
+
decision: z.enum(PROPOSAL_DECISIONS).describe("'not_sure' keeps both visible; 'different' = different people, never shown again (final); 'merge' = the pair is already merged elsewhere (run conduyt_merge_contacts first; this tool never merges)"),
|
|
271
|
+
}, async ({ contactId, proposalId, decision }) => {
|
|
272
|
+
const result = await client.post(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision });
|
|
273
|
+
return formatResult(result);
|
|
274
|
+
}));
|
|
275
|
+
strictInput(server.tool("conduyt_list_import_duplicate_proposals", "List the duplicate proposals one import raised on the records it created (GET /api/v1/imports/{importId}/duplicate-proposals; the import results section 'Created, similar record exists'; requires contacts:view). One row per proposal, EVERY status ('open', 'not_sure', 'different', 'merged'; a pair whose records are merged reads 'merged'), oldest first. Paged with page (1-100000, default 1) and per_page (1-100, default 50): answers { data: { rows, meta: { page, per_page, total } } }, total counts every row of the import. Each row: { proposalId, status, rowNum (the import file's row number), createdAt, decidedAt, decidedByName, newRecord (the record the import created), similar (the existing record it may duplicate), matchedOn (kinds only such as 'phone' or 'phone + company', never a value), keptName (for a merged pair, the record that was kept) }; newRecord and similar are { id, name, visible, editable, live }. A record outside your key's visibility has id null and the name 'A contact you cannot view': that row cannot be decided. Decide a row with conduyt_decide_duplicate_proposal (contactId = newRecord.id, proposalId; needs contacts:edit on both records). An owner, admin or view-all key reads any import of the account, any other key only the imports its user ran. Read-only. Refusals: 400 naming page or per_page (not an integer, out of range), 403 without contacts:view, 404 for an unknown import or one this key may not read.", {
|
|
276
|
+
importId: z.string().uuid().describe("Import job UUID"),
|
|
277
|
+
page: z.number().int().min(1).max(100000).optional().describe("page: integer 1-100000 (default 1)"),
|
|
278
|
+
per_page: pageLimit(100, 50, "per_page (rows per page)"),
|
|
279
|
+
}, async ({ importId, page, per_page }) => {
|
|
280
|
+
const qp = new URLSearchParams();
|
|
281
|
+
if (page !== undefined)
|
|
282
|
+
qp.set("page", String(page));
|
|
283
|
+
if (per_page !== undefined)
|
|
284
|
+
qp.set("per_page", String(per_page));
|
|
285
|
+
const qs = qp.toString();
|
|
286
|
+
const result = await client.get(`/api/v1/imports/${encodeURIComponent(importId)}/duplicate-proposals` + (qs ? `?${qs}` : ""));
|
|
287
|
+
return formatResult(result);
|
|
288
|
+
}));
|
|
238
289
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-16T09:38:16.598Z",
|
|
3
|
+
"toolCount": 355,
|
|
4
|
+
"totalEndpoints": 1093,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 314,
|
|
7
7
|
"excluded": 324,
|
|
8
8
|
"planned": 455,
|
|
9
9
|
"uncategorized": 0
|
|
@@ -1495,6 +1495,18 @@
|
|
|
1495
1495
|
"status": "planned",
|
|
1496
1496
|
"reason": "contact subroutes beyond the core CRUD/search tools"
|
|
1497
1497
|
},
|
|
1498
|
+
"GET /api/v1/contacts/:id/duplicate-proposals": {
|
|
1499
|
+
"status": "covered",
|
|
1500
|
+
"tools": [
|
|
1501
|
+
"conduyt_list_duplicate_proposals"
|
|
1502
|
+
]
|
|
1503
|
+
},
|
|
1504
|
+
"POST /api/v1/contacts/:id/duplicate-proposals/:proposalId": {
|
|
1505
|
+
"status": "covered",
|
|
1506
|
+
"tools": [
|
|
1507
|
+
"conduyt_decide_duplicate_proposal"
|
|
1508
|
+
]
|
|
1509
|
+
},
|
|
1498
1510
|
"POST /api/v1/contacts/:id/enrich": {
|
|
1499
1511
|
"status": "planned",
|
|
1500
1512
|
"reason": "contact subroutes beyond the core CRUD/search tools"
|
|
@@ -2964,6 +2976,12 @@
|
|
|
2964
2976
|
"status": "planned",
|
|
2965
2977
|
"reason": "import job monitoring/resubmission (ai-import tool covers creation)"
|
|
2966
2978
|
},
|
|
2979
|
+
"GET /api/v1/imports/:id/duplicate-proposals": {
|
|
2980
|
+
"status": "covered",
|
|
2981
|
+
"tools": [
|
|
2982
|
+
"conduyt_list_import_duplicate_proposals"
|
|
2983
|
+
]
|
|
2984
|
+
},
|
|
2967
2985
|
"GET /api/v1/imports/:id/integrity": {
|
|
2968
2986
|
"status": "planned",
|
|
2969
2987
|
"reason": "import job monitoring/resubmission (ai-import tool covers creation)"
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export type ActionConfigTypo = {
|
|
2
|
-
actionType: string;
|
|
3
|
-
key: string;
|
|
4
|
-
suggestion: string;
|
|
5
|
-
};
|
|
6
|
-
/** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
|
|
7
|
-
export declare function findActionConfigTypo(graph: unknown): ActionConfigTypo | null;
|
|
8
|
-
/** The refusal text; the details object is the final token */
|
|
9
|
-
export declare function actionConfigTypoMessage(t: ActionConfigTypo): string;
|
|
10
|
-
/** Throws (a plain Error) when the graph carries a misspelled option */
|
|
11
|
-
export declare function assertNoActionConfigTypo(graph: unknown): void;
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local typo guard for automation action configs (card ownership per lane, Codex r1/r2). The server validator is the authority
|
|
3
|
-
* and there the option names are not enforced yet, so a typoed `laneSoruce` on assign_to_user would silently assign the run's
|
|
4
|
-
* deal instead of the lane card. Only the options below are known here.
|
|
5
|
-
*
|
|
6
|
-
* Traversal rule: ONLY the real action positions of the graph are visited, exactly as the graph is shaped: a v1 `actions`
|
|
7
|
-
* array's elements `{type, config}`, and a v2 graph's `nodes[].action` `{type, config}`. For each action of a known type the
|
|
8
|
-
* IMMEDIATE keys of `config` are checked; the config VALUES are opaque and never descended into (a webhook payload may hold any
|
|
9
|
-
* object, including one shaped like an action), and nothing outside those positions (request metadata, other node fields) is
|
|
10
|
-
* scanned. A key that is not a known option but is a case-insensitive match or ONE edit (substitution, insertion, deletion or
|
|
11
|
-
* adjacent transposition) from one is refused; keys further away, and every other action type, pass through.
|
|
12
|
-
* Source of the names: lib/automation-action-registry.ts in the app (assign_to_user, create_deal, close_other_open_deals).
|
|
13
|
-
*/
|
|
14
|
-
const KNOWN_ACTION_OPTIONS = {
|
|
15
|
-
assign_to_user: ["runWindow", "mode", "userId", "userIds", "groupId", "target", "applyTo", "overwriteExisting", "laneSource", "lanePipelineId", "laneStageId"],
|
|
16
|
-
create_deal: ["pipelineId", "stageId", "dealName", "dealValue", "allowDuplicates", "status", "source", "assignedTo", "leaveUnassigned"],
|
|
17
|
-
close_other_open_deals: ["lostReason"],
|
|
18
|
-
};
|
|
19
|
-
/** Damerau-Levenshtein (optimal string alignment) distance, case-insensitive */
|
|
20
|
-
function distance(a, b) {
|
|
21
|
-
const x = a.toLowerCase(), y = b.toLowerCase();
|
|
22
|
-
const d = Array.from({ length: x.length + 1 }, (_, i) => [i, ...Array(y.length).fill(0)]);
|
|
23
|
-
for (let j = 1; j <= y.length; j++)
|
|
24
|
-
d[0][j] = j;
|
|
25
|
-
for (let i = 1; i <= x.length; i++) {
|
|
26
|
-
for (let j = 1; j <= y.length; j++) {
|
|
27
|
-
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1));
|
|
28
|
-
if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1])
|
|
29
|
-
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return d[x.length][y.length];
|
|
33
|
-
}
|
|
34
|
-
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
35
|
-
function checkAction(action) {
|
|
36
|
-
if (!isObject(action) || typeof action.type !== "string" || !isObject(action.config))
|
|
37
|
-
return null;
|
|
38
|
-
const known = KNOWN_ACTION_OPTIONS[action.type];
|
|
39
|
-
if (!known)
|
|
40
|
-
return null;
|
|
41
|
-
for (const key of Object.keys(action.config)) {
|
|
42
|
-
if (known.includes(key))
|
|
43
|
-
continue;
|
|
44
|
-
const near = known.find((k) => distance(key, k) <= 1);
|
|
45
|
-
if (near)
|
|
46
|
-
return { actionType: action.type, key, suggestion: near };
|
|
47
|
-
}
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
/** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
|
|
51
|
-
export function findActionConfigTypo(graph) {
|
|
52
|
-
if (Array.isArray(graph)) {
|
|
53
|
-
for (const action of graph) {
|
|
54
|
-
const t = checkAction(action);
|
|
55
|
-
if (t)
|
|
56
|
-
return t;
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
if (isObject(graph) && Array.isArray(graph.nodes)) {
|
|
61
|
-
for (const node of graph.nodes) {
|
|
62
|
-
const t = isObject(node) ? checkAction(node.action) : null;
|
|
63
|
-
if (t)
|
|
64
|
-
return t;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
/** The refusal text; the details object is the final token */
|
|
70
|
-
export function actionConfigTypoMessage(t) {
|
|
71
|
-
return `Unknown option "${t.key}" in ${t.actionType} config: did you mean "${t.suggestion}"? Nothing was sent. ${JSON.stringify(t)}`;
|
|
72
|
-
}
|
|
73
|
-
/** Throws (a plain Error) when the graph carries a misspelled option */
|
|
74
|
-
export function assertNoActionConfigTypo(graph) {
|
|
75
|
-
const t = findActionConfigTypo(graph);
|
|
76
|
-
if (t)
|
|
77
|
-
throw new Error(actionConfigTypoMessage(t));
|
|
78
|
-
}
|