conduyt-mcp 4.20.0 → 4.22.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/dist/client.d.ts CHANGED
@@ -49,6 +49,9 @@ export declare class ConduytClient {
49
49
  request(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<unknown>;
50
50
  requestText(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<string>;
51
51
  get(path: string, opts?: RequestOptions): Promise<unknown>;
52
+ /** Multipart upload (call-flow greeting audio): same auth, timeouts and
53
+ * response bounds as JSON calls; the body is a FormData the caller built. */
54
+ postMultipart(path: string, form: FormData, opts?: RequestOptions): Promise<unknown>;
52
55
  post(path: string, body: unknown, opts?: RequestOptions): Promise<unknown>;
53
56
  postText(path: string, body: unknown, opts?: RequestOptions): Promise<string>;
54
57
  patch(path: string, body: unknown, opts?: RequestOptions): Promise<unknown>;
@@ -60,3 +63,13 @@ export declare function formatResult(data: unknown): {
60
63
  text: string;
61
64
  }>;
62
65
  };
66
+ /** A tool-level failure (preflight refusals, guard violations): MCP treats a
67
+ * result without isError as SUCCESS, so refusals must set it explicitly or
68
+ * clients proceed as if the call happened. */
69
+ export declare function formatToolError(message: string): {
70
+ content: Array<{
71
+ type: "text";
72
+ text: string;
73
+ }>;
74
+ isError: true;
75
+ };
package/dist/client.js CHANGED
@@ -235,6 +235,32 @@ export class ConduytClient {
235
235
  get(path, opts) {
236
236
  return this.request("GET", path, undefined, opts);
237
237
  }
238
+ /** Multipart upload (call-flow greeting audio): same auth, timeouts and
239
+ * response bounds as JSON calls; the body is a FormData the caller built. */
240
+ async postMultipart(path, form, opts = {}) {
241
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
242
+ const url = `${this.baseUrl}${path}`;
243
+ let res;
244
+ try {
245
+ res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}` }, body: form, signal: AbortSignal.timeout(timeoutMs) });
246
+ }
247
+ catch (err) {
248
+ throw new ConduytApiError({ message: `Conduyt API network error for POST ${path}: ${err?.message ?? String(err)}`, code: err?.name === "TimeoutError" ? "timeout" : "network", retryable: true, cause: err });
249
+ }
250
+ const text = await res.text();
251
+ let json = null;
252
+ try {
253
+ json = text ? JSON.parse(text) : null;
254
+ }
255
+ catch {
256
+ json = { raw: text };
257
+ }
258
+ if (!res.ok) {
259
+ const msg = json?.error ?? text.slice(0, 300);
260
+ throw new ConduytApiError({ message: `Conduyt API ${res.status}: ${msg}`, status: res.status, code: "http", retryable: res.status >= 500 });
261
+ }
262
+ return json;
263
+ }
238
264
  post(path, body, opts) {
239
265
  return this.request("POST", path, body, opts);
240
266
  }
@@ -253,3 +279,12 @@ export function formatResult(data) {
253
279
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
254
280
  };
255
281
  }
282
+ /** A tool-level failure (preflight refusals, guard violations): MCP treats a
283
+ * result without isError as SUCCESS, so refusals must set it explicitly or
284
+ * clients proceed as if the call happened. */
285
+ export function formatToolError(message) {
286
+ return {
287
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: message }, null, 2) }],
288
+ isError: true,
289
+ };
290
+ }
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ import { registerPrivacyTools } from "./tools/privacy.js";
38
38
  import { registerAccountExportTools } from "./tools/account-exports.js";
39
39
  import { registerLifecycleTools } from "./tools/lifecycle.js";
40
40
  import { registerReplyCaptureTools } from "./tools/reply-capture.js";
41
+ import { registerCallFlowTools } from "./tools/call-flows.js";
41
42
  const apiUrl = process.env.CONDUYT_API_URL;
42
43
  const apiKey = process.env.CONDUYT_API_KEY;
43
44
  if (!apiUrl || !apiKey) {
@@ -98,5 +99,6 @@ registerPrivacyTools(server, client);
98
99
  registerAccountExportTools(server, client);
99
100
  registerLifecycleTools(server, client);
100
101
  registerReplyCaptureTools(server, client);
102
+ registerCallFlowTools(server, client);
101
103
  const transport = new StdioServerTransport();
102
104
  await server.connect(transport);
@@ -30,7 +30,10 @@ export function registerAutomationTools(server, client) {
30
30
  graphVersion: z.number().optional().describe("Graph version — 1 (legacy array) or 2 (node graph). Default 1"),
31
31
  schedule: z.string().optional().describe("5-field cron expression (required if triggerEvent='scheduled')"),
32
32
  scheduleTimezone: z.string().optional().describe("IANA timezone for scheduled triggers (e.g. 'America/Los_Angeles')"),
33
- triggerConditions: z.any().optional().describe("Conditions that must match for the trigger to fire"),
33
+ triggerConditions: z
34
+ .any()
35
+ .optional()
36
+ .describe("Conditions that must match for the trigger to fire. Scalar filters like tagId/pipelineId/bookingPageIds, plus per-event config: for 'contact.untouched' pass { untouchedDays: N } (integer 1-365) to fire once per quiet episode when a contact has had no outreach for N+ days — without it the trigger is the minutes-scale speed-to-lead alert. Unknown keys on native events are rejected at create/publish (422)."),
34
37
  }, async (params) => {
35
38
  const result = await client.post("/api/v1/automations", params);
36
39
  return formatResult(result);
@@ -40,6 +43,10 @@ export function registerAutomationTools(server, client) {
40
43
  name: z.string().optional().describe("Updated name"),
41
44
  description: z.string().optional().describe("Updated description"),
42
45
  triggerEvent: z.string().optional().describe("Updated trigger event"),
46
+ triggerConditions: z
47
+ .any()
48
+ .optional()
49
+ .describe("Updated trigger conditions (replaces the object). Same keys as create — e.g. { untouchedDays: 7 } on 'contact.untouched'. (#16 tri-surface parity: the UI and API could set this; MCP could not.)"),
43
50
  isActive: z.boolean().optional().describe("Enable or disable the automation"),
44
51
  actions: z.any().optional().describe("Updated actions graph"),
45
52
  schedule: z.string().optional().describe("Updated cron schedule"),
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ export declare function registerCallFlowTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,187 @@
1
+ import { z } from "zod";
2
+ import { formatResult, formatToolError } from "../client.js";
3
+ /**
4
+ * Call flows (#25 / B11) — the IVR graphs inbound numbers execute — and
5
+ * ring groups, their companion routing primitive. Both surfaces are
6
+ * "settings"-scoped at the API-key boundary: grant the key the settings
7
+ * scope or these tools return 403.
8
+ *
9
+ * The flow graph (draftGraph) is a JSON node/edge document; the API is the
10
+ * single validator (POST /call-flows/:id/validate previews problems, and
11
+ * publish refuses an invalid graph), so the tools pass it through untyped
12
+ * and surface the API's structured errors verbatim.
13
+ */
14
+ /** (p25-mcp-r3) An explicit graph must carry the parseFlowGraph minimum
15
+ * (string entryId + nodes array): the API silently substitutes a default
16
+ * entry→voicemail flow for anything unparseable, which a later publish
17
+ * would put live on the number. Passthrough keeps every nested field. */
18
+ const flowGraphSchema = z
19
+ .object({ entryId: z.string(), nodes: z.array(z.unknown()) })
20
+ .passthrough();
21
+ export function registerCallFlowTools(server, client) {
22
+ server.tool("conduyt_list_call_flows", "List call flows (IVR routing graphs) with their status (draft/published) and attached phone numbers.", {}, async () => {
23
+ const result = await client.get("/api/v1/call-flows");
24
+ return formatResult(result);
25
+ });
26
+ server.tool("conduyt_get_call_flow", "Get one call flow including its draft and published graphs.", {
27
+ id: z.string().describe("Call flow UUID"),
28
+ }, async ({ id }) => {
29
+ const result = await client.get(`/api/v1/call-flows/${id}`);
30
+ return formatResult(result);
31
+ });
32
+ server.tool("conduyt_create_call_flow", "Create a call flow. Starts as a DRAFT — edit the graph, validate, then publish to make it live on its number.", {
33
+ name: z.string().describe("Flow name"),
34
+ phoneNumber: z.string().optional().describe("E.164 number this flow answers (must belong to the account)"),
35
+ draftGraph: flowGraphSchema.optional().describe("Initial flow graph — MUST have a string entryId and a nodes array (anything else is silently replaced by a default voicemail flow server-side). Node kinds: entry, greeting, agent, group (queue: holdSeconds/pressOneVoicemail/repeatRing = queue mode), menu (options[{digit,label,next}], next = no-choice path), timing (hours[{day,start,end}], timezone, next = during hours, closedNext = outside hours), voicemail, forward, hangup. Every branch must reach an ending — publish refuses otherwise."),
36
+ }, async (params) => {
37
+ const result = await client.post("/api/v1/call-flows", params);
38
+ return formatResult(result);
39
+ });
40
+ server.tool("conduyt_update_call_flow", "Update a call flow's DRAFT (name, number, graph). Published behavior does not change until the next publish. Replacing draftGraph REQUIRES expectedDraftRevision (the API 428s without it; a concurrent graph edit then 409s instead of being overwritten — the token is graph-specific, so live calls and publish never invalidate it).", {
41
+ id: z.string().describe("Call flow UUID"),
42
+ name: z.string().optional().describe("New name"),
43
+ phoneNumber: z.string().optional().describe("New E.164 number"),
44
+ draftGraph: flowGraphSchema.optional().describe("Replacement draft graph — MUST have a string entryId and a nodes array; REQUIRES expectedDraftRevision. Kinds: entry, greeting, agent, group (queue.repeatRing = queue mode), menu (options[{digit,label,next}]), timing (hours, timezone, next/closedNext), voicemail, forward, hangup; every branch must reach an ending."),
45
+ expectedDraftRevision: z.number().int().min(0).optional().describe("draftRevision from your last conduyt_get_call_flow read (REQUIRED when replacing draftGraph)"),
46
+ }, async ({ id, ...updates }) => {
47
+ if (updates.draftGraph !== undefined && updates.expectedDraftRevision === undefined) {
48
+ return formatToolError("Replacing draftGraph requires expectedDraftRevision — call conduyt_get_call_flow first and pass its draftRevision.");
49
+ }
50
+ const result = await client.patch(`/api/v1/call-flows/${id}`, updates);
51
+ return formatResult(result);
52
+ });
53
+ server.tool("conduyt_delete_call_flow", "Delete a call flow. A PUBLISHED flow refuses deletion (409) — call conduyt_unpublish_call_flow first.", {
54
+ id: z.string().describe("Call flow UUID"),
55
+ }, async ({ id }) => {
56
+ const result = await client.del(`/api/v1/call-flows/${id}`);
57
+ return formatResult(result);
58
+ });
59
+ server.tool("conduyt_validate_call_flow", "Validate a call flow's draft graph WITHOUT publishing — returns structured problems (unreachable nodes, missing targets, etc.).", {
60
+ id: z.string().describe("Call flow UUID"),
61
+ }, async ({ id }) => {
62
+ const result = await client.get(`/api/v1/call-flows/${id}/validate`);
63
+ return formatResult(result);
64
+ });
65
+ server.tool("conduyt_publish_call_flow", "Publish a call flow's draft, making it live on its phone number. Refuses an invalid graph. expectedUpdatedAt is REQUIRED (the API returns 428 without it): read the flow first and pass its updatedAt — a concurrent edit then fails 409 instead of being published over.", {
66
+ id: z.string().describe("Call flow UUID"),
67
+ expectedUpdatedAt: z.string().describe("REQUIRED: updatedAt from your last conduyt_get_call_flow read (optimistic concurrency; missing → 428)"),
68
+ }, async ({ id, expectedUpdatedAt }) => {
69
+ const result = await client.post(`/api/v1/call-flows/${id}/publish`, { expectedUpdatedAt });
70
+ return formatResult(result);
71
+ });
72
+ server.tool("conduyt_unpublish_call_flow", "Unpublish a call flow — its number stops executing the graph (falls back to default routing).", {
73
+ id: z.string().describe("Call flow UUID"),
74
+ }, async ({ id }) => {
75
+ const result = await client.post(`/api/v1/call-flows/${id}/unpublish`, {});
76
+ return formatResult(result);
77
+ });
78
+ server.tool("conduyt_simulate_call_flow", "Answer \"if someone calls right now, who picks up?\" — simulates the DRAFT graph against LIVE member availability (DIDs, on-call state). No side effects.", {
79
+ id: z.string().describe("Call flow UUID"),
80
+ }, async ({ id }) => {
81
+ const result = await client.post(`/api/v1/call-flows/${id}/simulate`, {});
82
+ return formatResult(result);
83
+ });
84
+ server.tool("conduyt_call_flow_roster", "Get the routing roster: members with DIDs and their live availability — the people call-flow and ring-group steps can ring.", {}, async () => {
85
+ const result = await client.get("/api/v1/call-flows/roster");
86
+ return formatResult(result);
87
+ });
88
+ server.tool("conduyt_list_ring_groups", "List ring groups (simultaneous/sequential ring sets over member DIDs) with their numbers and members.", {}, async () => {
89
+ const result = await client.get("/api/v1/ring-groups");
90
+ return formatResult(result);
91
+ });
92
+ server.tool("conduyt_get_ring_group", "Get one ring group.", {
93
+ id: z.string().describe("Ring group UUID"),
94
+ }, async ({ id }) => {
95
+ const result = await client.get(`/api/v1/ring-groups/${id}`);
96
+ return formatResult(result);
97
+ });
98
+ server.tool("conduyt_create_ring_group", "Create a ring group on an account number. Without memberIds the group is created INACTIVE.", {
99
+ name: z.string().describe("Ring group name"),
100
+ phoneNumber: z.string().describe("REQUIRED: account E.164 number that rings this group"),
101
+ memberIds: z.array(z.string()).optional().describe("Member user UUIDs (omit → the group is created INACTIVE; memberless groups never ring)"),
102
+ strategy: z.string().optional().describe("Ring strategy (e.g. simultaneous, sequential)"),
103
+ ringSeconds: z.number().int().min(5).max(120).optional().describe("Seconds to ring before advancing/failing over (5-120)"),
104
+ isActive: z.boolean().optional().describe("Whether the group is active"),
105
+ }, async (params) => {
106
+ const result = await client.post("/api/v1/ring-groups", params);
107
+ return formatResult(result);
108
+ });
109
+ server.tool("conduyt_update_ring_group", "Update a ring group (name, number, members, strategy, ring time, active). Replacing memberIds REQUIRES expectedUpdatedAt (the API 428s without it); passing it on any update makes a concurrent edit 409 instead of being overwritten.", {
110
+ id: z.string().describe("Ring group UUID"),
111
+ name: z.string().optional().describe("New name"),
112
+ phoneNumber: z.string().optional().describe("New E.164 number"),
113
+ memberIds: z.array(z.string()).optional().describe("Replacement member user UUIDs — REQUIRES expectedUpdatedAt"),
114
+ strategy: z.string().optional().describe("Ring strategy"),
115
+ ringSeconds: z.number().int().min(5).max(120).optional().describe("Seconds to ring (5-120)"),
116
+ isActive: z.boolean().optional().describe("Active flag"),
117
+ expectedUpdatedAt: z.string().optional().describe("updatedAt from your last conduyt_get_ring_group read (REQUIRED when replacing memberIds)"),
118
+ }, async ({ id, ...updates }) => {
119
+ if (updates.memberIds !== undefined && updates.expectedUpdatedAt === undefined) {
120
+ // (p25-mcp-r2) isError REQUIRED: without it MCP reports success and
121
+ // the client proceeds although no PATCH occurred.
122
+ return formatToolError("Replacing memberIds requires expectedUpdatedAt — call conduyt_get_ring_group first and pass its updatedAt.");
123
+ }
124
+ const result = await client.patch(`/api/v1/ring-groups/${id}`, updates);
125
+ return formatResult(result);
126
+ });
127
+ server.tool("conduyt_delete_ring_group", "Delete a ring group. Its number keeps ringing default routing afterwards.", {
128
+ id: z.string().describe("Ring group UUID"),
129
+ }, async ({ id }) => {
130
+ const result = await client.del(`/api/v1/ring-groups/${id}`);
131
+ return formatResult(result);
132
+ });
133
+ server.tool("conduyt_release_ring_group_number", "Release a phone number from ring-group routing (DESTRUCTIVE). confirm must be the EXACT phone number repeated as a string. A number still referenced by an ACTIVE ring group cannot be released — delete or repoint that group first; force does NOT bypass it. force exists only for numbers whose Twilio ownership cannot be verified, and then a non-empty forceReason is required.", {
134
+ phoneNumber: z.string().describe("E.164 number to release"),
135
+ confirm: z.string().describe("EXACT repetition of phoneNumber (string) — the API rejects anything else"),
136
+ force: z.boolean().optional().describe("ONLY when Twilio ownership is unverifiable; requires forceReason"),
137
+ forceReason: z.string().optional().describe("Non-empty justification, required with force"),
138
+ }, async (params) => {
139
+ const result = await client.post("/api/v1/ring-groups/release-number", params);
140
+ return formatResult(result);
141
+ });
142
+ // ── Rollout fix 2026-08-24: numbers, Twilio wiring, greeting audio ──────
143
+ server.tool("conduyt_list_call_flow_numbers", "The voice numbers this workspace owns in Twilio, each with what answers it in Conduyt today (claimedBy: call flow / ring group / main line) and whether its Twilio voice webhook is already wired to Conduyt. Use it to pick a free number for a flow.", {}, async () => {
144
+ const result = await client.get("/api/v1/call-flows/numbers");
145
+ return formatResult(result);
146
+ });
147
+ server.tool("conduyt_get_call_flow_wiring", "Twilio's truth for a call flow's number: the voice webhook Twilio has right now vs the inboundUrl Conduyt needs, and whether they match (wired). A published flow that is NOT wired does not receive calls.", { id: z.string().describe("Call flow UUID") }, async ({ id }) => {
148
+ const result = await client.get(`/api/v1/call-flows/${id}/wiring`);
149
+ return formatResult(result);
150
+ });
151
+ server.tool("conduyt_wire_call_flow_number", "Point the flow's number's Twilio voice webhook at Conduyt (the same step publish performs; use it when publish reported wiring.error, or after changing the number in Twilio). Admin. Returns the resulting wiring state.", { id: z.string().describe("Call flow UUID") }, async ({ id }) => {
152
+ const result = await client.post(`/api/v1/call-flows/${id}/wiring`, {});
153
+ return formatResult(result);
154
+ });
155
+ server.tool("conduyt_release_call_flow_tombstone", "Release a number still held by a deleted call flow's tombstone: restores the number's pre-Conduyt Twilio configuration (verified) and lets the tombstone go. Admin. 404 when no tombstone holds the number; 409 when Conduyt still answers it and no prior configuration is known.", { phoneNumber: z.string().describe("E.164 number held by the tombstone") }, async ({ phoneNumber }) => {
156
+ const result = await client.post(`/api/v1/call-flows/tombstones/release`, { phoneNumber });
157
+ return formatResult(result);
158
+ });
159
+ server.tool("conduyt_upload_call_flow_audio", "Upload a greeting recording (mp3, wav or m4a, up to 10 MB) for use in a greeting node's audioUrl. Pass the file as base64. Returns { url } — put it on the greeting node via conduyt_update_call_flow.", {
160
+ filename: z.string().describe("File name with extension, e.g. greeting.mp3"),
161
+ contentBase64: z.string().describe("The file bytes, base64-encoded"),
162
+ mimeType: z.string().optional().describe("audio/mpeg | audio/wav | audio/mp4 (inferred from the extension when omitted)"),
163
+ }, async ({ filename, contentBase64, mimeType }) => {
164
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
165
+ const type = mimeType ?? { mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4" }[ext] ?? "application/octet-stream";
166
+ let bytes;
167
+ try {
168
+ bytes = Buffer.from(contentBase64, "base64");
169
+ }
170
+ catch {
171
+ return formatToolError("contentBase64 is not valid base64");
172
+ }
173
+ if (bytes.byteLength === 0)
174
+ return formatToolError("The file is empty");
175
+ if (bytes.byteLength > 10 * 1024 * 1024)
176
+ return formatToolError("The recording exceeds 10 MB");
177
+ const form = new FormData();
178
+ form.append("file", new Blob([Uint8Array.from(bytes)], { type }), filename);
179
+ try {
180
+ const result = await client.postMultipart("/api/v1/call-flows/audio", form, { timeoutMs: 60_000 });
181
+ return formatResult(result);
182
+ }
183
+ catch (err) {
184
+ return formatToolError(err.message);
185
+ }
186
+ });
187
+ }
@@ -51,6 +51,7 @@ export function registerContactTools(server, client) {
51
51
  firstName: z.string().optional().describe("First name"),
52
52
  lastName: z.string().optional().describe("Last name"),
53
53
  phone: z.string().optional().describe("Phone number"),
54
+ timezone: z.string().optional().describe("IANA timezone of the lead (e.g. America/Chicago). Omit to let Conduyt derive it from ZIP → phone area code → state (the stored contact carries timezone + timezoneSource)."),
54
55
  company: z.string().optional().describe("Company name"),
55
56
  source: z.string().trim().min(1).max(100).optional().describe("Lead source (e.g. 'website', 'referral', 'import') — FIRST-WRITE-WINS acquisition truth for the lead; later inbound events do not overwrite it. 1-100 chars."),
56
57
  masterStatus: z.string().trim().min(1).max(40).optional().describe("Lifecycle status of the LEAD (master record), distinct from any deal's status: 'open' (default), 'won', 'lost', 'abandoned', 'disqualified', or an account-defined custom status. A terminal status (won/lost/abandoned/disqualified) SILENCES all outbound automations for this contact — drips, sequences, and scheduled sends stop. Use conduyt_get_lifecycle_settings to see the account's configured statuses."),
@@ -69,6 +70,7 @@ export function registerContactTools(server, client) {
69
70
  firstName: z.string().optional().describe("Updated first name"),
70
71
  lastName: z.string().optional().describe("Updated last name"),
71
72
  phone: z.string().optional().describe("Updated phone"),
73
+ timezone: z.string().nullable().optional().describe("IANA timezone override (e.g. America/Denver); pass null to clear the override and re-derive from ZIP → area code → state"),
72
74
  company: z.string().optional().describe("Updated company name"),
73
75
  source: z.string().trim().min(1).max(100).nullable().optional().describe("Correct the lead's first-touch source (normally first-write-wins and set at creation — update only to FIX bad attribution). Pass null to clear."),
74
76
  masterStatus: z.string().trim().min(1).max(40).optional().describe("Lifecycle status of the LEAD (master record), distinct from any deal's status: 'open' (default), 'won', 'lost', 'abandoned', 'disqualified', or an account-defined custom status. A terminal status (won/lost/abandoned/disqualified) SILENCES all outbound automations for this contact — drips, sequences, and scheduled sends stop. Use conduyt_get_lifecycle_settings to see the account's configured statuses."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.20.0",
3
+ "version": "4.22.0",
4
4
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -506,14 +506,14 @@
506
506
  "reason": "automation-folder organization (shipped 2026-08) \u2014 agent-relevant workflow housekeeping; typed tools wanted"
507
507
  },
508
508
  {
509
- "match": "/api/v1/call-flows",
510
- "status": "planned",
511
- "reason": "call-flow builder API (shipped 2026-08-21) \u2014 agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
509
+ "match": "POST /api/v1/call-flows/audio",
510
+ "status": "excluded",
511
+ "reason": "multipart file upload (IVR audio) - not expressible as a text MCP tool; agents reference existing audio by id from conduyt_get_call_flow"
512
512
  },
513
513
  {
514
- "match": "/api/v1/ring-groups",
514
+ "match": "GET /api/v1/ops/capacity",
515
515
  "status": "planned",
516
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) \u2014 agent-relevant; typed tools with the call-flow set"
516
+ "reason": "operator capacity dashboard (#51, shipped 2026-09) \u2014 read-only queue/park telemetry; a typed conduyt_capacity_snapshot tool is agent-relevant for throughput questions"
517
517
  }
518
518
  ]
519
- }
519
+ }
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-08-22T18:59:24.922Z",
3
- "toolCount": 173,
4
- "totalEndpoints": 872,
2
+ "generatedAt": "2026-09-02T06:05:57.360Z",
3
+ "toolCount": 194,
4
+ "totalEndpoints": 883,
5
5
  "counts": {
6
- "covered": 161,
7
- "excluded": 255,
8
- "planned": 456,
6
+ "covered": 181,
7
+ "excluded": 261,
8
+ "planned": 441,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -991,48 +991,92 @@
991
991
  "reason": "calendar/booking management beyond current appointment tools"
992
992
  },
993
993
  "GET /api/v1/call-flows": {
994
- "status": "planned",
995
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
994
+ "status": "covered",
995
+ "tools": [
996
+ "conduyt_list_call_flows"
997
+ ]
996
998
  },
997
999
  "POST /api/v1/call-flows": {
998
- "status": "planned",
999
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1000
+ "status": "covered",
1001
+ "tools": [
1002
+ "conduyt_create_call_flow"
1003
+ ]
1000
1004
  },
1001
1005
  "GET /api/v1/call-flows/:id": {
1002
- "status": "planned",
1003
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1006
+ "status": "covered",
1007
+ "tools": [
1008
+ "conduyt_get_call_flow"
1009
+ ]
1004
1010
  },
1005
1011
  "PATCH /api/v1/call-flows/:id": {
1006
- "status": "planned",
1007
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1012
+ "status": "covered",
1013
+ "tools": [
1014
+ "conduyt_update_call_flow"
1015
+ ]
1008
1016
  },
1009
1017
  "DELETE /api/v1/call-flows/:id": {
1010
- "status": "planned",
1011
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1018
+ "status": "covered",
1019
+ "tools": [
1020
+ "conduyt_delete_call_flow"
1021
+ ]
1012
1022
  },
1013
1023
  "POST /api/v1/call-flows/:id/publish": {
1014
- "status": "planned",
1015
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1024
+ "status": "covered",
1025
+ "tools": [
1026
+ "conduyt_publish_call_flow"
1027
+ ]
1016
1028
  },
1017
1029
  "POST /api/v1/call-flows/:id/simulate": {
1018
- "status": "planned",
1019
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1030
+ "status": "covered",
1031
+ "tools": [
1032
+ "conduyt_simulate_call_flow"
1033
+ ]
1020
1034
  },
1021
1035
  "POST /api/v1/call-flows/:id/unpublish": {
1022
- "status": "planned",
1023
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1036
+ "status": "covered",
1037
+ "tools": [
1038
+ "conduyt_unpublish_call_flow"
1039
+ ]
1024
1040
  },
1025
1041
  "GET /api/v1/call-flows/:id/validate": {
1026
- "status": "planned",
1027
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1042
+ "status": "covered",
1043
+ "tools": [
1044
+ "conduyt_validate_call_flow"
1045
+ ]
1046
+ },
1047
+ "GET /api/v1/call-flows/:id/wiring": {
1048
+ "status": "covered",
1049
+ "tools": [
1050
+ "conduyt_get_call_flow_wiring"
1051
+ ]
1052
+ },
1053
+ "POST /api/v1/call-flows/:id/wiring": {
1054
+ "status": "covered",
1055
+ "tools": [
1056
+ "conduyt_wire_call_flow_number"
1057
+ ]
1028
1058
  },
1029
1059
  "POST /api/v1/call-flows/audio": {
1030
- "status": "planned",
1031
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1060
+ "status": "excluded",
1061
+ "reason": "multipart file upload (IVR audio) - not expressible as a text MCP tool; agents reference existing audio by id from conduyt_get_call_flow"
1062
+ },
1063
+ "GET /api/v1/call-flows/numbers": {
1064
+ "status": "covered",
1065
+ "tools": [
1066
+ "conduyt_list_call_flow_numbers"
1067
+ ]
1032
1068
  },
1033
1069
  "GET /api/v1/call-flows/roster": {
1034
- "status": "planned",
1035
- "reason": "call-flow builder API (shipped 2026-08-21) — agent-relevant; typed tools follow once the canvas UI (#25) settles the surface"
1070
+ "status": "covered",
1071
+ "tools": [
1072
+ "conduyt_call_flow_roster"
1073
+ ]
1074
+ },
1075
+ "POST /api/v1/call-flows/tombstones/release": {
1076
+ "status": "covered",
1077
+ "tools": [
1078
+ "conduyt_release_call_flow_tombstone"
1079
+ ]
1036
1080
  },
1037
1081
  "GET /api/v1/calls": {
1038
1082
  "status": "covered",
@@ -1397,6 +1441,14 @@
1397
1441
  "status": "excluded",
1398
1442
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1399
1443
  },
1444
+ "GET /api/v1/cron/capacity-monitor": {
1445
+ "status": "excluded",
1446
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1447
+ },
1448
+ "POST /api/v1/cron/capacity-monitor": {
1449
+ "status": "excluded",
1450
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1451
+ },
1400
1452
  "GET /api/v1/cron/capture-rfc-ids": {
1401
1453
  "status": "excluded",
1402
1454
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -1541,6 +1593,14 @@
1541
1593
  "status": "excluded",
1542
1594
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1543
1595
  },
1596
+ "GET /api/v1/cron/process-unattended-leads": {
1597
+ "status": "excluded",
1598
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1599
+ },
1600
+ "GET /api/v1/cron/process-wake-jobs": {
1601
+ "status": "excluded",
1602
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1603
+ },
1544
1604
  "GET /api/v1/cron/prune-screen-share-sessions": {
1545
1605
  "status": "excluded",
1546
1606
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -1577,6 +1637,10 @@
1577
1637
  "status": "excluded",
1578
1638
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1579
1639
  },
1640
+ "GET /api/v1/cron/reconcile-voice-releases": {
1641
+ "status": "excluded",
1642
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1643
+ },
1580
1644
  "GET /api/v1/cron/recover-booking-side-effects": {
1581
1645
  "status": "excluded",
1582
1646
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -1779,6 +1843,10 @@
1779
1843
  "status": "planned",
1780
1844
  "reason": "deal subroutes beyond core CRUD (history, files, links)"
1781
1845
  },
1846
+ "GET /api/v1/deals/:id/handoff-files/:fileId/download": {
1847
+ "status": "planned",
1848
+ "reason": "deal subroutes beyond core CRUD (history, files, links)"
1849
+ },
1782
1850
  "GET /api/v1/deals/:id/history": {
1783
1851
  "status": "planned",
1784
1852
  "reason": "deal subroutes beyond core CRUD (history, files, links)"
@@ -2665,6 +2733,10 @@
2665
2733
  "status": "planned",
2666
2734
  "reason": "notification surfaces"
2667
2735
  },
2736
+ "GET /api/v1/ops/capacity": {
2737
+ "status": "planned",
2738
+ "reason": "operator capacity dashboard (#51, shipped 2026-09) — read-only queue/park telemetry; a typed conduyt_capacity_snapshot tool is agent-relevant for throughput questions"
2739
+ },
2668
2740
  "GET /api/v1/phone-numbers": {
2669
2741
  "status": "planned",
2670
2742
  "reason": "phone number inventory"
@@ -3030,28 +3102,40 @@
3030
3102
  "reason": "additional report surfaces beyond ai/insights"
3031
3103
  },
3032
3104
  "GET /api/v1/ring-groups": {
3033
- "status": "planned",
3034
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) — agent-relevant; typed tools with the call-flow set"
3105
+ "status": "covered",
3106
+ "tools": [
3107
+ "conduyt_list_ring_groups"
3108
+ ]
3035
3109
  },
3036
3110
  "POST /api/v1/ring-groups": {
3037
- "status": "planned",
3038
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) — agent-relevant; typed tools with the call-flow set"
3111
+ "status": "covered",
3112
+ "tools": [
3113
+ "conduyt_create_ring_group"
3114
+ ]
3039
3115
  },
3040
3116
  "GET /api/v1/ring-groups/:id": {
3041
- "status": "planned",
3042
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) — agent-relevant; typed tools with the call-flow set"
3117
+ "status": "covered",
3118
+ "tools": [
3119
+ "conduyt_get_ring_group"
3120
+ ]
3043
3121
  },
3044
3122
  "PATCH /api/v1/ring-groups/:id": {
3045
- "status": "planned",
3046
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) — agent-relevant; typed tools with the call-flow set"
3123
+ "status": "covered",
3124
+ "tools": [
3125
+ "conduyt_update_ring_group"
3126
+ ]
3047
3127
  },
3048
3128
  "DELETE /api/v1/ring-groups/:id": {
3049
- "status": "planned",
3050
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) — agent-relevant; typed tools with the call-flow set"
3129
+ "status": "covered",
3130
+ "tools": [
3131
+ "conduyt_delete_ring_group"
3132
+ ]
3051
3133
  },
3052
3134
  "POST /api/v1/ring-groups/release-number": {
3053
- "status": "planned",
3054
- "reason": "ring-group management (call-flows companion, shipped 2026-08-21) — agent-relevant; typed tools with the call-flow set"
3135
+ "status": "covered",
3136
+ "tools": [
3137
+ "conduyt_release_ring_group_number"
3138
+ ]
3055
3139
  },
3056
3140
  "GET /api/v1/saved-filters": {
3057
3141
  "status": "planned",