Lightweight AI Commands toolbar — improve, simplify, translate, change tone, and more. Adds buttons to the toolbar, context bar, and addbar when ai.url is configured.
Requires a backend endpoint (for example /api/endpoint/ai-commands/). For the full AI product with grouped actions and review modes, use the AI Assistant plugin instead.
<script src="/assets/redactor/redactor.js"></script>
<script src="/assets/redactor/plugins/ai/ai.js"></script>
The plugin UI appears only when url is set.
const app = Redactor('#entry', {
plugins: ['ai'],
ai: {
url: '/api/endpoint/ai-commands/',
model: 'gpt-4o-mini'
}
});
string | false)
falsefalse.string | false)
falsebody.model.object)
{}string)
string)
object)
'1024x1024': 'Square').'1536x1024'.object)
title, icon, prompt, and nested items (for translate, tone, etc.).Redactor('#entry', {
plugins: ['ai'],
ai: {
url: '/api/endpoint/ai-commands/',
model: 'gpt-4o-mini',
headers: {
Authorization: 'Bearer …'
},
actions: {
improve: { title: 'Improve it', prompt: 'Improve it' },
prompt: { title: 'Ask AI…', prompt: '' }
},
image: {
sizes: {
'1536x1024': 'Landscape',
'1024x1024': 'Square'
},
defaultSize: '1024x1024'
}
}
});
en: {
ai: {
'ai': 'AI',
'ask': 'Ask AI…',
'no-selection': 'Select at least one block',
'empty-response': 'AI returned empty HTML'
}
}
The AI plugin does not call OpenAI, Claude, or any other provider directly. It sends POST requests with a JSON body to URLs you configure (url for text, editor-level ai.image.url for images). Your backend validates the session, calls the provider, and returns JSON in the shape the editor expects.
The examples below are illustrative only — simplified sketches, not production-ready endpoints. Adapt them to your CMS, framework, auth, rate limits, and logging. You can implement the same contract in any language or use official SDKs (OpenAI, Anthropic, etc.).
Endpoint: plugin option url (for example /api/ai/text).
Method: POST
Content-Type: application/json
Extra headers: from plugin option headers (merged into the request).
Request body:
| Field | Type | Description |
|---|---|---|
prompt |
string |
Action instruction or user prompt from Ask AI…. Required. |
html |
string |
HTML of the selected block(s). Empty when nothing is selected (prompt-only flow). |
model |
string |
Optional. Sent when plugin option model is set. |
Example payload:
{
"prompt": "Improve it",
"html": "<p>The product launch was really good and we had alot of people show up.</p>",
"model": "gpt-4o-mini"
}
Success response: HTTP 200, JSON with transformed HTML:
{
"html": "<p>The product launch went well, and many people attended.</p>"
}
The editor reads response.html (string). Empty or missing html is treated as an error (ai:action:empty-response).
Error response: non-2xx status and JSON:
{
"error": "Prompt is empty"
}
Same contract using the official OpenAI Node.js library and the Responses API:
import OpenAI from 'openai';
const client = new OpenAI();
// Inside your route handler — `prompt`, `html`, and `model` come from the editor POST body.
const userContent = html
? `Instruction:\n${prompt}\n\nHTML:\n${html}`
: `Instruction:\n${prompt}`;
const response = await client.responses.create({
model: model || 'gpt-4o-mini',
instructions: 'Return ONLY HTML. Preserve structure and attributes when possible. No markdown fences.',
input: userContent
});
const resultHtml = (response.output_text || '').trim();
// Respond to the editor:
res.json({ html: resultHtml });
With the Anthropic JavaScript SDK, the same pattern applies: read prompt / html from the request, call messages.create, extract text from response.content, return { html }.
Plain PHP without SDKs — read the editor payload, call OpenAI via cURL, return { html }:
<?php
// Illustrative only — not production-ready.
header('Content-Type: application/json; charset=utf-8');
$payload = json_decode(file_get_contents('php://input'), true) ?: [];
$prompt = trim($payload['prompt'] ?? '');
$html = trim($payload['html'] ?? '');
$model = $payload['model'] ?? 'gpt-4o-mini';
if ($prompt === '') {
http_response_code(400);
echo json_encode(['error' => 'Prompt is empty']);
exit;
}
$input = $html === ''
? "Instruction:\n{$prompt}"
: "Instruction:\n{$prompt}\n\nHTML:\n{$html}";
$ch = curl_init('https://api.openai.com/v1/responses');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OPENAI_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $model,
'instructions' => 'Return ONLY HTML. No markdown fences.',
'input' => $input,
]),
]);
$raw = curl_exec($ch);
curl_close($ch);
$data = json_decode($raw, true);
$resultHtml = trim($data['output_text'] ?? '');
if ($resultHtml === '') {
http_response_code(502);
echo json_encode(['error' => 'Model returned empty HTML']);
exit;
}
echo json_encode(['html' => $resultHtml]);
The editor contract is the same — { prompt, html, model? } in, { html } out. Only the upstream API call differs:
| OpenAI (Responses API) | Claude (Messages API) | |
|---|---|---|
| Endpoint | POST /v1/responses |
POST /v1/messages |
| Auth | Authorization: Bearer … |
x-api-key: … + anthropic-version: … |
| Request body | model, instructions, input |
model, messages, max_tokens |
| Response field | output_text |
content[].text (where type is "text") |
<?php
// Illustrative only — not production-ready.
header('Content-Type: application/json; charset=utf-8');
$payload = json_decode(file_get_contents('php://input'), true) ?: [];
$prompt = trim($payload['prompt'] ?? '');
$html = trim($payload['html'] ?? '');
$model = $payload['model'] ?? 'claude-sonnet-4-20250514';
if ($prompt === '') {
http_response_code(400);
echo json_encode(['error' => 'Prompt is empty']);
exit;
}
$input = $html === ''
? "Instruction:\n{$prompt}"
: "Instruction:\n{$prompt}\n\nHTML:\n{$html}";
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . getenv('ANTHROPIC_API_KEY'),
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $model,
'max_tokens' => 4096,
'system' => 'Return ONLY HTML. No markdown fences.',
'messages' => [
['role' => 'user', 'content' => $input],
],
]),
]);
$raw = curl_exec($ch);
curl_close($ch);
$data = json_decode($raw, true);
$resultHtml = '';
foreach ($data['content'] ?? [] as $block) {
if (($block['type'] ?? '') === 'text') {
$resultHtml .= $block['text'] ?? '';
}
}
$resultHtml = trim($resultHtml);
if ($resultHtml === '') {
http_response_code(502);
echo json_encode(['error' => 'Model returned empty HTML']);
exit;
}
echo json_encode(['html' => $resultHtml]);
Endpoint: editor option ai.image.url (separate from plugin url). The addbar image button is shown only when this URL is set.
Method: POST
Content-Type: application/json
Extra headers: from editor option ai.image.headers.
Request body:
| Field | Type | Description |
|---|---|---|
action |
string |
Always "image". |
prompt |
string |
User description for the image. Required. |
size |
string |
Dimensions, for example "1024x1024", "1536x1024". From the prompt UI / ai.image.size. |
n |
number |
Number of images to generate (default 1). |
model |
string |
Optional. Sent when editor option ai.image.model is set. |
Example payload:
{
"action": "image",
"prompt": "A minimal flat illustration of a mountain at sunset",
"size": "1024x1024",
"n": 1,
"model": "gpt-image-1"
}
Success response: HTTP 200, JSON:
{
"images": [
{
"url": "https://cdn.example.com/generated/abc.png"
}
],
"meta": {
"model": "gpt-image-1",
"size": "1024x1024"
}
}
Each item in images must include either:
url — public HTTPS URL of the image, orb64_json — raw base64 payload, with optional mime (for example "image/png").Optional fields: alt, prompt (revised prompt from the provider).
The editor normalizes this list and inserts the image block. Empty images means nothing is inserted.
Error response: same as text — { "error": "…" } with a non-2xx status.
import OpenAI from 'openai';
const client = new OpenAI();
// `prompt`, `size`, `n`, and `model` come from the editor POST body.
const response = await client.images.generate({
model: model || 'gpt-image-1',
prompt,
size: size || '1024x1024',
n: n || 1
});
const images = (response.data ?? []).flatMap((item) => {
if (item.url) return [{ url: item.url }];
if (item.b64_json) return [{ b64_json: item.b64_json, mime: 'image/png' }];
return [];
});
// Respond to the editor:
res.json({ images, meta: { model: model || 'gpt-image-1', size } });
The editor contract is the same for any image provider — { images } out, with each item as url or b64_json. Only the upstream API call changes.
Plain PHP without SDKs — read the editor payload, call OpenAI Images via cURL, return { images }:
<?php
// Illustrative only — not production-ready.
header('Content-Type: application/json; charset=utf-8');
$payload = json_decode(file_get_contents('php://input'), true) ?: [];
$prompt = trim($payload['prompt'] ?? '');
$size = $payload['size'] ?? '1024x1024';
$n = max(1, (int) ($payload['n'] ?? 1));
$model = $payload['model'] ?? 'gpt-image-1';
if ($prompt === '') {
http_response_code(400);
echo json_encode(['error' => 'Image prompt is empty']);
exit;
}
$ch = curl_init('https://api.openai.com/v1/images/generations');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OPENAI_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $model,
'prompt' => $prompt,
'size' => $size,
'n' => $n,
]),
]);
$raw = curl_exec($ch);
curl_close($ch);
$data = json_decode($raw, true);
$images = [];
foreach ($data['data'] ?? [] as $item) {
if (!empty($item['url'])) {
$images[] = ['url' => $item['url']];
} elseif (!empty($item['b64_json'])) {
$images[] = [
'b64_json' => $item['b64_json'],
'mime' => 'image/' . ($item['output_format'] ?? 'png'),
];
}
}
echo json_encode([
'images' => $images,
'meta' => ['model' => $model, 'size' => $size],
]);
Hook into the flow with ai:image:before if you need to adjust URL, headers, or body before the request leaves the browser.
Hook when an AI request is prepared. Return modified fields or call stop() to cancel.
Payload: action, actionKey, configPath, prompt, html, requestHtml, blocks, selected.
app.on('ai:create', ({ action, prompt, html, stop }) => {
if (shouldCancel()) {
stop();
return;
}
return { action, prompt, html };
});
Hook before the HTTP request. Return modified url, init, or body, or call stop() to cancel.
Payload: action, configPath, prompt, html, url, init, body.
app.on('ai:before:send', ({ action, prompt, url, body, stop }) => {
if (shouldCancel()) {
stop();
return;
}
return { url, body: { ...body, prompt } };
});
Hook before generated HTML is inserted. Call stop() to skip insertion.
Payload: action, actionKey, configPath, prompt, html, insertHtml, blocks, selected.
app.on('ai:before:insert', ({ action, html, insertHtml, stop }) => {
if (shouldCancel()) {
stop();
return;
}
return { html, insertHtml };
});
Fired when a request completes successfully.
Payload: action, actionKey, configPath, prompt, html, transformed.
app.on('ai:complete', ({ action, html, transformed }) => {
console.log(action, transformed.length);
});
Fired when AI content is inserted into the document.
Payload: blocks, html, finalHtml, action, prompt, newBlocks, insert.
app.on('ai:insert', ({ action, html }) => {
console.log(action, html.length);
});
Fired on request or processing errors.
Payload: error, action.
app.on('ai:error', ({ error, action }) => {
console.error('ai:error', action, error);
});
Fired when the free-form prompt panel opens.
Payload: blocks, targetBlocks, action, currentAction, mode, currentMode.
app.on('ai:prompt:open', ({ action, blocks }) => {
console.log('prompt open', action, blocks.length);
});
Fired when the free-form prompt panel closes.
app.on('ai:prompt:close', () => {
console.log('prompt closed');
});