WP Manifestindependent plugin directory
manifest / content / llm-friendly-preview

LLM Friendly Preview

WordPress plugin that generates anonymous, token-gated review URLs for unpublished posts so LLMs can fetch and review drafts before publishing.

by Michael Pusateri · github.com/cruftbox/llm-friendly-preview · website

0stars
0forks

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/cruftbox/llm-friendly-preview/archive/refs/heads/master.zip

Readme

LLM Friendly Preview

Generates shareable, anonymous review URLs for unpublished WordPress posts.

The goal is to allow various LLMs to review Wordpress posts before publishing.

The URLs render like normal public pages (theme templates, styling, no ?preview=1), so LLMs such as Claude, Gemini, Meta AI, Mistral, Kimi, DeepSeek, Qwen, and Grok can reliably fetch them for review, without needing a WordPress account, cookies, or JavaScript. Doesn't work on all LLMS. See Tested LLM Compatibility below for the full picture, including ChatGPT, Perplexity, and Microsoft Copilot.

Tested LLM Compatibility

Confirmed working (fetches and reads the review URL successfully):

  • Claude
  • Gemini
  • Meta AI
  • Mistral
  • Kimi
  • DeepSeek
  • Qwen
  • Grok
  • HuggingChat
  • Ernie Bot

Confirmed not working:

  • Perplexity
  • Microsoft Copilot
  • ChatGPT

ChatGPT cannot open these links. ChatGPT is unable to automatically open these preview URLs because its web-retrieval system rejects newly generated URLs containing private, high-entropy access tokens before making any HTTP request. This plugin intentionally uses such tokens to provide anonymous, time-limited access to unpublished content, so the limitation occurs in ChatGPT's URL-safety layer rather than in WordPress, the page markup, redirects, or response headers. Claude, Gemini, and normal browsers can resolve the same links successfully.

Perplexity and Microsoft Copilot were not individually diagnosed the way ChatGPT was (see git history for the ChatGPT investigation), so the specific cause of their failure is unconfirmed. It plausibly stems from similar retrieval-safety heuristics around newly generated, high-entropy URLs, but that has not been verified request-by-request the way it was for ChatGPT.

Requirements

  • WordPress 6.0+
  • PHP 7.4+
  • Pretty permalinks enabled (Settings → Permalinks). Plain permalinks are not supported.

Installation

  1. Copy the llm-friendly-preview directory into wp-content/plugins/.
  2. Activate LLM Friendly Preview from the Plugins screen. Activation registers the plugin's rewrite rule and flushes permalinks automatically.
  3. If review URLs 404 after activation, visit Settings → Permalinks and click Save (this forces a rewrite flush) — normally not required, but some hosts cache .htaccess/nginx rewrite maps.
  4. Open any post in the editor. The LLM Friendly Review Link panel appears in the sidebar.

Usage

  1. In the post editor sidebar, click Generate LLM Review Link.
  2. Copy the URL (https://example.com/llm-review/{post-id}/{token}/) and share it with an LLM or anyone who needs to review the draft.
  3. The link works for 3 days, then stops working automatically. Click Regenerate to issue a new token at any time, or Revoke to kill the link immediately.
  4. Publishing the post revokes its token — the review link stops working once the post is public. Unpublishing a previously published post also revokes its token; generate a new link if you need one for further review.

Architecture and data flow

Token storage. Each post gets at most one active token, stored in post meta (_llmfp_token, _llmfp_token_expires). The leading underscore marks it "protected" meta, which WordPress automatically hides from the Custom Fields UI and, combined with show_in_rest => false on registration, keeps it out of the REST API.

URL → post. A rewrite rule maps /llm-review/{post-id}/{token}/ to index.php?llmfp_post_id={post-id}&llmfp_token={token}.

Request handling (LLMFP_Request_Handler) hooks parse_request, which runs before WordPress builds its main query:

  1. Read and strip the llmfp_post_id / llmfp_token query vars.
  2. Check a per-IP rate limit (transient-backed).
  3. Validate the token: exists, not expired, hash_equals() match against the stored value.
  4. If invalid or rate-limited: rewrite the query vars to ['error' => '404'] — the same signal WordPress core uses internally when no rewrite rule matches — and stop. The rest of the request proceeds as an ordinary 404 through the theme's normal 404 template. Valid-token and rate-limited requests are indistinguishable from the outside; both produce a plain 404.
  5. If valid: send Cache-Control/X-Robots-Tag headers, define DONOTCACHEPAGE, and rewrite the query vars to a plain singular query (p={post-id}&post_type=post).

Two more hooks make that singular query actually return a non-public post to a logged-out visitor, which WordPress's main query does not do by default:

  • pre_get_posts widens post_status to include draft/pending/private/future on the main query.
  • posts_pre_query short-circuits WP_Query's SQL entirely and supplies the post via a direct get_post( $id ) call, which performs a raw fetch with no status/capability filtering. This is the actual mechanism that lets an anonymous visitor see a draft: WordPress's own query-building always re-applies capability checks to non-public statuses, so widening post_status alone is not enough.

Because the post still flows through the real main query object, the theme's normal template hierarchy, header/footer, featured image, and styling all render exactly as they would for a published page — nothing is faked or reconstructed by hand.

Admin UI (LLMFP_Admin) adds a meta box to the post editor sidebar and two admin-ajax.php actions (generate, revoke), each gated by a nonce (wp_create_nonce/check_ajax_referer) and current_user_can( 'edit_post', $post_id ).

Lifecycle cleanup (LLMFP_Cleanup) hooks delete_post (removes token meta when a post is deleted) and transition_post_status (revokes the token on publish, and again if a published post is later unpublished).

Security considerations

  • Token generation: PHP's native random_bytes( 20 ) (a CSPRNG, built into PHP since 7.0), hex-encoded to 40 characters — well above the 32-character minimum. wp_generate_password() is intentionally not used, since it is not guaranteed cryptographically secure.
  • Constant-time comparison: token validation uses hash_equals() to avoid timing side-channels.
  • No existence oracle: a missing post, missing token, expired token, and mismatched token all produce the identical native 404 response.
  • Expiry: tokens expire 3 days after generation, checked on every request in addition to explicit revocation.
  • Brute-force protection: failed attempts are rate-limited per IP address (REMOTE_ADDR) via transients — 10 failures per 15-minute window, after which further attempts also 404 without consulting the token. Note: REMOTE_ADDR can be unreliable behind a proxy/CDN; if you run one, terminate it such that REMOTE_ADDR reflects the real client, or adapt get_client_ip().
  • No caching of draft content: nocache_headers(), an explicit Cache-Control: no-store, no-cache, must-revalidate, and the DONOTCACHEPAGE constant (honored by most major caching plugins) are set on every valid preview request so page-cache plugins and CDNs don't persist a copy of unpublished content.
  • No indexing: X-Robots-Tag: noindex, nofollow is sent on preview responses.
  • Not in REST output: the token meta is registered with show_in_rest => false and an auth_callback of __return_false, and its underscore prefix keeps it out of the REST API's default protected-meta exposure.
  • Not in RSS feeds: the plugin never writes the token or review URL into post content, excerpt, or any feed template — it only appears in the admin editor sidebar — so there is nothing for feed output to leak.

Extending to other post types

Support is currently limited to the post post type by design. To add pages or a custom post type, the post-type string is centralized in a few places: LLMFP_Admin::add_meta_box() (where the meta box is registered), register_post_meta() calls in LLMFP_Token_Manager::register_meta(), and the post_type checks in LLMFP_Request_Handler::supply_post_directly() and LLMFP_Cleanup. Generalizing these to accept an array of post types (and including the type in the token meta so validation targets the right post) is the intended extension point.

Read the full README on GitHub →