diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index bbfd27d95c..3b9f6cd9a5 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -3980,6 +3980,17 @@ export function IntercomIcon(props: SVGProps) { ) } +export function LoopsIcon(props: SVGProps) { + return ( + + + + ) +} + export function LumaIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index f791ef73e0..164a4fdb09 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -80,6 +80,7 @@ import { LinearIcon, LinkedInIcon, LinkupIcon, + LoopsIcon, LumaIcon, MailchimpIcon, MailgunIcon, @@ -236,6 +237,7 @@ export const blockTypeToIconMap: Record = { linear: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, + loops: LoopsIcon, luma: LumaIcon, mailchimp: MailchimpIcon, mailgun: MailgunIcon, diff --git a/apps/docs/content/docs/en/tools/loops.mdx b/apps/docs/content/docs/en/tools/loops.mdx new file mode 100644 index 0000000000..aa45836d20 --- /dev/null +++ b/apps/docs/content/docs/en/tools/loops.mdx @@ -0,0 +1,273 @@ +--- +title: Loops +description: Manage contacts and send emails with Loops +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Loops](https://loops.so/) is an email platform built for modern SaaS companies, offering transactional emails, marketing campaigns, and event-driven automations through a clean API. This integration connects Loops directly into Sim workflows. + +With Loops in Sim, you can: + +- **Manage contacts**: Create, update, find, and delete contacts in your Loops audience +- **Send transactional emails**: Trigger templated transactional emails with dynamic data variables +- **Fire events**: Send events to Loops to trigger automated email sequences and workflows +- **Manage subscriptions**: Control mailing list subscriptions and contact properties programmatically +- **Enrich contact data**: Attach custom properties, user groups, and mailing list memberships to contacts + +In Sim, the Loops integration enables your agents to manage email operations as part of their workflows. Supported operations include: + +- **Create Contact**: Add a new contact to your Loops audience with email, name, and custom properties. +- **Update Contact**: Update an existing contact or create one if no match exists (upsert behavior). +- **Find Contact**: Look up a contact by email address or userId. +- **Delete Contact**: Remove a contact from your audience. +- **Send Transactional Email**: Send a templated transactional email to a recipient with dynamic data variables. +- **Send Event**: Trigger a Loops event to start automated email sequences for a contact. + +Configure the Loops block with your API key from the Loops dashboard (Settings > API), select an operation, and provide the required parameters. Your agents can then manage contacts and send emails as part of any workflow. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Loops into the workflow. Create and manage contacts, send transactional emails, and trigger event-based automations. + + + +## Tools + +### `loops_create_contact` + +Create a new contact in your Loops audience with an email address and optional properties like name, user group, and mailing list subscriptions. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | Yes | The email address for the new contact | +| `firstName` | string | No | The contact first name | +| `lastName` | string | No | The contact last name | +| `source` | string | No | Custom source value replacing the default "API" | +| `subscribed` | boolean | No | Whether the contact receives campaign emails \(defaults to true\) | +| `userGroup` | string | No | Group to segment the contact into \(one group per contact\) | +| `userId` | string | No | Unique user identifier from your application | +| `mailingLists` | json | No | Mailing list IDs mapped to boolean values \(true to subscribe, false to unsubscribe\) | +| `customProperties` | json | No | Custom contact properties as key-value pairs \(string, number, boolean, or date values\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was created successfully | +| `id` | string | The Loops-assigned ID of the created contact | + +### `loops_update_contact` + +Update an existing contact in Loops by email or userId. Creates a new contact if no match is found (upsert). Can update name, subscription status, user group, mailing lists, and custom properties. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId \(at least one of email or userId is required\) | +| `firstName` | string | No | The contact first name | +| `lastName` | string | No | The contact last name | +| `source` | string | No | Custom source value replacing the default "API" | +| `subscribed` | boolean | No | Whether the contact receives campaign emails \(sending true re-subscribes unsubscribed contacts\) | +| `userGroup` | string | No | Group to segment the contact into \(one group per contact\) | +| `mailingLists` | json | No | Mailing list IDs mapped to boolean values \(true to subscribe, false to unsubscribe\) | +| `customProperties` | json | No | Custom contact properties as key-value pairs \(send null to reset a property\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was updated successfully | +| `id` | string | The Loops-assigned ID of the updated or created contact | + +### `loops_find_contact` + +Find a contact in Loops by email address or userId. Returns an array of matching contacts with all their properties including name, subscription status, user group, and mailing lists. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address to search for \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId to search for \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contacts` | array | Array of matching contact objects \(empty array if no match found\) | +| ↳ `id` | string | Loops-assigned contact ID | +| ↳ `email` | string | Contact email address | +| ↳ `firstName` | string | Contact first name | +| ↳ `lastName` | string | Contact last name | +| ↳ `source` | string | Source the contact was created from | +| ↳ `subscribed` | boolean | Whether the contact receives campaign emails | +| ↳ `userGroup` | string | Contact user group | +| ↳ `userId` | string | External user identifier | +| ↳ `mailingLists` | object | Mailing list IDs mapped to subscription status | +| ↳ `optInStatus` | string | Double opt-in status: pending, accepted, rejected, or null | + +### `loops_delete_contact` + +Delete a contact from Loops by email address or userId. At least one identifier must be provided. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The email address of the contact to delete \(at least one of email or userId is required\) | +| `userId` | string | No | The userId of the contact to delete \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was deleted successfully | +| `message` | string | Status message from the API | + +### `loops_send_transactional_email` + +Send a transactional email to a recipient using a Loops template. Supports dynamic data variables for personalization and optionally adds the recipient to your audience. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | Yes | The email address of the recipient | +| `transactionalId` | string | Yes | The ID of the transactional email template to send | +| `dataVariables` | json | No | Template data variables as key-value pairs \(string or number values\) | +| `addToAudience` | boolean | No | Whether to create the recipient as a contact if they do not already exist \(default: false\) | +| `attachments` | json | No | Array of file attachments. Each object must have filename \(string\), contentType \(MIME type string\), and data \(base64-encoded string\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the transactional email was sent successfully | + +### `loops_send_event` + +Send an event to Loops to trigger automated email sequences for a contact. Identify the contact by email or userId and include optional event properties and mailing list changes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The email address of the contact \(at least one of email or userId is required\) | +| `userId` | string | No | The userId of the contact \(at least one of email or userId is required\) | +| `eventName` | string | Yes | The name of the event to trigger | +| `eventProperties` | json | No | Event data as key-value pairs \(string, number, boolean, or date values\) | +| `mailingLists` | json | No | Mailing list IDs mapped to boolean values \(true to subscribe, false to unsubscribe\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the event was sent successfully | + +### `loops_list_mailing_lists` + +Retrieve all mailing lists from your Loops account. Returns each list with its ID, name, description, and public/private status. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `mailingLists` | array | Array of mailing list objects | +| ↳ `id` | string | The mailing list ID | +| ↳ `name` | string | The mailing list name | +| ↳ `description` | string | The mailing list description \(null if not set\) | +| ↳ `isPublic` | boolean | Whether the list is public or private | + +### `loops_list_transactional_emails` + +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. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `perPage` | string | No | Number of results per page \(10-50, default: 20\) | +| `cursor` | string | No | Pagination cursor from a previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transactionalEmails` | array | Array of published transactional email templates | +| ↳ `id` | string | The transactional email template ID | +| ↳ `name` | string | The template name | +| ↳ `lastUpdated` | string | Last updated timestamp | +| ↳ `dataVariables` | array | Template data variable names | +| `pagination` | object | Pagination information | +| ↳ `totalResults` | number | Total number of results | +| ↳ `returnedResults` | number | Number of results returned | +| ↳ `perPage` | number | Results per page | +| ↳ `totalPages` | number | Total number of pages | +| ↳ `nextCursor` | string | Cursor for next page \(null if no more pages\) | +| ↳ `nextPage` | string | URL for next page \(null if no more pages\) | + +### `loops_create_contact_property` + +Create a new custom contact property in your Loops account. The property name must be in camelCase format. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `name` | string | Yes | The property name in camelCase format \(e.g., "favoriteColor"\) | +| `type` | string | Yes | The property data type \(e.g., "string", "number", "boolean", "date"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact property was created successfully | + +### `loops_list_contact_properties` + +Retrieve a list of contact properties from your Loops account. Returns each property with its key, label, and data type. Can filter to show all properties or only custom ones. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `list` | string | No | Filter type: "all" for all properties \(default\) or "custom" for custom properties only | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `properties` | array | Array of contact property objects | +| ↳ `key` | string | The property key \(camelCase identifier\) | +| ↳ `label` | string | The property display label | +| ↳ `type` | string | The property data type \(string, number, boolean, date\) | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 3fc74c2898..6f4e0593d9 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -77,6 +77,7 @@ "linear", "linkedin", "linkup", + "loops", "luma", "mailchimp", "mailgun", diff --git a/apps/sim/blocks/blocks/loops.ts b/apps/sim/blocks/blocks/loops.ts new file mode 100644 index 0000000000..ff2dccf1ee --- /dev/null +++ b/apps/sim/blocks/blocks/loops.ts @@ -0,0 +1,519 @@ +import { LoopsIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import type { LoopsResponse } from '@/tools/loops/types' + +export const LoopsBlock: BlockConfig = { + type: 'loops', + name: 'Loops', + description: 'Manage contacts and send emails with Loops', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Loops into the workflow. Create and manage contacts, send transactional emails, and trigger event-based automations.', + docsLink: 'https://docs.sim.ai/tools/loops', + category: 'tools', + bgColor: '#FAFAF9', + icon: LoopsIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create Contact', id: 'create_contact' }, + { label: 'Update Contact', id: 'update_contact' }, + { label: 'Find Contact', id: 'find_contact' }, + { label: 'Delete Contact', id: 'delete_contact' }, + { label: 'Send Transactional Email', id: 'send_transactional_email' }, + { label: 'Send Event', id: 'send_event' }, + { label: 'List Mailing Lists', id: 'list_mailing_lists' }, + { label: 'List Transactional Emails', id: 'list_transactional_emails' }, + { label: 'Create Contact Property', id: 'create_contact_property' }, + { label: 'List Contact Properties', id: 'list_contact_properties' }, + ], + value: () => 'create_contact', + }, + // Required email for create and send transactional + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'Enter email address', + required: true, + condition: { + field: 'operation', + value: ['create_contact', 'send_transactional_email'], + }, + }, + // Optional email for update, find, delete, send event + { + id: 'contactEmail', + title: 'Email', + type: 'short-input', + placeholder: 'Enter email address', + condition: { + field: 'operation', + value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + }, + }, + // User ID for operations that support it + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter user ID', + condition: { + field: 'operation', + value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + }, + }, + // Contact fields + { + id: 'firstName', + title: 'First Name', + type: 'short-input', + placeholder: 'Enter first name', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact'], + }, + }, + { + id: 'lastName', + title: 'Last Name', + type: 'short-input', + placeholder: 'Enter last name', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact'], + }, + }, + // Advanced contact fields + { + id: 'source', + title: 'Source', + type: 'short-input', + placeholder: 'Custom source (default: "API")', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact'], + }, + mode: 'advanced', + }, + { + id: 'subscribed', + title: 'Subscribed', + type: 'switch', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact'], + }, + mode: 'advanced', + }, + { + id: 'userGroup', + title: 'User Group', + type: 'short-input', + placeholder: 'Enter user group', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact'], + }, + mode: 'advanced', + }, + { + id: 'createUserId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter unique user ID', + condition: { + field: 'operation', + value: 'create_contact', + }, + mode: 'advanced', + }, + { + id: 'mailingLists', + title: 'Mailing Lists', + type: 'long-input', + placeholder: '{"listId123": true, "listId456": false}', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact', 'send_event'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON object mapping Loops mailing list IDs to boolean values. Use true to subscribe the contact to a list and false to unsubscribe. + +Current value: {context} + +The output must be a valid JSON object with string keys (mailing list IDs) and boolean values. + +Example: +{ + "clxf1nxlb000t0ml79ajwcsj0": true, + "clxf2q43u00010mlh12q9ggx1": false +}`, + placeholder: 'Describe the mailing list subscriptions...', + }, + }, + { + id: 'customProperties', + title: 'Custom Properties', + type: 'long-input', + placeholder: '{"plan": "pro", "company": "Acme"}', + condition: { + field: 'operation', + value: ['create_contact', 'update_contact'], + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON object of custom contact properties for Loops. Values can be strings, numbers, booleans, or ISO 8601 date strings. Send null to reset a property. + +Current value: {context} + +The output must be a valid JSON object. + +Example: +{ + "plan": "pro", + "company": "Acme Inc", + "signupDate": "2024-01-15T00:00:00Z", + "isActive": true, + "seats": 5 +}`, + placeholder: 'Describe the custom properties...', + }, + }, + // Transactional email fields + { + id: 'transactionalId', + title: 'Transactional Email ID', + type: 'short-input', + placeholder: 'Enter template ID (e.g., clx...)', + required: { field: 'operation', value: 'send_transactional_email' }, + condition: { + field: 'operation', + value: 'send_transactional_email', + }, + }, + { + id: 'dataVariables', + title: 'Data Variables', + type: 'long-input', + placeholder: '{"name": "John", "url": "https://..."}', + condition: { + field: 'operation', + value: 'send_transactional_email', + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object of data variables for a Loops transactional email template. Values must be strings or numbers, matching the variable names defined in the template. + +Current value: {context} + +The output must be a valid JSON object with string keys. + +Example: +{ + "name": "John Smith", + "confirmationUrl": "https://example.com/confirm?token=abc123", + "expiresIn": 24 +}`, + placeholder: 'Describe the template variables...', + }, + }, + { + id: 'addToAudience', + title: 'Add to Audience', + type: 'switch', + condition: { + field: 'operation', + value: 'send_transactional_email', + }, + mode: 'advanced', + }, + { + id: 'attachments', + title: 'Attachments', + type: 'long-input', + placeholder: + '[{"filename": "file.pdf", "contentType": "application/pdf", "data": "base64..."}]', + condition: { + field: 'operation', + value: 'send_transactional_email', + }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of file attachments for a Loops transactional email. Each object must have: filename (string), contentType (MIME type string), and data (base64-encoded file content string). + +Current value: {context} + +The output must be a valid JSON array. + +Example: +[ + { + "filename": "invoice.pdf", + "contentType": "application/pdf", + "data": "JVBERi0xLjQK..." + } +]`, + placeholder: 'Describe the attachments...', + }, + }, + // Event fields + { + id: 'eventName', + title: 'Event Name', + type: 'short-input', + placeholder: 'Enter event name (e.g., signup_completed)', + required: { field: 'operation', value: 'send_event' }, + condition: { + field: 'operation', + value: 'send_event', + }, + }, + { + id: 'eventProperties', + title: 'Event Properties', + type: 'long-input', + placeholder: '{"plan": "pro", "amount": 49.99}', + condition: { + field: 'operation', + value: 'send_event', + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object of event properties for a Loops event. Values can be strings, numbers, booleans, or ISO 8601 date strings. + +Current value: {context} + +The output must be a valid JSON object. + +Example: +{ + "plan": "pro", + "amount": 49.99, + "currency": "USD", + "isUpgrade": true +}`, + placeholder: 'Describe the event properties...', + }, + }, + // List transactional emails pagination fields + { + id: 'perPage', + title: 'Results Per Page', + type: 'short-input', + placeholder: '20 (range: 10-50)', + condition: { + field: 'operation', + value: 'list_transactional_emails', + }, + mode: 'advanced', + }, + { + id: 'cursor', + title: 'Pagination Cursor', + type: 'short-input', + placeholder: 'Cursor from previous response', + condition: { + field: 'operation', + value: 'list_transactional_emails', + }, + mode: 'advanced', + }, + // Create contact property fields + { + id: 'propertyName', + title: 'Property Name', + type: 'short-input', + placeholder: 'Enter property name in camelCase (e.g., favoriteColor)', + required: { field: 'operation', value: 'create_contact_property' }, + condition: { + field: 'operation', + value: 'create_contact_property', + }, + }, + { + id: 'propertyType', + title: 'Property Type', + type: 'dropdown', + options: [ + { label: 'String', id: 'string' }, + { label: 'Number', id: 'number' }, + { label: 'Boolean', id: 'boolean' }, + { label: 'Date', id: 'date' }, + ], + condition: { + field: 'operation', + value: 'create_contact_property', + }, + }, + // List contact properties filter + { + id: 'propertyFilter', + title: 'Filter', + type: 'dropdown', + options: [ + { label: 'All Properties', id: 'all' }, + { label: 'Custom Only', id: 'custom' }, + ], + condition: { + field: 'operation', + value: 'list_contact_properties', + }, + mode: 'advanced', + }, + // API Key (always visible) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Loops API key', + password: true, + required: true, + }, + ], + tools: { + access: [ + 'loops_create_contact', + 'loops_update_contact', + 'loops_find_contact', + 'loops_delete_contact', + 'loops_send_transactional_email', + 'loops_send_event', + 'loops_list_mailing_lists', + 'loops_list_transactional_emails', + 'loops_create_contact_property', + 'loops_list_contact_properties', + ], + config: { + tool: (params) => `loops_${params.operation}`, + params: (params) => { + const { operation, apiKey } = params + const result: Record = { apiKey } + + switch (operation) { + case 'create_contact': + result.email = params.email + if (params.firstName) result.firstName = params.firstName + if (params.lastName) result.lastName = params.lastName + if (params.source) result.source = params.source + if (params.subscribed != null) result.subscribed = params.subscribed + if (params.userGroup) result.userGroup = params.userGroup + if (params.createUserId) result.userId = params.createUserId + if (params.mailingLists) result.mailingLists = params.mailingLists + if (params.customProperties) result.customProperties = params.customProperties + break + + case 'update_contact': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + if (params.firstName) result.firstName = params.firstName + if (params.lastName) result.lastName = params.lastName + if (params.source) result.source = params.source + if (params.subscribed != null) result.subscribed = params.subscribed + if (params.userGroup) result.userGroup = params.userGroup + if (params.mailingLists) result.mailingLists = params.mailingLists + if (params.customProperties) result.customProperties = params.customProperties + break + + case 'find_contact': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + break + + case 'delete_contact': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + break + + case 'send_transactional_email': + result.email = params.email + result.transactionalId = params.transactionalId + if (params.dataVariables) result.dataVariables = params.dataVariables + if (params.addToAudience != null) result.addToAudience = params.addToAudience + if (params.attachments) result.attachments = params.attachments + break + + case 'send_event': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + result.eventName = params.eventName + if (params.eventProperties) result.eventProperties = params.eventProperties + if (params.mailingLists) result.mailingLists = params.mailingLists + break + + case 'list_transactional_emails': + if (params.perPage) result.perPage = params.perPage + if (params.cursor) result.cursor = params.cursor + break + + case 'create_contact_property': + result.name = params.propertyName + result.type = params.propertyType + break + + case 'list_contact_properties': + if (params.propertyFilter) result.list = params.propertyFilter + break + } + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + email: { type: 'string', description: 'Contact email address' }, + contactEmail: { type: 'string', description: 'Contact email for lookup operations' }, + userId: { type: 'string', description: 'Contact user ID' }, + firstName: { type: 'string', description: 'Contact first name' }, + lastName: { type: 'string', description: 'Contact last name' }, + source: { type: 'string', description: 'Contact source' }, + subscribed: { type: 'boolean', description: 'Subscription status' }, + userGroup: { type: 'string', description: 'Contact user group' }, + createUserId: { type: 'string', description: 'User ID for new contact' }, + mailingLists: { type: 'json', description: 'Mailing list subscriptions' }, + customProperties: { type: 'json', description: 'Custom contact properties' }, + transactionalId: { type: 'string', description: 'Transactional email template ID' }, + dataVariables: { type: 'json', description: 'Template data variables' }, + addToAudience: { type: 'boolean', description: 'Add recipient to audience' }, + attachments: { type: 'json', description: 'Email file attachments' }, + eventName: { type: 'string', description: 'Event name' }, + eventProperties: { type: 'json', description: 'Event properties' }, + perPage: { type: 'string', description: 'Results per page for pagination' }, + cursor: { type: 'string', description: 'Pagination cursor' }, + propertyName: { type: 'string', description: 'Contact property name (camelCase)' }, + propertyType: { type: 'string', description: 'Contact property data type' }, + propertyFilter: { type: 'string', description: 'Filter for listing properties' }, + apiKey: { type: 'string', description: 'Loops API key' }, + }, + outputs: { + success: { type: 'boolean', description: 'Whether the operation succeeded' }, + id: { type: 'string', description: 'Contact ID (create/update operations)' }, + contacts: { type: 'json', description: 'Array of matching contacts (find operation)' }, + message: { type: 'string', description: 'Status message (delete operation)' }, + mailingLists: { + type: 'json', + description: 'Array of mailing lists (list mailing lists operation)', + }, + transactionalEmails: { + type: 'json', + description: 'Array of transactional email templates (list transactional emails operation)', + }, + pagination: { + type: 'json', + description: 'Pagination info (list transactional emails operation)', + }, + properties: { + type: 'json', + description: 'Array of contact properties (list contact properties operation)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 0bc74a6d91..1a34d4f8fc 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -85,6 +85,7 @@ import { LemlistBlock } from '@/blocks/blocks/lemlist' import { LinearBlock } from '@/blocks/blocks/linear' import { LinkedInBlock } from '@/blocks/blocks/linkedin' import { LinkupBlock } from '@/blocks/blocks/linkup' +import { LoopsBlock } from '@/blocks/blocks/loops' import { LumaBlock } from '@/blocks/blocks/luma' import { MailchimpBlock } from '@/blocks/blocks/mailchimp' import { MailgunBlock } from '@/blocks/blocks/mailgun' @@ -284,6 +285,7 @@ export const registry: Record = { linear: LinearBlock, linkedin: LinkedInBlock, linkup: LinkupBlock, + loops: LoopsBlock, luma: LumaBlock, mailchimp: MailchimpBlock, mailgun: MailgunBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index bbfd27d95c..3b9f6cd9a5 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3980,6 +3980,17 @@ export function IntercomIcon(props: SVGProps) { ) } +export function LoopsIcon(props: SVGProps) { + return ( + + + + ) +} + export function LumaIcon(props: SVGProps) { return ( diff --git a/apps/sim/tools/loops/create_contact.ts b/apps/sim/tools/loops/create_contact.ts new file mode 100644 index 0000000000..ecb6d13e84 --- /dev/null +++ b/apps/sim/tools/loops/create_contact.ts @@ -0,0 +1,148 @@ +import type { LoopsCreateContactParams, LoopsCreateContactResponse } from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsCreateContactTool: ToolConfig< + LoopsCreateContactParams, + LoopsCreateContactResponse +> = { + id: 'loops_create_contact', + name: 'Loops Create Contact', + description: + 'Create a new contact in your Loops audience with an email address and optional properties like name, user group, and mailing list subscriptions.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address for the new contact', + }, + firstName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact first name', + }, + lastName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact last name', + }, + source: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom source value replacing the default "API"', + }, + subscribed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the contact receives campaign emails (defaults to true)', + }, + userGroup: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Group to segment the contact into (one group per contact)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unique user identifier from your application', + }, + mailingLists: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Mailing list IDs mapped to boolean values (true to subscribe, false to unsubscribe)', + }, + customProperties: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Custom contact properties as key-value pairs (string, number, boolean, or date values)', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/contacts/create', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => { + // Apply custom properties first so standard fields always take precedence + const body: Record = {} + + if (params.customProperties) { + const props = + typeof params.customProperties === 'string' + ? JSON.parse(params.customProperties) + : params.customProperties + Object.assign(body, props) + } + + body.email = params.email + if (params.firstName) body.firstName = params.firstName + if (params.lastName) body.lastName = params.lastName + if (params.source) body.source = params.source + if (params.subscribed != null) body.subscribed = params.subscribed + if (params.userGroup) body.userGroup = params.userGroup + if (params.userId) body.userId = params.userId + + if (params.mailingLists) { + body.mailingLists = + typeof params.mailingLists === 'string' + ? JSON.parse(params.mailingLists) + : params.mailingLists + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + id: null, + }, + error: data.message ?? 'Failed to create contact', + } + } + + return { + success: true, + output: { + success: true, + id: data.id ?? null, + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the contact was created successfully' }, + id: { + type: 'string', + description: 'The Loops-assigned ID of the created contact', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/loops/create_contact_property.ts b/apps/sim/tools/loops/create_contact_property.ts new file mode 100644 index 0000000000..5cd7b9e3b7 --- /dev/null +++ b/apps/sim/tools/loops/create_contact_property.ts @@ -0,0 +1,78 @@ +import type { + LoopsCreateContactPropertyParams, + LoopsCreateContactPropertyResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsCreateContactPropertyTool: ToolConfig< + LoopsCreateContactPropertyParams, + LoopsCreateContactPropertyResponse +> = { + id: 'loops_create_contact_property', + name: 'Loops Create Contact Property', + description: + 'Create a new custom contact property in your Loops account. The property name must be in camelCase format.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The property name in camelCase format (e.g., "favoriteColor")', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The property data type (e.g., "string", "number", "boolean", "date")', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/contacts/properties', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => ({ + name: params.name, + type: params.type, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + }, + error: data.message ?? 'Failed to create contact property', + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the contact property was created successfully', + }, + }, +} diff --git a/apps/sim/tools/loops/delete_contact.ts b/apps/sim/tools/loops/delete_contact.ts new file mode 100644 index 0000000000..b1e8b7ed77 --- /dev/null +++ b/apps/sim/tools/loops/delete_contact.ts @@ -0,0 +1,82 @@ +import type { LoopsDeleteContactParams, LoopsDeleteContactResponse } from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsDeleteContactTool: ToolConfig< + LoopsDeleteContactParams, + LoopsDeleteContactResponse +> = { + id: 'loops_delete_contact', + name: 'Loops Delete Contact', + description: + 'Delete a contact from Loops by email address or userId. At least one identifier must be provided.', + 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 email address of the contact to delete (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The userId of the contact to delete (at least one of email or userId is required)', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/contacts/delete', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to delete a contact') + } + const body: Record = {} + if (params.email) body.email = params.email + if (params.userId) body.userId = params.userId + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + message: data.message ?? 'Failed to delete contact', + }, + error: data.message ?? 'Failed to delete contact', + } + } + + return { + success: true, + output: { + success: true, + message: data.message ?? 'Contact deleted.', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the contact was deleted successfully' }, + message: { type: 'string', description: 'Status message from the API' }, + }, +} diff --git a/apps/sim/tools/loops/find_contact.ts b/apps/sim/tools/loops/find_contact.ts new file mode 100644 index 0000000000..f619c8ff74 --- /dev/null +++ b/apps/sim/tools/loops/find_contact.ts @@ -0,0 +1,91 @@ +import type { LoopsFindContactParams, LoopsFindContactResponse } from '@/tools/loops/types' +import { LOOPS_CONTACT_OUTPUT_PROPERTIES } from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsFindContactTool: ToolConfig = { + id: 'loops_find_contact', + name: 'Loops Find Contact', + description: + 'Find a contact in Loops by email address or userId. Returns an array of matching contacts with all their properties including name, subscription status, user group, and mailing lists.', + 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 search for (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact userId to search for (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 find a contact') + } + const base = 'https://app.loops.so/api/v1/contacts/find' + if (params.email) return `${base}?email=${encodeURIComponent(params.email)}` + return `${base}?userId=${encodeURIComponent(params.userId!)}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!Array.isArray(data)) { + return { + success: false, + output: { + contacts: [], + }, + error: data.message ?? 'Failed to find contact', + } + } + + return { + success: true, + output: { + contacts: data.map((contact: Record) => ({ + id: (contact.id as string) ?? '', + email: (contact.email as string) ?? '', + firstName: (contact.firstName as string) ?? null, + lastName: (contact.lastName as string) ?? null, + source: (contact.source as string) ?? null, + subscribed: (contact.subscribed as boolean) ?? false, + userGroup: (contact.userGroup as string) ?? null, + userId: (contact.userId as string) ?? null, + mailingLists: (contact.mailingLists as Record) ?? {}, + optInStatus: (contact.optInStatus as string) ?? null, + })), + }, + } + }, + + outputs: { + contacts: { + type: 'array', + description: 'Array of matching contact objects (empty array if no match found)', + items: { + type: 'object', + properties: LOOPS_CONTACT_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/loops/index.ts b/apps/sim/tools/loops/index.ts new file mode 100644 index 0000000000..bad17376cf --- /dev/null +++ b/apps/sim/tools/loops/index.ts @@ -0,0 +1,10 @@ +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 { loopsListContactPropertiesTool } from '@/tools/loops/list_contact_properties' +export { loopsListMailingListsTool } from '@/tools/loops/list_mailing_lists' +export { loopsListTransactionalEmailsTool } from '@/tools/loops/list_transactional_emails' +export { loopsSendEventTool } from '@/tools/loops/send_event' +export { loopsSendTransactionalEmailTool } from '@/tools/loops/send_transactional_email' +export { loopsUpdateContactTool } from '@/tools/loops/update_contact' diff --git a/apps/sim/tools/loops/list_contact_properties.ts b/apps/sim/tools/loops/list_contact_properties.ts new file mode 100644 index 0000000000..969d0b00aa --- /dev/null +++ b/apps/sim/tools/loops/list_contact_properties.ts @@ -0,0 +1,87 @@ +import type { + LoopsListContactPropertiesParams, + LoopsListContactPropertiesResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsListContactPropertiesTool: ToolConfig< + LoopsListContactPropertiesParams, + LoopsListContactPropertiesResponse +> = { + id: 'loops_list_contact_properties', + name: 'Loops List Contact Properties', + description: + 'Retrieve a list of contact properties from your Loops account. Returns each property with its key, label, and data type. Can filter to show all properties or only custom ones.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + list: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter type: "all" for all properties (default) or "custom" for custom properties only', + }, + }, + + request: { + url: (params) => { + const base = 'https://app.loops.so/api/v1/contacts/properties' + if (params.list) return `${base}?list=${encodeURIComponent(params.list)}` + return base + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!Array.isArray(data)) { + return { + success: false, + output: { + properties: [], + }, + error: data.message ?? 'Failed to list contact properties', + } + } + + return { + success: true, + output: { + properties: data.map((prop: Record) => ({ + key: (prop.key as string) ?? '', + label: (prop.label as string) ?? '', + type: (prop.type as string) ?? '', + })), + }, + } + }, + + outputs: { + properties: { + type: 'array', + description: 'Array of contact property objects', + items: { + type: 'object', + properties: { + key: { type: 'string', description: 'The property key (camelCase identifier)' }, + label: { type: 'string', description: 'The property display label' }, + type: { + type: 'string', + description: 'The property data type (string, number, boolean, date)', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/loops/list_mailing_lists.ts b/apps/sim/tools/loops/list_mailing_lists.ts new file mode 100644 index 0000000000..5b66894134 --- /dev/null +++ b/apps/sim/tools/loops/list_mailing_lists.ts @@ -0,0 +1,82 @@ +import type { + LoopsListMailingListsParams, + LoopsListMailingListsResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsListMailingListsTool: ToolConfig< + LoopsListMailingListsParams, + LoopsListMailingListsResponse +> = { + id: 'loops_list_mailing_lists', + name: 'Loops List Mailing Lists', + description: + 'Retrieve all mailing lists from your Loops account. Returns each list with its ID, name, description, and public/private status.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/lists', + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!Array.isArray(data)) { + return { + success: false, + output: { + mailingLists: [], + }, + error: data.message ?? 'Failed to list mailing lists', + } + } + + return { + success: true, + output: { + mailingLists: data.map((list: Record) => ({ + id: (list.id as string) ?? '', + name: (list.name as string) ?? '', + description: (list.description as string) ?? null, + isPublic: (list.isPublic as boolean) ?? false, + })), + }, + } + }, + + outputs: { + mailingLists: { + type: 'array', + description: 'Array of mailing list objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'The mailing list ID' }, + name: { type: 'string', description: 'The mailing list name' }, + description: { + type: 'string', + description: 'The mailing list description (null if not set)', + optional: true, + }, + isPublic: { + type: 'boolean', + description: 'Whether the list is public or private', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/loops/list_transactional_emails.ts b/apps/sim/tools/loops/list_transactional_emails.ts new file mode 100644 index 0000000000..bd36d37322 --- /dev/null +++ b/apps/sim/tools/loops/list_transactional_emails.ts @@ -0,0 +1,135 @@ +import type { + LoopsListTransactionalEmailsParams, + LoopsListTransactionalEmailsResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsListTransactionalEmailsTool: ToolConfig< + LoopsListTransactionalEmailsParams, + LoopsListTransactionalEmailsResponse +> = { + 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.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + perPage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (10-50, default: 20)', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous response to fetch the next page', + }, + }, + + request: { + url: (params) => { + const base = 'https://app.loops.so/api/v1/transactional' + const queryParams: string[] = [] + if (params.perPage) queryParams.push(`perPage=${encodeURIComponent(params.perPage)}`) + if (params.cursor) queryParams.push(`cursor=${encodeURIComponent(params.cursor)}`) + return queryParams.length > 0 ? `${base}?${queryParams.join('&')}` : base + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.data && !Array.isArray(data)) { + return { + success: false, + output: { + transactionalEmails: [], + pagination: { + totalResults: 0, + returnedResults: 0, + perPage: 0, + totalPages: 0, + nextCursor: null, + nextPage: null, + }, + }, + error: data.message ?? 'Failed to list transactional emails', + } + } + + const emails = data.data ?? data ?? [] + + return { + success: true, + output: { + transactionalEmails: emails.map((email: Record) => ({ + id: (email.id as string) ?? '', + name: (email.name as string) ?? '', + lastUpdated: (email.lastUpdated as string) ?? '', + dataVariables: (email.dataVariables as string[]) ?? [], + })), + pagination: { + totalResults: (data.pagination?.totalResults as number) ?? emails.length, + returnedResults: (data.pagination?.returnedResults as number) ?? emails.length, + perPage: (data.pagination?.perPage as number) ?? 20, + totalPages: (data.pagination?.totalPages as number) ?? 1, + nextCursor: (data.pagination?.nextCursor as string) ?? null, + nextPage: (data.pagination?.nextPage as string) ?? null, + }, + }, + } + }, + + outputs: { + transactionalEmails: { + type: 'array', + description: 'Array of published transactional email templates', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'The transactional email template ID' }, + name: { type: 'string', description: 'The template name' }, + lastUpdated: { type: 'string', description: 'Last updated timestamp' }, + dataVariables: { + type: 'array', + description: 'Template data variable names', + items: { type: 'string' }, + }, + }, + }, + }, + pagination: { + type: 'object', + description: 'Pagination information', + properties: { + totalResults: { type: 'number', description: 'Total number of results' }, + returnedResults: { type: 'number', description: 'Number of results returned' }, + perPage: { type: 'number', description: 'Results per page' }, + totalPages: { type: 'number', description: 'Total number of pages' }, + nextCursor: { + type: 'string', + description: 'Cursor for next page (null if no more pages)', + optional: true, + }, + nextPage: { + type: 'string', + description: 'URL for next page (null if no more pages)', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/loops/send_event.ts b/apps/sim/tools/loops/send_event.ts new file mode 100644 index 0000000000..7083e0d9f1 --- /dev/null +++ b/apps/sim/tools/loops/send_event.ts @@ -0,0 +1,112 @@ +import type { LoopsSendEventParams, LoopsSendEventResponse } from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsSendEventTool: ToolConfig = { + id: 'loops_send_event', + name: 'Loops Send Event', + description: + 'Send an event to Loops to trigger automated email sequences for a contact. Identify the contact by email or userId and include optional event properties and mailing list changes.', + 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 email address of the contact (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The userId of the contact (at least one of email or userId is required)', + }, + eventName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the event to trigger', + }, + eventProperties: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Event data as key-value pairs (string, number, boolean, or date values)', + }, + mailingLists: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Mailing list IDs mapped to boolean values (true to subscribe, false to unsubscribe)', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/events/send', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to send an event') + } + + const body: Record = { + eventName: params.eventName, + } + + if (params.email) body.email = params.email + if (params.userId) body.userId = params.userId + + if (params.eventProperties) { + body.eventProperties = + typeof params.eventProperties === 'string' + ? JSON.parse(params.eventProperties) + : params.eventProperties + } + + if (params.mailingLists) { + body.mailingLists = + typeof params.mailingLists === 'string' + ? JSON.parse(params.mailingLists) + : params.mailingLists + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + }, + error: data.message ?? 'Failed to send event', + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the event was sent successfully' }, + }, +} diff --git a/apps/sim/tools/loops/send_transactional_email.ts b/apps/sim/tools/loops/send_transactional_email.ts new file mode 100644 index 0000000000..284f69432f --- /dev/null +++ b/apps/sim/tools/loops/send_transactional_email.ts @@ -0,0 +1,120 @@ +import type { + LoopsSendTransactionalEmailParams, + LoopsSendTransactionalEmailResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsSendTransactionalEmailTool: ToolConfig< + LoopsSendTransactionalEmailParams, + LoopsSendTransactionalEmailResponse +> = { + id: 'loops_send_transactional_email', + name: 'Loops Send Transactional Email', + description: + 'Send a transactional email to a recipient using a Loops template. Supports dynamic data variables for personalization and optionally adds the recipient to your audience.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address of the recipient', + }, + transactionalId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the transactional email template to send', + }, + dataVariables: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Template data variables as key-value pairs (string or number values)', + }, + addToAudience: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether to create the recipient as a contact if they do not already exist (default: false)', + }, + attachments: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Array of file attachments. Each object must have filename (string), contentType (MIME type string), and data (base64-encoded string).', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/transactional', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => { + const body: Record = { + email: params.email, + transactionalId: params.transactionalId, + } + + if (params.dataVariables) { + body.dataVariables = + typeof params.dataVariables === 'string' + ? JSON.parse(params.dataVariables) + : params.dataVariables + } + + if (params.addToAudience != null) { + body.addToAudience = params.addToAudience + } + + if (params.attachments) { + body.attachments = + typeof params.attachments === 'string' + ? JSON.parse(params.attachments) + : params.attachments + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + }, + error: data.message ?? 'Failed to send transactional email', + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the transactional email was sent successfully', + }, + }, +} diff --git a/apps/sim/tools/loops/types.ts b/apps/sim/tools/loops/types.ts new file mode 100644 index 0000000000..88c3ed76db --- /dev/null +++ b/apps/sim/tools/loops/types.ts @@ -0,0 +1,209 @@ +import type { ToolResponse } from '@/tools/types' + +export interface LoopsBaseParams { + apiKey: string +} + +export interface LoopsCreateContactParams extends LoopsBaseParams { + email: string + firstName?: string + lastName?: string + source?: string + subscribed?: boolean + userGroup?: string + userId?: string + mailingLists?: string | Record + customProperties?: string | Record +} + +export interface LoopsUpdateContactParams extends LoopsBaseParams { + email?: string + userId?: string + firstName?: string + lastName?: string + source?: string + subscribed?: boolean + userGroup?: string + mailingLists?: string | Record + customProperties?: string | Record +} + +export interface LoopsFindContactParams extends LoopsBaseParams { + email?: string + userId?: string +} + +export interface LoopsDeleteContactParams extends LoopsBaseParams { + email?: string + userId?: string +} + +export interface LoopsSendTransactionalEmailParams extends LoopsBaseParams { + email: string + transactionalId: string + dataVariables?: string | Record + addToAudience?: boolean + attachments?: string | { filename: string; contentType: string; data: string }[] +} + +export interface LoopsSendEventParams extends LoopsBaseParams { + email?: string + userId?: string + eventName: string + eventProperties?: string | Record + mailingLists?: string | Record +} + +export interface LoopsListMailingListsParams extends LoopsBaseParams {} + +export interface LoopsListTransactionalEmailsParams extends LoopsBaseParams { + perPage?: string + cursor?: string +} + +export interface LoopsCreateContactPropertyParams extends LoopsBaseParams { + name: string + type: string +} + +export interface LoopsListContactPropertiesParams extends LoopsBaseParams { + list?: string +} + +export interface LoopsContact { + id: string + email: string + firstName: string | null + lastName: string | null + source: string | null + subscribed: boolean + userGroup: string | null + userId: string | null + mailingLists: Record + optInStatus: string | null +} + +export interface LoopsCreateContactResponse extends ToolResponse { + output: { + success: boolean + id: string | null + } +} + +export interface LoopsUpdateContactResponse extends ToolResponse { + output: { + success: boolean + id: string | null + } +} + +export interface LoopsFindContactResponse extends ToolResponse { + output: { + contacts: LoopsContact[] + } +} + +export interface LoopsDeleteContactResponse extends ToolResponse { + output: { + success: boolean + message: string | null + } +} + +export interface LoopsSendTransactionalEmailResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface LoopsSendEventResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface LoopsListMailingListsResponse extends ToolResponse { + output: { + mailingLists: { + id: string + name: string + description: string | null + isPublic: boolean + }[] + } +} + +export interface LoopsListTransactionalEmailsResponse extends ToolResponse { + output: { + transactionalEmails: { + id: string + name: string + lastUpdated: string + dataVariables: string[] + }[] + pagination: { + totalResults: number + returnedResults: number + perPage: number + totalPages: number + nextCursor: string | null + nextPage: string | null + } + } +} + +export interface LoopsCreateContactPropertyResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface LoopsListContactPropertiesResponse extends ToolResponse { + output: { + properties: { + key: string + label: string + type: string + }[] + } +} + +export type LoopsResponse = + | LoopsCreateContactResponse + | LoopsUpdateContactResponse + | LoopsFindContactResponse + | LoopsDeleteContactResponse + | LoopsSendTransactionalEmailResponse + | LoopsSendEventResponse + | LoopsListMailingListsResponse + | LoopsListTransactionalEmailsResponse + | LoopsCreateContactPropertyResponse + | LoopsListContactPropertiesResponse + +export const LOOPS_CONTACT_OUTPUT_PROPERTIES = { + id: { type: 'string' as const, description: 'Loops-assigned contact ID' }, + email: { type: 'string' as const, description: 'Contact email address' }, + firstName: { type: 'string' as const, description: 'Contact first name', optional: true }, + lastName: { type: 'string' as const, description: 'Contact last name', optional: true }, + source: { + type: 'string' as const, + description: 'Source the contact was created from', + optional: true, + }, + subscribed: { + type: 'boolean' as const, + description: 'Whether the contact receives campaign emails', + }, + userGroup: { type: 'string' as const, description: 'Contact user group', optional: true }, + userId: { type: 'string' as const, description: 'External user identifier', optional: true }, + mailingLists: { + type: 'object' as const, + description: 'Mailing list IDs mapped to subscription status', + optional: true, + }, + optInStatus: { + type: 'string' as const, + description: 'Double opt-in status: pending, accepted, rejected, or null', + optional: true, + }, +} diff --git a/apps/sim/tools/loops/update_contact.ts b/apps/sim/tools/loops/update_contact.ts new file mode 100644 index 0000000000..51d144bc4a --- /dev/null +++ b/apps/sim/tools/loops/update_contact.ts @@ -0,0 +1,152 @@ +import type { LoopsUpdateContactParams, LoopsUpdateContactResponse } from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsUpdateContactTool: ToolConfig< + LoopsUpdateContactParams, + LoopsUpdateContactResponse +> = { + id: 'loops_update_contact', + name: 'Loops Update Contact', + description: + 'Update an existing contact in Loops by email or userId. Creates a new contact if no match is found (upsert). Can update name, subscription status, user group, mailing lists, and custom properties.', + 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 (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact userId (at least one of email or userId is required)', + }, + firstName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact first name', + }, + lastName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact last name', + }, + source: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom source value replacing the default "API"', + }, + subscribed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether the contact receives campaign emails (sending true re-subscribes unsubscribed contacts)', + }, + userGroup: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Group to segment the contact into (one group per contact)', + }, + mailingLists: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Mailing list IDs mapped to boolean values (true to subscribe, false to unsubscribe)', + }, + customProperties: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Custom contact properties as key-value pairs (send null to reset a property)', + }, + }, + + request: { + url: 'https://app.loops.so/api/v1/contacts/update', + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to update a contact') + } + + // Apply custom properties first so standard fields always take precedence + const body: Record = {} + + if (params.customProperties) { + const props = + typeof params.customProperties === 'string' + ? JSON.parse(params.customProperties) + : params.customProperties + Object.assign(body, props) + } + + if (params.email) body.email = params.email + if (params.userId) body.userId = params.userId + if (params.firstName) body.firstName = params.firstName + if (params.lastName) body.lastName = params.lastName + if (params.source) body.source = params.source + if (params.subscribed != null) body.subscribed = params.subscribed + if (params.userGroup) body.userGroup = params.userGroup + + if (params.mailingLists) { + body.mailingLists = + typeof params.mailingLists === 'string' + ? JSON.parse(params.mailingLists) + : params.mailingLists + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + id: null, + }, + error: data.message ?? 'Failed to update contact', + } + } + + return { + success: true, + output: { + success: true, + id: data.id ?? null, + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the contact was updated successfully' }, + id: { + type: 'string', + description: 'The Loops-assigned ID of the updated or created contact', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 6ad7a864d9..ecf41bddd9 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1197,6 +1197,18 @@ import { import { linkedInGetProfileTool, linkedInSharePostTool } from '@/tools/linkedin' import { linkupSearchTool } from '@/tools/linkup' import { llmChatTool } from '@/tools/llm' +import { + loopsCreateContactPropertyTool, + loopsCreateContactTool, + loopsDeleteContactTool, + loopsFindContactTool, + loopsListContactPropertiesTool, + loopsListMailingListsTool, + loopsListTransactionalEmailsTool, + loopsSendEventTool, + loopsSendTransactionalEmailTool, + loopsUpdateContactTool, +} from '@/tools/loops' import { lumaAddGuestsTool, lumaCreateEventTool, @@ -2310,6 +2322,16 @@ export const tools: Record = { jina_read_url: jinaReadUrlTool, jina_search: jinaSearchTool, linkup_search: linkupSearchTool, + loops_create_contact: loopsCreateContactTool, + loops_create_contact_property: loopsCreateContactPropertyTool, + loops_update_contact: loopsUpdateContactTool, + loops_find_contact: loopsFindContactTool, + loops_delete_contact: loopsDeleteContactTool, + loops_list_contact_properties: loopsListContactPropertiesTool, + loops_list_mailing_lists: loopsListMailingListsTool, + loops_list_transactional_emails: loopsListTransactionalEmailsTool, + loops_send_transactional_email: loopsSendTransactionalEmailTool, + loops_send_event: loopsSendEventTool, luma_add_guests: lumaAddGuestsTool, luma_create_event: lumaCreateEventTool, luma_get_event: lumaGetEventTool,