Custom AI Model
The AI features in the email editor send their prompts to Topol by default, which forwards them to OpenAI and returns the result to the editor. Selected features can be pointed at your own endpoint instead, which receives the prompt, calls whichever model you like, and returns the text in the response format described below.
Who is this for?
This setup fits three cases: running your own model, keeping prompt data inside your own infrastructure, or metering AI usage per user.
How to Set This Up
The api object in the TOPOL_OPTIONS configuration is where the editor looks for custom endpoints. Each key provided there replaces the corresponding built-in endpoint.
const TOPOL_OPTIONS = {
id: "#app",
authorize: {
apiKey: "YOUR_API_KEY",
userId: "YOUR_USER_ID",
},
api: {
// Every AI action in the text toolbar and the "Add AI text block" dialog
GENERATE_TEXT: "https://your-domain.com/generate-text",
// The "magic wand" preheader button in template settings
GENERATE_PREHEADER: "https://your-domain.com/generate-preheader",
// Translating blocks and multilingual templates
GENERATE_TRANSLATION: "https://your-domain.com/generate-translation",
},
};The editor POSTs to exactly the URL given. Nothing is appended to it, and the endpoints can be named anything.
Overriding is opt-in per endpoint. Setting only GENERATE_TEXT sends text generation to your server while preheaders and translations still go through Topol.
Features That Cannot Be Overridden
Two AI features always go through Topol and ignore the api option:
| Feature | Description |
|---|---|
| AI Chat | The conversational AI assistant panel |
| AI section generation | Generating whole template sections from a prompt |
Keeping every AI request on your own infrastructure means disabling these two features for your users.
These constraints apply to all three endpoints and are worth checking before implementation starts.
Allow CORS from the editor origin. Requests come from the iframe the editor runs in, so the server must accept
https://v3.email-assets.topol.io(andhttps://v3.develop.email-assets.topol.ioon the develop build).Respond within 60 seconds. Every AI request uses a 60-second timeout. A slower endpoint means the request is aborted and the user sees an error notification.
Authorization headers are forwarded. Headers set through the
apiAuthorizationHeaderoption are attached automatically to requests to custom endpoints, which is how those requests get authenticated. See Working with API.userIdcan benull. TheapiKey,hostnameanduserIdfields are echoed from theauthorizeconfiguration and are available for attributing usage, butuserIdis only present when it has been set.Return HTTP 2xx on success. Any non-2xx response counts as a failure: the user gets a generic error notification and the action is cancelled.
Generating Text
Text editing with AI runs through the AI Assistant feature, reachable from the "stars" icon in the bottom-left of the text block toolbar.

All of the following actions are sent to GENERATE_TEXT, with the text from the block appended to the end of the prompt.
| Action | What it asks the model to do |
|---|---|
| Improve | Improve the given text |
| Rephrase | Rephrase it, keeping formatting as close as possible |
| Make longer | Expand it slightly, keeping structure and formatting |
| Make shorter | Shorten it slightly, keeping structure and formatting |
| Check grammar | Fix spelling, grammar and punctuation without rewriting |
| Keywords | Extract keywords from the template's text content |
Do not parse or pattern-match the prompt text
Treat the prompt field as opaque input for your model. The exact wording is not a stable contract:
- Most prompts are localized and can arrive in any of the editor's supported languages.
- Some prompts are sent in English regardless of the editor language.
- For multilingual templates, the grammar prompt has the detected language appended to it.
- The wording changes between editor versions.
Pass the prompt straight to the model instead of matching on it to decide what to do.
Generate Text
- URL: the value set for
GENERATE_TEXT - Method:
POST - Content-Type:
application/json
Request body:
| key | type | description |
|---|---|---|
| prompt | string | The full prompt, with the user's text appended |
| apiKey | string | Echoed from the authorize configuration |
| hostname | string | Echoed from the authorize configuration |
| userId | string | null | Echoed from the authorize configuration |
| useDeveloperPrompt | boolean | true for AI Assistant actions, false for keyword extraction. Safe to ignore, or usable as a signal to apply a system prompt of your own. |
Response:
{
"data": "output of your AI for use in the template"
}Here data is a plain string containing the generated text.
Generating Preheader
The preheader is the text shown next to the subject line that previews the email in the inbox. The "magic wand" button in template settings generates it:

The prompt asks the model for a preview text for the email, and the template content is sent alongside it.
What template data is sent?
Only the template's content structure goes out (the blocks and their text, serialized as JSON). Editor metadata stored in the full template JSON is not included.
Generate Preheader
- URL: the value set for
GENERATE_PREHEADER - Method:
POST - Content-Type:
application/json
Request body:
| key | type | description |
|---|---|---|
| template | string | The template content structure, JSON-serialized into a string |
| apiKey | string | Echoed from the authorize configuration |
| hostname | string | Echoed from the authorize configuration |
| userId | string | null | Echoed from the authorize configuration |
Response:
{
"data": {
"content": "text content to be used"
}
}Note the different response shape
GENERATE_TEXT returns a string in data. The preheader endpoint returns an object with a content property.
Content wrapped in double quotes has them stripped before the value reaches the Preview text field.
Generating Translations
Translating text blocks and multilingual templates goes to GENERATE_TRANSLATION. This endpoint receives a list of items rather than a single prompt, each identified by a uid.
Larger templates are split across several requests, batched by item count and total length, so a single translation can call the endpoint multiple times. Returning the same uid received for each item is what lets the editor map results back to the right blocks.
Generate Translation
- URL: the value set for
GENERATE_TRANSLATION - Method:
POST - Content-Type:
application/json
Request body:
| key | type | description |
|---|---|---|
| keyValue | array | Items to translate: [{ "uid": "...", "content": "..." }] |
| lang | string | Target language |
| apiKey | string | Echoed from the authorize configuration |
| hostname | string | Echoed from the authorize configuration |
| userId | string | null | Echoed from the authorize configuration |
Response:
{
"data": [
{ "uid": "uid-from-the-request", "content": "translated text" }
],
"detectedLanguage": "English"
}detectedLanguage is shown to the user in a notification once the translation finishes.
When the source language cannot be determined, an HTTP 4xx response with the following body shows a specific "unrecognized language" message instead of a generic error:
{
"errorCode": "UNRECOGNIZED_LANGUAGE"
}Example Implementation
A minimal Express implementation of the text endpoint:
app.post("/generate-text", async (req, res) => {
const { prompt, apiKey, hostname, userId, useDeveloperPrompt } = req.body;
// Authenticate the request using your own apiAuthorizationHeader value
// and optionally meter usage against userId here.
const completion = await yourModel.complete({
system: useDeveloperPrompt ? "You are an email copywriting assistant." : undefined,
prompt,
});
// Must respond within 60 seconds.
res.json({ data: completion.text });
});