AI Assistant async jobs

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' and jobs.createUrl — text actions then use create + poll, not ai.url.
  • pollInterval grows slightly on each wait (capped at 5s) to avoid hammering your API.
  • If the user aborts, the editor sends DELETE to the poll URL (best effort).

Events you can listen to: ai:job:created, ai:job:poll, ai:job:cancelled.

Protocol overview#

  1. Editor → POST createUrl — body is the same as sync (action, mode, context, …).
  2. Your API — enqueue a worker and respond immediately with HTTP 202: { jobId, status: "queued", pollUrl }.
  3. Worker — run the model with the same logic as sync, then store the editor payload on the job.
  4. Editor → GET pollUrl (repeat) — while working, return { jobId, status: "queued" | "running" }.
  5. Editor → GET pollUrl (finished) — { jobId, status: "done", result } where result is 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:

  1. Create — validate auth, write job row (queued), push queue message, return jobId + pollUrl.
  2. Worker — load payload, run the same loadTemplate → model → formatEditorResponse path as sync, write result + done (or failed).
  3. Poll — read job row only; never call the model on GET.
  4. TTL — expire jobs after a few hours; purge result blobs.
  5. Idempotency — optional Idempotency-Key header if clients retry create.
  6. Auth — same headers on POST and GET; use ai.jobs.getAuth for 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.