---
title: OpenAI Responses API + Gutenberg: Streaming AI Drafts Safely
description: Build real-time AI assistant workflows in Gutenberg using Server-Sent Events (SSE), React hooks, secure token proxying, and custom Assistant tool calling.
url: https://wpstack.online/2026/09/22/openai-responses-api-gutenberg-streaming-ai-drafts
date_modified: 2026-09-12
author: Aditya Bhimrajka
language: en_US
---

A useful AI feature in Gutenberg should feel fast without becoming a publishing backdoor. The editor can stream a draft as it is generated, but the API key must remain on the server, every request must be authorized, and the output must stay visibly separate from approved content until an editor accepts it.

There is also an important 2026 correction: do not start this integration with the OpenAI Assistants API. OpenAI deprecated Assistants after the Responses API reached feature parity and scheduled its shutdown for August 26, 2026. A new WordPress integration should use the Responses API.

## Use a three-part design

1. **Gutenberg sidebar:** collects the editorial brief and displays the live draft.
2. **Your server endpoint:** authenticates the WordPress user, enforces limits, and calls OpenAI.
3. **Responses API stream:** returns events that your endpoint forwards to the editor.

Never call OpenAI directly from block-editor JavaScript. Any browser-delivered key can be extracted and abused. The server boundary is also where you apply capability checks, usage quotas, audit logging, and prompt constraints.

## Stream from a server you control

The official JavaScript SDK supports asynchronous streaming from `responses.create()`. This example uses a server route and emits only the text deltas the editor needs.

```
// Server-side route; never bundle this into Gutenberg JavaScript.
import OpenAI from 'openai'

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })

export async function POST(request: Request) {
  const user = await authenticateWordPressRequest(request)
  if (!user?.capabilities?.edit_posts) {
    return new Response('Forbidden', { status: 403 })
  }

  const { brief } = await request.json()
  if (typeof brief !== 'string' || brief.length < 20 || brief.length > 4000) {
    return new Response('Invalid brief', { status: 400 })
  }

  await enforceRateLimit(user.id)

  const stream = await openai.responses.create({
    model: 'gpt-5',
    instructions: [
      'Create a factual editorial draft from the supplied brief.',
      'Do not invent statistics, quotations, links, or customer claims.',
      'Return plain text with short section headings.'
    ].join(' '),
    input: brief,
    stream: true,
  })

  const body = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()
      try {
        for await (const event of stream) {
          if (event.type === 'response.output_text.delta') {
            controller.enqueue(encoder.encode(event.delta))
          }
        }
      } finally {
        controller.close()
      }
    },
  })

  return new Response(body, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-store',
      'X-Content-Type-Options': 'nosniff',
    },
  })
}
```

`authenticateWordPressRequest()` and `enforceRateLimit()` are application-specific on purpose. If the streaming service is separate from WordPress, exchange a short-lived, signed token that contains the user ID, capability, audience, and expiry. Do not forward a reusable WordPress session cookie to another origin.

If you host the proxy inside PHP, test actual streaming through PHP-FPM, your reverse proxy, CDN, and browser. Buffering at any layer can turn a stream into one delayed response. A small Node or edge service is often more predictable for long-lived event delivery.

## Consume the stream in Gutenberg

A plugin sidebar can read the response body incrementally. Keep generated content in component state until the editor deliberately inserts it.

```
async function generateDraft(brief, setDraft, signal) {
  const response = await fetch(window.wpstackAi.endpoint, {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-WP-Nonce': window.wpstackAi.nonce,
    },
    body: JSON.stringify({ brief }),
    signal,
  });

  if (!response.ok || !response.body) {
    throw new Error(`Generation failed (${response.status})`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let draft = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    draft += decoder.decode(value, { stream: true });
    setDraft(draft);
  }

  draft += decoder.decode();
  setDraft(draft);
  return draft;
}
```

Use an `AbortController` for a visible Stop button and cancel when the sidebar unmounts. Disable duplicate submissions while a request is active. Show failure states clearly; silently inserting a partial response is worse than showing an error.

## Insert blocks, not an opaque HTML blob

Generated output should become normal Gutenberg blocks that an editor can inspect and rearrange. The safest baseline is plain text split into paragraphs and headings, then converted with WordPress block APIs.

```
import { createBlock } from '@wordpress/blocks';
import { dispatch } from '@wordpress/data';

function insertDraft(draft) {
  const blocks = draft
    .split(/\n{2,}/)
    .map((part) => part.trim())
    .filter(Boolean)
    .map((content) => createBlock('core/paragraph', { content }));

  dispatch('core/block-editor').insertBlocks(blocks);
}
```

This deliberately treats model output as text. If you request structured JSON, validate it against a strict schema on the server and allowlist block types and attributes before creating blocks. Never pass untrusted model HTML directly to `dangerouslySetInnerHTML` or save it without WordPress sanitization.

## Register the editor integration correctly

Register blocks on the server with `block.json`; WordPress recommends metadata-based registration. For a sidebar-only tool, enqueue the editor script with the required WordPress packages and expose only the endpoint URL and a REST nonce.

If you store generation state in post meta, register the field with a concrete type, `single => true`, an authorization callback, and `show_in_rest => true`. The post type must support custom fields. Do not store the OpenAI API key, complete prompts containing secrets, or raw provider responses in post meta.

## Control cost and abuse

- Check `edit_posts` or the more specific capability required by your workflow on every request.
- Rate-limit by authenticated user and site, with a bounded daily budget.
- Set maximum brief size and maximum output.
- Reject anonymous requests before contacting the model provider.
- Use an allowlist of models and server-owned instructions.
- Log request ID, user ID, model, latency, token usage, and outcome without recording sensitive content by default.

A WordPress nonce helps protect a logged-in REST request from cross-site request forgery, but it is not authorization by itself. Always verify the current user and capability server-side.

## Keep editorial review mandatory

The model can produce fluent errors. It may invent a product feature, misstate a version, or create a plausible but nonexistent source. Generated copy should enter a review state, not jump directly to publication.

Give editors a compact checklist: verify every factual claim, open every link, remove fabricated quotations, check licensing for media, confirm brand voice, inspect headings and accessibility, and disclose AI assistance where policy or law requires it. Preserve normal WordPress revisions so accepted changes remain auditable.

## Test failure paths before launch

1. Confirm an anonymous user and an authenticated subscriber receive 403 responses.
2. Cancel a stream midway and verify no partial content is inserted automatically.
3. Simulate a provider timeout, quota error, and malformed event.
4. Verify the API key never appears in page source, source maps, logs, or browser requests.
5. Try HTML and block-comment injection in the prompt and output.
6. Confirm rate limits work across multiple application servers.
7. Save, reload, undo, and restore a revision after inserting generated blocks.

## A current, safe baseline

Build new integrations on the Responses API, stream through a protected server endpoint, keep generated text outside the post until an editor accepts it, and convert approved text into ordinary Gutenberg blocks. The result is responsive without exposing credentials or bypassing the editorial process.

Use the official [OpenAI API quickstart](https://platform.openai.com/docs/quickstart), the [Assistants migration notice](https://platform.openai.com/docs/assistants/deep-dive), and the WordPress [block metadata](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/) and [post meta](https://developer.wordpress.org/block-editor/how-to-guides/metabox/) documentation as the source of truth.
