---
title: "Custom AI model"
description: "Route the editor's AI text features to your own AI model instead of the built-in OpenAI integration."
url: https://docs.topol.io/email-editor/guide/custom-ai.html
---

# 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.**

```ts
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.

## Important Notes

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` (and `https://v3.develop.email-assets.topol.io` on 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 `apiAuthorizationHeader` option are attached automatically to requests to custom endpoints, which is how those requests get authenticated. See [Working with API](https://docs.topol.io/email-editor/guide/how-to-work-with-your-api.html).

-   **`userId` can be `null`.** The `apiKey`, `hostname` and `userId` fields are echoed from the `authorize` configuration and are available for attributing usage, but `userId` is 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](https://support.topol.io/en/articles/10806233-ai-assistant) feature, reachable from the "stars" icon in the bottom-left of the text block toolbar.

![Location of triggers to edit text with AI](https://docs.topol.io/ai-assistant-options.png)

**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:**

```json
{
  "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:

![Location of trigger to generate preheader with AI](https://docs.topol.io/generate-preheader.png)

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:**

```json
{
  "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:**

```json
{
  "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:

```json
{
  "errorCode": "UNRECOGNIZED_LANGUAGE"
}
```

## Example Implementation

A minimal Express implementation of the text endpoint:

```js
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 });
});
```
