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
This commit is contained in:
Waleed
2026-07-02 09:48:20 -07:00
committed by GitHub
parent 7fd89bca35
commit dabb856b9a
8 changed files with 482 additions and 14 deletions
+95 -7
View File
@@ -32,6 +32,9 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
{ label: 'List Transactional Emails', id: 'list_transactional_emails' },
{ label: 'Create Contact Property', id: 'create_contact_property' },
{ label: 'List Contact Properties', id: 'list_contact_properties' },
{ label: 'Check Contact Suppression', id: 'check_contact_suppression' },
{ label: 'Remove Contact Suppression', id: 'remove_contact_suppression' },
{ label: 'Get Transactional Email', id: 'get_transactional_email' },
],
value: () => 'create_contact',
},
@@ -47,7 +50,7 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
value: ['create_contact', 'send_transactional_email'],
},
},
// Optional email for update, find, delete, send event
// Optional email for update, find, delete, send event, suppression lookups
{
id: 'contactEmail',
title: 'Email',
@@ -55,7 +58,14 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
placeholder: 'Enter email address',
condition: {
field: 'operation',
value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'],
value: [
'update_contact',
'find_contact',
'delete_contact',
'send_event',
'check_contact_suppression',
'remove_contact_suppression',
],
},
},
// User ID for operations that support it
@@ -66,7 +76,14 @@ export const LoopsBlock: BlockConfig<LoopsResponse> = {
placeholder: 'Enter user ID',
condition: {
field: 'operation',
value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'],
value: [
'update_contact',
'find_contact',
'delete_contact',
'send_event',
'check_contact_suppression',
'remove_contact_suppression',
],
},
},
// Contact fields
@@ -199,10 +216,13 @@ Return ONLY the JSON object - no explanations, no extra text.`,
title: 'Transactional Email ID',
type: 'short-input',
placeholder: 'Enter template ID (e.g., clx...)',
required: { field: 'operation', value: 'send_transactional_email' },
required: {
field: 'operation',
value: ['send_transactional_email', 'get_transactional_email'],
},
condition: {
field: 'operation',
value: 'send_transactional_email',
value: ['send_transactional_email', 'get_transactional_email'],
},
},
{
@@ -426,6 +446,9 @@ Return ONLY the JSON object - no explanations, no extra text.`,
'loops_list_transactional_emails',
'loops_create_contact_property',
'loops_list_contact_properties',
'loops_check_contact_suppression',
'loops_remove_contact_suppression',
'loops_get_transactional_email',
],
config: {
tool: (params) => `loops_${params.operation}`,
@@ -497,6 +520,16 @@ Return ONLY the JSON object - no explanations, no extra text.`,
case 'list_contact_properties':
if (params.propertyFilter) result.list = params.propertyFilter
break
case 'check_contact_suppression':
case 'remove_contact_suppression':
if (params.contactEmail) result.email = params.contactEmail
if (params.userId) result.userId = params.userId
break
case 'get_transactional_email':
result.transactionalId = params.transactionalId
break
}
return result
@@ -531,7 +564,10 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
outputs: {
success: { type: 'boolean', description: 'Whether the operation succeeded' },
id: { type: 'string', description: 'Contact ID (create/update operations)' },
id: {
type: 'string',
description: 'Contact ID (create/update operations) or template ID (get transactional email)',
},
contacts: {
type: 'json',
description:
@@ -544,7 +580,8 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
transactionalEmails: {
type: 'json',
description: 'Array of transactional email templates (id, name, lastUpdated, dataVariables)',
description:
'Array of transactional email templates (id, name, createdAt, updatedAt, lastUpdated (deprecated alias of updatedAt), dataVariables)',
},
pagination: {
type: 'json',
@@ -555,6 +592,50 @@ Return ONLY the JSON object - no explanations, no extra text.`,
type: 'json',
description: 'Array of contact properties (key, label, type)',
},
isSuppressed: {
type: 'boolean',
description: 'Whether the contact is on the suppression list (check suppression)',
},
contactId: {
type: 'string',
description: 'The Loops-assigned contact ID (check suppression)',
},
removalQuotaLimit: {
type: 'number',
description: 'Total suppression-removal quota for the team',
},
removalQuotaRemaining: {
type: 'number',
description: 'Remaining suppression-removal quota for the team',
},
name: {
type: 'string',
description: 'Transactional email template name (get transactional email)',
},
draftEmailMessageId: {
type: 'string',
description: 'ID of the draft email message, if any (get transactional email)',
},
publishedEmailMessageId: {
type: 'string',
description: 'ID of the published email message, if any (get transactional email)',
},
transactionalGroupId: {
type: 'string',
description: 'ID of the transactional group, if any (get transactional email)',
},
createdAt: {
type: 'string',
description: 'Creation timestamp (get transactional email)',
},
updatedAt: {
type: 'string',
description: 'Last updated timestamp (get transactional email)',
},
dataVariables: {
type: 'json',
description: 'Template data variable names (get transactional email)',
},
},
}
@@ -655,5 +736,12 @@ export const LoopsBlockMeta = {
content:
'# 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.',
},
{
name: 'manage-suppression-compliance',
description:
'Check and clear Loops suppression status for a contact to keep deliverability and unsubscribe compliance in check.',
content:
'# 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.',
},
],
} as const satisfies BlockMeta
@@ -0,0 +1,104 @@
import type {
LoopsCheckContactSuppressionParams,
LoopsCheckContactSuppressionResponse,
} from '@/tools/loops/types'
import type { ToolConfig } from '@/tools/types'
export const loopsCheckContactSuppressionTool: ToolConfig<
LoopsCheckContactSuppressionParams,
LoopsCheckContactSuppressionResponse
> = {
id: 'loops_check_contact_suppression',
name: 'Loops Check Contact Suppression',
description:
'Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Loops API key for authentication',
},
email: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The contact email address to check (at least one of email or userId is required)',
},
userId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The contact userId to check (at least one of email or userId is required)',
},
},
request: {
url: (params) => {
if (!params.email && !params.userId) {
throw new Error('At least one of email or userId is required to check suppression status')
}
const base = 'https://app.loops.so/api/v1/contacts/suppression'
if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}`
return `${base}?userId=${encodeURIComponent(params.userId!.trim())}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.isSuppressed == null) {
return {
success: false,
output: {
contactId: null,
email: null,
userId: null,
isSuppressed: false,
removalQuotaLimit: null,
removalQuotaRemaining: null,
},
error: data.message ?? 'Failed to check contact suppression status',
}
}
return {
success: true,
output: {
contactId: (data.contact?.id as string) ?? null,
email: (data.contact?.email as string) ?? null,
userId: (data.contact?.userId as string) ?? null,
isSuppressed: (data.isSuppressed as boolean) ?? false,
removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
},
}
},
outputs: {
contactId: { type: 'string', description: 'The Loops-assigned contact ID', optional: true },
email: { type: 'string', description: 'The contact email address', optional: true },
userId: { type: 'string', description: 'The contact userId', optional: true },
isSuppressed: {
type: 'boolean',
description: 'Whether the contact is on the suppression list',
},
removalQuotaLimit: {
type: 'number',
description: 'Total suppression-removal quota for the team',
optional: true,
},
removalQuotaRemaining: {
type: 'number',
description: 'Remaining suppression-removal quota for the team',
optional: true,
},
},
}
@@ -0,0 +1,106 @@
import type {
LoopsGetTransactionalEmailParams,
LoopsGetTransactionalEmailResponse,
} from '@/tools/loops/types'
import type { ToolConfig } from '@/tools/types'
export const loopsGetTransactionalEmailTool: ToolConfig<
LoopsGetTransactionalEmailParams,
LoopsGetTransactionalEmailResponse
> = {
id: 'loops_get_transactional_email',
name: 'Loops Get Transactional Email',
description:
'Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Loops API key for authentication',
},
transactionalId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the transactional email template to retrieve',
},
},
request: {
url: (params) =>
`https://app.loops.so/api/v1/transactional-emails/${encodeURIComponent(params.transactionalId.trim())}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.id) {
return {
success: false,
output: {
id: null,
name: null,
draftEmailMessageId: null,
publishedEmailMessageId: null,
transactionalGroupId: null,
createdAt: null,
updatedAt: null,
dataVariables: [],
},
error: data.message ?? 'Failed to get transactional email',
}
}
return {
success: true,
output: {
id: (data.id as string) ?? null,
name: (data.name as string) ?? null,
draftEmailMessageId: (data.draftEmailMessageId as string) ?? null,
publishedEmailMessageId: (data.publishedEmailMessageId as string) ?? null,
transactionalGroupId: (data.transactionalGroupId as string) ?? null,
createdAt: (data.createdAt as string) ?? null,
updatedAt: (data.updatedAt as string) ?? null,
dataVariables: (data.dataVariables as string[]) ?? [],
},
}
},
outputs: {
id: { type: 'string', description: 'The transactional email template ID', optional: true },
name: { type: 'string', description: 'The template name', optional: true },
draftEmailMessageId: {
type: 'string',
description: 'ID of the draft email message, if any',
optional: true,
},
publishedEmailMessageId: {
type: 'string',
description: 'ID of the published email message, if any',
optional: true,
},
transactionalGroupId: {
type: 'string',
description: 'ID of the transactional group this template belongs to, if any',
optional: true,
},
createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)', optional: true },
updatedAt: {
type: 'string',
description: 'Last updated timestamp (ISO 8601)',
optional: true,
},
dataVariables: {
type: 'array',
description: 'Template data variable names',
items: { type: 'string' },
},
},
}
+3
View File
@@ -1,10 +1,13 @@
export { loopsCheckContactSuppressionTool } from '@/tools/loops/check_contact_suppression'
export { loopsCreateContactTool } from '@/tools/loops/create_contact'
export { loopsCreateContactPropertyTool } from '@/tools/loops/create_contact_property'
export { loopsDeleteContactTool } from '@/tools/loops/delete_contact'
export { loopsFindContactTool } from '@/tools/loops/find_contact'
export { loopsGetTransactionalEmailTool } from '@/tools/loops/get_transactional_email'
export { loopsListContactPropertiesTool } from '@/tools/loops/list_contact_properties'
export { loopsListMailingListsTool } from '@/tools/loops/list_mailing_lists'
export { loopsListTransactionalEmailsTool } from '@/tools/loops/list_transactional_emails'
export { loopsRemoveContactSuppressionTool } from '@/tools/loops/remove_contact_suppression'
export { loopsSendEventTool } from '@/tools/loops/send_event'
export { loopsSendTransactionalEmailTool } from '@/tools/loops/send_transactional_email'
export { loopsUpdateContactTool } from '@/tools/loops/update_contact'
@@ -11,7 +11,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
id: 'loops_list_transactional_emails',
name: 'Loops List Transactional Emails',
description:
'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables.',
'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables.',
version: '1.0.0',
params: {
@@ -37,7 +37,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
request: {
url: (params) => {
const base = 'https://app.loops.so/api/v1/transactional'
const base = 'https://app.loops.so/api/v1/transactional-emails'
const queryParams: string[] = []
if (params.perPage) queryParams.push(`perPage=${encodeURIComponent(params.perPage)}`)
if (params.cursor) queryParams.push(`cursor=${encodeURIComponent(params.cursor)}`)
@@ -78,7 +78,11 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
transactionalEmails: emails.map((email: Record<string, unknown>) => ({
id: (email.id as string) ?? '',
name: (email.name as string) ?? '',
lastUpdated: (email.lastUpdated as string) ?? '',
createdAt: (email.createdAt as string) ?? '',
updatedAt: (email.updatedAt as string) ?? '',
// Deprecated alias of updatedAt, kept for backwards compatibility with the old
// (now-removed) /api/v1/transactional list endpoint, which returned this field.
lastUpdated: (email.updatedAt as string) ?? '',
dataVariables: (email.dataVariables as string[]) ?? [],
})),
pagination: {
@@ -102,7 +106,12 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
properties: {
id: { type: 'string', description: 'The transactional email template ID' },
name: { type: 'string', description: 'The template name' },
lastUpdated: { type: 'string', description: 'Last updated timestamp' },
createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)' },
updatedAt: { type: 'string', description: 'Last updated timestamp (ISO 8601)' },
lastUpdated: {
type: 'string',
description: 'Deprecated alias of updatedAt, kept for backwards compatibility',
},
dataVariables: {
type: 'array',
description: 'Template data variable names',
@@ -0,0 +1,99 @@
import type {
LoopsRemoveContactSuppressionParams,
LoopsRemoveContactSuppressionResponse,
} from '@/tools/loops/types'
import type { ToolConfig } from '@/tools/types'
export const loopsRemoveContactSuppressionTool: ToolConfig<
LoopsRemoveContactSuppressionParams,
LoopsRemoveContactSuppressionResponse
> = {
id: 'loops_remove_contact_suppression',
name: 'Loops Remove Contact Suppression',
description:
'Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Loops API key for authentication',
},
email: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The contact email address to remove from suppression (at least one of email or userId is required)',
},
userId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The contact userId to remove from suppression (at least one of email or userId is required)',
},
},
request: {
url: (params) => {
if (!params.email && !params.userId) {
throw new Error('At least one of email or userId is required to remove suppression')
}
const base = 'https://app.loops.so/api/v1/contacts/suppression'
if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}`
return `${base}?userId=${encodeURIComponent(params.userId!.trim())}`
},
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
success: false,
message: data.message ?? 'Failed to remove contact suppression',
removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
},
error: data.message ?? 'Failed to remove contact suppression',
}
}
return {
success: true,
output: {
success: true,
message: data.message ?? 'Contact removed from suppression list.',
removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
},
}
},
outputs: {
success: {
type: 'boolean',
description: 'Whether the contact was removed from suppression successfully',
},
message: { type: 'string', description: 'Status message from the API', optional: true },
removalQuotaLimit: {
type: 'number',
description: 'Total suppression-removal quota for the team',
optional: true,
},
removalQuotaRemaining: {
type: 'number',
description: 'Remaining suppression-removal quota for the team',
optional: true,
},
},
}
+53
View File
@@ -70,6 +70,20 @@ export interface LoopsListContactPropertiesParams extends LoopsBaseParams {
list?: string
}
export interface LoopsCheckContactSuppressionParams extends LoopsBaseParams {
email?: string
userId?: string
}
export interface LoopsRemoveContactSuppressionParams extends LoopsBaseParams {
email?: string
userId?: string
}
export interface LoopsGetTransactionalEmailParams extends LoopsBaseParams {
transactionalId: string
}
interface LoopsContact {
id: string
email: string
@@ -138,6 +152,9 @@ export interface LoopsListTransactionalEmailsResponse extends ToolResponse {
transactionalEmails: {
id: string
name: string
createdAt: string
updatedAt: string
/** @deprecated Alias of updatedAt, kept for backwards compatibility */
lastUpdated: string
dataVariables: string[]
}[]
@@ -168,6 +185,39 @@ export interface LoopsListContactPropertiesResponse extends ToolResponse {
}
}
export interface LoopsCheckContactSuppressionResponse extends ToolResponse {
output: {
contactId: string | null
email: string | null
userId: string | null
isSuppressed: boolean
removalQuotaLimit: number | null
removalQuotaRemaining: number | null
}
}
export interface LoopsRemoveContactSuppressionResponse extends ToolResponse {
output: {
success: boolean
message: string | null
removalQuotaLimit: number | null
removalQuotaRemaining: number | null
}
}
export interface LoopsGetTransactionalEmailResponse extends ToolResponse {
output: {
id: string | null
name: string | null
draftEmailMessageId: string | null
publishedEmailMessageId: string | null
transactionalGroupId: string | null
createdAt: string | null
updatedAt: string | null
dataVariables: string[]
}
}
export type LoopsResponse =
| LoopsCreateContactResponse
| LoopsUpdateContactResponse
@@ -179,6 +229,9 @@ export type LoopsResponse =
| LoopsListTransactionalEmailsResponse
| LoopsCreateContactPropertyResponse
| LoopsListContactPropertiesResponse
| LoopsCheckContactSuppressionResponse
| LoopsRemoveContactSuppressionResponse
| LoopsGetTransactionalEmailResponse
export const LOOPS_CONTACT_OUTPUT_PROPERTIES = {
id: { type: 'string' as const, description: 'Loops-assigned contact ID' },
+9 -3
View File
@@ -2065,13 +2065,16 @@ import {
logsQueryTool,
} from '@/tools/logs'
import {
loopsCheckContactSuppressionTool,
loopsCreateContactPropertyTool,
loopsCreateContactTool,
loopsDeleteContactTool,
loopsFindContactTool,
loopsGetTransactionalEmailTool,
loopsListContactPropertiesTool,
loopsListMailingListsTool,
loopsListTransactionalEmailsTool,
loopsRemoveContactSuppressionTool,
loopsSendEventTool,
loopsSendTransactionalEmailTool,
loopsUpdateContactTool,
@@ -4633,16 +4636,19 @@ export const tools: Record<string, ToolConfig> = {
logs_get: logsGetTool,
logs_get_execution: logsGetExecutionTool,
logs_get_run_details: logsGetRunDetailsTool,
loops_check_contact_suppression: loopsCheckContactSuppressionTool,
loops_create_contact: loopsCreateContactTool,
loops_create_contact_property: loopsCreateContactPropertyTool,
loops_update_contact: loopsUpdateContactTool,
loops_find_contact: loopsFindContactTool,
loops_delete_contact: loopsDeleteContactTool,
loops_find_contact: loopsFindContactTool,
loops_get_transactional_email: loopsGetTransactionalEmailTool,
loops_list_contact_properties: loopsListContactPropertiesTool,
loops_list_mailing_lists: loopsListMailingListsTool,
loops_list_transactional_emails: loopsListTransactionalEmailsTool,
loops_send_transactional_email: loopsSendTransactionalEmailTool,
loops_remove_contact_suppression: loopsRemoveContactSuppressionTool,
loops_send_event: loopsSendEventTool,
loops_send_transactional_email: loopsSendTransactionalEmailTool,
loops_update_contact: loopsUpdateContactTool,
luma_add_guests: lumaAddGuestsTool,
luma_cancel_event: lumaCancelEventTool,
luma_create_event: lumaCreateEventTool,