@rcs-campaigns/agent-toolkit 0.1.5

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/tools.js ADDED
@@ -0,0 +1,1333 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TOOLS = exports.WEBHOOK_EVENT_NAMES = void 0;
4
+ exports.findTool = findTool;
5
+ exports.acceptsChannel = acceptsChannel;
6
+ exports.withDefaultChannel = withDefaultChannel;
7
+ /**
8
+ * The shared agent tool catalog.
9
+ *
10
+ * One declarative list of the gateway's capabilities — name, description, the
11
+ * JSON-Schema for inputs (used verbatim as the MCP `inputSchema`), and a pure
12
+ * function that turns validated args into a gateway RequestSpec. Both the MCP
13
+ * server (tools/list + tools/call) and the CLI build on this list, so adding a
14
+ * capability means editing ONE place and both skins gain it.
15
+ */
16
+ const client_1 = require("./client");
17
+ /**
18
+ * Optional per-call channel, accepted by every tool that acts ON a channel.
19
+ *
20
+ * The gateway used to derive the channel from the org's signup brand alone, so
21
+ * a key belonging to a WhatsApp org could only ever drive the WhatsApp path —
22
+ * the RCS equivalents of these same tools were unreachable. Passing `channel`
23
+ * selects the path explicitly; the server validates it against the org's
24
+ * purchased add-on brands (403 when it is not entitled) and defaults to the
25
+ * org's own brand channel when omitted, so existing callers are unaffected.
26
+ */
27
+ const CHANNEL_PROP = {
28
+ type: 'string',
29
+ enum: ['RCS', 'WHATSAPP'],
30
+ description: "Channel to act on (RCS or WHATSAPP). Defaults to your org's own brand channel; any other channel requires the matching add-on brand."
31
+ };
32
+ /**
33
+ * The outbound webhook event types an endpoint may subscribe to — THE list,
34
+ * not a copy of one.
35
+ *
36
+ * It lives here because this is the package both ends can reach: the toolkit
37
+ * is a standalone npm dependency and cannot import from `apps/server`, but
38
+ * `apps/server` already depends on the toolkit (`workspace:*`) for `TOOLS` and
39
+ * the OpenAPI generator, so the arrow only points one way. `services/webhooks.ts`
40
+ * re-exports this array as `WEBHOOK_EVENTS`; there is no second array to keep
41
+ * in step, and **adding an event means editing this one list**.
42
+ *
43
+ * It used to be hand-listed here and hand-listed again in the server, kept
44
+ * honest by an exact-equality test across the two packages. That test could
45
+ * only fail AFTER a merge — a branch adding an eleventh event to one list and
46
+ * a branch adding the enum to the other are each green alone and red together,
47
+ * and `deploy.yml` runs the server suite before deploying, so the desync would
48
+ * have blocked the backend deploy rather than merely CI. Deriving removes the
49
+ * failure mode instead of detecting it later.
50
+ *
51
+ * The values are persisted on `WebhookEndpoint.events`, so a name here is a
52
+ * contract with every endpoint already registered: ADD, never rename.
53
+ *
54
+ * `contact.lead_captured` (MSG-1832) is the eleventh, appended never renamed.
55
+ */
56
+ exports.WEBHOOK_EVENT_NAMES = [
57
+ // Surveys (the original two).
58
+ 'survey.completed',
59
+ 'response.received',
60
+ // Messaging. These are what make the platform integrable at all — a CRM or
61
+ // a Zapier trigger reacting to real delivery, not just to survey answers.
62
+ 'message.received',
63
+ 'message.delivered',
64
+ 'message.failed',
65
+ 'broadcast.completed',
66
+ 'contact.opted_out',
67
+ // A recipient tapped a `webhook` suggested action. Delivered here rather
68
+ // than by POSTing the author's per-template URL directly: this path is
69
+ // already signed, retried and logged, and it keeps arbitrary
70
+ // author-supplied outbound requests off the backend entirely.
71
+ 'action.tapped',
72
+ // Bots and journeys (MSG-1827 / MSG-1808): a person entered a flow, and a
73
+ // run ended (with its exit reason and collected vars).
74
+ 'flow.started',
75
+ 'flow.completed',
76
+ // Payments in chat (MSG-1831). Fired ONLY from the signed Stripe Connect
77
+ // webhook handler — never from the request-payment route and never from the
78
+ // click path, because those know only that we ASKED, not that money moved.
79
+ // There is deliberately no `payment.expired`: a lapsed link is nothing to
80
+ // notify a CRM about.
81
+ 'payment.completed',
82
+ 'payment.failed',
83
+ // A lead reached us from outside (MSG-1832) — a click-to-chat `ref:` inbound
84
+ // or a third-party form POSTing the lead webhook. APPENDED, never renamed:
85
+ // this array is a contract with every stored WebhookEndpoint.events value.
86
+ 'contact.lead_captured'
87
+ ];
88
+ exports.TOOLS = [
89
+ {
90
+ name: 'capabilities',
91
+ description: 'Describe the gateway: tool catalog, the scopes this key holds, and pricing posture. Call first to plan what you may do.',
92
+ scope: '(none)',
93
+ method: 'GET',
94
+ path: '/capabilities',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {},
98
+ additionalProperties: false
99
+ },
100
+ toRequest: () => ({ method: 'GET', path: '/capabilities' })
101
+ },
102
+ {
103
+ name: 'search_templates',
104
+ description: 'Search the published RCS template marketplace. Returns normalized listings (id, name, rating, cloneCount, categoryKeys).',
105
+ scope: 'templates:read',
106
+ method: 'GET',
107
+ path: '/templates',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ q: {
112
+ type: 'string',
113
+ description: 'Free-text search over name + description.'
114
+ },
115
+ category: {
116
+ type: 'string',
117
+ description: 'MarketplaceCategory key, e.g. EVENTS.'
118
+ },
119
+ channel: { type: 'string', enum: ['RCS', 'WHATSAPP', 'SMS'] },
120
+ sort: { type: 'string', enum: ['rating', 'used'], default: 'rating' },
121
+ page: { type: 'number', minimum: 1 },
122
+ pageSize: { type: 'number', minimum: 1, maximum: 100 }
123
+ },
124
+ additionalProperties: false
125
+ },
126
+ toRequest: (a) => ({
127
+ method: 'GET',
128
+ path: '/templates',
129
+ query: {
130
+ q: a.q,
131
+ category: a.category,
132
+ channel: a.channel,
133
+ sort: a.sort,
134
+ page: a.page,
135
+ pageSize: a.pageSize
136
+ }
137
+ })
138
+ },
139
+ {
140
+ name: 'get_template',
141
+ description: 'Fetch one template by id (a published marketplace listing or one your org owns).',
142
+ scope: 'templates:read',
143
+ method: 'GET',
144
+ path: '/templates/:id',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: { id: { type: 'string' } },
148
+ required: ['id'],
149
+ additionalProperties: false
150
+ },
151
+ toRequest: (a) => ({
152
+ method: 'GET',
153
+ path: `/templates/${encodeURIComponent(a.id)}`
154
+ })
155
+ },
156
+ {
157
+ name: 'render_template',
158
+ description: 'Validate a template and return the rendered RCS payload + variable-bound SMS/MMS fallback, without sending. Use to inspect before cloning/sending.',
159
+ scope: 'templates:read',
160
+ method: 'POST',
161
+ path: '/templates/:id/render',
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ id: { type: 'string' },
166
+ variables: {
167
+ type: 'object',
168
+ description: 'Merge fields ({{key}} -> value) to bind into the preview.',
169
+ additionalProperties: { type: 'string' }
170
+ }
171
+ },
172
+ required: ['id'],
173
+ additionalProperties: false
174
+ },
175
+ toRequest: (a) => ({
176
+ method: 'POST',
177
+ path: `/templates/${encodeURIComponent(a.id)}/render`,
178
+ body: { variables: a.variables ?? {} }
179
+ })
180
+ },
181
+ {
182
+ name: 'create_template',
183
+ description: 'Create a template draft in your org. Compose a TemplateContent (text/card/carousel) with a REQUIRED non-empty smsFallback; the shared RCS validator gates it. Submit it (submit_template) and await provider approval before send_message for rich RCS; an unsubmitted template delivers as SMS fallback.',
184
+ scope: 'templates:write',
185
+ method: 'POST',
186
+ path: '/templates',
187
+ successStatus: 201,
188
+ inputSchema: {
189
+ type: 'object',
190
+ properties: {
191
+ name: { type: 'string' },
192
+ description: { type: 'string' },
193
+ channel: { type: 'string', enum: ['RCS', 'WHATSAPP', 'SMS'] },
194
+ category: {
195
+ type: 'string',
196
+ enum: ['MARKETING', 'UTILITY', 'AUTHENTICATION']
197
+ },
198
+ content: {
199
+ type: 'object',
200
+ description: 'TemplateContent. Must include a non-empty smsFallback. One of text/card/carousel is the primary body.'
201
+ },
202
+ tags: { type: 'array', items: { type: 'string' } }
203
+ },
204
+ required: ['name', 'content'],
205
+ additionalProperties: false
206
+ },
207
+ toRequest: (a) => ({
208
+ method: 'POST',
209
+ path: '/templates',
210
+ body: {
211
+ name: a.name,
212
+ description: a.description,
213
+ channel: a.channel,
214
+ category: a.category,
215
+ content: a.content,
216
+ tags: a.tags
217
+ }
218
+ })
219
+ },
220
+ {
221
+ name: 'update_template',
222
+ description: 'Partially update a template your org owns: name, description, category, tags, knowledgeDoc, and content (re-validated by the shared RCS validator — a non-empty smsFallback is still required). Only the fields you pass change. Editing the CONTENT of an already-approved template invalidates that approval: submit_template it again and await approval, or it delivers as SMS fallback until then. Use get_template first to edit incrementally rather than overwriting blind.',
223
+ scope: 'templates:write',
224
+ method: 'PUT',
225
+ path: '/templates/:id',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ id: { type: 'string' },
230
+ name: { type: 'string' },
231
+ description: { type: 'string' },
232
+ channel: {
233
+ type: 'string',
234
+ enum: ['RCS', 'WHATSAPP'],
235
+ description: "Move the template to another channel. Requires the matching add-on brand; omit to leave it where it is."
236
+ },
237
+ category: {
238
+ type: 'string',
239
+ enum: ['MARKETING', 'UTILITY', 'AUTHENTICATION']
240
+ },
241
+ content: {
242
+ type: 'object',
243
+ description: 'Full replacement TemplateContent (not a deep merge). Must include a non-empty smsFallback.'
244
+ },
245
+ tags: { type: 'array', items: { type: 'string' } },
246
+ knowledgeDoc: { type: 'string' }
247
+ },
248
+ required: ['id'],
249
+ additionalProperties: false
250
+ },
251
+ toRequest: (a) => ({
252
+ method: 'PUT',
253
+ path: `/templates/${encodeURIComponent(a.id)}`,
254
+ body: {
255
+ name: a.name,
256
+ description: a.description,
257
+ channel: a.channel,
258
+ category: a.category,
259
+ content: a.content,
260
+ tags: a.tags,
261
+ knowledgeDoc: a.knowledgeDoc
262
+ }
263
+ })
264
+ },
265
+ {
266
+ name: 'duplicate_template',
267
+ description: 'Duplicate a template your org OWNS into a fresh private draft (e.g. to iterate a variant without touching the original). The copy keeps the content, channel, category, tags and knowledge base, but starts unsubmitted — it has no provider approval of its own until you submit_template it. For copying someone else\'s PUBLISHED marketplace listing, use clone_template instead.',
268
+ scope: 'templates:write',
269
+ method: 'POST',
270
+ path: '/templates/:id/duplicate',
271
+ successStatus: 201,
272
+ inputSchema: {
273
+ type: 'object',
274
+ properties: { id: { type: 'string' } },
275
+ required: ['id'],
276
+ additionalProperties: false
277
+ },
278
+ toRequest: (a) => ({
279
+ method: 'POST',
280
+ path: `/templates/${encodeURIComponent(a.id)}/duplicate`
281
+ })
282
+ },
283
+ {
284
+ name: 'delete_template',
285
+ description: 'Delete a template your org owns. IRREVERSIBLE, and it also removes the template from the provider (Twilio), so an approved template loses its approval and can no longer be sent. Broadcasts that already went out are unaffected. Prefer this only for drafts and abandoned iterations; ask the user before deleting anything they have submitted or sent.',
286
+ scope: 'templates:write',
287
+ method: 'DELETE',
288
+ path: '/templates/:id',
289
+ inputSchema: {
290
+ type: 'object',
291
+ properties: { id: { type: 'string' } },
292
+ required: ['id'],
293
+ additionalProperties: false
294
+ },
295
+ toRequest: (a) => ({
296
+ method: 'DELETE',
297
+ path: `/templates/${encodeURIComponent(a.id)}`
298
+ })
299
+ },
300
+ {
301
+ name: 'clone_template',
302
+ description: 'Clone a published marketplace template into your org as a fresh private draft. Returns the new template. To deliver rich RCS rather than SMS fallback, submit_template it and await provider approval before send_message.',
303
+ scope: 'marketplace:clone',
304
+ method: 'POST',
305
+ path: '/marketplace/:id/clone',
306
+ successStatus: 201,
307
+ inputSchema: {
308
+ type: 'object',
309
+ properties: { id: { type: 'string' } },
310
+ required: ['id'],
311
+ additionalProperties: false
312
+ },
313
+ toRequest: (a) => ({
314
+ method: 'POST',
315
+ path: `/marketplace/${encodeURIComponent(a.id)}/clone`
316
+ })
317
+ },
318
+ {
319
+ name: 'submit_template',
320
+ description: 'Submit an org-owned template to the provider (Twilio) for approval. Returns the contentSid + approval status.',
321
+ scope: 'templates:submit',
322
+ method: 'POST',
323
+ path: '/templates/:id/submit',
324
+ inputSchema: {
325
+ type: 'object',
326
+ properties: { id: { type: 'string' } },
327
+ required: ['id'],
328
+ additionalProperties: false
329
+ },
330
+ toRequest: (a) => ({
331
+ method: 'POST',
332
+ path: `/templates/${encodeURIComponent(a.id)}/submit`
333
+ })
334
+ },
335
+ {
336
+ name: 'send_message',
337
+ description: 'Send an org-owned template to a list of E.164 phone numbers (RCS-first with mandatory SMS/MMS fallback). Pass the template\'s blueprintId or id. The template sends as rich RCS only after submit_template + provider approval; until then it delivers as SMS fallback — the returned funnel\'s `fellBackToSms` count signals how many recipients got SMS instead of RCS. Bills your org. Returns the send funnel.',
338
+ scope: 'messages:send',
339
+ method: 'POST',
340
+ path: '/messages/send',
341
+ successStatus: 201,
342
+ inputSchema: {
343
+ type: 'object',
344
+ properties: {
345
+ blueprintId: { type: 'string' },
346
+ to: {
347
+ type: 'array',
348
+ items: { type: 'string' },
349
+ minItems: 1,
350
+ maxItems: 500
351
+ },
352
+ variables: { type: 'object', additionalProperties: { type: 'string' } },
353
+ name: { type: 'string' }
354
+ },
355
+ required: ['blueprintId', 'to'],
356
+ additionalProperties: false
357
+ },
358
+ toRequest: (a) => ({
359
+ method: 'POST',
360
+ path: '/messages/send',
361
+ body: {
362
+ blueprintId: a.blueprintId,
363
+ to: a.to,
364
+ variables: a.variables,
365
+ name: a.name
366
+ }
367
+ })
368
+ },
369
+ // ── Survey tools (the conversational-survey surface) ──
370
+ {
371
+ name: 'search_surveys',
372
+ description: 'List/search the surveys your org owns. Returns each survey with its recipients + responses counts.',
373
+ scope: 'surveys:read',
374
+ method: 'GET',
375
+ path: '/surveys',
376
+ inputSchema: {
377
+ type: 'object',
378
+ properties: {
379
+ q: { type: 'string', description: 'Free-text search over the name.' },
380
+ status: {
381
+ type: 'string',
382
+ enum: ['DRAFT', 'SCHEDULED', 'ACTIVE', 'CLOSED']
383
+ },
384
+ page: { type: 'number', minimum: 1 },
385
+ pageSize: { type: 'number', minimum: 1, maximum: 100 }
386
+ },
387
+ additionalProperties: false
388
+ },
389
+ toRequest: (a) => ({
390
+ method: 'GET',
391
+ path: '/surveys',
392
+ query: { q: a.q, status: a.status, page: a.page, pageSize: a.pageSize }
393
+ })
394
+ },
395
+ {
396
+ name: 'get_survey',
397
+ description: 'Fetch one survey your org owns by id (its full definition).',
398
+ scope: 'surveys:read',
399
+ method: 'GET',
400
+ path: '/surveys/:id',
401
+ inputSchema: {
402
+ type: 'object',
403
+ properties: { id: { type: 'string' } },
404
+ required: ['id'],
405
+ additionalProperties: false
406
+ },
407
+ toRequest: (a) => ({
408
+ method: 'GET',
409
+ path: `/surveys/${encodeURIComponent(a.id)}`
410
+ })
411
+ },
412
+ {
413
+ name: 'render_survey',
414
+ description: 'Preview a survey the way a respondent sees it: the text body + RCS chip titles for each question. Side-effect-free.',
415
+ scope: 'surveys:read',
416
+ method: 'POST',
417
+ path: '/surveys/:id/render',
418
+ inputSchema: {
419
+ type: 'object',
420
+ properties: { id: { type: 'string' } },
421
+ required: ['id'],
422
+ additionalProperties: false
423
+ },
424
+ toRequest: (a) => ({
425
+ method: 'POST',
426
+ path: `/surveys/${encodeURIComponent(a.id)}/render`
427
+ })
428
+ },
429
+ {
430
+ name: 'create_survey',
431
+ description: 'Create a survey draft in your org. Provide a SurveyDefinition (intro? + questions[] of type rating/single_select/text/comment); the shared validator gates it. PICTURES: every question should carry an `imageUrl`, and the definition should carry `headerImageUrl` (start picture) and `outroImageUrl` (finish picture). If the user provided no pictures, WEB-SEARCH for suitable, relevant images first and pass their https URLs — the server automatically re-hosts every image onto the platform GCS bucket, so external URLs are safe to pass and never hotlinked.',
432
+ scope: 'surveys:write',
433
+ method: 'POST',
434
+ path: '/surveys',
435
+ successStatus: 201,
436
+ inputSchema: {
437
+ type: 'object',
438
+ properties: {
439
+ name: { type: 'string' },
440
+ description: { type: 'string' },
441
+ definition: {
442
+ type: 'object',
443
+ description: 'SurveyDefinition: { intro?, outro?, questions: [{ questionId, prompt, type, options?, minRating?, maxRating?, required? }] }.'
444
+ },
445
+ startDate: { type: 'string', description: 'ISO date; schedules the survey.' },
446
+ endDate: { type: 'string' },
447
+ timezone: { type: 'string' },
448
+ languageCode: { type: 'array', items: { type: 'string' } },
449
+ isPreferencesSurvey: { type: 'boolean' },
450
+ channel: CHANNEL_PROP
451
+ },
452
+ required: ['name', 'definition'],
453
+ additionalProperties: false
454
+ },
455
+ toRequest: (a) => ({
456
+ method: 'POST',
457
+ path: '/surveys',
458
+ body: {
459
+ name: a.name,
460
+ description: a.description,
461
+ definition: a.definition,
462
+ startDate: a.startDate,
463
+ endDate: a.endDate,
464
+ timezone: a.timezone,
465
+ languageCode: a.languageCode,
466
+ isPreferencesSurvey: a.isPreferencesSurvey,
467
+ channel: a.channel
468
+ }
469
+ })
470
+ },
471
+ {
472
+ name: 'clone_survey',
473
+ description: 'Duplicate an org-owned survey into a fresh DRAFT (e.g. to iterate a variant). Returns the new survey.',
474
+ scope: 'marketplace:clone',
475
+ method: 'POST',
476
+ path: '/surveys/:id/clone',
477
+ successStatus: 201,
478
+ inputSchema: {
479
+ type: 'object',
480
+ properties: { id: { type: 'string' } },
481
+ required: ['id'],
482
+ additionalProperties: false
483
+ },
484
+ toRequest: (a) => ({
485
+ method: 'POST',
486
+ path: `/surveys/${encodeURIComponent(a.id)}/clone`
487
+ })
488
+ },
489
+ {
490
+ name: 'delete_survey',
491
+ description: 'Delete a survey your org owns. IRREVERSIBLE: its responses and sessions are deleted with it, so any results you have not already read are lost. Ask the user before deleting a survey that has been sent or has responses — read get_results first if in doubt.',
492
+ scope: 'surveys:write',
493
+ method: 'DELETE',
494
+ path: '/surveys/:id',
495
+ inputSchema: {
496
+ type: 'object',
497
+ properties: { id: { type: 'string' } },
498
+ required: ['id'],
499
+ additionalProperties: false
500
+ },
501
+ toRequest: (a) => ({
502
+ method: 'DELETE',
503
+ path: `/surveys/${encodeURIComponent(a.id)}`
504
+ })
505
+ },
506
+ {
507
+ name: 'submit_survey',
508
+ description: "Submit a survey for provider approval: on RCS its button-question templates, on WhatsApp its single Flow template. Returns the survey with the resulting approval state.",
509
+ scope: 'surveys:submit',
510
+ method: 'POST',
511
+ path: '/surveys/:id/submit',
512
+ inputSchema: {
513
+ type: 'object',
514
+ properties: { id: { type: 'string' }, channel: CHANNEL_PROP },
515
+ required: ['id'],
516
+ additionalProperties: false
517
+ },
518
+ toRequest: (a) => ({
519
+ method: 'POST',
520
+ path: `/surveys/${encodeURIComponent(a.id)}/submit`,
521
+ body: { channel: a.channel }
522
+ })
523
+ },
524
+ {
525
+ name: 'send_survey',
526
+ description: 'Send a survey to explicit E.164 numbers (the CRM-triggered single-customer send) and/or saved audiences. Flips the survey ACTIVE. Bills your org. Returns { recipients, sent }.',
527
+ scope: 'surveys:send',
528
+ method: 'POST',
529
+ path: '/surveys/:id/send',
530
+ inputSchema: {
531
+ type: 'object',
532
+ properties: {
533
+ id: { type: 'string' },
534
+ to: {
535
+ type: 'array',
536
+ items: { type: 'string' },
537
+ maxItems: 500,
538
+ description: 'E.164 destinations, e.g. ["+15551234567"].'
539
+ },
540
+ audienceIds: { type: 'array', items: { type: 'string' }, maxItems: 100 },
541
+ channel: CHANNEL_PROP
542
+ },
543
+ required: ['id'],
544
+ additionalProperties: false
545
+ },
546
+ toRequest: (a) => ({
547
+ method: 'POST',
548
+ path: `/surveys/${encodeURIComponent(a.id)}/send`,
549
+ body: { to: a.to, audienceIds: a.audienceIds, channel: a.channel }
550
+ })
551
+ },
552
+ {
553
+ name: 'update_survey',
554
+ description: 'Partially update a survey: name, description, definition (re-validated; editing a submitted WhatsApp survey requires resubmission), startDate/endDate, and audienceIds — setting audienceIds on a future-dated survey schedules the send to fire automatically at startDate. PICTURES: as with create_survey, keep every question\'s `imageUrl` plus `headerImageUrl`/`outroImageUrl` populated — web-search for images when none were provided; the server re-hosts all image URLs onto GCS.',
555
+ scope: 'surveys:write',
556
+ method: 'PATCH',
557
+ path: '/surveys/:id',
558
+ inputSchema: {
559
+ type: 'object',
560
+ properties: {
561
+ id: { type: 'string' },
562
+ name: { type: 'string' },
563
+ description: { type: 'string' },
564
+ definition: { type: 'object' },
565
+ status: {
566
+ type: 'string',
567
+ enum: ['DRAFT', 'SCHEDULED', 'ACTIVE', 'CLOSED']
568
+ },
569
+ startDate: { type: 'string' },
570
+ endDate: { type: 'string' },
571
+ audienceIds: { type: 'array', items: { type: 'string' } },
572
+ channel: CHANNEL_PROP
573
+ },
574
+ required: ['id'],
575
+ additionalProperties: false
576
+ },
577
+ toRequest: (a) => ({
578
+ method: 'PATCH',
579
+ path: `/surveys/${encodeURIComponent(a.id)}`,
580
+ body: {
581
+ name: a.name,
582
+ description: a.description,
583
+ definition: a.definition,
584
+ status: a.status,
585
+ startDate: a.startDate,
586
+ endDate: a.endDate,
587
+ audienceIds: a.audienceIds,
588
+ channel: a.channel
589
+ }
590
+ })
591
+ },
592
+ {
593
+ name: 'list_survey_sends',
594
+ description: "A survey's outbound send log (live + test sends), newest first, with per-message delivery status — including failed/stubbed attempts and the reason they did not deliver.",
595
+ scope: 'surveys:read',
596
+ method: 'GET',
597
+ path: '/surveys/:id/sends',
598
+ inputSchema: {
599
+ type: 'object',
600
+ properties: { id: { type: 'string' } },
601
+ required: ['id'],
602
+ additionalProperties: false
603
+ },
604
+ toRequest: (a) => ({
605
+ method: 'GET',
606
+ path: `/surveys/${encodeURIComponent(a.id)}/sends`
607
+ })
608
+ },
609
+ {
610
+ name: 'send_survey_test',
611
+ description: 'Send a one-off TEST of the survey to a single E.164 number (does not change status or audiences). Check list_survey_sends afterwards for the delivery outcome.',
612
+ scope: 'surveys:send',
613
+ method: 'POST',
614
+ path: '/surveys/:id/send-test',
615
+ inputSchema: {
616
+ type: 'object',
617
+ properties: {
618
+ id: { type: 'string' },
619
+ number: { type: 'string', description: 'E.164, e.g. +15551234567.' },
620
+ channel: CHANNEL_PROP
621
+ },
622
+ required: ['id', 'number'],
623
+ additionalProperties: false
624
+ },
625
+ toRequest: (a) => ({
626
+ method: 'POST',
627
+ path: `/surveys/${encodeURIComponent(a.id)}/send-test`,
628
+ body: { number: a.number, channel: a.channel }
629
+ })
630
+ },
631
+ {
632
+ name: 'get_results',
633
+ description: 'Read a survey’s aggregated results: per-question option counts / rating averages / text samples + completion totals (multi-part questions aggregate per part; free-text answers are auto-translated to English).',
634
+ scope: 'results:read',
635
+ method: 'GET',
636
+ path: '/surveys/:id/results',
637
+ inputSchema: {
638
+ type: 'object',
639
+ properties: { id: { type: 'string' } },
640
+ required: ['id'],
641
+ additionalProperties: false
642
+ },
643
+ toRequest: (a) => ({
644
+ method: 'GET',
645
+ path: `/surveys/${encodeURIComponent(a.id)}/results`
646
+ })
647
+ },
648
+ {
649
+ name: 'upload_media',
650
+ description: 'Store one picture on the platform GCS bucket and get its stable https URL — the only kind of URL survey/template media should use. Pass EITHER the image inline (base64 + mime) OR an https sourceUrl the server fetches and re-hosts (use this for web-searched pictures).',
651
+ scope: 'surveys:write',
652
+ method: 'POST',
653
+ path: '/media/upload',
654
+ successStatus: 201,
655
+ inputSchema: {
656
+ type: 'object',
657
+ properties: {
658
+ base64: {
659
+ type: 'string',
660
+ description: 'Raw base64 or data: URL of the image bytes.'
661
+ },
662
+ mime: {
663
+ type: 'string',
664
+ description: 'Image MIME type (required with base64), e.g. image/png.'
665
+ },
666
+ filename: { type: 'string' },
667
+ sourceUrl: {
668
+ type: 'string',
669
+ description: 'https image URL to fetch and re-host onto GCS.'
670
+ }
671
+ },
672
+ additionalProperties: false
673
+ },
674
+ toRequest: (a) => ({
675
+ method: 'POST',
676
+ path: '/media/upload',
677
+ body: {
678
+ base64: a.base64,
679
+ mime: a.mime,
680
+ filename: a.filename,
681
+ sourceUrl: a.sourceUrl
682
+ }
683
+ })
684
+ },
685
+ {
686
+ name: 'get_knowledge',
687
+ description: "Read a template's knowledge base: the grounding document text plus whether answering is enabled. Use before set_knowledge to edit a doc incrementally instead of overwriting it blind.",
688
+ scope: 'templates:read',
689
+ method: 'GET',
690
+ path: '/templates/:id/knowledge',
691
+ inputSchema: {
692
+ type: 'object',
693
+ properties: { id: { type: 'string' } },
694
+ required: ['id'],
695
+ additionalProperties: false
696
+ },
697
+ toRequest: (a) => ({
698
+ method: 'GET',
699
+ path: `/templates/${encodeURIComponent(a.id)}/knowledge`
700
+ })
701
+ },
702
+ {
703
+ name: 'set_knowledge',
704
+ description: 'Set/replace a template\'s knowledge base: the grounding document AI answers are generated from when recipients reply. Include operating instructions in the doc itself — e.g. "Any question not answered by this document must be answered exactly: …". Setting a doc re-enables answering.',
705
+ scope: 'templates:write',
706
+ method: 'PUT',
707
+ path: '/templates/:id/knowledge',
708
+ inputSchema: {
709
+ type: 'object',
710
+ properties: {
711
+ id: { type: 'string' },
712
+ doc: {
713
+ type: 'string',
714
+ description: 'The knowledge document (markdown or plain text).'
715
+ }
716
+ },
717
+ required: ['id', 'doc'],
718
+ additionalProperties: false
719
+ },
720
+ toRequest: (a) => ({
721
+ method: 'PUT',
722
+ path: `/templates/${encodeURIComponent(a.id)}/knowledge`,
723
+ body: { doc: a.doc }
724
+ })
725
+ },
726
+ {
727
+ name: 'delete_knowledge',
728
+ description: 'Delete a template\'s knowledge base document.',
729
+ scope: 'templates:write',
730
+ method: 'DELETE',
731
+ path: '/templates/:id/knowledge',
732
+ inputSchema: {
733
+ type: 'object',
734
+ properties: { id: { type: 'string' } },
735
+ required: ['id'],
736
+ additionalProperties: false
737
+ },
738
+ toRequest: (a) => ({
739
+ method: 'DELETE',
740
+ path: `/templates/${encodeURIComponent(a.id)}/knowledge`
741
+ })
742
+ },
743
+ {
744
+ name: 'enable_knowledge',
745
+ description: 'Enable knowledge answering on a template (the doc is used again).',
746
+ scope: 'templates:write',
747
+ method: 'POST',
748
+ path: '/templates/:id/knowledge/enable',
749
+ inputSchema: {
750
+ type: 'object',
751
+ properties: { id: { type: 'string' } },
752
+ required: ['id'],
753
+ additionalProperties: false
754
+ },
755
+ toRequest: (a) => ({
756
+ method: 'POST',
757
+ path: `/templates/${encodeURIComponent(a.id)}/knowledge/enable`
758
+ })
759
+ },
760
+ {
761
+ name: 'disable_knowledge',
762
+ description: 'Disable knowledge answering on a template WITHOUT deleting the doc (pause).',
763
+ scope: 'templates:write',
764
+ method: 'POST',
765
+ path: '/templates/:id/knowledge/disable',
766
+ inputSchema: {
767
+ type: 'object',
768
+ properties: { id: { type: 'string' } },
769
+ required: ['id'],
770
+ additionalProperties: false
771
+ },
772
+ toRequest: (a) => ({
773
+ method: 'POST',
774
+ path: `/templates/${encodeURIComponent(a.id)}/knowledge/disable`
775
+ })
776
+ },
777
+ {
778
+ name: 'get_usage',
779
+ description: 'Read your org’s month-to-date metered message usage.',
780
+ scope: 'usage:read',
781
+ method: 'GET',
782
+ path: '/usage',
783
+ inputSchema: {
784
+ type: 'object',
785
+ properties: {},
786
+ additionalProperties: false
787
+ },
788
+ toRequest: () => ({ method: 'GET', path: '/usage' })
789
+ },
790
+ // ── Audience tools (contact-list authoring, e.g. importing leads) ──
791
+ {
792
+ name: 'search_audiences',
793
+ description: 'List/search the audiences (saved contact lists) your org owns. Returns each audience with its name, source, and contactCount.',
794
+ scope: 'audiences:read',
795
+ method: 'GET',
796
+ path: '/audiences',
797
+ inputSchema: {
798
+ type: 'object',
799
+ properties: {
800
+ q: { type: 'string', description: 'Free-text search over the name.' },
801
+ page: { type: 'number', minimum: 1 },
802
+ pageSize: { type: 'number', minimum: 1, maximum: 100 }
803
+ },
804
+ additionalProperties: false
805
+ },
806
+ toRequest: (a) => ({
807
+ method: 'GET',
808
+ path: '/audiences',
809
+ query: { q: a.q, page: a.page, pageSize: a.pageSize }
810
+ })
811
+ },
812
+ {
813
+ name: 'get_audience',
814
+ description: 'Fetch one audience your org owns by id.',
815
+ scope: 'audiences:read',
816
+ method: 'GET',
817
+ path: '/audiences/:id',
818
+ inputSchema: {
819
+ type: 'object',
820
+ properties: { id: { type: 'string' } },
821
+ required: ['id'],
822
+ additionalProperties: false
823
+ },
824
+ toRequest: (a) => ({
825
+ method: 'GET',
826
+ path: `/audiences/${encodeURIComponent(a.id)}`
827
+ })
828
+ },
829
+ {
830
+ name: 'list_audience_contacts',
831
+ description: "Paginated list of an audience's contacts. Use `q` to find someone in a large imported batch instead of paging through it — it matches on the server, over the whole audience, so it keeps working on lists too big to page.",
832
+ scope: 'audiences:read',
833
+ method: 'GET',
834
+ path: '/audiences/:id/contacts',
835
+ inputSchema: {
836
+ type: 'object',
837
+ properties: {
838
+ id: { type: 'string' },
839
+ q: {
840
+ type: 'string',
841
+ maxLength: 120,
842
+ description: 'Free-text over number, names and email. A number matches however it is written — "(206) 300-0001" finds "+12063000001".'
843
+ },
844
+ page: { type: 'number', minimum: 1 },
845
+ pageSize: { type: 'number', minimum: 1, maximum: 200 }
846
+ },
847
+ required: ['id'],
848
+ additionalProperties: false
849
+ },
850
+ toRequest: (a) => ({
851
+ method: 'GET',
852
+ path: `/audiences/${encodeURIComponent(a.id)}/contacts`,
853
+ query: { q: a.q, page: a.page, pageSize: a.pageSize }
854
+ })
855
+ },
856
+ {
857
+ name: 'create_audience',
858
+ description: 'Create a saved audience (contact list) in your org, optionally seeded with contacts (e.g. leads parsed from a CSV). Every contact needs a reachable E.164-ish `number` — a contact with no valid number fails the whole create with a 400 (fix the row rather than silently dropping it). Use `attributes` for any extra CSV columns you want available as {{key}} merge fields later.',
859
+ scope: 'audiences:write',
860
+ method: 'POST',
861
+ path: '/audiences',
862
+ successStatus: 201,
863
+ inputSchema: {
864
+ type: 'object',
865
+ properties: {
866
+ name: { type: 'string' },
867
+ description: { type: 'string' },
868
+ source: { type: 'string', description: 'Free-text provenance, e.g. "Jaipur wedding planners leads.csv".' },
869
+ contacts: {
870
+ type: 'array',
871
+ items: {
872
+ type: 'object',
873
+ properties: {
874
+ firstName: { type: 'string' },
875
+ lastName: { type: 'string' },
876
+ number: { type: 'string', description: 'Phone number; required for the contact to be reachable.' },
877
+ email: { type: 'string' },
878
+ attributes: { type: 'object', additionalProperties: { type: 'string' } }
879
+ },
880
+ additionalProperties: false
881
+ }
882
+ }
883
+ },
884
+ required: ['name'],
885
+ additionalProperties: false
886
+ },
887
+ toRequest: (a) => ({
888
+ method: 'POST',
889
+ path: '/audiences',
890
+ body: {
891
+ name: a.name,
892
+ description: a.description,
893
+ source: a.source,
894
+ contacts: a.contacts
895
+ }
896
+ })
897
+ },
898
+ {
899
+ name: 'import_contacts',
900
+ description: 'Append contacts to an existing audience (e.g. more leads from a later CSV batch). Same reachability rule as create_audience: every contact needs a valid `number` or the whole import 400s.',
901
+ scope: 'audiences:write',
902
+ method: 'POST',
903
+ path: '/audiences/:id/contacts',
904
+ inputSchema: {
905
+ type: 'object',
906
+ properties: {
907
+ id: { type: 'string' },
908
+ contacts: {
909
+ type: 'array',
910
+ items: {
911
+ type: 'object',
912
+ properties: {
913
+ firstName: { type: 'string' },
914
+ lastName: { type: 'string' },
915
+ number: { type: 'string' },
916
+ email: { type: 'string' },
917
+ attributes: { type: 'object', additionalProperties: { type: 'string' } }
918
+ },
919
+ additionalProperties: false
920
+ },
921
+ minItems: 1
922
+ }
923
+ },
924
+ required: ['id', 'contacts'],
925
+ additionalProperties: false
926
+ },
927
+ toRequest: (a) => ({
928
+ method: 'POST',
929
+ path: `/audiences/${encodeURIComponent(a.id)}/contacts`,
930
+ body: { contacts: a.contacts }
931
+ })
932
+ },
933
+ {
934
+ name: 'delete_audience',
935
+ description: 'Delete an audience and all of its contacts (org-scoped, irreversible).',
936
+ scope: 'audiences:write',
937
+ method: 'DELETE',
938
+ path: '/audiences/:id',
939
+ inputSchema: {
940
+ type: 'object',
941
+ properties: { id: { type: 'string' } },
942
+ required: ['id'],
943
+ additionalProperties: false
944
+ },
945
+ toRequest: (a) => ({
946
+ method: 'DELETE',
947
+ path: `/audiences/${encodeURIComponent(a.id)}`
948
+ })
949
+ },
950
+ // ── Conversation tools (the Inbox: read a thread, answer it, hand it over) ──
951
+ {
952
+ name: 'list_conversations',
953
+ description: 'List the org\'s conversation threads (one per phone number), newest activity first. Archived threads are hidden. Filter by free text, unread, unassigned, a specific assignee, or the campaign a thread was messaged in.',
954
+ scope: 'conversations:read',
955
+ method: 'GET',
956
+ path: '/conversations',
957
+ inputSchema: {
958
+ type: 'object',
959
+ properties: {
960
+ search: {
961
+ type: 'string',
962
+ description: 'Match the number or the resolved display name.'
963
+ },
964
+ unread: {
965
+ type: 'boolean',
966
+ description: 'Only threads with unanswered arrivals (unreadCount > 0). Org-wide, not per-viewer: a key is not a person and has no read state.'
967
+ },
968
+ unassigned: { type: 'boolean', description: 'Only threads nobody owns.' },
969
+ assignedTo: {
970
+ type: 'string',
971
+ description: 'Only threads owned by this Clerk user id.'
972
+ },
973
+ broadcastId: {
974
+ type: 'string',
975
+ description: 'Only threads this campaign was sent to.'
976
+ },
977
+ page: { type: 'number', minimum: 1 },
978
+ pageSize: { type: 'number', minimum: 1, maximum: 100 }
979
+ },
980
+ additionalProperties: false
981
+ },
982
+ toRequest: (a) => ({
983
+ method: 'GET',
984
+ path: '/conversations',
985
+ query: {
986
+ search: a.search,
987
+ unread: a.unread,
988
+ unassigned: a.unassigned,
989
+ assignedTo: a.assignedTo,
990
+ broadcastId: a.broadcastId,
991
+ page: a.page,
992
+ pageSize: a.pageSize
993
+ }
994
+ })
995
+ },
996
+ {
997
+ name: 'get_conversation',
998
+ description: 'Read one thread in a single call: its meta, the most recent messages (ascending), the contact summary (marketing status, tags, audiences) and whether the WhatsApp 24h service window is open. Internal team notes are deliberately NOT included.',
999
+ scope: 'conversations:read',
1000
+ method: 'GET',
1001
+ path: '/conversations/:id',
1002
+ inputSchema: {
1003
+ type: 'object',
1004
+ properties: {
1005
+ id: { type: 'string' },
1006
+ limit: {
1007
+ type: 'number',
1008
+ minimum: 1,
1009
+ maximum: 200,
1010
+ default: 50,
1011
+ description: 'How many of the most recent messages to include.'
1012
+ }
1013
+ },
1014
+ required: ['id'],
1015
+ additionalProperties: false
1016
+ },
1017
+ toRequest: (a) => ({
1018
+ method: 'GET',
1019
+ path: `/conversations/${encodeURIComponent(a.id)}`,
1020
+ query: { limit: a.limit }
1021
+ })
1022
+ },
1023
+ {
1024
+ name: 'reply',
1025
+ description: 'Reply into a thread — free text (with an optional image) or an approved template. Reaches a real phone and bills your org. Every gate an operator hits applies unchanged: an opted-out number is refused (409), the org allowance is enforced, WhatsApp free text needs the 24h window open (an approved template otherwise), and an RCS send always carries its SMS fallback. When the thread has an outbound language set, free text is translated into it first, exactly as the human composer does.',
1026
+ scope: 'conversations:write',
1027
+ method: 'POST',
1028
+ path: '/conversations/:id/reply',
1029
+ successStatus: 201,
1030
+ inputSchema: {
1031
+ type: 'object',
1032
+ properties: {
1033
+ id: { type: 'string' },
1034
+ kind: { type: 'string', enum: ['text', 'template'] },
1035
+ text: {
1036
+ type: 'string',
1037
+ maxLength: 4096,
1038
+ description: 'kind=text: the body. May be empty when mediaUrl carries the message.'
1039
+ },
1040
+ mediaUrl: {
1041
+ type: 'string',
1042
+ description: 'kind=text: a public https image URL to attach.'
1043
+ },
1044
+ blueprintId: {
1045
+ type: 'string',
1046
+ description: "kind=template: the org template to send."
1047
+ },
1048
+ variables: {
1049
+ type: 'object',
1050
+ additionalProperties: { type: 'string' },
1051
+ description: 'kind=template: values for the template\'s {{variables}}.'
1052
+ }
1053
+ },
1054
+ required: ['id', 'kind'],
1055
+ additionalProperties: false
1056
+ },
1057
+ toRequest: (a) => ({
1058
+ method: 'POST',
1059
+ path: `/conversations/${encodeURIComponent(a.id)}/reply`,
1060
+ body: {
1061
+ kind: a.kind,
1062
+ text: a.text,
1063
+ mediaUrl: a.mediaUrl,
1064
+ blueprintId: a.blueprintId,
1065
+ variables: a.variables
1066
+ }
1067
+ })
1068
+ },
1069
+ {
1070
+ name: 'assign_conversation',
1071
+ description: 'Give a thread an owner, or release it back to the pool with `userId: null`. The assignee is a bare Clerk user id and is NOT validated against the org roster — a stale id degrades to "assigned to someone who left", visibly, rather than to an outage in a one-field write.',
1072
+ scope: 'conversations:write',
1073
+ method: 'POST',
1074
+ path: '/conversations/:id/assign',
1075
+ inputSchema: {
1076
+ type: 'object',
1077
+ properties: {
1078
+ id: { type: 'string' },
1079
+ userId: {
1080
+ type: ['string', 'null'],
1081
+ description: 'Clerk user id to hand the thread to; null releases it.'
1082
+ }
1083
+ },
1084
+ required: ['id', 'userId'],
1085
+ additionalProperties: false
1086
+ },
1087
+ toRequest: (a) => ({
1088
+ method: 'POST',
1089
+ path: `/conversations/${encodeURIComponent(a.id)}/assign`,
1090
+ body: { userId: a.userId ?? null }
1091
+ })
1092
+ },
1093
+ // ── Webhook tools (subscribe yourself to platform events) ──
1094
+ {
1095
+ name: 'list_webhooks',
1096
+ description: "The org's registered outbound webhook endpoints. The signing secret is redacted — it is shown only once, by create_webhook.",
1097
+ scope: 'webhooks:manage',
1098
+ method: 'GET',
1099
+ path: '/webhooks',
1100
+ inputSchema: {
1101
+ type: 'object',
1102
+ properties: {},
1103
+ additionalProperties: false
1104
+ },
1105
+ toRequest: () => ({ method: 'GET', path: '/webhooks' })
1106
+ },
1107
+ {
1108
+ name: 'create_webhook',
1109
+ description: 'Register an HTTPS endpoint to receive signed platform events. The response carries the signing `secret` EXACTLY ONCE — store it now; every later read redacts it and there is no way to retrieve it again. Each delivery is signed `X-RCS-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<rawBody>">`.',
1110
+ scope: 'webhooks:manage',
1111
+ method: 'POST',
1112
+ path: '/webhooks',
1113
+ successStatus: 201,
1114
+ inputSchema: {
1115
+ type: 'object',
1116
+ properties: {
1117
+ url: { type: 'string', description: 'HTTPS endpoint to POST events to.' },
1118
+ events: {
1119
+ type: 'array',
1120
+ minItems: 1,
1121
+ items: { type: 'string', enum: [...exports.WEBHOOK_EVENT_NAMES] },
1122
+ description: 'Event types to subscribe to.'
1123
+ },
1124
+ description: { type: 'string', maxLength: 200 }
1125
+ },
1126
+ required: ['url', 'events'],
1127
+ additionalProperties: false
1128
+ },
1129
+ toRequest: (a) => ({
1130
+ method: 'POST',
1131
+ path: '/webhooks',
1132
+ body: { url: a.url, events: a.events, description: a.description }
1133
+ })
1134
+ },
1135
+ {
1136
+ name: 'update_webhook',
1137
+ description: 'Update an endpoint\'s url, subscribed events, description, or active flag. Only the fields you pass change. The signing secret is never returned or rotated here.',
1138
+ scope: 'webhooks:manage',
1139
+ method: 'PUT',
1140
+ path: '/webhooks/:id',
1141
+ inputSchema: {
1142
+ type: 'object',
1143
+ properties: {
1144
+ id: { type: 'string' },
1145
+ url: { type: 'string' },
1146
+ events: {
1147
+ type: 'array',
1148
+ minItems: 1,
1149
+ items: { type: 'string', enum: [...exports.WEBHOOK_EVENT_NAMES] }
1150
+ },
1151
+ description: { type: ['string', 'null'], maxLength: 200 },
1152
+ active: { type: 'boolean' }
1153
+ },
1154
+ required: ['id'],
1155
+ additionalProperties: false
1156
+ },
1157
+ toRequest: (a) => ({
1158
+ method: 'PUT',
1159
+ path: `/webhooks/${encodeURIComponent(a.id)}`,
1160
+ body: {
1161
+ url: a.url,
1162
+ events: a.events,
1163
+ description: a.description,
1164
+ active: a.active
1165
+ }
1166
+ })
1167
+ },
1168
+ {
1169
+ name: 'delete_webhook',
1170
+ description: 'Remove a webhook endpoint. Its delivery history is deleted with it; anything still queued for it stops.',
1171
+ scope: 'webhooks:manage',
1172
+ method: 'DELETE',
1173
+ path: '/webhooks/:id',
1174
+ inputSchema: {
1175
+ type: 'object',
1176
+ properties: { id: { type: 'string' } },
1177
+ required: ['id'],
1178
+ additionalProperties: false
1179
+ },
1180
+ toRequest: (a) => ({
1181
+ method: 'DELETE',
1182
+ path: `/webhooks/${encodeURIComponent(a.id)}`
1183
+ })
1184
+ },
1185
+ {
1186
+ name: 'test_webhook',
1187
+ description: 'Fire a synthetic signed event at one endpoint to verify the receiver. Returns the delivery id and whether it was accepted.',
1188
+ scope: 'webhooks:manage',
1189
+ method: 'POST',
1190
+ path: '/webhooks/:id/test',
1191
+ inputSchema: {
1192
+ type: 'object',
1193
+ properties: { id: { type: 'string' } },
1194
+ required: ['id'],
1195
+ additionalProperties: false
1196
+ },
1197
+ toRequest: (a) => ({
1198
+ method: 'POST',
1199
+ path: `/webhooks/${encodeURIComponent(a.id)}/test`
1200
+ })
1201
+ },
1202
+ // ── Contact tools (the org-level person record behind every audience) ──
1203
+ {
1204
+ name: 'list_contacts',
1205
+ description: "Search the org's contact records (one person per number, across every audience). Returns consent, opt-out, number verdict, marketing status, merge fields and tags.",
1206
+ scope: 'contacts:read',
1207
+ method: 'GET',
1208
+ path: '/contacts',
1209
+ inputSchema: {
1210
+ type: 'object',
1211
+ properties: {
1212
+ q: {
1213
+ type: 'string',
1214
+ maxLength: 120,
1215
+ description: 'Free-text over number, names and email.'
1216
+ },
1217
+ status: {
1218
+ type: 'string',
1219
+ enum: ['SUBSCRIBED', 'UNSUBSCRIBED', 'UNDELIVERABLE', 'UNKNOWN'],
1220
+ description: 'Marketing status: SUBSCRIBED (consent, not opted out), UNSUBSCRIBED (opted out — sticky), UNDELIVERABLE (number known unreachable), UNKNOWN (no consent on record).'
1221
+ },
1222
+ tag: { type: 'string', maxLength: 64 },
1223
+ audienceId: {
1224
+ type: 'string',
1225
+ description: 'Only people who are members of this audience.'
1226
+ },
1227
+ page: { type: 'number', minimum: 1 },
1228
+ pageSize: { type: 'number', minimum: 1, maximum: 100 }
1229
+ },
1230
+ additionalProperties: false
1231
+ },
1232
+ toRequest: (a) => ({
1233
+ method: 'GET',
1234
+ path: '/contacts',
1235
+ query: {
1236
+ q: a.q,
1237
+ status: a.status,
1238
+ tag: a.tag,
1239
+ audienceId: a.audienceId,
1240
+ page: a.page,
1241
+ pageSize: a.pageSize
1242
+ }
1243
+ })
1244
+ },
1245
+ {
1246
+ name: 'get_contact',
1247
+ description: 'Fetch one contact by its public contact id, with the audiences they belong to.',
1248
+ scope: 'contacts:read',
1249
+ method: 'GET',
1250
+ path: '/contacts/:id',
1251
+ inputSchema: {
1252
+ type: 'object',
1253
+ properties: { id: { type: 'string' } },
1254
+ required: ['id'],
1255
+ additionalProperties: false
1256
+ },
1257
+ toRequest: (a) => ({
1258
+ method: 'GET',
1259
+ path: `/contacts/${encodeURIComponent(a.id)}`
1260
+ })
1261
+ },
1262
+ {
1263
+ name: 'update_contact',
1264
+ description: "Correct a contact's names, email, merge fields (`attributes`, a whole-map replace) or tags. Consent and opt-out are deliberately NOT reachable from here — recording or clearing them is a compliance-sensitive action that stays with a human operator.",
1265
+ scope: 'contacts:write',
1266
+ method: 'PATCH',
1267
+ path: '/contacts/:id',
1268
+ inputSchema: {
1269
+ type: 'object',
1270
+ properties: {
1271
+ id: { type: 'string' },
1272
+ firstName: { type: ['string', 'null'], maxLength: 120 },
1273
+ lastName: { type: ['string', 'null'], maxLength: 120 },
1274
+ email: { type: ['string', 'null'], maxLength: 200 },
1275
+ attributes: {
1276
+ type: ['object', 'null'],
1277
+ additionalProperties: { type: 'string' },
1278
+ description: 'Whole-map replace of the {{merge field}} values.'
1279
+ },
1280
+ tags: { type: 'array', items: { type: 'string' }, maxItems: 50 }
1281
+ },
1282
+ required: ['id'],
1283
+ additionalProperties: false
1284
+ },
1285
+ toRequest: (a) => ({
1286
+ method: 'PATCH',
1287
+ path: `/contacts/${encodeURIComponent(a.id)}`,
1288
+ body: {
1289
+ firstName: a.firstName,
1290
+ lastName: a.lastName,
1291
+ email: a.email,
1292
+ attributes: a.attributes,
1293
+ tags: a.tags
1294
+ }
1295
+ })
1296
+ }
1297
+ ];
1298
+ /** Look up a tool by name. */
1299
+ function findTool(name) {
1300
+ return exports.TOOLS.find((t) => t.name === name);
1301
+ }
1302
+ /** Does this tool accept a `channel` argument at all? */
1303
+ function acceptsChannel(tool) {
1304
+ return Object.prototype.hasOwnProperty.call(tool.inputSchema.properties ?? {}, 'channel');
1305
+ }
1306
+ /**
1307
+ * Fill in the skin's default channel for a call that accepts one and did not
1308
+ * specify it.
1309
+ *
1310
+ * Both skins run the same catalog against the same gateway, so the only thing
1311
+ * that should differ is which channel you get when you don't ask: the `wa` bins
1312
+ * assume WhatsApp, the `rcs` bins stay silent and let the server fall back to
1313
+ * the org's own brand channel. An explicit `channel` from the caller always
1314
+ * wins, and tools that take no channel are returned untouched — so this can be
1315
+ * applied unconditionally at the CLI/MCP boundary.
1316
+ */
1317
+ function withDefaultChannel(tool, args, defaultChannel) {
1318
+ if (!acceptsChannel(tool))
1319
+ return args;
1320
+ if (args.channel !== undefined && args.channel !== null) {
1321
+ // An explicit channel wins — but a brand id typed in its place (legacy
1322
+ // `wa-templates` or canonical `wa-campaigns`, MSG-1867 phase 3) is resolved
1323
+ // to the canonical channel enum the gateway expects. Unknown values pass
1324
+ // through untouched so the server can reject them with its own message.
1325
+ const canonical = (0, client_1.normalizeChannel)(args.channel);
1326
+ return canonical && canonical !== args.channel
1327
+ ? { ...args, channel: canonical }
1328
+ : args;
1329
+ }
1330
+ if (!defaultChannel)
1331
+ return args;
1332
+ return { ...args, channel: defaultChannel };
1333
+ }