Editorial Assistant
AI editorial assistant for WordPress publishers: headline variants, internal links, multi-turn chat, and block rewrites.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/alansmodic/editorial-assistant/archive/refs/heads/main.zipAn AI editorial assistant for WordPress publishers. Lives in the block-editor sidebar and helps writers ship better drafts faster — suggesting headlines, surfacing internal links from the archive, rewriting blocks on request, and holding a multi-turn chat scoped to the post in front of you.
Every suggestion is a proposal, never a silent mutation. Accept what's useful, reject the rest. Decisions persist across reloads.
Table of contents
- What it does
- Requirements
- Installation
- Configuration
- Using the assistant
- How proposals are persisted
- Architecture
- REST and streaming API
- Style guide and voice memory
- Cron jobs
- Development
- Troubleshooting
- Roadmap
- License
What it does
Editorial Assistant adds a sidebar to the WordPress block editor with three collapsible panels:
| Panel | What it gives you |
|---|---|
| Headlines | Five distinct angle variants (news / analysis / feature / service / wildcard) with rationale and tradeoffs. One-click apply to the post title. |
| Internal links | Up to six high-confidence link suggestions filtered from a scored candidate list. One-click splice into the matching block, preserving inline formatting. |
| Chat | Multi-turn streaming chat scoped to the current post. Quote blocks for context, ask for rewrites, request link searches conversationally. |
It is built on top of the WordPress AI Client (wp_ai_client_prompt()) and registers with the Agents API substrate when present. Without Agents API the plugin still works — REST endpoints and the sidebar exercise the AI client directly.
Requirements
- WordPress 7.0+ —
wp_ai_client_prompt()must be available - PHP 8.1+ — strict types,
str_starts_with, readonly properties - Node 20+ — only needed to build the sidebar JS
- A configured AI provider under Settings → AI Credentials
- (Optional) the Agents API plugin — enables agent registration; the plugin degrades gracefully without it
Installation
- Drop the plugin into
wp-content/plugins/editorial-assistant/. - (Recommended) install the Agents API plugin from its GitHub releases.
- Build the sidebar JS:
cd wp-content/plugins/editorial-assistant npm install npm run build - Activate Editorial Assistant in WordPress admin. Activation runs four things:
- Creates the
editorial_assistant_pending_actionsDB table - Flushes rewrite rules so
/editorial-assistant/chat-streamresolves - Schedules the weekly authority-index drift-recovery cron
- Schedules the daily pending-action cleanup cron
- Creates the
- Configure an AI provider under Settings → AI Credentials.
- Visit Settings → Editorial Assistant to review and edit the publication style guide.
Upgrading from v0.2 — deactivate + reactivate to pick up the new schema and cron schedule.
Configuration
AI provider
Editorial Assistant uses whichever provider is configured under Settings → AI Credentials (provided by the AI Client). No provider keys are stored by this plugin.
Style guide
Settings → Editorial Assistant exposes the publication style guide — tone, banned words, house preferences. The style guide is interpolated into every prompt by Conversation\ContextComposer, so changes apply immediately on the next request.
Using the assistant
Open any post in the block editor. The Editorial Assistant icon appears in the top-right toolbar; click it to open the sidebar. All three panels are collapsible; only the one you expand makes network calls.
Headlines
- Click Suggest headlines.
- Five variant cards appear, each with a different editorial angle, a one-line rationale, and a tradeoffs note.
- Accept sets the post title (and is remembered in voice memory). Reject dismisses the card.
Pending variants survive a page reload — close the tab, come back, and the unresolved cards are still there.
Internal links
- Click Find related links.
- The plugin runs a four-stage pipeline:
- Anchor extraction — named entities, recurring noun phrases, and "as we reported" backreferences are pulled from the draft.
- Candidate retrieval — the archive is searched for matching published posts.
- Scoring — each candidate gets
0.5 × relevance + 0.2 × recency + 0.15 × authority + 0.15 × anchor strength. - Model filter — top ~10 candidates are handed to the model with an editor-quality bar; up to 6 high-confidence picks come back.
- Each result is an accept/reject card showing the anchor, the target, and the model's rationale.
- Insert link splices an
<a>into the first matching block using@wordpress/rich-text'sapplyFormat(), preserving the surrounding bold/italic/link formatting.
Authority (inbound link count) updates incrementally on every save_post, so scores stay current without a nightly batch. The weekly full rebuild catches drift if something slips through.
Chat
A multi-turn streaming chat panel scoped to the current post. The transcript is stored in post meta and auto-resumes when you reopen the post — including any pending tool-call cards from a previous session, which re-render under a "Resumed" message.
Examples:
find related linkswhat angle am I missing?- Quote a block, then:
make this tighterorremove the hedging
Quote selected block — select a block in the editor, click the button, the block's text becomes context for your next message. This is required for rewrite_block tool calls.
Streaming uses Server-Sent Events over a custom non-REST endpoint. If your host buffers SSE (some reverse proxies do), the client falls back to a buffered REST call after 3 seconds — same events, same shape, just batched. See REST and streaming API for the contract.
Block rewrites
When you quote a block and ask for a change, the model calls the rewrite_block tool. The result renders as a side-by-side word-level diff:
- Original on top with removed words struck through
- Rewritten below with added words highlighted
Apply rewrite replaces the block's text; Keep original dismisses.
Two safety nets:
- Similarity check — if the model returns essentially the same text (>95% word overlap), the card is suppressed with a "try a more specific instruction" message rather than wasting a click.
- Formatting reset warning — applying a rewrite writes to the block's text attribute, which clears inline bold / italic / inline links in the rewritten paragraph. This is surfaced in the card so writers aren't surprised.
How proposals are persisted
Every accept/reject decision — headline, link, or rewrite — flows through one endpoint (POST /editorial-assistant/v1/actions/resolve) backed by a custom DB table (editorial_assistant_pending_actions).
The schema mirrors the Agents API WP_Agent_Pending_Action value object, so the data is in the right shape for a future migration to the substrate's store interface.
Lifecycle:
- Created when a tool produces a proposal
- Expires after 7 days if undecided
- Resolved (accepted or rejected) records prune after 30 days via the daily cleanup cron
This is also what powers rehydration: when any panel mounts, it queries the store for the current post and re-renders undecided proposals as fresh cards. There's no React-only ephemeral state for suggestions.
Architecture
editorial-assistant.php Plugin bootstrap: hooks, autoloader, activation
src/
Agent.php Agents API registration (modes, tool & action policy)
Approvals/
PendingActionStore.php DB-backed pending-action store (substrate-compatible schema)
REST/
HeadlinesController.php /v1/headlines/{suggest,accept}
ChatController.php /v1/chat/{transcript,clear,send},
/v1/links/{suggest,accept},
/v1/actions/resolve
Streaming/
EventSink.php Interface — both emitter and buffer satisfy this
EventEmitter.php SSE emitter with proxy-flush padding
BufferingEventSink.php In-memory accumulator for the non-streaming fallback
ChatStreamEndpoint.php Custom POST /editorial-assistant/chat-stream
Conversation/
ContextComposer.php Assemble system prompt from draft + style + voice
ToolRegistry.php Tool declarations + dispatch
StreamingRunner.php Per-turn loop with idempotency replay
Linking/
AnchorExtractor.php Stage 1: pull anchor candidates from draft
AuthorityIndex.php Incremental inbound-link counting + weekly rebuild
CandidateRanker.php Stages 2-3: retrieve + score targets
LinkProposer.php Stage 4: model filter + persist
Tools/
HeadlineGenerator.php Headline variants + Tier A validation + persist
BlockRewriter.php Block rewrite + similarity check + persist
StyleGuide/Repository.php Publication style guide
Memory/VoiceMemory.php Per-user accepted-headline memory
Settings/StyleGuideAdmin.php Settings → Editorial Assistant screen
Chat/TranscriptStore.php Per-post chat history in post_meta
src-js/sidebar/
index.js Sidebar registration with three panels
HeadlinePanel.jsx Headlines panel (with rehydration)
VariantCard.jsx Headline variant card
linking/
LinksPanel.jsx "Find related links" + results
LinkCard.jsx Per-link accept/reject card
chat/
ChatPanel.jsx Chat UI, transcript+pending rehydration, streaming
streamReader.js POST-fetch SSE reader with 3s timeout → REST fallback
RewriteCard.jsx Word-level diff display + apply/keep
shared/
blockMutations.js findAnchorInBlocks, insertLinkInBlocks, getSelectedBlockText
build/sidebar/ Compiled JS (npm run build)
Design notes
One resolve endpoint. Headlines, links, and rewrites all share the same persistence path. Adding a new tool means returning a pending action — no new resolve plumbing.
Streaming with a buffered fallback. Streaming\EventSink is implemented by both EventEmitter (SSE) and BufferingEventSink (in-memory). The same Conversation\StreamingRunner runs against either. The frontend's streamReader.js arms a 3-second timer; if no event arrives, it aborts and POSTs to the buffered REST fallback. Client UI handling is identical either way.
Idempotency. Because stream-then-fallback can result in two requests with the same payload, the client mints a per-send request_id. The server tags the user message in transcript with it, and any second request with the same request_id replays from transcript rather than re-prompting the model. This protects against double-billing.
Incremental authority indexing. Linking\AuthorityIndex listens on three hooks:
save_post— diffs previous vs current outbound links and adjusts target countstransition_post_status— adds or subtracts the whole outbound set on publish/unpublishbefore_delete_post— removes the post's outbound contribution
The weekly full rebuild exists as drift recovery, not as the primary index path.
Block rewrites preserve meaning, not formatting. The rewriter prompt instructs the model to keep the writer's voice and invent no new claims. A post-hoc word-overlap similarity check suppresses no-op rewrites. Applying a rewrite uses the block's text attribute directly — inline formatting is reset and the UI says so.
REST and streaming API
All REST routes are registered under the editorial-assistant/v1 namespace. Authentication uses the standard WordPress REST nonce; permission callbacks check edit_post on the relevant post.
Headlines
| Method | Route | Purpose |
|---|---|---|
POST |
/editorial-assistant/v1/headlines/suggest |
Generate five headline variants for a post |
POST |
/editorial-assistant/v1/headlines/accept |
Apply a variant to the post title and record voice memory |
Chat
| Method | Route | Purpose |
|---|---|---|
GET |
/editorial-assistant/v1/chat/transcript |
Load the transcript + any pending tool-call cards for a post |
POST |
/editorial-assistant/v1/chat/clear |
Clear the transcript |
POST |
/editorial-assistant/v1/chat/send |
Non-streaming fallback for a chat turn (buffered SSE replay) |
Links
| Method | Route | Purpose |
|---|---|---|
POST |
/editorial-assistant/v1/links/suggest |
Run the four-stage link pipeline for a post |
POST |
/editorial-assistant/v1/links/accept |
Mark a link suggestion as accepted (insertion happens client-side) |
Actions
| Method | Route | Purpose |
|---|---|---|
POST |
/editorial-assistant/v1/actions/resolve |
Accept or reject any pending action by ID |
Streaming endpoint
| Method | Route | Purpose |
|---|---|---|
POST |
/editorial-assistant/chat-stream |
SSE stream of a chat turn (not a REST route) |
This endpoint sits outside the REST API on purpose — it needs raw control over headers and flushing for SSE. It carries its own nonce (editorialAssistant.streamNonce, exposed via wp_localize_script).
Event shape. Each data: line is a JSON envelope { type, payload } where type is one of: assistant.delta, tool.call, tool.result, action.proposed, turn.complete, error. The buffered fallback emits the same envelopes in batch, so the client folds them into the UI identically.
Style guide and voice memory
Style guide (StyleGuide\Repository) — site-wide. Edited under Settings → Editorial Assistant. Interpolated into the system prompt by Conversation\ContextComposer.
Voice memory (Memory\VoiceMemory) — per-user. Stores the last N accepted headlines so the model can lean toward the writer's existing voice on future suggestions. Currently backed by user_meta; planned migration to the Agents API memory store with workspace scope + provenance.
Cron jobs
| Hook | Schedule | Purpose |
|---|---|---|
editorial_assistant_authority_rebuild |
weekly | Full rebuild of the inbound-link authority index — drift recovery |
editorial_assistant_pending_action_cleanup |
daily | Prunes resolved pending actions older than 30 days; expires undecided actions after 7 days |
Both are scheduled at activation and cleared at deactivation.
Development
npm install
npm run build # Production build → build/sidebar/
npm run start # Watch mode (use during sidebar JS work)
npm run lint:js # @wordpress/scripts ESLint
npm run format # @wordpress/scripts formatter
PHP is autoloaded by a tiny PSR-4-ish autoloader in editorial-assistant.php mapping EditorialAssistant\ → src/. No Composer.
Conventions:
- One class per file, namespaced
EditorialAssistant\<Subpath>, path matches namespace. - Every PHP file starts with
declare( strict_types=1 );. - New tools live in
src/Tools/, are wired intoConversation\ToolRegistry, and persist proposals throughApprovals\PendingActionStore.
There is no PHP test suite in this iteration. After any schema or cron change, deactivate + reactivate the plugin to pick up the new state.
Troubleshooting
"wp_ai_client_prompt() is not available" admin notice The AI Client component of WordPress 7.0+ isn't enabled, or you're on an older WordPress version. The sidebar will not function until this is resolved.
Streaming never starts; everything arrives in a batch
Your host (or a reverse proxy in front of it) is buffering SSE. The 3-second fallback to the buffered REST endpoint is doing its job — functionality is preserved but you lose the streaming feel. Common culprits: nginx without proxy_buffering off, Cloudflare on a non-Enterprise plan, certain shared hosts.
Suggestions disappear after refresh
They shouldn't — they're persisted in the pending-action store and rehydrated on mount. If they don't come back, check that activation ran cleanly (the editorial_assistant_pending_actions table must exist). Deactivate + reactivate is the safe fix.
Link suggestions feel stale
The authority index updates incrementally on save_post. If you've imported a large archive or done bulk edits outside the editor, run wp cron event run editorial_assistant_authority_rebuild to force a rebuild rather than waiting for the weekly cron.
Two chat replies for one message
Indicates the idempotency request_id isn't reaching the server. Hard-refresh the editor to reload the sidebar JS — older cached builds predate the idempotency contract.
Agent isn't registered The Agents API plugin isn't installed/active. The rest of Editorial Assistant continues to work; agent registration is a forward-compatibility step.
Roadmap
rewrite_ledevariant that auto-finds the first paragraph instead of requiring a quoted block- Shared proposals so two writers editing the same draft see each other's pending cards
- Tier C exemplars in the style guide (positive examples, not just rules)
- Voice memory migration to the Agents API memory store with workspace scope + provenance
- New tools —
find_archive(semantic archive search),draft_diagnose(structural critique),extract_quotes(pull-quote candidates) - Per-feature opt-in/opt-out preferences (chat on, headlines off, etc.)
License
GPL-2.0-or-later