@sendinel/mcp-server 1.0.63 → 1.0.64
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/build/lib/action-approvals.d.ts +22 -0
- package/build/lib/action-approvals.js +94 -1
- package/build/lib/approval-exempt.d.ts +13 -0
- package/build/lib/approval-exempt.js +134 -0
- package/build/tools/ab-testing.js +4 -2
- package/build/tools/analytics.js +4 -2
- package/build/tools/automations.js +6 -3
- package/build/tools/brand-kit.js +4 -2
- package/build/tools/campaigns.js +23 -18
- package/build/tools/contacts.js +17 -13
- package/build/tools/content.js +7 -5
- package/build/tools/data.js +13 -10
- package/build/tools/delivery-ops.js +4 -3
- package/build/tools/landing-pages.js +7 -5
- package/build/tools/lists.js +4 -4
- package/build/tools/newsletters.js +8 -5
- package/build/tools/org.js +13 -10
- package/build/tools/segments.js +4 -2
- package/build/tools/signatures.js +4 -3
- package/build/tools/sms.js +11 -7
- package/build/tools/social-posts.js +4 -3
- package/build/tools/social-replies.js +4 -3
- package/build/tools/templates.js +4 -3
- package/build/tools/webhooks.js +10 -6
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** True when the action name is on the gated or protected list (the default-deny test uses this). */
|
|
2
|
+
export declare function isApprovalGatedAction(action: string): boolean;
|
|
1
3
|
export type ApprovalTier = 'autonomous' | 'gated' | 'protected';
|
|
2
4
|
export declare function getApprovalTier(action: string, params?: Record<string, unknown>): ApprovalTier;
|
|
3
5
|
/**
|
|
@@ -64,3 +66,23 @@ export declare function checkApproval(approvalId: string, options?: {
|
|
|
64
66
|
status: string;
|
|
65
67
|
decided_by?: string;
|
|
66
68
|
}>;
|
|
69
|
+
type ToolResult = {
|
|
70
|
+
content: Array<{
|
|
71
|
+
type: 'text';
|
|
72
|
+
text: string;
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
|
|
77
|
+
* (no approval_id) records the exact arguments and returns the approval request without running
|
|
78
|
+
* the handler; the follow-up call with an approved approval_id runs the handler on those same
|
|
79
|
+
* arguments. getApprovalTier() decides when no approval is needed (autonomous mode, special
|
|
80
|
+
* cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
|
|
81
|
+
* Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
|
|
82
|
+
*/
|
|
83
|
+
export declare function withApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R, options?: {
|
|
84
|
+
display?: (args: A) => Promise<ApprovalDisplay> | ApprovalDisplay;
|
|
85
|
+
}): (args: A & {
|
|
86
|
+
approval_id?: string;
|
|
87
|
+
}, extra?: unknown) => Promise<R | ToolResult>;
|
|
88
|
+
export {};
|
|
@@ -32,6 +32,46 @@ const GATED_ACTIONS = new Set([
|
|
|
32
32
|
'remove_team_member', // revokes workspace access
|
|
33
33
|
'set_team_member_role', // changes workspace access privileges
|
|
34
34
|
'delete_provider_credential', // permanently removes or revokes stored provider access
|
|
35
|
+
// #4005: every outward or destructive tool is approval-gated unless it sits on APPROVAL_EXEMPT
|
|
36
|
+
// (src/lib/approval-exempt.ts). These are wrapped with withApproval() in their tool files.
|
|
37
|
+
'send_sms', // texts a real phone number
|
|
38
|
+
'send_sms_campaign', // can activate an SMS broadcast
|
|
39
|
+
'send_newsletter_issue', // sends an issue to every subscriber
|
|
40
|
+
'schedule_newsletter_issue', // queues an issue to every subscriber
|
|
41
|
+
'reply_to_comment', // queues a public LinkedIn reply
|
|
42
|
+
'send_awaiting_reply', // sends a real email
|
|
43
|
+
'send_test_email', // sends a real email to any address
|
|
44
|
+
'remove_suppression', // re-enables mail to a suppressed address
|
|
45
|
+
'schedule_social_post', // publishes to a connected social account
|
|
46
|
+
'invite_team_member', // emails an outside person and grants access
|
|
47
|
+
'set_member_project_access', // changes workspace access
|
|
48
|
+
'revoke_invitation', // cancels a pending teammate invitation
|
|
49
|
+
'create_api_key', // mints new access to the project
|
|
50
|
+
'revoke_api_key', // breaks every integration using the key
|
|
51
|
+
'tenant_export_full', // copies all customer data out of Sendinel
|
|
52
|
+
'enroll_contact', // starts real emails to a contact
|
|
53
|
+
'enroll_automation', // starts automated messages to a contact
|
|
54
|
+
'enroll_in_escalation_ladder', // starts email/SMS escalation to a contact
|
|
55
|
+
'set_contact_sms_consent', // changes the consent record that allows texting
|
|
56
|
+
'bulk_update_subscribers', // bulk contact data write
|
|
57
|
+
'update_webhook_subscription', // can redirect event data to a new URL
|
|
58
|
+
'delete_webhook_subscription', // removes an outbound data channel
|
|
59
|
+
'delete_campaign', // deletes a campaign and its steps
|
|
60
|
+
'delete_campaign_step', // deletes an email from a campaign
|
|
61
|
+
'delete_content_block', // deletes customer content
|
|
62
|
+
'delete_custom_field', // deletes a field and its values
|
|
63
|
+
'delete_landing_page', // takes a public page down and deletes it
|
|
64
|
+
'delete_list', // deletes a contact list
|
|
65
|
+
'delete_signature', // deletes a saved signature
|
|
66
|
+
'delete_social_post', // deletes a social post record
|
|
67
|
+
'delete_subscriber_view', // deletes a saved view
|
|
68
|
+
'delete_template', // deletes an email template
|
|
69
|
+
'import_brand_kit_text', // overwrites the brand kit
|
|
70
|
+
'disconnect_platform', // deletes stored platform credentials
|
|
71
|
+
'start_provider_switch', // moves a sending domain to another provider
|
|
72
|
+
'update_landing_page', // publishing makes the page public (see special case below)
|
|
73
|
+
'create_blog_post', // auto_publish posts to the customer's own site (see special case below)
|
|
74
|
+
'replay_enrollment_dlq_rows', // confirmed replay sends real emails (see special case below)
|
|
35
75
|
]);
|
|
36
76
|
const PROTECTED_ACTIONS = new Set([
|
|
37
77
|
'delete_subscriber_data', // GDPR delete — irreversible
|
|
@@ -41,6 +81,10 @@ const PROTECTED_ACTIONS = new Set([
|
|
|
41
81
|
'promote_ab_winner', // deletes losing variants
|
|
42
82
|
'action_ccpa_request', // CCPA delete completion is irreversible
|
|
43
83
|
]);
|
|
84
|
+
/** True when the action name is on the gated or protected list (the default-deny test uses this). */
|
|
85
|
+
export function isApprovalGatedAction(action) {
|
|
86
|
+
return GATED_ACTIONS.has(action) || PROTECTED_ACTIONS.has(action);
|
|
87
|
+
}
|
|
44
88
|
export function getApprovalTier(action, params) {
|
|
45
89
|
// CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
|
|
46
90
|
// These are irreversible (GDPR delete, merge, etc.) — no opt-out.
|
|
@@ -48,6 +92,9 @@ export function getApprovalTier(action, params) {
|
|
|
48
92
|
// Exception: identify_inactive in dry_run mode is safe (read-only report)
|
|
49
93
|
if (action === 'identify_inactive' && params?.dry_run !== false)
|
|
50
94
|
return 'autonomous';
|
|
95
|
+
// Without confirmed=true the tool only previews which variants would be deleted.
|
|
96
|
+
if (action === 'promote_ab_winner' && params?.confirmed !== true)
|
|
97
|
+
return 'autonomous';
|
|
51
98
|
if (action === 'action_ccpa_request' && !(params?.status === 'completed' && params?.request_type === 'delete'))
|
|
52
99
|
return 'autonomous';
|
|
53
100
|
return 'protected';
|
|
@@ -73,6 +120,13 @@ export function getApprovalTier(action, params) {
|
|
|
73
120
|
// in campaigns.ts — so the special case must live here, not just at the call site.
|
|
74
121
|
if (action === 'update_campaign_status' && params?.status !== 'active')
|
|
75
122
|
return 'autonomous';
|
|
123
|
+
// Only the publishing / sending form of these is outward; drafts, previews and dry runs are not.
|
|
124
|
+
if (action === 'update_landing_page' && params?.status !== 'published')
|
|
125
|
+
return 'autonomous';
|
|
126
|
+
if (action === 'create_blog_post' && params?.auto_publish !== true)
|
|
127
|
+
return 'autonomous';
|
|
128
|
+
if (action === 'replay_enrollment_dlq_rows' && params?.confirmed !== true)
|
|
129
|
+
return 'autonomous';
|
|
76
130
|
if (GATED_ACTIONS.has(action))
|
|
77
131
|
return 'gated';
|
|
78
132
|
return 'autonomous';
|
|
@@ -216,9 +270,17 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
216
270
|
remove_team_member: (p) => `Remove team member ${p.user_id} from the organization`,
|
|
217
271
|
set_team_member_role: (p) => `Change team member ${p.user_id}'s role to ${p.role}`,
|
|
218
272
|
delete_provider_credential: (p) => `Delete saved ${p.provider ?? 'provider'} credential ${p.credential_id} (${p.source})`,
|
|
273
|
+
send_sms: (p) => `Send an SMS to ${p.to_number ?? `contact ${p.contact_id ?? 'unknown'}`} (${p.classification ?? 'marketing'}): "${approvalExcerpt(p.body)}"`,
|
|
274
|
+
send_newsletter_issue: (p) => `Send newsletter issue ${display.issue_subject ? `"${display.issue_subject}"` : p.issue_id} now to every subscriber of newsletter ${display.newsletter_name ?? p.newsletter_id}`,
|
|
275
|
+
reply_to_comment: (p) => `Queue a LinkedIn reply to ${p.comment_url_or_urn}: "${approvalExcerpt(p.text)}"`,
|
|
276
|
+
send_awaiting_reply: (p) => `Send an email to ${p.to}${p.subject ? ` — "${approvalExcerpt(p.subject, 120)}"` : ''}${p.html || p.text ? ` — "${approvalExcerpt(p.html ?? p.text)}"` : ''} and track a reply for ${p.expect_reply_within}`,
|
|
277
|
+
remove_suppression: (p) => `Remove ${p.email} from the suppression list — mail to this address will resume`,
|
|
219
278
|
};
|
|
220
279
|
const fn = summaries[action];
|
|
221
|
-
|
|
280
|
+
if (fn)
|
|
281
|
+
return fn(params);
|
|
282
|
+
const payload = JSON.stringify(normalizeApprovalParams(params));
|
|
283
|
+
return `Execute ${action} with: ${payload.length > 400 ? `${payload.slice(0, 399)}…` : payload}`;
|
|
222
284
|
}
|
|
223
285
|
function approvalPageUrl(approvalId) {
|
|
224
286
|
const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
@@ -337,3 +399,34 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
337
399
|
decided_by: claimed.decided_by,
|
|
338
400
|
};
|
|
339
401
|
}
|
|
402
|
+
function toolText(value) {
|
|
403
|
+
return { content: [{ type: 'text', text: value }] };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
|
|
407
|
+
* (no approval_id) records the exact arguments and returns the approval request without running
|
|
408
|
+
* the handler; the follow-up call with an approved approval_id runs the handler on those same
|
|
409
|
+
* arguments. getApprovalTier() decides when no approval is needed (autonomous mode, special
|
|
410
|
+
* cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
|
|
411
|
+
* Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
|
|
412
|
+
*/
|
|
413
|
+
export function withApproval(action, handler, options = {}) {
|
|
414
|
+
return async (args, extra) => {
|
|
415
|
+
const { approval_id, ...params } = args;
|
|
416
|
+
const channelContext = currentChannelContext();
|
|
417
|
+
const decision = assertChannelCanExecute(action, params, channelContext);
|
|
418
|
+
if (!decision.allowed)
|
|
419
|
+
return toolText(decision.reason ?? 'This channel cannot request this action.');
|
|
420
|
+
if (decision.tier !== 'autonomous') {
|
|
421
|
+
if (!approval_id) {
|
|
422
|
+
const display = options.display ? await options.display(params) : undefined;
|
|
423
|
+
return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
|
|
424
|
+
}
|
|
425
|
+
const check = await checkApproval(approval_id, { consume: true, action, params });
|
|
426
|
+
if (!check.approved) {
|
|
427
|
+
return toolText(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return handler(params, extra);
|
|
431
|
+
};
|
|
432
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write- and admin-scope MCP tools that are deliberately NOT approval-gated (#4005).
|
|
3
|
+
*
|
|
4
|
+
* __tests__/approval-default-deny.test.ts fails for any registered tool whose scope is write or
|
|
5
|
+
* admin unless it is approval-gated (listed in GATED_ACTIONS or PROTECTED_ACTIONS in
|
|
6
|
+
* action-approvals.ts AND wrapped with withApproval()/requestApproval()) or listed here with a
|
|
7
|
+
* one-line reason. Nothing that sends, posts or publishes outside Sendinel, or deletes or
|
|
8
|
+
* irreversibly changes customer data, belongs here: gate it instead. A new outward tool therefore
|
|
9
|
+
* cannot ship ungated by accident.
|
|
10
|
+
*
|
|
11
|
+
* Entries marked FOLLOW-UP are exempt today but flagged for a closer look.
|
|
12
|
+
*/
|
|
13
|
+
export declare const APPROVAL_EXEMPT: Record<string, string>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write- and admin-scope MCP tools that are deliberately NOT approval-gated (#4005).
|
|
3
|
+
*
|
|
4
|
+
* __tests__/approval-default-deny.test.ts fails for any registered tool whose scope is write or
|
|
5
|
+
* admin unless it is approval-gated (listed in GATED_ACTIONS or PROTECTED_ACTIONS in
|
|
6
|
+
* action-approvals.ts AND wrapped with withApproval()/requestApproval()) or listed here with a
|
|
7
|
+
* one-line reason. Nothing that sends, posts or publishes outside Sendinel, or deletes or
|
|
8
|
+
* irreversibly changes customer data, belongs here: gate it instead. A new outward tool therefore
|
|
9
|
+
* cannot ship ungated by accident.
|
|
10
|
+
*
|
|
11
|
+
* Entries marked FOLLOW-UP are exempt today but flagged for a closer look.
|
|
12
|
+
*/
|
|
13
|
+
export const APPROVAL_EXEMPT = {
|
|
14
|
+
abuse_monitor_override: "admin-only override of Sendinel abuse monitoring on the caller own project; no outward send",
|
|
15
|
+
add_blackout_date: "adds a no-send date; reduces sending",
|
|
16
|
+
add_campaign_branch: "adds branching config to a campaign; recreatable and sends nothing itself",
|
|
17
|
+
add_campaign_step: "adds an email to a campaign; sends only when the campaign is activated (gated)",
|
|
18
|
+
add_subscriber: "adds one contact; contact count limits apply and nothing is sent",
|
|
19
|
+
add_suppression: "only stops mail to an address; reduces sending",
|
|
20
|
+
add_synced_folder: "starts syncing a Drive folder into the project own media library; inbound only",
|
|
21
|
+
add_white_label_domain: "adds a pending branding domain record; reversible and sends nothing",
|
|
22
|
+
approve_data_proposal: "turns a proposal into an AI draft for review; sends nothing",
|
|
23
|
+
approve_draft: "marks a draft ready; sending still requires a gated send or activation",
|
|
24
|
+
cancel_send_queue_rows: "cancels queued mail before it sends; reduces sending",
|
|
25
|
+
clone_campaign: "creates a draft copy of a campaign; nothing sends until activated (gated)",
|
|
26
|
+
clone_sequence_template: "creates a draft campaign from a template; nothing sends until activated (gated)",
|
|
27
|
+
comment_on_post: "creates a review card; the tool description says it never posts",
|
|
28
|
+
commit_staged_import: "copies a staged file into the project own media library; internal",
|
|
29
|
+
configure_platform_trigger: "configures what an inbound platform event does; sends nothing itself",
|
|
30
|
+
connect_platform: "stores an inbound platform connection; sends nothing outward",
|
|
31
|
+
convert_imported_campaigns: "without create it only previews; with create it makes new drafts that cannot send until activated (gated)",
|
|
32
|
+
create_ab_test: "creates a draft A/B test; nothing sends until the campaign is activated (activation is gated)",
|
|
33
|
+
create_asset_link: "creates an email-gated download link; sends nothing",
|
|
34
|
+
create_campaign: "creates a DRAFT campaign; it must be activated through update_campaign_status (gated)",
|
|
35
|
+
create_campaign_set: "creates draft campaigns; activate_campaigns (gated) turns them on",
|
|
36
|
+
create_content_block: "creates reusable content; sends nothing",
|
|
37
|
+
create_custom_field: "adds a field definition; nothing sent or deleted",
|
|
38
|
+
create_custom_webhook: "creates an INBOUND webhook endpoint; no outward data flow",
|
|
39
|
+
create_draft: "creates a draft for review; sends nothing",
|
|
40
|
+
create_escalation_ladder: "creates ladder config; contacts are only messaged by enroll_in_escalation_ladder (gated)",
|
|
41
|
+
create_form: "creates a signup form; sends nothing",
|
|
42
|
+
create_landing_page: "creates a DRAFT page; publishing via update_landing_page status=published is gated",
|
|
43
|
+
create_list: "creates an empty list; sends nothing",
|
|
44
|
+
create_newsletter: "creates a newsletter definition; sends nothing",
|
|
45
|
+
create_newsletter_issue: "creates a draft issue; send_newsletter_issue and schedule_newsletter_issue are gated",
|
|
46
|
+
create_segment: "creates a saved audience definition; sends nothing",
|
|
47
|
+
create_segment_nl: "creates a saved audience definition from plain language; sends nothing",
|
|
48
|
+
create_sender: "creates a pending sender identity; sends nothing until verified and used",
|
|
49
|
+
create_short_link: "creates a tracked short link; sends nothing",
|
|
50
|
+
create_signature: "creates a saved signature; sends nothing",
|
|
51
|
+
create_site: "admin creates a site record; sends nothing",
|
|
52
|
+
create_social_campaign: "creates a UTM-tracked link record; posting is schedule_social_post (gated)",
|
|
53
|
+
create_social_post: "creates a social post record; publishing is schedule_social_post (gated)",
|
|
54
|
+
create_subscriber_view: "saves a filter view; internal",
|
|
55
|
+
create_survey: "creates a survey; sends nothing",
|
|
56
|
+
create_template: "creates a template; sends nothing",
|
|
57
|
+
create_template_from_brief: "creates a draft template; sends nothing",
|
|
58
|
+
diagnose_delivery_issue: "runs a diagnosis and writes an internal report",
|
|
59
|
+
discard_staged_import: "discards a staged import file the caller uploaded; no customer contacts or sends affected",
|
|
60
|
+
dismiss_ai_work_item: "dismisses an internal AI work item",
|
|
61
|
+
draft_social_reply: "drafts reply text for review; never posts",
|
|
62
|
+
enrich_subscriber: "adds enrichment data to one contact; sends nothing",
|
|
63
|
+
generate_blog_post_image: "generates an image for a draft post; sends nothing",
|
|
64
|
+
generate_email: "AI-writes email copy and returns it; sends nothing",
|
|
65
|
+
generate_qr_code: "generates a QR image; sends nothing",
|
|
66
|
+
get_audit_stats: "admin read of audit stats; changes nothing",
|
|
67
|
+
get_enrollment_status: "read-style lookup registered with write scope; changes nothing",
|
|
68
|
+
get_provider_switch_status: "read-style lookup registered with write scope; changes nothing",
|
|
69
|
+
ingest_brand_assets_by_url: "pulls assets into the project media library; SSRF-guarded, internal",
|
|
70
|
+
list_audit_log: "admin read of the audit log; changes nothing",
|
|
71
|
+
list_campaign_steps: "read-style lookup registered with write scope; changes nothing",
|
|
72
|
+
list_ccpa_requests: "admin read of CCPA requests; changes nothing",
|
|
73
|
+
list_deletion_log: "admin read of the deletion log; changes nothing",
|
|
74
|
+
list_invitations: "admin read of invitations; changes nothing",
|
|
75
|
+
mark_ai_work_item_seen: "marks an internal AI work item as seen",
|
|
76
|
+
mark_notifications_read: "marks the caller own notifications read",
|
|
77
|
+
migrate_campaigns_from: "imports other-platform campaigns as drafts; nothing sends until activated (gated)",
|
|
78
|
+
onboard_new_site: "creates a site and a pending sending-domain record; sends no mail and does not register the domain",
|
|
79
|
+
optimize_subject_lines: "generates subject-line suggestions; sends nothing",
|
|
80
|
+
pause_warmup: "pauses warmup; reduces sending",
|
|
81
|
+
post_ticket_reply: "replies on the caller own Sendinel support ticket; not a message to a customer contact",
|
|
82
|
+
promote_draft_to_campaign: "creates a campaign in draft status; activation is gated",
|
|
83
|
+
propose_campaign_from_data: "creates a proposal for review; sends nothing",
|
|
84
|
+
propose_social_reply: "creates a review card; a human Accept posts it",
|
|
85
|
+
register_domain: "creates a pending sending-domain record; does not register the domain with a provider",
|
|
86
|
+
rehost_brand_asset: "copies an asset into Sendinel own storage; internal",
|
|
87
|
+
reject_draft: "rejects a draft; reduces sending",
|
|
88
|
+
remove_blackout_date: "removes a no-send date; recreatable. FOLLOW-UP: it can allow sends on a blocked date",
|
|
89
|
+
remove_campaign_branch: "removes branching config; recreatable with add_campaign_branch, no customer records deleted",
|
|
90
|
+
remove_campaign_goal: "removes a goal definition; recreatable with set_campaign_goal, no customer records deleted",
|
|
91
|
+
remove_synced_folder: "stops a folder sync; does not delete imported assets or revoke the Drive share",
|
|
92
|
+
request_social_reply: "creates a review card from an external trigger; never posts",
|
|
93
|
+
resend_invitation: "extends an invitation expiry and returns the link; sends no email from the server",
|
|
94
|
+
restore_template_version: "restores an earlier template version; sends nothing",
|
|
95
|
+
resume_warmup: "resumes the owner own warmup schedule; sends only mail already scheduled",
|
|
96
|
+
retry_send_queue_rows: "makes already-queued retry rows eligible; no new audience is created",
|
|
97
|
+
review_campaign_portfolio: "writes an internal review report only",
|
|
98
|
+
rollback_provider_switch: "restores the prior provider state after a failed switch; start_provider_switch is gated",
|
|
99
|
+
run_list_hygiene: "suppresses invalid or bouncing addresses, which only reduces who receives mail",
|
|
100
|
+
run_portfolio_analysis: "writes an internal analysis report only",
|
|
101
|
+
set_active_project: "switches which project this connection targets; no data written",
|
|
102
|
+
set_campaign_goal: "sets a goal definition on a campaign; sends nothing",
|
|
103
|
+
set_subscriber_tags: "edits tags on one contact; tag triggers go through enrollment rules the owner configured. FOLLOW-UP: confirm tag-triggered automations are covered",
|
|
104
|
+
set_white_label_settings: "edits branding settings; reversible and sends nothing",
|
|
105
|
+
setup_campaign_from_brief: "creates a draft campaign from a brief; nothing sends until activated (gated)",
|
|
106
|
+
snooze_ai_work_item: "snoozes an internal AI work item",
|
|
107
|
+
start_platform_migration: "imports data into Sendinel from a connected platform; inbound only",
|
|
108
|
+
submit_ai_context_event: "records an internal context event for the AI worker",
|
|
109
|
+
submit_support_ticket: "files a ticket with Sendinel support from the caller own account; not a message to a customer contact",
|
|
110
|
+
subscribe_to_newsletter: "adds one subscriber who asked to join; the confirmation flow applies",
|
|
111
|
+
translate_template: "creates a translated template draft; sends nothing",
|
|
112
|
+
trigger_content_pipeline: "marks content sources ready for the cron; the pipeline lands drafts unless the owner enabled auto-publish. FOLLOW-UP: review the cron publish path",
|
|
113
|
+
unsubscribe_from_newsletter: "only stops mail to a subscriber; reduces sending",
|
|
114
|
+
trigger_automation: 'generates a draft into the approval queue (or returns it with dry_run); nothing is sent until a person approves the draft',
|
|
115
|
+
unsubscribe_subscriber: "only stops mail to a contact; reduces sending",
|
|
116
|
+
update_brand_voice: "edits brand voice text on the caller own project; internal",
|
|
117
|
+
update_campaign: "edits campaign settings; status changes go through update_campaign_status (gated). FOLLOW-UP: same live-edit caveat as update_campaign_step",
|
|
118
|
+
update_campaign_step: "edits email copy; sends only through an already-gated activation. FOLLOW-UP: editing a live campaign changes what goes out (#4005 summary)",
|
|
119
|
+
update_custom_field: "renames or edits a field definition; values are untouched",
|
|
120
|
+
update_hygiene_config: "changes hygiene settings; suppression runs are gated (identify_inactive) or only reduce sending",
|
|
121
|
+
update_list: "renames or edits a list; membership changes are gated",
|
|
122
|
+
update_newsletter: "edits newsletter settings; sends nothing",
|
|
123
|
+
update_scoring_rules: "edits scoring settings; sends nothing",
|
|
124
|
+
update_segment: "edits a saved audience definition; sends only through gated activations",
|
|
125
|
+
update_signature: "edits a saved signature; sends nothing",
|
|
126
|
+
update_site: "admin edits site settings; sends nothing",
|
|
127
|
+
update_social_post: "edits a social post record; publishing is schedule_social_post (gated)",
|
|
128
|
+
update_subscriber: "edits one contact record; recoverable and sends nothing",
|
|
129
|
+
update_subscriber_view: "edits a saved filter view; internal",
|
|
130
|
+
update_survey: "edits a survey; sends nothing",
|
|
131
|
+
update_template: "edits a template; previous versions can be restored with restore_template_version",
|
|
132
|
+
update_warmup_schedule: "edits the warmup schedule; reduces or paces sending",
|
|
133
|
+
upload_brand_asset: "adds an asset to the project media library; internal",
|
|
134
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -161,11 +162,12 @@ export function registerAbTestingTools(server) {
|
|
|
161
162
|
},
|
|
162
163
|
description: 'End an A/B test and keep only the winning variant. Requires confirmation (two-call pattern). Deletes the losing variant step(s) from campaign_steps.',
|
|
163
164
|
inputSchema: {
|
|
165
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
164
166
|
test_id: z.string().uuid(),
|
|
165
167
|
winner: z.string().optional().describe('Override winner variant label (default: use statistically determined winner)'),
|
|
166
168
|
confirmed: z.boolean().optional().describe('Set true to execute after previewing'),
|
|
167
169
|
},
|
|
168
|
-
}, async ({ test_id, winner, confirmed }) => {
|
|
170
|
+
}, withApproval('promote_ab_winner', async ({ test_id, winner, confirmed }) => {
|
|
169
171
|
const projectId = getProjectId();
|
|
170
172
|
const { data: test, error: testErr } = await db()
|
|
171
173
|
.from('ab_tests')
|
|
@@ -228,5 +230,5 @@ export function registerAbTestingTools(server) {
|
|
|
228
230
|
deleted_variants: losingVariants,
|
|
229
231
|
message: `A/B test "${test.name}" completed. Variant "${winnerLabel}" promoted, ${loserSteps.length} losing step(s) removed.`,
|
|
230
232
|
});
|
|
231
|
-
});
|
|
233
|
+
}));
|
|
232
234
|
}
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { fetchAllRows, readAllRows } from '@sendinel/domain';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { db, adminDb, requestContext } from '../db.js';
|
|
@@ -1452,13 +1453,14 @@ export function registerAnalyticsTools(server) {
|
|
|
1452
1453
|
},
|
|
1453
1454
|
description: 'Schedule a social campaign post via a connected BYO publisher (UploadPost, Buffer). Returns publisherPostId on success.',
|
|
1454
1455
|
inputSchema: {
|
|
1456
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
1455
1457
|
campaign_id: z.string().uuid().describe('UUID of the social campaign in Sendinel'),
|
|
1456
1458
|
content: z.string().min(1).describe('Post body text'),
|
|
1457
1459
|
publisher: z.enum(socialPublishers).describe('Which BYO publisher to route through'),
|
|
1458
1460
|
scheduled_at: z.string().optional().describe('ISO 8601 datetime. Omit for immediate publish.'),
|
|
1459
1461
|
media_urls: z.array(z.string().url()).optional().describe('Optional image/video URLs'),
|
|
1460
1462
|
},
|
|
1461
|
-
}, async (p) => {
|
|
1463
|
+
}, withApproval('schedule_social_post', async (p) => {
|
|
1462
1464
|
const projectId = getProjectId();
|
|
1463
1465
|
const scheduledAt = p.scheduled_at ? new Date(p.scheduled_at) : undefined;
|
|
1464
1466
|
if (scheduledAt && Number.isNaN(scheduledAt.getTime()))
|
|
@@ -1535,5 +1537,5 @@ export function registerAnalyticsTools(server) {
|
|
|
1535
1537
|
.eq('id', p.campaign_id);
|
|
1536
1538
|
return txt(`Error: ${message}`);
|
|
1537
1539
|
}
|
|
1538
|
-
});
|
|
1540
|
+
}));
|
|
1539
1541
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
4
|
import { db } from '../db.js';
|
|
@@ -133,6 +134,7 @@ export function registerAutomationTools(server) {
|
|
|
133
134
|
},
|
|
134
135
|
description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
|
|
135
136
|
inputSchema: {
|
|
137
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
136
138
|
subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
|
|
137
139
|
campaign_id: z.string().uuid().optional().describe('Specific triggered campaign UUID'),
|
|
138
140
|
site_id: z.string().uuid().optional().describe('Optional site scope when matching by trigger'),
|
|
@@ -143,7 +145,7 @@ export function registerAutomationTools(server) {
|
|
|
143
145
|
next_send_at: z.string().datetime().optional().describe('First send time. Defaults to now.'),
|
|
144
146
|
context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
|
|
145
147
|
},
|
|
146
|
-
}, enrollSubscriberIntoTriggeredCampaigns);
|
|
148
|
+
}, withApproval('enroll_automation', enrollSubscriberIntoTriggeredCampaigns));
|
|
147
149
|
server.registerTool('create_escalation_ladder', {
|
|
148
150
|
title: 'Create Escalation Ladder',
|
|
149
151
|
annotations: {
|
|
@@ -214,12 +216,13 @@ export function registerAutomationTools(server) {
|
|
|
214
216
|
},
|
|
215
217
|
description: 'Enroll a subscriber into an active triggered campaign that has escalation ladder steps. Reuses enroll_automation suppression and unsubscribe checks.',
|
|
216
218
|
inputSchema: {
|
|
219
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
217
220
|
subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
|
|
218
221
|
campaign_id: z.string().uuid().describe('Triggered campaign UUID with ladder steps'),
|
|
219
222
|
next_send_at: z.string().datetime().optional().describe('First eligible send time. Defaults to now.'),
|
|
220
223
|
context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
|
|
221
224
|
},
|
|
222
|
-
}, async ({ subscriber_id, campaign_id, next_send_at, context }) => {
|
|
225
|
+
}, withApproval('enroll_in_escalation_ladder', async ({ subscriber_id, campaign_id, next_send_at, context }) => {
|
|
223
226
|
const projectId = getProjectId();
|
|
224
227
|
const { data: gate, error: gateError } = await db()
|
|
225
228
|
.from('campaign_step_gates')
|
|
@@ -237,7 +240,7 @@ export function registerAutomationTools(server) {
|
|
|
237
240
|
next_send_at,
|
|
238
241
|
context: { ...(context ?? {}), escalation_ladder: true, project_id: projectId },
|
|
239
242
|
});
|
|
240
|
-
});
|
|
243
|
+
}));
|
|
241
244
|
server.registerTool('list_escalation_ladder_status', {
|
|
242
245
|
title: 'List Escalation Ladder Status',
|
|
243
246
|
annotations: {
|
package/build/tools/brand-kit.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { adminDb } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -37,12 +38,13 @@ export function registerBrandKitTools(server) {
|
|
|
37
38
|
},
|
|
38
39
|
description: 'Import raw Brand Core / Brand Brain / Content Library / Brand Kit markdown text into the current project.',
|
|
39
40
|
inputSchema: {
|
|
41
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
40
42
|
text: z.string().min(1).describe('Raw markdown text from the Brand Core / Brand Kit companion output.'),
|
|
41
43
|
},
|
|
42
|
-
}, async ({ text }) => {
|
|
44
|
+
}, withApproval('import_brand_kit_text', async ({ text }) => {
|
|
43
45
|
const result = await callBrandKitApi('/api/v1/brand-kit/import-text', { text });
|
|
44
46
|
return result.ok ? j(result.data) : j({ error: result.error });
|
|
45
|
-
});
|
|
47
|
+
}));
|
|
46
48
|
server.registerTool('rehost_brand_asset', {
|
|
47
49
|
title: 'Rehost Drive-Linked Brand Asset',
|
|
48
50
|
annotations: {
|
package/build/tools/campaigns.js
CHANGED
|
@@ -12,7 +12,7 @@ import { getProjectId } from '../project.js';
|
|
|
12
12
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
13
13
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
14
14
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
15
|
-
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
15
|
+
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
16
16
|
import { resolveSendAudience } from '../lib/send-audience.js';
|
|
17
17
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
18
18
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
@@ -873,11 +873,12 @@ export function registerCampaignTools(server) {
|
|
|
873
873
|
},
|
|
874
874
|
description: 'Enroll a single contact in a campaign',
|
|
875
875
|
inputSchema: {
|
|
876
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
876
877
|
campaign_id: z.string().uuid(),
|
|
877
878
|
contact_id: z.string().uuid(),
|
|
878
879
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
879
880
|
},
|
|
880
|
-
}, async ({ campaign_id, contact_id, next_send_at }) => {
|
|
881
|
+
}, withApproval('enroll_contact', async ({ campaign_id, contact_id, next_send_at }) => {
|
|
881
882
|
const { data: contact, error: contactError } = await db()
|
|
882
883
|
.from('subscribers')
|
|
883
884
|
.select('id, global_unsubscribed, cooled_at')
|
|
@@ -905,7 +906,7 @@ export function registerCampaignTools(server) {
|
|
|
905
906
|
await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + 1 }).eq('id', campaign_id);
|
|
906
907
|
}
|
|
907
908
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
908
|
-
});
|
|
909
|
+
}));
|
|
909
910
|
server.registerTool('get_enrollment_status', {
|
|
910
911
|
title: 'Get Enrollment Status',
|
|
911
912
|
annotations: {
|
|
@@ -995,11 +996,12 @@ export function registerCampaignTools(server) {
|
|
|
995
996
|
},
|
|
996
997
|
description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
|
|
997
998
|
inputSchema: {
|
|
999
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
998
1000
|
campaign_id: z.string().uuid(),
|
|
999
1001
|
site_id: z.string().uuid(),
|
|
1000
1002
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
1001
1003
|
},
|
|
1002
|
-
}, async ({ campaign_id, site_id, next_send_at }) => {
|
|
1004
|
+
}, withApproval('enroll_segment', async ({ campaign_id, site_id, next_send_at }) => {
|
|
1003
1005
|
const sendAt = next_send_at ?? new Date().toISOString();
|
|
1004
1006
|
const { data: subs, error: subErr } = await db()
|
|
1005
1007
|
.from('site_subscriptions')
|
|
@@ -1034,7 +1036,7 @@ export function registerCampaignTools(server) {
|
|
|
1034
1036
|
await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + eligible.length }).eq('id', campaign_id);
|
|
1035
1037
|
}
|
|
1036
1038
|
return { content: [{ type: 'text', text: JSON.stringify({ enrolled: eligible.length, campaign_id, site_id }, null, 2) }] };
|
|
1037
|
-
});
|
|
1039
|
+
}));
|
|
1038
1040
|
server.registerTool('broadcast_to_segment', {
|
|
1039
1041
|
title: 'Broadcast To Segment',
|
|
1040
1042
|
annotations: {
|
|
@@ -2217,6 +2219,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2217
2219
|
},
|
|
2218
2220
|
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
|
|
2219
2221
|
inputSchema: {
|
|
2222
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2220
2223
|
to_email: z.string().email().describe('Email address to send the test to'),
|
|
2221
2224
|
site_id: z.string().uuid().describe('Site UUID (determines from address)'),
|
|
2222
2225
|
step_id: z.string().uuid().optional(),
|
|
@@ -2225,7 +2228,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2225
2228
|
body_html: z.string().optional(),
|
|
2226
2229
|
body_text: z.string().optional(),
|
|
2227
2230
|
},
|
|
2228
|
-
}, async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
|
|
2231
|
+
}, withApproval('send_test_email', async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
|
|
2229
2232
|
const projectId = getProjectId();
|
|
2230
2233
|
let subj = subject ?? '';
|
|
2231
2234
|
let html = body_html ?? '';
|
|
@@ -2308,7 +2311,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2308
2311
|
provider_message_id: sendResult.messageId,
|
|
2309
2312
|
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
2310
2313
|
});
|
|
2311
|
-
});
|
|
2314
|
+
}));
|
|
2312
2315
|
server.registerTool('send_transactional', {
|
|
2313
2316
|
title: 'Send Transactional',
|
|
2314
2317
|
annotations: {
|
|
@@ -2319,6 +2322,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2319
2322
|
},
|
|
2320
2323
|
description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
|
|
2321
2324
|
inputSchema: {
|
|
2325
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2322
2326
|
to: z.string().email().describe('Recipient email address'),
|
|
2323
2327
|
site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
|
|
2324
2328
|
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
@@ -2329,7 +2333,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2329
2333
|
template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
|
|
2330
2334
|
template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
|
|
2331
2335
|
},
|
|
2332
|
-
}, async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
|
|
2336
|
+
}, withApproval('send_transactional', async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
|
|
2333
2337
|
const projectId = getProjectId();
|
|
2334
2338
|
if (template_id && html)
|
|
2335
2339
|
return txt("Error: Provide either 'template_id' or 'html', not both");
|
|
@@ -2459,7 +2463,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2459
2463
|
provider_message_id: sendResult.messageId,
|
|
2460
2464
|
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
2461
2465
|
});
|
|
2462
|
-
});
|
|
2466
|
+
}));
|
|
2463
2467
|
function parseReplyWindow(input) {
|
|
2464
2468
|
const match = /^(\d{1,2})d$/.exec(input.trim());
|
|
2465
2469
|
if (!match)
|
|
@@ -2485,8 +2489,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2485
2489
|
idempotentHint: false,
|
|
2486
2490
|
openWorldHint: true,
|
|
2487
2491
|
},
|
|
2488
|
-
description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only.',
|
|
2492
|
+
description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
|
|
2489
2493
|
inputSchema: {
|
|
2494
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2490
2495
|
to: z.string().email().describe('Recipient email address'),
|
|
2491
2496
|
site_id: z.string().uuid().describe('Site UUID (determines from address and provider)'),
|
|
2492
2497
|
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
@@ -2498,7 +2503,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2498
2503
|
template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
|
|
2499
2504
|
template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
|
|
2500
2505
|
},
|
|
2501
|
-
}, async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
|
|
2506
|
+
}, withApproval('send_awaiting_reply', async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
|
|
2502
2507
|
const projectId = getProjectId();
|
|
2503
2508
|
if (template_id && html)
|
|
2504
2509
|
return txt("Error: Provide either 'template_id' or 'html', not both");
|
|
@@ -2694,7 +2699,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2694
2699
|
provider_message_id: sendResult.messageId,
|
|
2695
2700
|
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
2696
2701
|
});
|
|
2697
|
-
});
|
|
2702
|
+
}));
|
|
2698
2703
|
server.registerTool('get_reply_status', {
|
|
2699
2704
|
title: 'Get Reply Status',
|
|
2700
2705
|
annotations: {
|
|
@@ -3190,8 +3195,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3190
3195
|
openWorldHint: false,
|
|
3191
3196
|
},
|
|
3192
3197
|
description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
|
|
3193
|
-
inputSchema: { step_id: z.string().uuid() },
|
|
3194
|
-
}, async ({ step_id }) => {
|
|
3198
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), step_id: z.string().uuid() },
|
|
3199
|
+
}, withApproval('delete_campaign_step', async ({ step_id }) => {
|
|
3195
3200
|
const projectId = getProjectId();
|
|
3196
3201
|
const { data: step } = await db()
|
|
3197
3202
|
.from('campaign_steps')
|
|
@@ -3218,7 +3223,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3218
3223
|
if (error)
|
|
3219
3224
|
return j({ error: error.message });
|
|
3220
3225
|
return j({ ok: true, deleted: step_id });
|
|
3221
|
-
});
|
|
3226
|
+
}));
|
|
3222
3227
|
server.registerTool('delete_campaign', {
|
|
3223
3228
|
title: 'Delete Campaign',
|
|
3224
3229
|
annotations: {
|
|
@@ -3228,8 +3233,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3228
3233
|
openWorldHint: false,
|
|
3229
3234
|
},
|
|
3230
3235
|
description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
|
|
3231
|
-
inputSchema: { campaign_id: z.string().uuid() },
|
|
3232
|
-
}, async ({ campaign_id }) => {
|
|
3236
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), campaign_id: z.string().uuid() },
|
|
3237
|
+
}, withApproval('delete_campaign', async ({ campaign_id }) => {
|
|
3233
3238
|
const projectId = getProjectId();
|
|
3234
3239
|
const { data: campaign, error: fetchErr } = await db()
|
|
3235
3240
|
.from('campaigns')
|
|
@@ -3250,7 +3255,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3250
3255
|
if (error)
|
|
3251
3256
|
return j({ error: error.message });
|
|
3252
3257
|
return j({ success: true, deleted: campaign.name });
|
|
3253
|
-
});
|
|
3258
|
+
}));
|
|
3254
3259
|
server.registerTool('update_campaign', {
|
|
3255
3260
|
title: 'Update Campaign',
|
|
3256
3261
|
annotations: {
|