Skip to content

Commit dabb856

Browse files
authored
fix(loops): align integration with live API docs, add suppression + get-template tools (#5358)
* fix(loops): align integration with live API docs, add suppression + get-template tools - fix list_transactional_emails endpoint URL (was /transactional, now /transactional-emails) - fix response fields to match actual API schema (createdAt/updatedAt, not the never-existent lastUpdated) - add loops_check_contact_suppression, loops_remove_contact_suppression, loops_get_transactional_email tools - wire new tools into block operations, outputs, and registries - alphabetize tools/registry.ts loops entries * fix(loops): expose contactId output, fix stale tool description - add missing contactId block output for check_contact_suppression (Greptile P1) - fix list_transactional_emails description to mention createdAt (Greptile P2) * fix(loops): restore lastUpdated as backwards-compat alias on list_transactional_emails Final validation pass found that /api/v1/transactional (the endpoint this tool used before this PR) is a real, functional, deprecated Loops endpoint whose schema genuinely returns lastUpdated - it was not a broken/invented field. Migrating to /api/v1/transactional-emails is still correct (current endpoint, better error semantics), but dropping lastUpdated would break any existing workflow reading it from this block's output. Keep it as a deprecated alias of updatedAt alongside the new createdAt/updatedAt fields. * fix(loops): update id output description to cover get_transactional_email
1 parent 7fd89bc commit dabb856

8 files changed

Lines changed: 482 additions & 14 deletions

File tree

apps/sim/blocks/blocks/loops.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
3232
{ label: 'List Transactional Emails', id: 'list_transactional_emails' },
3333
{ label: 'Create Contact Property', id: 'create_contact_property' },
3434
{ label: 'List Contact Properties', id: 'list_contact_properties' },
35+
{ label: 'Check Contact Suppression', id: 'check_contact_suppression' },
36+
{ label: 'Remove Contact Suppression', id: 'remove_contact_suppression' },
37+
{ label: 'Get Transactional Email', id: 'get_transactional_email' },
3538
],
3639
value: () => 'create_contact',
3740
},
@@ -47,15 +50,22 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
4750
value: ['create_contact', 'send_transactional_email'],
4851
},
4952
},
50-
// Optional email for update, find, delete, send event
53+
// Optional email for update, find, delete, send event, suppression lookups
5154
{
5255
id: 'contactEmail',
5356
title: 'Email',
5457
type: 'short-input',
5558
placeholder: 'Enter email address',
5659
condition: {
5760
field: 'operation',
58-
value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'],
61+
value: [
62+
'update_contact',
63+
'find_contact',
64+
'delete_contact',
65+
'send_event',
66+
'check_contact_suppression',
67+
'remove_contact_suppression',
68+
],
5969
},
6070
},
6171
// User ID for operations that support it
@@ -66,7 +76,14 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
6676
placeholder: 'Enter user ID',
6777
condition: {
6878
field: 'operation',
69-
value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'],
79+
value: [
80+
'update_contact',
81+
'find_contact',
82+
'delete_contact',
83+
'send_event',
84+
'check_contact_suppression',
85+
'remove_contact_suppression',
86+
],
7087
},
7188
},
7289
// Contact fields
@@ -199,10 +216,13 @@ Return ONLY the JSON object - no explanations, no extra text.`,
199216
title: 'Transactional Email ID',
200217
type: 'short-input',
201218
placeholder: 'Enter template ID (e.g., clx...)',
202-
required: { field: 'operation', value: 'send_transactional_email' },
219+
required: {
220+
field: 'operation',
221+
value: ['send_transactional_email', 'get_transactional_email'],
222+
},
203223
condition: {
204224
field: 'operation',
205-
value: 'send_transactional_email',
225+
value: ['send_transactional_email', 'get_transactional_email'],
206226
},
207227
},
208228
{
@@ -426,6 +446,9 @@ Return ONLY the JSON object - no explanations, no extra text.`,
426446
'loops_list_transactional_emails',
427447
'loops_create_contact_property',
428448
'loops_list_contact_properties',
449+
'loops_check_contact_suppression',
450+
'loops_remove_contact_suppression',
451+
'loops_get_transactional_email',
429452
],
430453
config: {
431454
tool: (params) => `loops_${params.operation}`,
@@ -497,6 +520,16 @@ Return ONLY the JSON object - no explanations, no extra text.`,
497520
case 'list_contact_properties':
498521
if (params.propertyFilter) result.list = params.propertyFilter
499522
break
523+
524+
case 'check_contact_suppression':
525+
case 'remove_contact_suppression':
526+
if (params.contactEmail) result.email = params.contactEmail
527+
if (params.userId) result.userId = params.userId
528+
break
529+
530+
case 'get_transactional_email':
531+
result.transactionalId = params.transactionalId
532+
break
500533
}
501534

502535
return result
@@ -531,7 +564,10 @@ Return ONLY the JSON object - no explanations, no extra text.`,
531564
},
532565
outputs: {
533566
success: { type: 'boolean', description: 'Whether the operation succeeded' },
534-
id: { type: 'string', description: 'Contact ID (create/update operations)' },
567+
id: {
568+
type: 'string',
569+
description: 'Contact ID (create/update operations) or template ID (get transactional email)',
570+
},
535571
contacts: {
536572
type: 'json',
537573
description:
@@ -544,7 +580,8 @@ Return ONLY the JSON object - no explanations, no extra text.`,
544580
},
545581
transactionalEmails: {
546582
type: 'json',
547-
description: 'Array of transactional email templates (id, name, lastUpdated, dataVariables)',
583+
description:
584+
'Array of transactional email templates (id, name, createdAt, updatedAt, lastUpdated (deprecated alias of updatedAt), dataVariables)',
548585
},
549586
pagination: {
550587
type: 'json',
@@ -555,6 +592,50 @@ Return ONLY the JSON object - no explanations, no extra text.`,
555592
type: 'json',
556593
description: 'Array of contact properties (key, label, type)',
557594
},
595+
isSuppressed: {
596+
type: 'boolean',
597+
description: 'Whether the contact is on the suppression list (check suppression)',
598+
},
599+
contactId: {
600+
type: 'string',
601+
description: 'The Loops-assigned contact ID (check suppression)',
602+
},
603+
removalQuotaLimit: {
604+
type: 'number',
605+
description: 'Total suppression-removal quota for the team',
606+
},
607+
removalQuotaRemaining: {
608+
type: 'number',
609+
description: 'Remaining suppression-removal quota for the team',
610+
},
611+
name: {
612+
type: 'string',
613+
description: 'Transactional email template name (get transactional email)',
614+
},
615+
draftEmailMessageId: {
616+
type: 'string',
617+
description: 'ID of the draft email message, if any (get transactional email)',
618+
},
619+
publishedEmailMessageId: {
620+
type: 'string',
621+
description: 'ID of the published email message, if any (get transactional email)',
622+
},
623+
transactionalGroupId: {
624+
type: 'string',
625+
description: 'ID of the transactional group, if any (get transactional email)',
626+
},
627+
createdAt: {
628+
type: 'string',
629+
description: 'Creation timestamp (get transactional email)',
630+
},
631+
updatedAt: {
632+
type: 'string',
633+
description: 'Last updated timestamp (get transactional email)',
634+
},
635+
dataVariables: {
636+
type: 'json',
637+
description: 'Template data variable names (get transactional email)',
638+
},
558639
},
559640
}
560641

@@ -655,5 +736,12 @@ export const LoopsBlockMeta = {
655736
content:
656737
'# Send Transactional Email\n\nDeliver a templated transactional email through Loops.\n\n## Steps\n1. Confirm the transactional email template ID to use.\n2. Build the data variables JSON to match the variable names in the template, such as name and a confirmation URL.\n3. Send Transactional Email with the recipient email, template ID, and data variables, attaching files if needed.\n\n## Output\nConfirmation of send success and the template ID and recipient used.',
657738
},
739+
{
740+
name: 'manage-suppression-compliance',
741+
description:
742+
'Check and clear Loops suppression status for a contact to keep deliverability and unsubscribe compliance in check.',
743+
content:
744+
'# Manage Suppression Compliance\n\nKeep Loops sending compliant and deliverable.\n\n## Steps\n1. Check Contact Suppression by email or user ID to see if the contact bounced, complained, or unsubscribed.\n2. If the contact should be re-enabled (e.g. a confirmed re-opt-in), Remove Contact Suppression for the same identifier, noting the remaining removal quota.\n3. Log the result so support and compliance workflows have an audit trail.\n\n## Output\nThe suppression status before and after the change, plus the remaining removal quota.',
745+
},
658746
],
659747
} as const satisfies BlockMeta
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type {
2+
LoopsCheckContactSuppressionParams,
3+
LoopsCheckContactSuppressionResponse,
4+
} from '@/tools/loops/types'
5+
import type { ToolConfig } from '@/tools/types'
6+
7+
export const loopsCheckContactSuppressionTool: ToolConfig<
8+
LoopsCheckContactSuppressionParams,
9+
LoopsCheckContactSuppressionResponse
10+
> = {
11+
id: 'loops_check_contact_suppression',
12+
name: 'Loops Check Contact Suppression',
13+
description:
14+
'Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId.',
15+
version: '1.0.0',
16+
17+
params: {
18+
apiKey: {
19+
type: 'string',
20+
required: true,
21+
visibility: 'user-only',
22+
description: 'Loops API key for authentication',
23+
},
24+
email: {
25+
type: 'string',
26+
required: false,
27+
visibility: 'user-or-llm',
28+
description:
29+
'The contact email address to check (at least one of email or userId is required)',
30+
},
31+
userId: {
32+
type: 'string',
33+
required: false,
34+
visibility: 'user-or-llm',
35+
description: 'The contact userId to check (at least one of email or userId is required)',
36+
},
37+
},
38+
39+
request: {
40+
url: (params) => {
41+
if (!params.email && !params.userId) {
42+
throw new Error('At least one of email or userId is required to check suppression status')
43+
}
44+
const base = 'https://app.loops.so/api/v1/contacts/suppression'
45+
if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}`
46+
return `${base}?userId=${encodeURIComponent(params.userId!.trim())}`
47+
},
48+
method: 'GET',
49+
headers: (params) => ({
50+
Authorization: `Bearer ${params.apiKey}`,
51+
}),
52+
},
53+
54+
transformResponse: async (response: Response) => {
55+
const data = await response.json()
56+
57+
if (data.isSuppressed == null) {
58+
return {
59+
success: false,
60+
output: {
61+
contactId: null,
62+
email: null,
63+
userId: null,
64+
isSuppressed: false,
65+
removalQuotaLimit: null,
66+
removalQuotaRemaining: null,
67+
},
68+
error: data.message ?? 'Failed to check contact suppression status',
69+
}
70+
}
71+
72+
return {
73+
success: true,
74+
output: {
75+
contactId: (data.contact?.id as string) ?? null,
76+
email: (data.contact?.email as string) ?? null,
77+
userId: (data.contact?.userId as string) ?? null,
78+
isSuppressed: (data.isSuppressed as boolean) ?? false,
79+
removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
80+
removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
81+
},
82+
}
83+
},
84+
85+
outputs: {
86+
contactId: { type: 'string', description: 'The Loops-assigned contact ID', optional: true },
87+
email: { type: 'string', description: 'The contact email address', optional: true },
88+
userId: { type: 'string', description: 'The contact userId', optional: true },
89+
isSuppressed: {
90+
type: 'boolean',
91+
description: 'Whether the contact is on the suppression list',
92+
},
93+
removalQuotaLimit: {
94+
type: 'number',
95+
description: 'Total suppression-removal quota for the team',
96+
optional: true,
97+
},
98+
removalQuotaRemaining: {
99+
type: 'number',
100+
description: 'Remaining suppression-removal quota for the team',
101+
optional: true,
102+
},
103+
},
104+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type {
2+
LoopsGetTransactionalEmailParams,
3+
LoopsGetTransactionalEmailResponse,
4+
} from '@/tools/loops/types'
5+
import type { ToolConfig } from '@/tools/types'
6+
7+
export const loopsGetTransactionalEmailTool: ToolConfig<
8+
LoopsGetTransactionalEmailParams,
9+
LoopsGetTransactionalEmailResponse
10+
> = {
11+
id: 'loops_get_transactional_email',
12+
name: 'Loops Get Transactional Email',
13+
description:
14+
'Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs.',
15+
version: '1.0.0',
16+
17+
params: {
18+
apiKey: {
19+
type: 'string',
20+
required: true,
21+
visibility: 'user-only',
22+
description: 'Loops API key for authentication',
23+
},
24+
transactionalId: {
25+
type: 'string',
26+
required: true,
27+
visibility: 'user-or-llm',
28+
description: 'The ID of the transactional email template to retrieve',
29+
},
30+
},
31+
32+
request: {
33+
url: (params) =>
34+
`https://app.loops.so/api/v1/transactional-emails/${encodeURIComponent(params.transactionalId.trim())}`,
35+
method: 'GET',
36+
headers: (params) => ({
37+
Authorization: `Bearer ${params.apiKey}`,
38+
}),
39+
},
40+
41+
transformResponse: async (response: Response) => {
42+
const data = await response.json()
43+
44+
if (!data.id) {
45+
return {
46+
success: false,
47+
output: {
48+
id: null,
49+
name: null,
50+
draftEmailMessageId: null,
51+
publishedEmailMessageId: null,
52+
transactionalGroupId: null,
53+
createdAt: null,
54+
updatedAt: null,
55+
dataVariables: [],
56+
},
57+
error: data.message ?? 'Failed to get transactional email',
58+
}
59+
}
60+
61+
return {
62+
success: true,
63+
output: {
64+
id: (data.id as string) ?? null,
65+
name: (data.name as string) ?? null,
66+
draftEmailMessageId: (data.draftEmailMessageId as string) ?? null,
67+
publishedEmailMessageId: (data.publishedEmailMessageId as string) ?? null,
68+
transactionalGroupId: (data.transactionalGroupId as string) ?? null,
69+
createdAt: (data.createdAt as string) ?? null,
70+
updatedAt: (data.updatedAt as string) ?? null,
71+
dataVariables: (data.dataVariables as string[]) ?? [],
72+
},
73+
}
74+
},
75+
76+
outputs: {
77+
id: { type: 'string', description: 'The transactional email template ID', optional: true },
78+
name: { type: 'string', description: 'The template name', optional: true },
79+
draftEmailMessageId: {
80+
type: 'string',
81+
description: 'ID of the draft email message, if any',
82+
optional: true,
83+
},
84+
publishedEmailMessageId: {
85+
type: 'string',
86+
description: 'ID of the published email message, if any',
87+
optional: true,
88+
},
89+
transactionalGroupId: {
90+
type: 'string',
91+
description: 'ID of the transactional group this template belongs to, if any',
92+
optional: true,
93+
},
94+
createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)', optional: true },
95+
updatedAt: {
96+
type: 'string',
97+
description: 'Last updated timestamp (ISO 8601)',
98+
optional: true,
99+
},
100+
dataVariables: {
101+
type: 'array',
102+
description: 'Template data variable names',
103+
items: { type: 'string' },
104+
},
105+
},
106+
}

0 commit comments

Comments
 (0)