Sample Marketing Extension: Tone Checker

For detailed, step-by-step instructions about creating an extension for marketing content types, see Build Extensions for Marketing Content in Marketing Cloud Next. These code samples are for an extension that allows marketers to use AI to edit the tone of content within a selected component on the canvas.

Apex Controller 

This sample Apex controller contains the secure callout using named credentials (GeminiNC), and it connects the extension to Gemini. It can revise content in a selected component to match a specified tone.

1public with sharing class ToneChecker {
2
3    // System prompt for generating email blocks in CNAVS structure format
4    private static final String TONE_CHECKER_SYSTEM_PROMPT =
5        'You are a tone transformation assistant. Your task is to rewrite content in a specified tone while preserving the original meaning, intent, and key details.\n\n' +
6        'You will receive the following input parameters:\n' +
7        'Text: {text}\n' +
8        'TargetTone: {target_tone}\n\n' +
9        'Guidelines:\n' +
10        '- Do not add or remove important information.\n' +
11        '- Change only the tone, vocabulary, and sentence style.\n' +
12        '- Keep the output clear, natural, and concise.\n' +
13        '- Do not include explanations or extra formatting.\n' +
14        '- Return only the rewritten text.\n\n' +
15        'Supported tones:\n' +
16        '- Energetic\n' +
17        '- Professional\n' +
18        '- Straight\n\n' +
19        'Always rewrite the provided text strictly in the requested tone and output only the transformed text.';
20
21    /**
22     * Change tone of text content using Gemini AI
23     * Returns tone corrected text
24     * @param targetTone - The target tone selected by the user
25     * @param inputText - The text input selected by the user
26     * @return String - Tone corrected text
27     */
28    @AuraEnabled
29    public static String changeTone(String targetTone, String inputText) {
30        try {
31        // Replace placeholders
32        String fullPrompt = TONE_CHECKER_SYSTEM_PROMPT
33            .replace('{text}', inputText)
34            .replace('{target_tone}', targetTone);
35
36            String result = callGeminiAPI(fullPrompt, 8192);
37            return result;
38
39        } catch (Exception e) {
40            System.debug('Error in changeTone: ' + e.getMessage());
41            throw new AuraHandledException('Error changing tone of text: ' + e.getMessage());
42        }
43    }
44
45    /**
46     * Call Gemini AI API to change tone of content
47     * @param prompt - The prompt text to send
48     * @param maxOutputTokens - Maximum number of tokens for the response
49     * @return String - The generated text from Gemini AI
50     */
51    private static String callGeminiAPI(String prompt, Integer maxOutputTokens) {
52        try {
53            // Build Gemini API request structure
54            Map<String, Object> geminiRequest = new Map<String, Object>{
55                'contents' => new List<Object>{
56                    new Map<String, Object>{
57                        'parts' => new List<Object>{
58                            new Map<String, Object>{
59                                'text' => prompt
60                            }
61                        }
62                    }
63                },
64                'generationConfig' => new Map<String, Object>{
65                    'temperature' => 0.7,
66                    'maxOutputTokens' => maxOutputTokens
67                }
68            };
69
70            // Create HTTP request
71            HttpRequest req = new HttpRequest();
72            req.setEndpoint('callout:GeminiNC');
73            req.setHeader('Content-Type', 'application/json');
74            req.setMethod('POST');
75            // Increase timeout to 60 or 120 seconds
76            req.setTimeout(120000);
77
78            // Set request body
79            String requestBody = JSON.serialize(geminiRequest);
80            req.setBody(requestBody);
81
82            System.debug('Gemini API Request Body: ' + requestBody);
83
84            // Make the callout
85            Http http = new Http();
86            HttpResponse res = http.send(req);
87
88            System.debug('Response Status Code: ' + res.getStatusCode());
89            System.debug('Response Body: ' + res.getBody());
90
91            // Handle response
92            Integer statusCode = res.getStatusCode();
93
94            if (statusCode == 200) {
95                return parseGeminiResponse(res.getBody());
96            } else if (statusCode == 429) {
97                // Rate limit exceeded - parse error for details
98                String errorMessage = parseErrorResponse(res.getBody());
99                throw new CalloutException('Rate limit exceeded. ' + errorMessage + ' Please wait a moment and try again.');
100            } else if (statusCode == 400) {
101                String errorMessage = parseErrorResponse(res.getBody());
102                throw new CalloutException('Bad request: ' + errorMessage);
103            } else if (statusCode == 401 || statusCode == 403) {
104                throw new CalloutException('Authentication failed. Please check your Gemini API key configuration.');
105            } else {
106                String errorMessage = parseErrorResponse(res.getBody());
107                throw new CalloutException('API error (Status ' + statusCode + '): ' + errorMessage);
108            }
109
110        } catch (Exception e) {
111            System.debug('Error calling Gemini API: ' + e.getMessage());
112            throw new CalloutException('Gemini API Error: ' + e.getMessage());
113        }
114    }
115
116    /**
117     * Parse the Gemini API response and extract the generated text
118     * Expected response structure:
119     * {
120     *   "candidates": [{
121     *     "content": {
122     *       "parts": [{ "text": "generated text" }]
123     *     }
124     *   }]
125     * }
126     * @param responseBody - The raw JSON response from Gemini API
127     * @return String - The extracted generated text
128     */
129    private static String parseGeminiResponse(String responseBody) {
130        Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
131
132        if (responseMap.containsKey('candidates')) {
133            List<Object> candidates = (List<Object>) responseMap.get('candidates');
134
135            if (candidates != null && !candidates.isEmpty()) {
136                Map<String, Object> firstCandidate = (Map<String, Object>) candidates[0];
137
138                if (firstCandidate.containsKey('content')) {
139                    Map<String, Object> content = (Map<String, Object>) firstCandidate.get('content');
140
141                    if (content.containsKey('parts')) {
142                        List<Object> parts = (List<Object>) content.get('parts');
143
144                        if (parts != null && !parts.isEmpty()) {
145                            Map<String, Object> firstPart = (Map<String, Object>) parts[0];
146
147                            if (firstPart.containsKey('text')) {
148                                String generatedText = (String) firstPart.get('text');
149                                System.debug('Successfully extracted text from Gemini response');
150                                return generatedText;
151                            }
152                        }
153                    }
154                }
155            }
156        }
157
158        throw new CalloutException('Unable to extract text from Gemini response. Unexpected structure.');
159    }
160
161    /**
162     * Parse error response from Gemini API
163     * Expected error structure:
164     * {
165     *   "error": {
166     *     "code": 429,
167     *     "message": "Resource has been exhausted...",
168     *     "status": "RESOURCE_EXHAUSTED"
169     *   }
170     * }
171     * @param responseBody - The raw JSON error response
172     * @return String - The extracted error message
173     */
174    private static String parseErrorResponse(String responseBody) {
175        try {
176            Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
177
178            if (responseMap.containsKey('error')) {
179                Map<String, Object> errorObj = (Map<String, Object>) responseMap.get('error');
180
181                if (errorObj.containsKey('message')) {
182                    return (String) errorObj.get('message');
183                }
184
185                if (errorObj.containsKey('status')) {
186                    return (String) errorObj.get('status');
187                }
188            }
189
190            return responseBody;
191        } catch (Exception ex) {
192            return responseBody;
193        }
194    }
195}

Lightning Web Component (LWC) 

Configuration File 

In this sample, the Tone Checker extension is made available only to heading components within email content. When opened, the extension appears in a 640x600 px floating panel within the email builder.

1<?xml version="1.0" encoding="UTF-8"?>
2<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
3    <!-- The apiVersion may need to be increased for the current release -->
4    <apiVersion>66.0</apiVersion>
5    <isExposed>true</isExposed>
6    <masterLabel>Tone Checker</masterLabel>
7    <description>Refine text with AI on Heading for email content</description>
8    <targets>
9        <target>lightning__CmsEditorExtension</target>
10    </targets>
11    <targetConfigs>
12	<targetConfig targets="lightning__CmsEditorExtension">
13        <size width="x-large" height="600"></size>
14		<contentTypes>
15			<contentType fullyQualifiedName="sfdc_cms__email">
16                <blockTypes>
17                    <blockType fullyQualifiedName="lightning__heading"></blockType>
18                </blockTypes>
19            </contentType>
20		</contentTypes>
21	</targetConfig>
22</targetConfigs>
23</LightningComponentBundle>

HTML File 

In this sample, the Tone Checker extension UI includes radio buttons for tone selection, a preview container for the revised content, and action buttons that the user can click to apply, to try generating content again, or to replace the content with the revised version.

1<template>
2  <div class="slds-card">
3    <div class="slds-form">
4      <h2 class="slds-text-heading_medium slds-m-bottom_medium">Change Tone</h2>
5
6      <!-- Radio button group for tone selection -->
7      <lightning-radio-group
8        name="toneOptions"
9        label=""
10        options="{toneOptions}"
11        value="{selectedTone}"
12        onchange="{handleToneChange}"
13        type="radio"
14      >
15      </lightning-radio-group>
16
17      <!-- Preview text container -->
18      <div class="preview-container slds-m-top_medium">
19        <div class="preview-text">
20          {previewText}
21        </div>
22      </div>
23
24      <!-- Action buttons -->
25      <div class="slds-m-top_medium slds-align_absolute-center">
26        <lightning-button
27          label="Try again"
28          icon-name="utility:refresh"
29          onclick="{handleTryAgain}"
30          class="slds-m-bottom_small"
31        >
32        </lightning-button>
33      </div>
34
35      <div class="slds-align_absolute-center">
36        <lightning-button variant="brand" label="Replace" onclick="{handleReplace}">
37        </lightning-button>
38      </div>
39    </div>
40  </div>
41</template>

CSS File (Optional) 

Use this sample CSS file to style your Tone Checker extension. If you don’t use this CSS file, your extension automatically inherits Salesforce Lightning Design System (SLDS) styles.

1.preview-container {
2  border: 1px solid #dddbda;
3  border-radius: 0.25rem;
4  padding: 1rem;
5  min-height: 150px;
6  background-color: #ffffff;
7  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
8}
9
10.preview-text {
11  font-size: 0.875rem;
12  line-height: 1.5;
13  color: #3e3e3c;
14  white-space: pre-wrap;
15  word-wrap: break-word;
16}
17
18.slds-card {
19  padding: 1rem;
20}
21
22.slds-text-heading_medium {
23  font-size: 1.25rem;
24  font-weight: 700;
25  color: #181818;
26}

JavaScript File 

This file demonstrates JavaScript that integrates with experience/blockBuilderApi methods to edit content within components. It takes the content from a selected heading component and revises it using Gemini to match the tone selected by the user. Then it can replace the content in the heading component with the revised content.

1import { LightningElement, wire } from "lwc";
2import { getCurrentSelectedBlock, replaceBlock } from "experience/blockBuilderApi";
3import changeTone from "@salesforce/apex/ToneChecker.changeTone";
4
5/**
6 * This component allows users to change the tone of content
7 * by selecting different tone options (Energetic, Professional, Straight)
8 */
9export default class ToneChecker extends LightningElement {
10  selectedTone = "";
11  previewText = "";
12  originalText = "";
13  block;
14  isLoading = false;
15
16  @wire(getCurrentSelectedBlock, {})
17  onCurrentSelectedBlock({ data }) {
18    if (data) {
19      this.originalText = data.attributes.text;
20      this.block = JSON.parse(JSON.stringify(data));
21    }
22  }
23
24  get toneOptions() {
25    return [
26      { label: "Energetic", value: "energetic" },
27      { label: "Professional", value: "professional" },
28      { label: "Straight", value: "straight" },
29    ];
30  }
31
32  handleToneChange(event) {
33    this.selectedTone = event.detail.value;
34    this.generateTonedText();
35  }
36
37  handleTryAgain() {
38    // Regenerate the text with the same tone
39    this.generateTonedText();
40  }
41
42  handleReplace() {
43    // Replace the block text on canvas with the new toned text
44    if (this.previewText && this.block) {
45      let _id = this.block.id;
46      this.block.attributes.text = this.previewText.replace(/\n/g, "<br/>");
47
48      replaceBlock(this.block, {
49        nodeId: _id,
50      })
51        .then((response) => {
52          console.log("Block replaced successfully", response);
53        })
54        .catch((error) => {
55          console.error("Error replacing block:", error);
56        });
57    }
58  }
59
60  async generateTonedText() {
61    if (!this.selectedTone) {
62      return;
63    }
64
65    if (!this.originalText) {
66      console.error("No text available to change tone");
67      return;
68    }
69
70    this.isLoading = true;
71
72    try {
73      // Call Apex method to change tone
74      const tonedText = await changeTone({
75        targetTone: this.selectedTone,
76        inputText: this.originalText,
77      });
78
79      this.previewText = tonedText;
80      console.log("Tone changed successfully");
81    } catch (error) {
82      console.error("Error changing tone:", error);
83      this.previewText = "Error generating toned text. Please try again.";
84    } finally {
85      this.isLoading = false;
86    }
87  }
88}