UI and integration layer for AI-assisted writing workflows with grouped actions — edit, generate, review, and insights. Opens a dropdown from the toolbar and addbar with improve, translate, summarize, check writing, better wording, and more.
Open the AI button in the toolbar to browse grouped actions — Edit, Generate, Review, and Insights. Select one block or several, then pick an action. This article is intentionally long so you can test rewriting, summarizing, translating, and reviewing on real paragraphs.
<script src="/assets/redactor/redactor.js"></script>
<script src="/assets/redactor/plugins/assistant/assistant.js"></script>
The plugin UI appears when ai.url or ai.jobs.createUrl is configured.
const app = Redactor('#entry', {
plugins: ['assistant'],
ai: {
url: '/api/ai/text',
model: 'gpt-4o-mini',
image: {
url: '/api/ai/image'
}
}
});
Configure the Assistant under ai (backend transport) and assistant (UI and actions) in the editor init. Defaults ship with the plugin; override or extend what you need.
string | false)
falseai.url nor ai.jobs.createUrl is set.string | false)
falsebody.model.object)
{}'sync' | 'jobs' | 'custom')
'sync'object)
transport is 'jobs': createUrl, pollInterval, pollMaxWait, headers, getAuth.object)
false, the image addbar button is hidden.'1536x1024': 'Landscape'). Default includes landscape, portrait, and square.'1536x1024'.string)
string[] | false)
['edit', 'review', 'insights']edit, generate, review, insights, …). Set to false for a flat list.object)
assistant.actions holds plugin defaults; ai.actions is deep-merged on top for overrides.Redactor('#entry', {
plugins: ['assistant'],
// Shared AI settings — backend transport, model, image API
ai: {
url: '/api/ai/text',
model: 'gpt-4o-mini',
transport: 'sync',
headers: { Authorization: 'Bearer …' },
image: {
url: '/api/ai/image',
model: 'gpt-image-1',
sizes: {
'1536x1024': 'Landscape',
'1024x1024': 'Square'
},
defaultSize: '1536x1024'
},
actions: {
improve: { title: 'Improve writing' } // override a preset action label
}
},
// Assistant plugin UI — icons, dropdown groups, action menu
assistant: {
groups: ['edit', 'generate', 'review', 'insights'],
actions: {
shorter: { title: 'Make shorter', group: 'edit' }
}
}
});
Each key in actions is an action id sent to your backend as body.action. Your backend uses this id to pick a prompt template (for example shorter → prompts/shorter.txt) or to build the instruction programmatically. The editor does not send the full prompt text for preset menu items — only the action id, optional prompt hint, mode, context, and any extra fields defined on the action entry.
When the user picks an action, the editor calls aiCore.execute() with that id. A top-level entry sends action: "shorter". Nested submenu entries send the parent id plus extra fields — for example Translate → Swedish sends action: "translate" and language: "Swedish"; Change tone → Clear & Professional sends action: "tone" and tone: "Clear, concise, …".
Per-action fields:
| Field | Role |
|---|---|
title, icon |
Dropdown label and icon. |
group |
Group id (edit, generate, review, insights). |
mode |
'review' (default), 'apply', 'report', 'report-review'. |
prompt |
Optional hint in body.prompt (Ask AI and custom actions). Usually empty for presets — the backend owns the instruction via the action id. |
documentContext |
Use the full document as context instead of the selection. |
diff |
Show diff UI when accepting replacements. |
items |
Nested submenu. Child fields (for example language, tone) are merged into the request body alongside action. |
Redactor('#entry', {
plugins: ['assistant'],
ai: {
url: '/api/ai/text',
model: 'gpt-4o-mini',
headers: { Authorization: 'Bearer …' },
image: {
url: '/api/ai/image',
model: 'gpt-image-1',
sizes: {
'1536x1024': 'Landscape',
'1024x1024': 'Square'
},
defaultSize: '1536x1024'
},
actions: {
improve: { title: 'Improve writing' },
translate: {
items: {
sv: { title: 'Swedish', language: 'Swedish' }
}
}
}
},
assistant: {
groups: ['edit', 'generate', 'review', 'insights'],
actions: {
shorter: { title: 'Make shorter', group: 'edit' }
}
}
});
Clicking Make shorter on selected blocks sends:
{
"action": "shorter",
"prompt": "",
"mode": "review",
"context": { "scope": "blocks", "blocks": [ … ] },
"lang": "en",
"model": "gpt-4o-mini"
}
Your backend loads the shorter template, substitutes [JSON] with context.blocks, and calls the model.
en: {
ai: {
'ai': 'AI',
'ask-ai': 'Ask AI',
'edit': 'Edit',
'generate': 'Generate',
'review': 'Review',
'insights': 'Insights',
'improve': 'Improve writing',
'shorter': 'Make shorter',
'longer': 'Make longer',
'fix': 'Fix spelling & grammar',
'continue': 'Continue writing',
'proofread': 'Proofread',
'summarize': 'Summarize',
'meta-description': 'Meta description',
'key-points': 'Key points',
'preview-text': 'Preview text',
'brainstorm-titles': 'Brainstorm titles',
'translate': 'Translate',
'change-tone': 'Change tone',
'lead-text': 'Lead text',
'headings': 'Add headings',
'enrich-structure': 'Suggest lists & tables',
'plain-language': 'Plain language',
'improve-headings': 'Improve headings',
'expand-ideas': 'Expand ideas',
'explore-directions': 'Explore directions',
'suggest-new-angles': 'Suggest new angles',
'better-wording': 'Better wording',
'check-writing': 'Check writing',
'toast-applied': 'Changes applied'
}
}
The AI Assistant does not call OpenAI, Claude, or any other provider directly. It sends a compact JSON payload to your backend (ai.url or ai.jobs.createUrl). Your backend picks a prompt template for the action, renders it with context.blocks (and optional extras such as language or tone), calls the model, parses the JSON reply, and returns the shaped result to the editor. The transport option controls delivery (sync, jobs, or custom).
The editor never expects raw HTML rewrites. Edit and review actions return { issues }; insight actions return a report object { title, tab, html, data? } where html is built on the server from structured model output. On failure, return { error: "…" } with a non-2xx status.
For step-by-step guides with full prompt patterns, copy-paste Node.js / curl samples, and jobs transport, see:
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.).
Method: POST
Content-Type: application/json
Extra headers: from editor option ai.headers.
| Field | Type | Description |
|---|---|---|
action |
string |
Action id (improve, shorter, summarize, translate, …). Selects the prompt template on your backend. |
prompt |
string |
Optional user hint. Usually empty for preset actions; used for free-form prompts. |
mode |
string |
'review' (default), 'apply', 'report', or 'report-review'. Controls how the editor handles the response. |
context |
object |
{ scope, blocks } — document or selection blocks. Each block: { uid, type, tag, content? \| items? }. |
lang |
string |
Editor UI language code. |
model |
string |
Optional. Sent when editor option ai.model is set. |
| … | Action-specific extras merged into the body (for example language for translate, tone for change-tone). |
Example payload (edit action on selected blocks):
{
"action": "shorter",
"prompt": "",
"mode": "review",
"context": {
"scope": "blocks",
"blocks": [
{
"uid": "4wtth2uuf4",
"type": "text",
"tag": "p",
"content": "Most texts on the internet go unnoticed…"
}
]
},
"lang": "en",
"model": "gpt-4o-mini"
}
Your backend renders the full model prompt from an action template — the editor does not send pre-built instructions. Keep one template per action and substitute placeholders such as [JSON] (serialized context.blocks), [UI_LANGUAGE], and action-specific fields ([LANGUAGE] for translate, [TONE] for change-tone, …).
Example template for the shorter action:
You are a writing assistant embedded in a rich-text editor.
You receive document blocks as JSON: `{uid, type, tag, content? | items?}`.
- `type`: `heading`, `text`, or `list`.
- `tag`: `h1`–`h6`, `p`, `ul`, or `ol`.
- `content`: inner HTML for heading/text blocks.
- `items`: string array — one entry per top-level list item.
Shorten the provided blocks. Remove redundancy; keep essential meaning.
Respond with a single JSON object:
{
"issues": [
{
"id": "<uid>",
"type": "Shorten",
"description": "<short explanation>",
"tag": "<same as input>",
"content": "<shortened inner HTML>"
}
]
}
For `list` blocks use `items` instead of `content`.
Rules:
- Only include blocks that actually change.
- Preserve the original `tag`.
- No markdown, no code fences — JSON only.
- If nothing should change, return {"issues": []}.
Respond in the user's UI language when possible: [UI_LANGUAGE].
### Input:
[JSON]
After substitution, [JSON] becomes the serialized context.blocks from the request and [UI_LANGUAGE] becomes the lang field (for example en).
Return the final JSON directly (no job envelope in sync mode). Shape depends on mode:
| Mode | Response |
|---|---|
review (default edit) |
{ "issues": [ … ] } — user accepts or rejects each change. |
apply |
{ "issues": [ … ] } — editor applies changes immediately. |
report |
{ "title", "tab", "html", "data"? } — html is server-rendered for the report tab. |
report-review |
{ "title", "tab", "html"?, "issues"?, "data"? } — report plus optional inline fixes. |
Edit / review issue (one entry per changed block):
{
"issues": [
{
"id": "4wtth2uuf4",
"type": "Shorten",
"description": "Shortened the paragraph to enhance clarity.",
"tag": "p",
"content": "Most online texts go unnoticed…"
}
]
}
Report (Summarize, Key points, Meta description, …):
{
"title": "Summary",
"tab": "summary",
"html": "<p>Short recap of the document.</p>",
"data": {
"summary": ["Paragraph one.", "Paragraph two."]
}
}
If the model text is not valid JSON, return HTTP 502 with { "error": "…", "text": "…" }.
sync (default)One HTTP request per action, response returned inline.
ai: {
transport: 'sync',
url: '/api/ai/text',
model: 'gpt-4o-mini'
}
PHP + cURL (illustrative):
<?php
// Illustrative only — not production-ready.
header('Content-Type: application/json; charset=utf-8');
$payload = json_decode(file_get_contents('php://input'), true) ?: [];
$action = (string) ($payload['action'] ?? '');
$prompt = (string) ($payload['prompt'] ?? '');
$mode = (string) ($payload['mode'] ?? 'review');
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$model = (string) ($payload['model'] ?? 'gpt-4o-mini');
// Your code: load `./prompts/{$action}.txt`, substitute [JSON], [UI_LANGUAGE], …
$userContent = renderActionPrompt($action, $prompt, $context, $payload);
$ch = curl_init('https://api.openai.com/v1/chat/completions');
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,
'response_format' => ['type' => 'json_object'],
'messages' => [['role' => 'user', 'content' => $userContent]],
]),
]);
$raw = curl_exec($ch);
curl_close($ch);
$data = json_decode($raw, true);
$text = (string) ($data['choices'][0]['message']['content'] ?? '');
$parsed = extractModelJson($text); // strip fences, decode JSON object
if ($parsed === null) {
http_response_code(502);
echo json_encode(['error' => 'Model did not return parseable JSON.', 'text' => $text]);
exit;
}
// Edit/review: return { issues }. Report: add title, tab, html from parsed fields.
echo json_encode(formatEditorResponse($action, $mode, $parsed));
Node.js (OpenAI SDK):
import OpenAI from 'openai';
import { readFileSync } from 'node:fs';
const client = new OpenAI();
const payload = req.body; // { action, prompt, mode, context, model, lang, … }
// Your code: load `./prompts/${payload.action}.txt`, substitute [JSON], [UI_LANGUAGE], …
const template = readFileSync(`./prompts/${payload.action}.txt`, 'utf8');
const userContent = renderPrompt(template, payload);
const completion = await client.chat.completions.create({
model: payload.model || 'gpt-4o-mini',
response_format: { type: 'json_object' },
messages: [{ role: 'user', content: userContent }],
});
const parsed = JSON.parse(completion.choices[0].message.content || '{}');
// Edit/review: return { issues }. Report: add title, tab, html from parsed fields.
res.json(formatEditorResponse(payload.action, payload.mode, parsed));
For Claude, keep the same request/response contract and swap the upstream call to the Anthropic Messages API. Ask the model for a single JSON object (response_format equivalent via system prompt).
jobs (async)For slow providers or long documents. The editor sends one create request, then polls until the job finishes.
ai: {
transport: 'jobs',
jobs: {
createUrl: '/api/ai/jobs',
pollInterval: 1500,
pollMaxWait: 120000
}
}
Flow: POST createUrl → poll pollUrl every pollInterval ms → stop when status is done, failed, or cancelled.
POST createUrlSame JSON body as the sync endpoint (see Request above).
Create response — HTTP 202 (or 200):
{
"jobId": "job_abc123",
"status": "queued",
"pollUrl": "https://example.com/api/ai/jobs?id=job_abc123"
}
Required: jobId (or id). Optional: pollUrl — if omitted, the editor builds it as createUrl?id={jobId}.
GET pollUrlWhile the worker runs, return a pending status:
{
"jobId": "job_abc123",
"status": "queued"
}
status may be queued or running. The editor keeps polling.
When finished, return status: "done" and put the final payload in result — the same JSON your sync endpoint would return directly:
Edit actions (mode: "review"):
{
"jobId": "job_abc123",
"status": "done",
"result": {
"issues": [
{
"id": "4wtth2uuf4",
"type": "Shorten",
"description": "Shortened the paragraph.",
"tag": "p",
"content": "Most online texts go unnoticed…"
}
]
}
}
Report actions (mode: "report"):
{
"jobId": "job_abc123",
"status": "done",
"result": {
"title": "Summary",
"tab": "summary",
"html": "<p>Short recap of the document.</p>",
"data": { "summary": ["…"] }
}
}
Review with apply (mode: "apply" — Better wording):
{
"jobId": "job_abc123",
"status": "done",
"result": {
"issues": [
{
"id": "k78lynbrvp",
"type": "Better wording",
"description": "Alternative wordings for key phrases",
"tag": "p",
"content": "Most texts go <span data-block=\"wording\">unnoticed</span>…",
"annotations": [["ignored", "neglected", "missed"]]
}
]
}
}
On failure:
{
"jobId": "job_abc123",
"status": "failed",
"error": "Model did not return parseable JSON",
"code": "upstream_error"
}
The provider call inside your worker reuses the same logic as sync — only delivery is split into create + poll.
customSkip HTTP and provide a JavaScript handler. Return the same JSON shape as sync — { issues } for edit/review, or { title, tab, html } for reports:
ai: {
transport: 'custom',
request: async (ctx, app) => {
// ctx.body: { action, prompt, mode, context, model, lang, … }
const { action, mode } = ctx.body;
const modelJson = await callYourModel(action, ctx.body);
if (mode === 'report' || mode === 'report-review') {
return formatReport(action, modelJson); // { title, tab, html, data? }
}
return modelJson; // { issues: [...] }
}
}
For images, point ai.image.url at an endpoint that accepts { action, prompt, size, n, model? } and returns { images: [{ url }] } or { images: [{ b64_json, mime }] }.
Use these when you already have a shaped response (demos, fixtures, offline previews):
// Report tabs on init
hostTabs: [
{ key: 'summary', title: 'Summary', html: '<p>…</p>', mode: 'report' },
{ key: 'key-points', title: 'Key points', html: '<ul>…</ul>', mode: 'report' }
]
// Or present a full response after load (same shapes as the sync endpoint)
app.on('editor:ready', () => {
app.aiCore.present({
action: 'check-writing',
mode: 'report-review',
raw: {
title: 'Writing check',
tab: 'check-writing',
html: '<section>…</section>',
issues: [/* { id, type, description, tag, content } */]
}
});
});
// Issues only
app.aiReview.open({ action: 'check-writing', issues: […] });
aiCore.present() runs the same pipeline as a successful request: host tabs for reports, review cards for issues, apply path for mode: 'apply'. Issue id values must match block data-uids in the document.
The Assistant plugin uses the shared ai:* event namespace from AICore. Hooks let you modify or cancel requests; lifecycle events fire when review cards, reports, or insertions run.
Hook when a request payload is prepared. Return modified fields or call stop() to cancel.
Payload: action, prompt, context ({ scope, blocks }).
app.on('ai:create', ({ action, prompt, context, stop }) => {
if (shouldCancel()) {
stop();
return;
}
return { action, prompt, context };
});
Hook before the HTTP request. Return modified url, body, or headers, or call stop() to cancel.
Payload: url, body, headers.
app.on('ai:before:send', ({ url, body, headers, stop }) => {
return {
url,
body: { ...body, tenantId: getTenantId() },
headers
};
});
Fired when a text request is dispatched (after ai:before:send).
Payload: url, body, batch (optional batch metadata).
app.on('ai:send', ({ url, body }) => {
console.log('ai:send', body.action, body.context.blocks.length);
});
Fired when a text request completes successfully.
Payload: raw — parsed backend JSON ({ issues } or { title, tab, html, … }).
app.on('ai:complete', ({ raw }) => {
console.log('issues:', raw?.issues?.length ?? 0);
});
Fired on request or processing errors.
Payload: error, action (optional, for example 'image').
app.on('ai:error', ({ error, action }) => {
console.error('ai:error', action, error);
});
Fired when an action is triggered with no target blocks (for example edit on an empty selection).
Payload: action, mode.
app.on('ai:action:empty', ({ action, mode }) => {
console.log('no blocks for', action, mode);
});
Fired when the backend returns no applicable issues (or nothing to apply).
Payload: action, mode.
app.on('ai:action:empty-response', ({ action, mode }) => {
console.log('empty response for', action, mode);
});
Fired when mode: 'apply' changes are applied immediately (for example Better wording).
Payload: action, issues, applied (count).
app.on('ai:apply', ({ action, issues, applied }) => {
console.log(action, applied, 'of', issues.length);
});
Fired when the review UI opens with suggested changes.
Payload: action, issues.
app.on('ai:review:open', ({ action, issues }) => {
console.log(action, issues.length);
});
Fired when the user accepts one or all issues in a review card.
Payload: action, issue or issues.
app.on('ai:review:accept', ({ action, issue }) => {
console.log('accepted', action, issue.id);
});
Fired when the user rejects one or all issues in a review card.
Payload: action, issue or issues.
app.on('ai:review:reject', ({ action, issue }) => {
console.log('rejected', action, issue.id);
});
Fired when a review card is closed.
Payload: action.
app.on('ai:review:close', ({ action }) => {
console.log('review closed', action);
});
Fired when review cannot open (no valid issues or blocks).
app.on('ai:review:empty', () => {
console.log('review empty');
});
Fired when a report tab opens (Summarize, Key points, Meta description, …).
Payload: action, raw, title, key.
app.on('ai:report:open', ({ action, title, key }) => {
console.log('report', action, title, key);
});
Fired when a report action returns no renderable HTML.
app.on('ai:report:empty', () => {
console.log('report empty');
});
Hook before an issue is applied to the document. Return modified html or call stop() to skip.
Payload: html, blocks, action, issue.
app.on('ai:before:insert', ({ html, action, issue, stop }) => {
return { html: sanitize(html) };
});
Fired when issue content is inserted or a block is replaced.
Payload: blocks, html, action, issue.
app.on('ai:insert', ({ action, html, blocks }) => {
console.log(action, blocks.length, html.length);
});
Fired when wording annotations should bind to inserted content (works with the Annotation plugin).
Payload: root, entries, kind, issue, blocks, ignore.
app.on('ai:annotations:bind', ({ root, entries, issue }) => {
console.log('bind annotations', issue.id, entries.length);
});
Fired when the Ask AI or image prompt panel opens.
Payload: blocks, action, mode.
app.on('ai:prompt:open', ({ action, mode, blocks }) => {
console.log('prompt open', action, mode, blocks.length);
});
Fired when the prompt panel closes.
app.on('ai:prompt:close', () => {
console.log('prompt closed');
});
Fired when Ask AI is triggered with no block selection.
app.on('ai:prompt:empty', () => {
console.log('no selection for prompt');
});
Fired when the user discards the open prompt without submitting.
Payload: action, mode, blocks.
app.on('ai:discard', ({ action, mode }) => {
console.log('discarded', action, mode);
});
Fired when in-flight requests are aborted (for example via the loading toast).
Payload: source.
app.on('ai:stop', ({ source }) => {
console.log('stopped', source);
});
Hook before an image request. Return modified url, body, or headers, or call stop() to cancel.
Payload: url, body, headers.
app.on('ai:image:before', ({ url, body, headers, stop }) => {
return { url, body, headers };
});
Image generation lifecycle. ai:image:response payload includes images and raw.
app.on('ai:image:response', ({ images }) => {
console.log('generated', images.length);
});
Async job transport (ai.transport: 'jobs').
Payloads: jobId, pollUrl, createUrl (created); jobId, pollCount, status (poll); jobId (cancelled).
app.on('ai:job:poll', ({ jobId, status, pollCount }) => {
console.log(jobId, status, pollCount);
});
Fired when a batched review request retries with smaller block chunks after a gateway timeout.
Payload: error, blockCount, batchSize.
app.on('ai:batch:fallback', ({ blockCount, batchSize }) => {
console.log('batch fallback', blockCount, batchSize);
});