diff --git a/README.md b/README.md index 5966826..a42f86b 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ Use `get_ab_test` to discover variant IDs before editing. Variant updates accept | `list_campaigns` | List campaigns, optionally filtered by status. | | `get_campaign` | Get campaign details and stats. | | `get_email_send` | Inspect a sent email detail record. | -| `create_campaign` | Create a draft campaign from HTML, blocks, a template, or campaign data. | +| `create_campaign` | Create a draft campaign from a prompt, HTML, blocks, a template, or campaign data. | | `update_campaign` | Update a draft campaign, including campaign data and computed lists. | | `schedule_campaign` | Schedule a draft or reschedule an existing scheduled campaign. | | `send_test_email` | Send a test email to one address. | @@ -450,13 +450,13 @@ For compatibility with older agent prompts, top-level style keys such as `backgr ### Transactional Email -| Tool | Description | -| ---------------------------- | ------------------------------------------------------ | -| `list_transactional_emails` | List transactional templates and API slugs. | -| `get_transactional_email` | Read a transactional email by ID or slug. | -| `create_transactional_email` | Create a transactional email template. | -| `update_transactional_email` | Update transactional metadata or body content. | -| `send_email` | Send a single transactional email by template or HTML. | +| Tool | Description | +| ---------------------------- | --------------------------------------------------------------- | +| `list_transactional_emails` | List transactional templates and API slugs. | +| `get_transactional_email` | Read a transactional email by ID or slug. | +| `create_transactional_email` | Create a transactional template from a prompt, HTML, or blocks. | +| `update_transactional_email` | Update transactional metadata or body content. | +| `send_email` | Send a single transactional email by template or HTML. | `send_email` variables support nested arrays for repeat blocks, such as `{ "event": { "items": [...] } }`. @@ -495,13 +495,17 @@ Analytics tools exclude detected bot, scanner, link-preview, and tracked asset o ### AI Generation -| Tool | Description | -| ------------------------ | -------------------------------------------- | -| `generate_email` | Generate email blocks from a prompt. | -| `generate_sequence` | Generate a multi-email sequence from a goal. | -| `generate_subject_lines` | Generate A/B subject line variants. | - -Generated content is returned as draft content for review. +| Tool | Description | +| ------------------------ | ---------------------------------------------------- | +| `generate_email` | Generate branded email blocks from a prompt. | +| `generate_sequence` | Generate a branded multi-email sequence from a goal. | +| `generate_subject_lines` | Generate A/B subject line variants. | + +Generated email content includes the company's logo and footer by default. +`generate_email` accepts `applyBranding: false` for raw content blocks and +`emailType: "transactional"` for a footer without an unsubscribe link. +Prompt-based campaigns inherit the company's configured email font. Generated +content is returned as draft content for review. ## Resources diff --git a/bun.lock b/bun.lock index 1504a7b..098b522 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@types/bun": "latest", "@types/node": "^22.0.0", "bun-types": "latest", - "typescript": "^5.8.0", + "typescript": "^7.0.2", }, }, }, @@ -24,6 +24,46 @@ "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -192,7 +232,7 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/package.json b/package.json index e0a5faa..08d7f67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sequenzy/mcp", - "version": "0.0.83", + "version": "0.0.94", "mcpName": "io.github.sequenzy/mcp", "description": "Sequenzy MCP server for AI-powered email marketing automation", "type": "module", @@ -18,7 +18,7 @@ "dev": "bun --watch src/index.ts", "build": "bun build src/index.ts src/server.ts src/runtime.ts --outdir dist --target node --minify", "start": "node dist/index.js", - "type-check": "tsc --noEmit", + "type-check": "bunx --bun tsc --noEmit", "test": "bun test", "test:watch": "bun test --watch", "prepublishOnly": "bun run build" @@ -30,7 +30,7 @@ "@types/bun": "latest", "@types/node": "^22.0.0", "bun-types": "latest", - "typescript": "^5.8.0" + "typescript": "^7.0.2" }, "files": [ "dist", diff --git a/server.json b/server.json index fb896d6..0d66903 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/Sequenzy/mcp", "source": "github" }, - "version": "0.0.83", + "version": "0.0.94", "packages": [ { "registryType": "npm", "identifier": "@sequenzy/mcp", - "version": "0.0.83", + "version": "0.0.94", "runtime": "node", "transport": { "type": "stdio" diff --git a/src/server.ts b/src/server.ts index e83534e..af765cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -59,6 +59,11 @@ export function createSequenzyMcpServer( tools: {}, resources: {}, }, + instructions: [ + "Sequenzy email marketing MCP server: manage subscribers, campaigns, sequences, templates, and delivery stats.", + "If you have access to multiple companies, call get_account and then select_company (or pass companyId per call) before other tools.", + "IMPORTANT: whenever the user wanted to accomplish something and these tools did not expose functionality for it (missing tool, missing argument, unsupported workflow, or broken behavior), call submit_feedback describing the gap before finishing the task. The Sequenzy team reads every submission and uses it to decide what to build next.", + ].join("\n"), } ); diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts index 4571797..f6d4986 100644 --- a/src/tools/index.test.ts +++ b/src/tools/index.test.ts @@ -84,6 +84,51 @@ function getPublishedToolSchemas(tool: (typeof tools)[number]) { ]; } +describe("AI generation tools", () => { + it("publishes branding controls for generate_email", () => { + const generateEmailTool = tools.find( + (tool) => tool.name === "generate_email" + ); + const properties = generateEmailTool?.inputSchema.properties as + | Record> + | undefined; + + expect(properties?.["applyBranding"]?.["type"]).toBe("boolean"); + expect(properties?.["emailType"]?.["enum"]).toEqual([ + "marketing", + "transactional", + ]); + }); + + it("forwards branding controls through generate_email", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + subject: "Password reset", + previewText: "Reset your password securely", + blocks: [], + }); + + await handleToolCall("generate_email", { + companyId: "company_123", + prompt: "Create a password reset email", + applyBranding: false, + emailType: "transactional", + }); + + expect(mockApiRequest).toHaveBeenCalledWith( + "POST", + "/api/v1/generate/email", + { + companyId: "company_123", + prompt: "Create a password reset email", + applyBranding: false, + emailType: "transactional", + }, + "company_123" + ); + }); +}); + describe("tool schema compatibility", () => { it("publishes required boolean tool annotations", () => { const requiredHints = [ @@ -920,6 +965,7 @@ describe("transactional email tools", () => { "/api/v1/generate/email", { prompt: "Create a concise password reset email with RESET_URL.", + emailType: "transactional", style: "minimal", tone: "professional", }, @@ -1057,6 +1103,13 @@ describe("update_campaign tool validation", () => { expect(inputSchema?.required).toEqual(["campaignId", "scheduledAt"]); expect(inputSchema?.additionalProperties).toBe(false); expect(inputSchema?.properties).toHaveProperty("targetLists"); + expect( + ( + inputSchema?.properties?.["targetLists"] as + | { description?: string } + | undefined + )?.description + ).toContain("{type:'rules'"); expect(inputSchema?.properties).toHaveProperty("sendTimeOptimization"); expect(inputSchema?.properties).toHaveProperty("spreadOverHours"); }); @@ -1204,6 +1257,42 @@ describe("update_campaign tool validation", () => { expect(payload.appUrls.campaignPreview).toBe(payload.campaign.previewUrl); }); + it("forwards rules audience targeting to the schedule endpoint", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + campaign: { + id: "camp_123", + name: "Launch", + subject: "Hello", + status: "scheduled", + }, + scheduledAt: "2026-06-01T14:00:00.000Z", + }); + + const targetLists = { + type: "rules", + include: [{ type: "lists", listIds: ["list_123"] }], + exclude: [{ type: "segments", segmentIds: ["seg_123"] }], + }; + const result = await handleToolCall("schedule_campaign", { + companyId: "comp_123", + campaignId: "camp_123", + scheduledAt: "2026-06-01T14:00:00Z", + targetLists, + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "POST", + "/api/v1/campaigns/camp_123/schedule", + { + scheduledAt: "2026-06-01T14:00:00Z", + targetLists, + }, + "comp_123" + ); + }); + it("rejects invalid schedule campaign arguments before hitting the API", async () => { const result = await handleToolCall("schedule_campaign", { campaignId: "camp_123", @@ -1217,6 +1306,137 @@ describe("update_campaign tool validation", () => { ); expect(mockApiRequest).not.toHaveBeenCalled(); }); + + it("forwards recurringInterval to the schedule endpoint", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + campaign: { + id: "camp_123", + name: "Monthly digest", + subject: "Hello", + status: "scheduled", + }, + scheduledAt: "2026-06-01T14:00:00.000Z", + }); + + const result = await handleToolCall("schedule_campaign", { + companyId: "comp_123", + campaignId: "camp_123", + scheduledAt: "2026-06-01T14:00:00Z", + recurringInterval: "monthly", + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "POST", + "/api/v1/campaigns/camp_123/schedule", + { + scheduledAt: "2026-06-01T14:00:00Z", + recurringInterval: "monthly", + }, + "comp_123" + ); + }); + + it("rejects an invalid recurringInterval before hitting the API", async () => { + const result = await handleToolCall("schedule_campaign", { + campaignId: "camp_123", + scheduledAt: "2026-06-01T14:00:00Z", + recurringInterval: "daily", + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain( + "`recurringInterval` must be 'weekly' or 'monthly' when calling `schedule_campaign`." + ); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); +}); + +describe("sync rules tools", () => { + beforeEach(() => { + mockApiRequest.mockClear(); + }); + + it("fetches sync rules", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + syncRules: [], + isDefault: true, + }); + + const result = await handleToolCall("get_sync_rules", { + companyId: "comp_123", + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "GET", + "/api/v1/sync-rules", + undefined, + "comp_123" + ); + }); + + it("replaces sync rules", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + syncRules: [], + isDefault: false, + }); + + const syncRules = [ + { + triggerEvent: "ecommerce.order_placed", + actions: { addTags: ["vinyl-collector"], removeTags: [] }, + conditions: { purchasedProduct: { tags: ["Vinyl"] } }, + }, + ]; + const result = await handleToolCall("update_sync_rules", { + companyId: "comp_123", + syncRules, + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "PUT", + "/api/v1/sync-rules", + { syncRules }, + "comp_123" + ); + }); + + it("resets sync rules with null", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + syncRules: [], + isDefault: true, + }); + + const result = await handleToolCall("update_sync_rules", { + syncRules: null, + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "PUT", + "/api/v1/sync-rules", + { syncRules: null }, + undefined + ); + }); + + it("rejects non-array sync rules before hitting the API", async () => { + const result = await handleToolCall("update_sync_rules", { + syncRules: "nope", + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain( + "`syncRules` must be an array of rules or null when calling `update_sync_rules`." + ); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); }); describe("create_template tool validation", () => { @@ -1949,6 +2169,8 @@ describe("update_sequence tool", () => { expect(inputSchema?.properties).toHaveProperty("confirmStructuralChange"); expect(inputSchema?.properties).toHaveProperty("sendingWindow"); expect(inputSchema?.properties).toHaveProperty("clearSendingWindow"); + expect(inputSchema?.properties).toHaveProperty("bccEmails"); + expect(inputSchema?.properties).toHaveProperty("clearBccEmails"); expect(inputSchema?.properties).toHaveProperty("stopCondition"); expect(inputSchema?.properties).toHaveProperty("branch"); expect(inputSchema?.properties).toHaveProperty("insertSteps"); @@ -2262,6 +2484,82 @@ describe("update_sequence tool", () => { expect(mockApiRequest).not.toHaveBeenCalled(); }); + it("maps clearBccEmails to a null API update", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + sequence: { + id: "seq_123", + name: "Activation Sequence", + status: "draft", + bccEmails: null, + }, + }); + + const result = await handleToolCall("update_sequence", { + companyId: "comp_123", + sequenceId: "seq_123", + clearBccEmails: true, + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "PUT", + "/api/v1/sequences/seq_123", + { + companyId: "comp_123", + sequenceId: "seq_123", + bccEmails: null, + }, + "comp_123" + ); + }); + + it("forwards bccEmails to the API", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + sequence: { + id: "seq_123", + name: "Activation Sequence", + status: "draft", + bccEmails: ["cs-team@example.com", "founder@example.com"], + }, + }); + + const result = await handleToolCall("update_sequence", { + companyId: "comp_123", + sequenceId: "seq_123", + bccEmails: ["cs-team@example.com", "founder@example.com"], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "PUT", + "/api/v1/sequences/seq_123", + { + companyId: "comp_123", + sequenceId: "seq_123", + bccEmails: ["cs-team@example.com", "founder@example.com"], + }, + "comp_123" + ); + }); + + it("rejects clearBccEmails with bccEmails before hitting the API", async () => { + const result = await handleToolCall("update_sequence", { + companyId: "comp_123", + sequenceId: "seq_123", + bccEmails: ["cs-team@example.com"], + clearBccEmails: true, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.type).toBe("text"); + expect(result.content[0]?.text).toContain( + "Provide either `bccEmails` or `clearBccEmails` when calling `update_sequence`, not both." + ); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + it("rejects clearSendingWindow with sendingWindow before hitting the API", async () => { const result = await handleToolCall("update_sequence", { companyId: "comp_123", @@ -2335,7 +2633,7 @@ describe("insert_sequence_step tool", () => { } | undefined; - expect(inputSchema?.required).toEqual(["sequenceId", "subject"]); + expect(inputSchema?.required).toEqual(["sequenceId"]); expect(inputSchema?.additionalProperties).toBe(false); expect(inputSchema?.properties).toHaveProperty("afterNodeId"); expect(inputSchema?.properties).toHaveProperty("confirmStructuralChange"); @@ -2346,6 +2644,117 @@ describe("insert_sequence_step tool", () => { expect(inputSchema?.properties).toHaveProperty("delay"); expect(inputSchema?.properties).toHaveProperty("delayMs"); expect(inputSchema?.properties).toHaveProperty("waitUntil"); + expect(inputSchema?.properties).toHaveProperty("type"); + expect(inputSchema?.properties).toHaveProperty("text"); + expect(inputSchema?.properties).toHaveProperty("ineligibleAction"); + }); + + it("rejects email steps without a subject", async () => { + const result = await handleToolCall("insert_sequence_step", { + sequenceId: "seq_123", + html: "

Missing subject

", + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("subject"); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it("wraps an SMS step in update_sequence insertSteps", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + sequence: { + id: "seq_123", + name: "Activation Sequence", + status: "draft", + updatedEmailCount: 0, + insertedNodeIds: ["delay_inserted", "node_sms"], + }, + }); + + const result = await handleToolCall("insert_sequence_step", { + companyId: "comp_123", + sequenceId: "seq_123", + type: "sms", + afterNodeId: "node_email", + text: "Hey {{FIRST_NAME}}, your order shipped!", + label: "Shipping text", + ineligibleAction: "skip", + delay: { days: 1 }, + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "PUT", + "/api/v1/sequences/seq_123", + { + insertSteps: { + afterNodeId: "node_email", + steps: [ + { + type: "sms", + text: "Hey {{FIRST_NAME}}, your order shipped!", + label: "Shipping text", + ineligibleAction: "skip", + delay: { days: 1 }, + }, + ], + }, + }, + "comp_123" + ); + }); + + it("rejects SMS steps without text or blocks", async () => { + const result = await handleToolCall("insert_sequence_step", { + sequenceId: "seq_123", + type: "sms", + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("text"); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it("passes smsSteps updates through update_sequence", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + sequence: { + id: "seq_123", + name: "SMS Sequence", + status: "draft", + updatedEmailCount: 0, + updatedSmsStepCount: 1, + }, + }); + + const result = await handleToolCall("update_sequence", { + sequenceId: "seq_123", + smsSteps: [ + { + nodeId: "node_sms", + text: "Updated message", + ineligibleAction: "exit", + }, + ], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "PUT", + "/api/v1/sequences/seq_123", + { + sequenceId: "seq_123", + smsSteps: [ + { + nodeId: "node_sms", + text: "Updated message", + ineligibleAction: "exit", + }, + ], + }, + undefined + ); }); it("wraps one new delayed email step in update_sequence insertSteps", async () => { @@ -4478,3 +4887,41 @@ describe("audience sync tools", () => { ); }); }); + +describe("send_test_sms tool", () => { + beforeEach(() => { + mockApiRequest.mockClear(); + }); + + it("posts a test SMS send", async () => { + mockApiRequest.mockResolvedValueOnce({ + success: true, + smsSendId: "sms_test", + toPhone: "+15550100123", + message: "Test SMS queued to +15550100123.", + }); + + const result = await handleToolCall("send_test_sms", { + companyId: "comp_123", + to: "+15550100123", + text: "Test from Sequenzy", + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiRequest).toHaveBeenCalledWith( + "POST", + "/api/v1/sms/test", + { to: "+15550100123", text: "Test from Sequenzy" }, + "comp_123" + ); + }); + + it("requires a destination phone", async () => { + const result = await handleToolCall("send_test_sms", { + text: "Missing phone", + }); + + expect(result.isError).toBe(true); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 4f27de7..9995232 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -240,6 +240,10 @@ const OUTBOUND_WEBHOOK_EVENT_TYPES = [ "email.clicked", "email.replied", "email.unsubscribed", + "sms.sent", + "sms.delivered", + "sms.failed", + "sms.opted_out", "subscriber.invalid", "subscriber.updated", "subscriber.unsubscribed", @@ -256,6 +260,8 @@ const READ_ONLY_TOOL_NAMES = new Set([ "get_account", "get_app_urls", "get_company", + "get_sync_rules", + "get_shopify_automation_settings", "list_websites", "check_website", "get_integration_guide", @@ -297,12 +303,16 @@ const READ_ONLY_TOOL_NAMES = new Set([ "generate_email", "generate_sequence", "generate_subject_lines", + "generate_sms", + "get_sms_settings", ]); const MUTATING_TOOL_NAMES = new Set([ "select_company", "create_company", "update_company", + "update_sync_rules", + "update_shopify_automation_settings", "create_api_key", "add_website", "add_subscriber", @@ -343,6 +353,7 @@ const MUTATING_TOOL_NAMES = new Set([ "update_campaign", "schedule_campaign", "send_test_email", + "send_test_sms", "cancel_campaign", "pause_campaign", "resume_campaign", @@ -379,10 +390,12 @@ const MUTATING_TOOL_NAMES = new Set([ "delete_webhook", "test_webhook", "replay_webhook_delivery", + "submit_feedback", ]); const OPEN_WORLD_TOOL_NAMES = new Set([ "send_test_email", + "send_test_sms", "schedule_campaign", "resume_campaign", "publish_landing_page", @@ -453,6 +466,8 @@ function withRequiredToolHints(tool: Tool): Tool { const segmentOperatorsByField = { status: ["is", "is_not"], + phone: ["is_not_empty", "is_empty"], + smsStatus: ["is", "is_not"], tag: ["contains", "not_contains", "is_empty", "is_not_empty"], email: ["contains", "not_contains"], emailProvider: ["is", "is_not", "is_empty", "is_not_empty"], @@ -509,6 +524,8 @@ const segmentFilterItemSchema = { type: "string", enum: [ "status", + "phone", + "smsStatus", "tag", "email", "emailProvider", @@ -531,7 +548,7 @@ const segmentFilterItemSchema = { "commerceProduct", ], description: - "Filter field. Use `event` for custom subscriber events, `segment` for saved segment membership, `stripeProduct`/`stripeCurrentProduct`/`stripeTrialProduct` for Stripe product-based segments, and `commerceProduct` for products purchased through commerce orders - the value is `provider:productId` (provider one of `shopify`, `woocommerce`, `api`; product ids are provider-scoped), optionally with an order-count threshold (`provider:productId:count`) for at_least/less_than_count; a bare product id matches the id on any provider. Engagement fields (`emailSent`, `emailDelivered`, `emailOpened`, `emailClicked`, `emailBounced`, `emailComplained`) accept a time range as the value or a specific campaign via `campaign:`.", + "Filter field. Use `phone` with is_not_empty/is_empty to check whether a subscriber has a phone number, and `smsStatus` (is/is_not one of `subscribed`, `unsubscribed`, `not_subscribed`) for SMS marketing consent. Use `event` for custom subscriber events, `segment` for saved segment membership, `stripeProduct`/`stripeCurrentProduct`/`stripeTrialProduct` for Stripe product-based segments, and `commerceProduct` for products purchased through commerce orders - the value is `provider:productId` (provider one of `shopify`, `woocommerce`, `api`; product ids are provider-scoped), optionally with an order-count threshold (`provider:productId:count`) for at_least/less_than_count; a bare product id matches the id on any provider. Engagement fields (`emailSent`, `emailDelivered`, `emailOpened`, `emailClicked`, `emailBounced`, `emailComplained`) accept a time range as the value or a specific campaign via `campaign:`.", }, operator: { type: "string", @@ -1314,6 +1331,11 @@ function buildUpdateSequenceBody( "Provide either `sendingWindow` or `clearSendingWindow` when calling `update_sequence`, not both." ); } + if (args.clearBccEmails === true && args.bccEmails !== undefined) { + throw new Error( + "Provide either `bccEmails` or `clearBccEmails` when calling `update_sequence`, not both." + ); + } if (args.branch !== undefined && args.insertSteps !== undefined) { throw new Error( "Provide either `branch` or `insertSteps` when calling `update_sequence`, not both." @@ -1323,6 +1345,7 @@ function buildUpdateSequenceBody( const body = { ...args }; delete body.clearEnrollmentFieldPath; delete body.clearSendingWindow; + delete body.clearBccEmails; if (args.clearEnrollmentFieldPath === true) { body.enrollmentFieldPath = null; @@ -1330,6 +1353,9 @@ function buildUpdateSequenceBody( if (args.clearSendingWindow === true) { body.sendingWindow = null; } + if (args.clearBccEmails === true) { + body.bccEmails = null; + } return body; } @@ -1337,24 +1363,54 @@ function buildUpdateSequenceBody( function buildInsertSequenceStepBody( args: Record ): Record { - validateHtmlOrBlocksArgs("insert_sequence_step", args, { - requireContent: true, - }); + const isSmsStep = args.type === "sms"; - const step: Record = { - subject: requiredString("insert_sequence_step", args, "subject"), - }; - for (const key of [ - "name", - "previewText", - "html", - "blocks", - "delay", - "delayMs", - "waitUntil", - ]) { - if (args[key] !== undefined) { - step[key] = args[key]; + let step: Record; + if (isSmsStep) { + const text = optionalString(args, "text"); + const hasBlocks = Array.isArray(args.blocks) && args.blocks.length > 0; + if (text === undefined && !hasBlocks) { + throw new Error( + "Provide `text` (or `blocks`) when inserting an SMS step with `insert_sequence_step`." + ); + } + + step = { type: "sms" }; + for (const key of [ + "text", + "blocks", + "imageUrls", + "label", + "name", + "ineligibleAction", + "delay", + "delayMs", + "waitUntil", + ]) { + if (args[key] !== undefined) { + step[key] = args[key]; + } + } + } else { + validateHtmlOrBlocksArgs("insert_sequence_step", args, { + requireContent: true, + }); + + step = { + subject: requiredString("insert_sequence_step", args, "subject"), + }; + for (const key of [ + "name", + "previewText", + "html", + "blocks", + "delay", + "delayMs", + "waitUntil", + ]) { + if (args[key] !== undefined) { + step[key] = args[key]; + } } } @@ -1492,6 +1548,16 @@ function validateScheduleCampaignArgs(args: Record): void { ); } } + + if ( + args.recurringInterval !== undefined && + args.recurringInterval !== "weekly" && + args.recurringInterval !== "monthly" + ) { + throw new Error( + "`recurringInterval` must be 'weekly' or 'monthly' when calling `schedule_campaign`." + ); + } } function buildLandingPageUpdateBody( @@ -2916,6 +2982,44 @@ const outputPropertiesByToolName: Record = { get_company: { company: resourceOutputProperty("company"), }, + get_sync_rules: { + syncRules: { + type: "array", + description: "Effective sync rules for the company.", + items: objectOutputProperty("One sync rule."), + }, + isDefault: booleanOutputProperty( + "Whether the company still uses the platform default rules." + ), + }, + update_sync_rules: { + syncRules: { + type: "array", + description: "Effective sync rules after the update.", + items: objectOutputProperty("One sync rule."), + }, + isDefault: booleanOutputProperty( + "Whether the company now uses the platform default rules." + ), + }, + get_shopify_automation_settings: { + browseAbandonment: objectOutputProperty( + "Effective browse-abandonment settings (defaults applied)." + ), + priceDrop: objectOutputProperty( + "Effective price-drop settings (defaults applied)." + ), + shopDomain: stringOutputProperty("Connected Shopify store domain."), + }, + update_shopify_automation_settings: { + browseAbandonment: objectOutputProperty( + "Effective browse-abandonment settings after the update." + ), + priceDrop: objectOutputProperty( + "Effective price-drop settings after the update." + ), + shopDomain: stringOutputProperty("Connected Shopify store domain."), + }, create_api_key: { apiKey: objectOutputProperty( "Created API key metadata: the newly created secret in `key`, plus `scopes` listing the assigned permission scopes (null when the key has full access)." @@ -3140,6 +3244,10 @@ const outputPropertiesByToolName: Record = { emailSend: resourceOutputProperty("test email send"), recipient: stringOutputProperty("Test email recipient."), }, + send_test_sms: { + smsSendId: stringOutputProperty("Created test SMS send ID."), + toPhone: stringOutputProperty("Normalized E.164 destination phone."), + }, cancel_campaign: { campaign: resourceOutputProperty("campaign"), }, @@ -3345,7 +3453,8 @@ const outputPropertiesByToolName: Record = { html: stringOutputProperty("Generated HTML email body."), blocks: { type: "array", - description: "Generated Sequenzy email blocks.", + description: + "Generated Sequenzy email blocks, wrapped with company branding (logo + footer) unless applyBranding was false.", items: objectOutputProperty("One generated email block."), }, subject: stringOutputProperty("Generated subject line."), @@ -3363,6 +3472,21 @@ const outputPropertiesByToolName: Record = { }, variants: resourceListOutputProperty("generated subject line variant"), }, + generate_sms: { + prompt: stringOutputProperty("The prompt the messages were generated for."), + messages: { + type: "array", + description: + "Generated SMS message variants with encoding and segment counts.", + items: objectOutputProperty("One generated SMS message."), + }, + }, + get_sms_settings: { + sms: objectOutputProperty( + "SMS add-on status: enabled, planEligible, creditsBalance, brandPrefix, numbers, readyToSend (paid plan or credits plus an active number)." + ), + }, + submit_feedback: {}, }; function getToolOutputSchema(toolName: string): ToolOutputSchema { @@ -3641,6 +3765,175 @@ The response shows 'companies' (all available) and 'selectedCompanyId' (currentl }, }, }, + { + name: "get_shopify_automation_settings", + description: + "Get the connected Shopify store's automation settings: browse abandonment (emails shoppers who viewed a product but didn't buy) and price drop alerts (emails recent viewers when a product's price falls). Returns the effective values with platform defaults applied.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "update_shopify_automation_settings", + description: + "Update the connected Shopify store's browse-abandonment and/or price-drop automation settings. Partial update: omitted sections are untouched, omitted fields within a section keep their current value, and passing null for a section resets it to the platform defaults (browse abandonment: on, 2h delay, 24h cooldown; price drop: on, 5% minimum drop, 30-day viewer lookback, 7-day cooldown).", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + browseAbandonment: { + type: ["object", "null"], + description: + "Browse abandonment settings, or null to reset to defaults.", + properties: { + enabled: { type: "boolean" }, + delayHours: { + type: "number", + description: + "Hours to wait after a product view before checking for abandonment (default 2).", + }, + cooldownHours: { + type: "number", + description: + "Minimum hours between browse-abandoned events per subscriber (default 24).", + }, + }, + additionalProperties: false, + }, + priceDrop: { + type: ["object", "null"], + description: "Price drop settings, or null to reset to defaults.", + properties: { + enabled: { type: "boolean" }, + minPercent: { + type: "number", + description: + "Minimum price decrease percent to alert on (default 5).", + }, + lookbackDays: { + type: "number", + description: + "How many days back product viewers qualify as the audience (default 30).", + }, + cooldownDays: { + type: "number", + description: + "Minimum days between price-drop events per subscriber and product (default 7).", + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + }, + { + name: "get_sync_rules", + description: + "Get the company's sync rules: the automatic tag changes applied when events fire (e.g. order placed -> add a tag). Returns the effective rules plus isDefault, which is true while the company still uses the platform defaults.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + }, + additionalProperties: false, + }, + }, + { + name: "update_sync_rules", + description: + "Replace the company's sync rules. Pass the FULL rule set (fetch with get_sync_rules first and edit it - this is not a partial update), or null to reset to the platform defaults. Each rule: { triggerEvent, actions: { addTags, removeTags }, conditions? }. Conditions support requiresTags, requiresNotTags, and purchasedProduct ({ tags?, collectionIds?, productTypes?, vendors? }) which matches products on commerce events - e.g. tag buyers of products carrying a given product tag.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + syncRules: { + type: ["array", "null"], + description: + "Full replacement rule set, or null to reset to the platform defaults.", + items: { + type: "object", + properties: { + triggerEvent: { + type: "string", + description: + "Event name that triggers the rule, e.g. ecommerce.order_placed or saas.purchase.", + }, + actions: { + type: "object", + properties: { + addTags: { type: "array", items: { type: "string" } }, + removeTags: { type: "array", items: { type: "string" } }, + }, + required: ["addTags", "removeTags"], + additionalProperties: false, + }, + conditions: { + type: "object", + properties: { + requiresTags: { + type: "array", + items: { type: "string" }, + description: + "Rule only applies if the subscriber has ALL of these tags.", + }, + requiresNotTags: { + type: "array", + items: { type: "string" }, + description: + "Rule only applies if the subscriber has NONE of these tags.", + }, + purchasedProduct: { + type: "object", + description: + "For commerce events with product context: rule only applies when a product on the event matches every specified selector.", + properties: { + tags: { type: "array", items: { type: "string" } }, + collectionIds: { + type: "array", + items: { type: "string" }, + }, + productTypes: { + type: "array", + items: { type: "string" }, + }, + vendors: { type: "array", items: { type: "string" } }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + }, + required: ["triggerEvent", "actions"], + additionalProperties: false, + }, + }, + }, + required: ["syncRules"], + additionalProperties: false, + }, + }, { name: "create_api_key", description: @@ -5307,7 +5600,7 @@ Before implementing, use create_api_key to generate an API key and save it to .e prompt: { type: "string", description: - "Generate campaign blocks from a prompt. Mutually exclusive with `html`, `blocks`, and `templateId`.", + "Generate campaign blocks from a prompt. Generated blocks include the company's logo and footer, and the created campaign inherits the company brand font. Mutually exclusive with `html`, `blocks`, and `templateId`.", }, style: { type: "string", @@ -5452,7 +5745,7 @@ Before implementing, use create_api_key to generate an API key and save it to .e targetLists: { type: "object", description: - "Optional campaign targeting object. Omit to use saved targeting or all active subscribers. Examples: {type:'all'}, {type:'lists', listIds:['list_123']}, {type:'segment', segmentId:'seg_123'}, or {type:'filtered', filters:[...], filterJoinOperator:'and'}.", + "Optional campaign targeting object. Omit to use saved targeting or all active subscribers. Examples: {type:'all'}, {type:'lists', listIds:['list_123']}, {type:'segment', segmentId:'seg_123'}, {type:'filtered', filters:[...], filterJoinOperator:'and'}, or {type:'rules', include:[{type:'lists', listIds:['list_123']}], exclude:[{type:'segments', segmentIds:['seg_123']}]}. Rules audiences require at least one include rule.", additionalProperties: true, }, sendTimeOptimization: { @@ -5464,6 +5757,12 @@ Before implementing, use create_api_key to generate an API key and save it to .e description: "Spread delivery over an integer number of hours from 1 to 72. When set, spread delivery takes precedence over send-time optimization.", }, + recurringInterval: { + type: "string", + enum: ["weekly", "monthly"], + description: + "Repeat this campaign on a cadence starting at scheduledAt. The campaign becomes a recurring template: each run is duplicated and sent automatically, re-evaluating audience membership every time. Omit for a one-shot send; scheduling again without it stops the recurrence.", + }, }, required: ["campaignId", "scheduledAt"], additionalProperties: false, @@ -5910,7 +6209,7 @@ Before implementing, use create_api_key to generate an API key and save it to .e }, { name: "create_sequence", - description: `Create a new email sequence. Provide either a goal for AI generation or explicit steps. Explicit steps can include email content and create_discount actions that dynamically create a provider discount/code when each subscriber reaches the step; emails after a discount action can use merge tags such as {{discount.code}} and {{discount.percentOff}}. For AI-generated sequences, the tool polls until emails are generated (typically 30-60 seconds). + description: `Create a new email sequence. Provide either a goal for AI generation or explicit steps. Explicit steps can include email content, native SMS steps (type: 'sms' with 'text'; check get_sms_settings first and generate copy with generate_sms), and create_discount actions that dynamically create a provider discount/code when each subscriber reaches the step; emails after a discount action can use merge tags such as {{discount.code}} and {{discount.percentOff}}. For AI-generated sequences, the tool polls until emails are generated (typically 30-60 seconds). MIGRATIONS: When moving sequences or flows from Brevo, Mailchimp, Klaviyo, MailerLite, or another provider, pass the exact provider HTML in each email step's html field and pass fixed waits as delay or delayMs. Use waitUntil when a wait should resolve from the trigger event payload, for example { "field": "renews_at", "direction": "before", "offset": { "days": 1 } }. The API stores provider HTML as raw HTML blocks and creates real logic_delay nodes for waits. @@ -6219,15 +6518,15 @@ OTHER BUILT-IN EVENTS: steps: { type: "array", description: - "Explicit sequence steps. Omit type for email steps, or use type: 'create_discount' for a dynamic Stripe or Shopify discount action. Later email steps can reference the most recent generated code with {{discount.code}}, {{discount.percentOff}}, {{discount.amountOff}}, and {{discount.expiresAt}}.", + "Explicit sequence steps. Omit type for email steps, use type: 'sms' for a native SMS step (provide 'text'; generate copy with generate_sms and check get_sms_settings first), or use type: 'create_discount' for a dynamic Stripe or Shopify discount action. Later email steps can reference the most recent generated code with {{discount.code}}, {{discount.percentOff}}, {{discount.amountOff}}, and {{discount.expiresAt}}.", items: { type: "object", properties: { type: { type: "string", - enum: ["email", "create_discount", "discount"], + enum: ["email", "sms", "create_discount", "discount"], description: - "Step type. Omit or use 'email' for email content. Use 'create_discount' to dynamically generate a discount code before later emails.", + "Step type. Omit or use 'email' for email content. Use 'sms' to send a text message (requires 'text'). Use 'create_discount' to dynamically generate a discount code before later emails.", }, subject: { type: "string", @@ -6247,6 +6546,23 @@ OTHER BUILT-IN EVENTS: description: "HTML content for email steps. Stored as one raw HTML block. Use this for imported provider HTML.", }, + text: { + type: "string", + description: + "SMS steps only: plain-text message body. Merge tags like {{FIRST_NAME}} work. Do not include opt-out text or a brand prefix - the platform adds those automatically.", + }, + imageUrls: { + type: "array", + items: { type: "string" }, + description: + "SMS steps only: up to 2 publicly reachable image URLs sent as MMS media.", + }, + ineligibleAction: { + type: "string", + enum: ["skip", "exit"], + description: + "SMS steps only: what happens when the contact can't receive SMS (no phone, not opted in, unsupported country). Default 'skip' continues the sequence.", + }, delay: sequenceDelaySchema, delayMs: { type: "number", @@ -6432,7 +6748,7 @@ OTHER BUILT-IN EVENTS: { name: "update_sequence", description: - "Update an existing sequence. To target a specific existing step, use the emailId or nodeId returned in get_sequence.sequence.emails. To insert new linear steps, use insertSteps with an afterNodeId from get_sequence; omit afterNodeId only to append to an unambiguous linear tail. The emails/steps arrays edit existing email steps only; items without emailId/nodeId are matched by existing step order and do not create new steps. For active sequences, structural changes such as insertSteps or branch require confirmStructuralChange:true after the user confirms the live-flow impact. You can also update enrollmentMode and enrollmentFieldPath for event-triggered matching-field enrollment. When inserting an if/else branch, include steps for every branch arm and elseSteps so the branch is usable immediately. Branch conditions support tags, lists, saved segments, custom events, clicked links, and subscriber field comparisons.", + "Update an existing sequence. To target a specific existing step, use the emailId or nodeId returned in get_sequence.sequence.emails. To insert new linear steps, use insertSteps with an afterNodeId from get_sequence; omit afterNodeId only to append to an unambiguous linear tail. The emails/steps arrays edit existing email steps only; items without emailId/nodeId are matched by existing step order and do not create new steps. Use smsSteps (targeted by action_sms nodeId) to edit the text, label, or ineligibleAction of existing SMS steps. For active sequences, structural changes such as insertSteps or branch require confirmStructuralChange:true after the user confirms the live-flow impact. You can also update enrollmentMode and enrollmentFieldPath for event-triggered matching-field enrollment. When inserting an if/else branch, include steps for every branch arm and elseSteps so the branch is usable immediately. Branch conditions support tags, lists, saved segments, custom events, clicked links, and subscriber field comparisons.", inputSchema: { type: "object", properties: { @@ -6481,6 +6797,17 @@ OTHER BUILT-IN EVENTS: description: "Set true to remove the sequence sending window. Do not provide this together with sendingWindow.", }, + bccEmails: { + type: "array", + items: { type: "string" }, + description: + "Email addresses that receive a blind copy of every email this sequence sends, such as a customer support inbox (max 10). Omit to leave unchanged. Use clearBccEmails to remove them.", + }, + clearBccEmails: { + type: "boolean", + description: + "Set true to remove the sequence BCC addresses. Do not provide this together with bccEmails.", + }, stopCondition: { type: "object", description: @@ -6550,8 +6877,11 @@ OTHER BUILT-IN EVENTS: "field_contains", "field_greater_than", "field_less_than", + "has_phone", + "sms_subscribed", ], - description: "Condition type for this branch.", + description: + "Condition type for this branch. has_phone (contact has a phone number) and sms_subscribed (contact opted in to SMS marketing) need no extra fields.", }, tagName: { type: "string", @@ -6738,6 +7068,48 @@ OTHER BUILT-IN EVENTS: }, }, }, + smsSteps: { + type: "array", + description: + "Content updates for existing SMS steps, targeted by nodeId (an action_sms node from get_sequence.sequence.nodes). Content-only edits - use insertSteps to create new SMS steps.", + items: { + type: "object", + properties: { + nodeId: { + type: "string", + description: "Target action_sms node ID. Required.", + }, + text: { + type: "string", + description: + "Replacement plain-text message body. Merge tags like {{FIRST_NAME}} work. Provide text or blocks, not both.", + }, + blocks: { + type: "array", + items: { type: "object" }, + description: + "Replacement SMS content blocks (text + image subset).", + }, + imageUrls: { + type: "array", + items: { type: "string" }, + description: + "Up to 2 publicly reachable MMS image URLs. Only valid together with text.", + }, + label: { + type: "string", + description: "Updated display label for the step.", + }, + ineligibleAction: { + type: "string", + enum: ["skip", "exit"], + description: + "Updated behavior when the contact can't receive SMS.", + }, + }, + required: ["nodeId"], + }, + }, }, required: ["sequenceId"], }, @@ -6745,7 +7117,7 @@ OTHER BUILT-IN EVENTS: { name: "insert_sequence_step", description: - "Insert one new email step into an existing sequence. Prefer this over update_sequence.steps/emails when adding a step: it creates a new email template, a new action_email node, and, when delay or delayMs is provided, a logic_delay node immediately before the email. Use get_sequence first, then pass afterNodeId from sequence.nodes or sequence.emails to choose the insertion point. If afterNodeId is omitted, the step is appended only when the sequence has exactly one linear tail. For active sequences, set confirmStructuralChange:true only after the user confirms the live-flow impact.", + "Insert one new email or SMS step into an existing sequence. Prefer this over update_sequence.steps/emails when adding a step: it creates the step node (plus an email template for email steps) and, when delay or delayMs is provided, a logic_delay node immediately before it. For SMS steps set type:'sms' and provide 'text' (generate copy with generate_sms; check get_sms_settings first and warn the user if SMS is not enabled). Use get_sequence first, then pass afterNodeId from sequence.nodes or sequence.emails to choose the insertion point. If afterNodeId is omitted, the step is appended only when the sequence has exactly one linear tail. For active sequences, set confirmStructuralChange:true only after the user confirms the live-flow impact.", inputSchema: { type: "object", properties: { @@ -6758,6 +7130,12 @@ OTHER BUILT-IN EVENTS: type: "string", description: "Sequence ID.", }, + type: { + type: "string", + enum: ["email", "sms"], + description: + "Step type. Defaults to 'email'. Use 'sms' to insert an SMS step (requires 'text').", + }, afterNodeId: { type: "string", description: @@ -6774,7 +7152,8 @@ OTHER BUILT-IN EVENTS: }, subject: { type: "string", - description: "Email subject for the new step.", + description: + "Email subject for the new step. Required for email steps; not used for SMS steps.", }, previewText: { type: "string", @@ -6790,15 +7169,36 @@ OTHER BUILT-IN EVENTS: description: "HTML content for the new step. Stored as one raw HTML block. Use this for imported provider HTML. Provide either html or blocks, not both.", }, + text: { + type: "string", + description: + "SMS steps only: plain-text message body. Merge tags like {{FIRST_NAME}} work. Do not include opt-out text or a brand prefix - the platform adds those automatically.", + }, + imageUrls: { + type: "array", + items: { type: "string" }, + description: + "SMS steps only: up to 2 publicly reachable image URLs sent as MMS media.", + }, + label: { + type: "string", + description: "SMS steps only: display label for the step.", + }, + ineligibleAction: { + type: "string", + enum: ["skip", "exit"], + description: + "SMS steps only: what happens when the contact can't receive SMS (no phone, not opted in, unsupported country). Default 'skip' continues the sequence.", + }, delay: sequenceDelaySchema, delayMs: { type: "number", description: - "Optional wait in milliseconds before the new email step. Prefer delay for readability.", + "Optional wait in milliseconds before the new step. Prefer delay for readability.", }, waitUntil: sequenceWaitUntilSchema, }, - required: ["sequenceId", "subject"], + required: ["sequenceId"], additionalProperties: false, }, }, @@ -7070,7 +7470,7 @@ OTHER BUILT-IN EVENTS: prompt: { type: "string", description: - "Generate transactional email blocks from a prompt. Mutually exclusive with `html` and `blocks`.", + "Generate transactional email blocks from a prompt. Generated blocks include the company's branding (logo and a footer without an unsubscribe link). Mutually exclusive with `html` and `blocks`.", }, style: { type: "string", @@ -7789,7 +8189,8 @@ OTHER BUILT-IN EVENTS: // ============================================================================ { name: "generate_email", - description: "Generate email blocks from a prompt", + description: + "Generate email blocks from a prompt. Generated blocks include the company's branding chrome (logo, footer with company name/address/unsubscribe) by default, matching emails created in the dashboard.", inputSchema: { type: "object", properties: { @@ -7810,6 +8211,17 @@ OTHER BUILT-IN EVENTS: type: "string", description: "Tone: professional, casual, friendly", }, + applyBranding: { + type: "boolean", + description: + "Wrap the generated content with company branding (logo + footer). Defaults to true; set false to get raw content blocks only.", + }, + emailType: { + type: "string", + enum: ["marketing", "transactional"], + description: + "Email type. Transactional emails get a footer without the unsubscribe link. Defaults to marketing.", + }, }, required: ["prompt"], }, @@ -7866,6 +8278,121 @@ OTHER BUILT-IN EVENTS: required: ["topic"], }, }, + { + name: "generate_sms", + description: + "Generate SMS marketing message variants from a prompt. Returns plain-text messages with per-message encoding and segment counts. Use the results as the 'text' of an SMS sequence step (insert_sequence_step with type 'sms' or a create_sequence sms step). Messages exclude opt-out footers and brand prefixes - the platform adds those automatically at send time.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + prompt: { + type: "string", + description: + "Description of the SMS to generate (e.g., 'cart reminder with a free-shipping hook')", + }, + count: { + type: "number", + description: + "Number of message variants to generate (default: 3, max: 10)", + }, + }, + required: ["prompt"], + }, + }, + { + name: "get_sms_settings", + description: + "Get the company's SMS add-on status: whether SMS is enabled, plan eligibility, credit balance, brand prefix, phone numbers, and whether SMS steps will actually send. Check this before adding SMS steps to sequences so you can warn the user when SMS is not ready.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + }, + }, + }, + { + name: "send_test_sms", + description: + "Send a test SMS to a phone number. IMPORTANT: sends a real text message and charges SMS credits - only call when the user explicitly asks for a test send. Requires the SMS add-on with a verified number (check get_sms_settings). Limited to 5 test sends per hour; bypasses quiet hours; excluded from step stats.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + to: { + type: "string", + description: + "Destination phone number in international E.164 format, e.g. +15550100123.", + }, + text: { + type: "string", + description: + "Plain-text message body. Merge tags are not resolved for test sends to arbitrary numbers. Provide text or blocks.", + }, + imageUrls: { + type: "array", + items: { type: "string" }, + description: + "Up to 2 publicly reachable image URLs sent as MMS media (US/CA only).", + }, + blocks: { + type: "array", + items: { type: "object" }, + description: + "SMS content blocks (text + image subset). Provide text or blocks, not both.", + }, + }, + required: ["to"], + }, + }, + + // ============================================================================ + // Feedback + // ============================================================================ + { + name: "submit_feedback", + description: + "Send product feedback about Sequenzy itself to the Sequenzy team. IMPORTANT: call this tool whenever the user wanted to accomplish something and Sequenzy did not expose functionality for it - a missing tool or argument, an unsupported workflow, a confusing behavior, or a bug. Briefly tell the user you reported the gap. This feedback goes straight to the team and shapes what gets built next. Do not use it for questions about the user's own account or data, and never include secrets or API keys. Submitting feedback never changes account data.", + inputSchema: { + type: "object", + properties: { + companyId: { + type: "string", + description: + "Company ID. If not provided, uses the currently selected company.", + }, + message: { + type: "string", + description: + "The feedback itself. Be specific: what was needed, what was missing or wrong, and what you did instead (workaround, gave up, used the dashboard).", + }, + category: { + type: "string", + enum: ["missing_capability", "bug", "docs", "ux", "praise", "other"], + description: + "Feedback category. Use missing_capability when the user wanted something these tools do not support (default: other).", + }, + context: { + type: "string", + description: + "Optional: what the user was trying to accomplish, in their words, plus any relevant tool names or arguments you tried.", + }, + }, + required: ["message"], + }, + }, ]; export const tools: Tool[] = toolDefinitions @@ -8036,6 +8563,67 @@ export async function handleToolCall( break; } + case "get_sync_rules": { + const companyId = args.companyId as string | undefined; + result = await apiRequest( + "GET", + "/api/v1/sync-rules", + undefined, + companyId + ); + break; + } + + case "update_sync_rules": { + const companyId = args.companyId as string | undefined; + if (args.syncRules !== null && !Array.isArray(args.syncRules)) { + throw new Error( + "`syncRules` must be an array of rules or null when calling `update_sync_rules`." + ); + } + result = await apiRequest( + "PUT", + "/api/v1/sync-rules", + { syncRules: args.syncRules }, + companyId + ); + break; + } + + case "get_shopify_automation_settings": { + const companyId = args.companyId as string | undefined; + result = await apiRequest( + "GET", + "/api/v1/shopify/automation-settings", + undefined, + companyId + ); + break; + } + + case "update_shopify_automation_settings": { + const companyId = args.companyId as string | undefined; + const payload: Record = {}; + if (args.browseAbandonment !== undefined) { + payload["browseAbandonment"] = args.browseAbandonment; + } + if (args.priceDrop !== undefined) { + payload["priceDrop"] = args.priceDrop; + } + if (Object.keys(payload).length === 0) { + throw new Error( + "Provide `browseAbandonment` and/or `priceDrop` when calling `update_shopify_automation_settings`." + ); + } + result = await apiRequest( + "PUT", + "/api/v1/shopify/automation-settings", + payload, + companyId + ); + break; + } + case "create_api_key": { const companyId = args.companyId as string; const body: Record = {}; @@ -9365,6 +9953,7 @@ export async function handleToolCall( "targetLists", "sendTimeOptimization", "spreadOverHours", + "recurringInterval", ]); const unsupportedCampaignScheduleKeys = Object.keys(args).filter( (key) => !allowedCampaignScheduleKeys.has(key) @@ -9372,7 +9961,7 @@ export async function handleToolCall( if (unsupportedCampaignScheduleKeys.length > 0) { throw new Error( - `\`schedule_campaign\` accepts only \`campaignId\`, \`scheduledAt\`, \`targetLists\`, \`sendTimeOptimization\`, and \`spreadOverHours\`. Unsupported field${unsupportedCampaignScheduleKeys.length === 1 ? "" : "s"}: ${unsupportedCampaignScheduleKeys.map((key) => `\`${key}\``).join(", ")}.` + `\`schedule_campaign\` accepts only \`campaignId\`, \`scheduledAt\`, \`targetLists\`, \`sendTimeOptimization\`, \`spreadOverHours\`, and \`recurringInterval\`. Unsupported field${unsupportedCampaignScheduleKeys.length === 1 ? "" : "s"}: ${unsupportedCampaignScheduleKeys.map((key) => `\`${key}\``).join(", ")}.` ); } @@ -9392,6 +9981,9 @@ export async function handleToolCall( ...(args.spreadOverHours !== undefined && { spreadOverHours: args.spreadOverHours, }), + ...(args.recurringInterval !== undefined && { + recurringInterval: args.recurringInterval, + }), }, companyId ); @@ -9982,6 +10574,7 @@ export async function handleToolCall( "/api/v1/generate/email", { prompt, + emailType: "transactional", ...(args.style !== undefined && { style: args.style }), ...(args.tone !== undefined && { tone: args.tone }), }, @@ -10649,6 +11242,57 @@ export async function handleToolCall( break; } + case "generate_sms": { + const companyId = args.companyId as string | undefined; + result = await apiRequest( + "POST", + "/api/v1/generate/sms", + args, + companyId + ); + break; + } + + case "get_sms_settings": { + const companyId = args.companyId as string | undefined; + result = await apiRequest( + "GET", + "/api/v1/sms/settings", + undefined, + companyId + ); + break; + } + + case "send_test_sms": { + const companyId = args.companyId as string | undefined; + const body: Record = { + to: requiredString("send_test_sms", args, "to"), + }; + for (const key of ["text", "imageUrls", "blocks"]) { + if (args[key] !== undefined) { + body[key] = args[key]; + } + } + result = await apiRequest("POST", "/api/v1/sms/test", body, companyId); + break; + } + + case "submit_feedback": { + const companyId = args.companyId as string | undefined; + const body: Record = { + message: requiredString("submit_feedback", args, "message"), + source: "mcp", + }; + for (const key of ["category", "context"]) { + if (args[key] !== undefined) { + body[key] = args[key]; + } + } + result = await apiRequest("POST", "/api/v1/feedback", body, companyId); + break; + } + default: throw new Error(`Unknown tool: ${name}`); }