For slow models or long documents, use jobs transport: the editor creates a job, then polls until your worker finishes. The final result is the same JSON you would return from the sync endpoint.
Related: Writing prompts · Backend examples · Assistant plugin reference
When to use jobs#
Prefer sync |
Prefer jobs |
|---|---|
| Short edits, fast models | Large documents, slow / queued providers |
| Simple hosting (one request) | Background workers, queues (Redis, SQS, …) |
| Timeouts under ~30–60s are fine | Work that may take minutes |
The prompt templates and response shapes do not change — only delivery.
Configure the editor#
Redactor('#entry', {
plugins: ['assistant'],
ai: {
transport: 'jobs',
model: 'gpt-4o-mini',
jobs: {
createUrl: '/api/ai/jobs',
pollInterval: 1500, // ms between polls (min ~500)
pollMaxWait: 120000, // give up after this many ms
headers: {
// static headers on create + poll + cancel
},
getAuth: async () => {
// optional dynamic headers (token refresh)
return { Authorization: `Bearer ${await getAccessToken()}` };
}
}
}
});
Notes:
- Set
transport: 'jobs'andjobs.createUrl— text actions then use create + poll, notai.url. pollIntervalgrows slightly on each wait (capped at 5s) to avoid hammering your API.- If the user aborts, the editor sends
DELETEto the poll URL (best effort).
Events you can listen to: ai:job:created, ai:job:poll, ai:job:cancelled.
Protocol overview#
- Editor → POST createUrl — body is the same as sync (
action,mode,context, …). - Your API — enqueue a worker and respond immediately with HTTP
202:{ jobId, status: "queued", pollUrl }. - Worker — run the model with the same logic as sync, then store the editor payload on the job.
- Editor → GET pollUrl (repeat) — while working, return
{ jobId, status: "queued" | "running" }. - Editor → GET pollUrl (finished) —
{ jobId, status: "done", result }whereresultis the same JSON sync would return.
1. Create — POST createUrl
Body: identical to the sync AI request (action, mode, context, …).
Response: HTTP 202 (or 200):
{
"jobId": "job_abc123",
"status": "queued",
"pollUrl": "https://example.com/api/ai/jobs?id=job_abc123"
}
| Field | Required | Notes |
|---|---|---|
jobId or id |
yes | Stable id for this job |
pollUrl |
no | If omitted, editor uses createUrl?id={jobId} |
status |
recommended | "queued" / "running" |
2. Poll — GET pollUrl
While working:
{
"jobId": "job_abc123",
"status": "queued"
}
or "running". The editor keeps polling.
When finished, put the final editor payload inside result:
{
"jobId": "job_abc123",
"status": "done",
"result": {
"issues": [
{
"id": "4wtth2uuf4",
"type": "Shorten",
"description": "Shortened the paragraph.",
"tag": "p",
"content": "Most online texts go unnoticed…"
}
]
}
}
Report example:
{
"jobId": "job_abc123",
"status": "done",
"result": {
"title": "Summary",
"tab": "summary",
"html": "<p>Short recap.</p>",
"data": { "summary": ["…"] }
}
}
Failure:
{
"jobId": "job_abc123",
"status": "failed",
"error": "Model did not return parseable JSON",
"code": "upstream_error"
}
Terminal statuses: done, failed, cancelled. Anything else is treated as still in progress.
3. Cancel — DELETE pollUrl (optional)
Sent when the request is aborted. Mark the job cancelled and stop the worker if you can. Respond with 200 / 204; errors are ignored by the editor.
Minimal in-memory Node.js example#
Good for local demos. Replace the Map with Redis / a database in production.
// routes/ai-jobs.js
import express from 'express';
import { randomUUID } from 'node:crypto';
import OpenAI from 'openai';
import {
loadTemplate,
renderPrompt,
extractModelJson,
formatEditorResponse,
} from '../prompts/render.js';
// reuse helpers from the backend guide
const router = express.Router();
const client = new OpenAI();
const jobs = new Map(); // jobId -> { status, result?, error?, payload }
async function runJob(jobId) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'running';
try {
const payload = job.payload;
const action = String(payload.action || '');
const mode = String(payload.mode || 'review');
const model = String(payload.model || 'gpt-4o-mini');
const template = loadTemplate(action);
const userContent = renderPrompt(template, payload);
const completion = await client.chat.completions.create({
model,
response_format: { type: 'json_object' },
messages: [{ role: 'user', content: userContent }],
});
const text = completion.choices[0]?.message?.content || '';
const parsed = extractModelJson(text);
if (!parsed) {
job.status = 'failed';
job.error = 'Model did not return parseable JSON';
job.code = 'upstream_error';
return;
}
job.result = formatEditorResponse(action, mode, parsed);
job.status = 'done';
} catch (err) {
job.status = 'failed';
job.error = err.message || 'Job failed';
job.code = 'worker_error';
}
}
/** Create */
router.post('/api/ai/jobs', (req, res) => {
const jobId = `job_${randomUUID()}`;
const payload = req.body || {};
jobs.set(jobId, {
status: 'queued',
payload,
result: null,
error: null,
});
// fire-and-forget worker
setImmediate(() => runJob(jobId));
const pollUrl = `${req.protocol}://${req.get('host')}/api/ai/jobs?id=${encodeURIComponent(jobId)}`;
res.status(202).json({ jobId, status: 'queued', pollUrl });
});
/** Poll */
router.get('/api/ai/jobs', (req, res) => {
const jobId = String(req.query.id || '');
const job = jobs.get(jobId);
if (!job) {
return res.status(404).json({ error: 'Unknown job', jobId });
}
const body = { jobId, status: job.status };
if (job.status === 'done') body.result = job.result;
if (job.status === 'failed' || job.status === 'cancelled') {
body.error = job.error || job.status;
if (job.code) body.code = job.code;
}
return res.json(body);
});
/** Cancel */
router.delete('/api/ai/jobs', (req, res) => {
const jobId = String(req.query.id || '');
const job = jobs.get(jobId);
if (job && job.status !== 'done') {
job.status = 'cancelled';
job.error = 'cancelled';
}
return res.status(204).end();
});
export default router;
Mount alongside your sync route if you keep both:
app.use(express.json({ limit: '2mb' }));
app.use(aiText); // optional sync
app.use(aiJobs);
Same flow in PHP#
Reuse the helpers from the backend guide (ai-helpers.php: load template, render placeholders, call OpenAI via curl, format the editor response). Jobs are stored as JSON files under jobs/ for a local demo — replace with Redis / a database in production.
Endpoint (ai-jobs.php)
Handles create (POST), poll (GET), and cancel (DELETE). After create, it starts a background worker that calls OpenAI.
<?php
// ai-jobs.php — point the editor here:
// ai: { transport: 'jobs', jobs: { createUrl: '/ai-jobs.php' } }
require __DIR__ . '/ai-helpers.php';
$jobsDir = __DIR__ . '/jobs';
if (!is_dir($jobsDir)) {
mkdir($jobsDir, 0775, true);
}
function job_path(string $jobId): string {
global $jobsDir;
return $jobsDir . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $jobId) . '.json';
}
function job_load(string $jobId): ?array {
$path = job_path($jobId);
if (!is_readable($path)) {
return null;
}
$data = json_decode((string) file_get_contents($path), true);
return is_array($data) ? $data : null;
}
function job_save(string $jobId, array $job): void {
file_put_contents(job_path($jobId), json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
function job_json($data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$jobId = (string) ($_GET['id'] ?? '');
/** Create — POST /ai-jobs.php */
if ($method === 'POST') {
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload)) {
job_json(['error' => 'Invalid JSON body'], 400);
}
$jobId = 'job_' . bin2hex(random_bytes(8));
job_save($jobId, [
'status' => 'queued',
'payload' => $payload,
'result' => null,
'error' => null,
]);
// Fire-and-forget worker (CLI). Adjust the php binary path if needed.
$worker = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/ai-jobs-worker.php')
. ' ' . escapeshellarg($jobId) . ' > /dev/null 2>&1 &';
exec($worker);
$pollUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://'
. ($_SERVER['HTTP_HOST'] ?? 'localhost')
. strtok($_SERVER['REQUEST_URI'] ?? '/ai-jobs.php', '?')
. '?id=' . rawurlencode($jobId);
job_json(['jobId' => $jobId, 'status' => 'queued', 'pollUrl' => $pollUrl], 202);
}
/** Poll — GET /ai-jobs.php?id=… */
if ($method === 'GET') {
$job = job_load($jobId);
if ($job === null) {
job_json(['error' => 'Unknown job', 'jobId' => $jobId], 404);
}
$body = ['jobId' => $jobId, 'status' => $job['status']];
if ($job['status'] === 'done') {
$body['result'] = $job['result'];
}
if ($job['status'] === 'failed' || $job['status'] === 'cancelled') {
$body['error'] = $job['error'] ?? $job['status'];
if (!empty($job['code'])) {
$body['code'] = $job['code'];
}
}
job_json($body);
}
/** Cancel — DELETE /ai-jobs.php?id=… */
if ($method === 'DELETE') {
$job = job_load($jobId);
if ($job && ($job['status'] ?? '') !== 'done') {
$job['status'] = 'cancelled';
$job['error'] = 'cancelled';
job_save($jobId, $job);
}
http_response_code(204);
exit;
}
job_json(['error' => 'Method not allowed'], 405);
Worker (ai-jobs-worker.php)
Runs off the HTTP request. Loads the job payload, builds the prompt, calls OpenAI with curl (via ai_call_openai from the helpers), then stores result for the editor poll.
<?php
// ai-jobs-worker.php — started by ai-jobs.php after create
// Usage: php ai-jobs-worker.php job_abc123
require __DIR__ . '/ai-helpers.php';
$jobId = $argv[1] ?? '';
$path = __DIR__ . '/jobs/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $jobId) . '.json';
if ($jobId === '' || !is_readable($path)) {
fwrite(STDERR, "Unknown job\n");
exit(1);
}
$job = json_decode((string) file_get_contents($path), true);
if (!is_array($job) || ($job['status'] ?? '') === 'cancelled') {
exit(0);
}
$job['status'] = 'running';
file_put_contents($path, json_encode($job));
$apiKey = getenv('OPENAI_API_KEY') ?: '';
if ($apiKey === '') {
$job['status'] = 'failed';
$job['error'] = 'OPENAI_API_KEY is not set';
$job['code'] = 'config_error';
file_put_contents($path, json_encode($job));
exit(1);
}
try {
$payload = $job['payload'] ?? [];
$action = (string) ($payload['action'] ?? '');
$mode = (string) ($payload['mode'] ?? 'review');
$model = (string) ($payload['model'] ?? 'gpt-4o-mini');
$template = ai_load_template($action);
$userContent = ai_render_prompt($template, $payload);
// curl → OpenAI (defined in ai-helpers.php from the backend guide)
$text = ai_call_openai($apiKey, $model, $userContent);
$parsed = ai_extract_model_json($text);
if ($parsed === null) {
$job['status'] = 'failed';
$job['error'] = 'Model did not return parseable JSON';
$job['code'] = 'upstream_error';
} else {
$job['result'] = ai_format_editor_response($action, $mode, $parsed);
$job['status'] = 'done';
}
} catch (Throwable $e) {
$job['status'] = 'failed';
$job['error'] = $e->getMessage();
$job['code'] = 'worker_error';
}
// Do not overwrite a cancel that happened while we were running
$latest = json_decode((string) file_get_contents($path), true);
if (is_array($latest) && ($latest['status'] ?? '') === 'cancelled') {
exit(0);
}
file_put_contents($path, json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
Point the editor at this endpoint:
ai: {
transport: 'jobs',
jobs: {
createUrl: '/ai-jobs.php',
pollInterval: 1500,
pollMaxWait: 120000
}
}
result inside a done poll response is the same shaped JSON as the sync backend — { issues } or { title, tab, html, … }.
Production layout (recommended)#
Keep create/poll thin; do the model call in a worker:
- Create — validate auth, write job row (
queued), push queue message, returnjobId+pollUrl. - Worker — load payload, run the same
loadTemplate→ model →formatEditorResponsepath as sync, writeresult+done(orfailed). - Poll — read job row only; never call the model on GET.
- TTL — expire jobs after a few hours; purge
resultblobs. - Idempotency — optional
Idempotency-Keyheader if clients retry create. - Auth — same headers on POST and GET; use
ai.jobs.getAuthfor short-lived tokens.
Pseudo-queue with Redis:
POST /jobs → SET job:{id} {status,payload} → LPUSH ai:queue {id} → 202
Worker → BRPOP ai:queue → run model → SET job:{id} {status:done,result}
GET /jobs → GET job:{id}
Matching result shapes#
Whatever you put in result must match sync:
| Mode | result |
|---|---|
review / apply |
{ "issues": [ … ] } |
report |
{ "title", "tab", "html", "data"? } |
report-review |
{ "title", "tab", "html"?, "issues"?, "data"? } |
Do not wrap twice (result: { result: { issues } }). The editor reads statusPayload.result and treats it as the final AI response.
Troubleshooting#
| Symptom | Check |
|---|---|
| UI missing | ai.jobs.createUrl set and transport: 'jobs' |
create response missing jobId |
Return jobId or id |
completed job has no result |
On done, always include object result |
| Poll forever then timeout | Worker not updating status; or pollMaxWait too low |
| CORS errors on poll | Allow GET (and DELETE) from the editor origin |
| Abort does nothing | Implement DELETE; still safe if no-op |
Editor options reference#
| Option | Default | Meaning |
|---|---|---|
ai.transport |
'sync' |
Set to 'jobs' |
ai.jobs.createUrl |
— | POST URL to create a job |
ai.jobs.pollInterval |
1500 |
Initial delay between polls (ms) |
ai.jobs.pollMaxWait |
120000 |
Max wait before editor errors (ms) |
ai.jobs.headers |
— | Extra headers for create/poll/cancel |
ai.jobs.getAuth |
— | async () => ({ …headers }) |
You can keep a sync URL for other tools and use jobs only for the Assistant — set transport per product init as needed.