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
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.zipReadme
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
- Copy the
llm-friendly-previewdirectory intowp-content/plugins/. - Activate LLM Friendly Preview from the Plugins screen. Activation registers the plugin's rewrite rule and flushes permalinks automatically.
- 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. - Open any post in the editor. The LLM Friendly Review Link panel appears in the sidebar.
Usage
- In the post editor sidebar, click Generate LLM Review Link.
- 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. - 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.
- 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:
- Read and strip the
llmfp_post_id/llmfp_tokenquery vars. - Check a per-IP rate limit (transient-backed).
- Validate the token: exists, not expired,
hash_equals()match against the stored value. - 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. - If valid: send
Cache-Control/X-Robots-Tagheaders, defineDONOTCACHEPAGE, 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_postswidenspost_statusto includedraft/pending/private/futureon the main query.posts_pre_queryshort-circuits WP_Query's SQL entirely and supplies the post via a directget_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 wideningpost_statusalone 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_ADDRcan be unreliable behind a proxy/CDN; if you run one, terminate it such thatREMOTE_ADDRreflects the real client, or adaptget_client_ip(). - No caching of draft content:
nocache_headers(), an explicitCache-Control: no-store, no-cache, must-revalidate, and theDONOTCACHEPAGEconstant (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, nofollowis sent on preview responses. - Not in REST output: the token meta is registered with
show_in_rest => falseand anauth_callbackof__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.