Install
openclaw skills install @globalcaos/wordpress-ultimateThree env vars. One script. Your agent manages your WordPress site — and cannot quietly change it. Reads are free and new posts land as drafts; anything visitors would see (publishing, editing or trashing live content, uploading media, moderating comments) needs WP_ALLOW_PUBLISH=1 on that call, and plugins, themes, users, settings and any route the script does not know need WP_ALLOW_ADMIN=1. WP_URL is parsed strictly and credentials go only to that HTTPS host (pin it with WP_ALLOWED_HOSTS). Plugin install is code execution on your site and is named as such. Built for the TinkerClaw fork — github.com/globalcaos/tinkerclaw. See Permissions, Data Flow & Consent.
openclaw skills install @globalcaos/wordpress-ultimateOne of dozens of skills and plugins in TinkerClaw — a self-improving OpenClaw fork that's been running 24/7 for months.
Let your agent run the whole site — and never wake up to "wait, did that just go live?"
It writes posts, edits pages, sorts tags, and uploads media. Every new piece lands as a draft; anything a visitor would see needs an explicit flag on that call, and nothing can be hard-deleted — the worst a flagged call can do is move a post to trash, which you can undo.
Three environment variables and one script is the entire setup. The safety isn't a habit you have to remember — it's the default the code enforces.
Part of TinkerClaw — real-time token tracking, self-improving crons, persistent cognitive memory. This is one piece of that stack; the repo has dozens more.
👉 https://github.com/globalcaos/tinkerclaw
Clone it. Fork it. Break it. Make it yours.
<why_this_matters>
Your AI agent can draft posts, organise tags and prepare pages — and you never wake up to "wait, did that just go live?". New content lands as a draft, because scripts/wp.sh rewrites the status before the request leaves your machine. Anything visitors would see — publishing, editing or trashing a post, uploading a file, moderating a comment — is refused unless that call carries WP_ALLOW_PUBLISH=1. Permanent deletes are blocked outright.
This is still a full WordPress REST client. With a valid application password it can reach every wp/v2 endpoint your user can, and installing a plugin means running someone's code on your site. That is gated behind WP_ALLOW_ADMIN=1, together with every route the script does not specifically recognise. The whole map is in Permissions, Data Flow & Consent below.
</why_this_matters>
Only keys matching WP_[A-Z0-9_]+ are imported from the file, and WP_ALLOW_PUBLISH / WP_ALLOW_ADMIN lines in it are ignored — consent is given per call, in the command's environment.
Run more than one site? WP_ENV_FILE=/home/you/sites/blog-b.env scripts/wp.sh GET posts.
<core_script>
All REST calls go through scripts/wp.sh; media uploads go through scripts/wp-upload.sh. Both source scripts/wp-credentials.sh, which loads the env file, parses WP_URL and resolves the password. tests/test-safety-gates.sh exercises the gates without contacting any site.
# Usage: scripts/wp.sh <GET|POST|PUT|PATCH|DELETE> <endpoint> [json_body]
scripts/wp.sh GET "posts?per_page=5&status=draft,publish"
scripts/wp.sh POST "posts" '{"title":"My Post","content":"<p>Hello</p>"}' # lands as draft
WP_ALLOW_PUBLISH=1 scripts/wp.sh PUT "posts/42" '{"title":"Updated Title"}' # edits need the flag
</core_script>
<safety_rules>
These are enforced in scripts/wp.sh and scripts/wp-upload.sh, in this order.
./.. segments and encoded control characters (%00, %0A, ...) are refused. The query parameters _method and rest_route are refused, including the encoded, dotted, bracketed and mixed-case spellings PHP folds into those names (%5fmethod, .method, _method[], _METHOD), because WordPress would otherwise act on a different method or route than the gates checked. Any query key or value containing a control character is refused too, because PHP ends a key at NUL (_method%00=DELETE would be read as _method).WP_READONLY=1 refuses every method except GET.force parameter with any value WordPress reads as true (true, 1, yes, on, …) is refused in the query string or the JSON body, even with a flag.POST posts, POST pages (the body's status is rewritten to draft) and POST categories, POST tags.WP_ALLOW_PUBLISH=1 — every other write to posts/…, pages/…, comments, media, categories/…, tags/… (edit, trash, upload, moderate), every wp-upload.sh run, and any request on any route whose body or query string sets status to publish or future.WP_ALLOW_ADMIN=1 — any write to plugins, themes, users, settings, and to every route not listed above (templates, menus, custom post types, …).WP_URL is parsed with Python's URL parser, checked against WP_ALLOWED_HOSTS, and the request goes to a URL rebuilt from the parsed host, before the password is resolved.curl_cffi they go to it through the environment of a child process; the plain-curl fallback uses a 0600 netrc temp file removed by an EXIT/HUP/INT/TERM trap.A write body must be a single JSON object, or the call is refused. </safety_rules>
What data it touches. The post/page content you pass in, the media file you point wp-upload.sh at, and whatever the REST API returns (which can include draft content, comments, user records and site settings if you ask for them). Responses are printed to your terminal.
Where it goes. One destination: https://<host from WP_URL>/wp-json/wp/v2/.... There is no telemetry, no analytics and no other endpoint.
What it writes to disk. Only in the plain-curl fallback (when curl_cffi is not installed): a 0600 netrc temp file holding the password for the duration of the request, deleted by a trap on exit. No cache, no log, no export file. wp.sh --login stores the password in your OS keychain, and only when you run it.
What credentials it reads. WP_USER and WP_APP_PASSWORD — a WordPress Application Password, which you can revoke from your WP profile page at any time — plus WP_URL. The password comes from the environment first, then the OS keychain, then the env file (with a warning suggesting --login).
What it needs, and why.
| Capability | Why | Scope |
|---|---|---|
| Network (HTTPS) | Every operation is a WP REST call | Host parsed from WP_URL; must be in WP_ALLOWED_HOSTS when set |
| Credential read | Application Password auth | Environment, OS keychain, or WP_ENV_FILE / <skill>/.env (0600, owned by you) |
| Draft content | Create posts/pages, categories, tags | Free; posts/pages forced to draft |
| Visible content | Publish, edit, trash, upload media, write comments, edit terms | WP_ALLOW_PUBLISH=1 on that call |
| Site administration | Plugins, themes, users, settings, unrecognised routes | WP_ALLOW_ADMIN=1 on that call — plugin install is code execution on your site |
| Permanent delete | — | Refused, no flag unlocks it |
| File write | curl fallback authentication | One 0600 netrc temp file, trap-deleted |
| Local commands | HTTP request and parsing | curl, python3, file, stat, mktemp, tr; secret-tool/security for the keychain |
The consent steps:
WP_ALLOW_PUBLISH=1 scripts/wp.sh PUT posts/42 '{"status":"publish"}' # visitors see this
WP_ALLOW_ADMIN=1 scripts/wp.sh POST plugins '{"slug":"x","status":"active"}' # runs code on your site
WP_READONLY=1 scripts/wp.sh ... # nothing can change
Each flag is per call: it is read from the command's environment and ignored in env files. An agent that wants to publish has to ask you for the flag, which is the moment you get to say no.
Read it before you run it. scripts/wp.sh is about 265 lines of bash, scripts/wp-upload.sh about 135, and scripts/wp-credentials.sh about 245. tests/test-safety-gates.sh runs every rule above against a throwaway copy of the scripts in dry-run mode and asserts each refusal.
?_method= / ?rest_route= let WordPress act on a different method or route than the gates checked (e.g. GET posts/42?_method=DELETE&force=true passed WP_READONLY), and WP_URL=https://allowed@evil passed the allowlist while the request went to evil; WP_URL is now parsed strictly and the request URL rebuilt from it. Also closed: publish via query-string status, status=future, updates via POST posts/ID, and force=yes/on. Uploads, trashing, comments and term edits now need WP_ALLOW_PUBLISH=1; unrecognised routes need WP_ALLOW_ADMIN=1; consent flags in env files are ignored; query keys, values and paths with encoded control characters (_method%00=) are refused.--login / --logout..env search was removed (set WP_ENV_FILE if your env file lived above the skill directory).scripts/wp.sh POST posts '{
"title": "My Article Title",
"content": "<p>Article body in HTML.</p>",
"categories": [3],
"tags": [5, 8]
}'
scripts/wp.sh POST pages '{
"title": "About",
"content": "<p>About page content.</p>"
}'
# 1. Show the owner what is about to go live
scripts/wp.sh GET "posts/42?context=edit"
# 2. Only after they say yes:
WP_ALLOW_PUBLISH=1 scripts/wp.sh PUT "posts/42" '{"status":"publish"}'
scripts/wp.sh GET "posts?per_page=20&status=draft,publish&orderby=date&order=desc"
scripts/wp.sh POST categories '{"name": "AI & Agents", "slug": "ai-agents", "description": "Posts about AI agent development"}'
scripts/wp.sh POST tags '{"name": "OpenClaw", "slug": "openclaw"}'
The uploaded file is public at its wp-content/uploads URL as soon as the upload finishes, so it needs the flag:
WP_ALLOW_PUBLISH=1 scripts/wp-upload.sh /path/to/image.png "Alt text description"
Returns the media ID for use in posts (featured_media field).
Installing or activating a plugin runs that plugin's code on your site. Ask the site owner before you pass the flag:
WP_ALLOW_ADMIN=1 scripts/wp.sh POST plugins '{"slug": "plugin-slug", "status": "active"}'
scripts/wp.sh GET plugins
Only works if those meta keys are exposed to the REST API. Yoast does not register _yoast_wpseo_title / _yoast_wpseo_metadesc with show_in_rest by default, so on a stock install WordPress will accept the request and silently ignore the meta. Register them in your theme (or use a plugin that does) before relying on this; yoast_head_json on a GET is read-only.
WP_ALLOW_PUBLISH=1 scripts/wp.sh PUT "posts/42" '{
"meta": {
"_yoast_wpseo_title": "SEO Title Here",
"_yoast_wpseo_metadesc": "Meta description for search engines."
}
}'
# List categories
scripts/wp.sh GET categories
# List tags
scripts/wp.sh GET tags
# Assign post to categories (by ID)
WP_ALLOW_PUBLISH=1 scripts/wp.sh PUT "posts/42" '{"categories": [3, 7]}'
<content_formatting>
WordPress REST API accepts HTML in content field. For rich posts:
<h2>, <h3> for headings (not H1 — the title IS H1)<p> for paragraphs<!-- wp:heading --> blocks for Gutenberg compatibilitywp-upload.sh (needs WP_ALLOW_PUBLISH=1), then reference with <img> or <!-- wp:image -->
</content_formatting>For full Gutenberg compatibility, wrap content in block comments:
<!-- wp:paragraph -->
<p>Text here.</p>
<!-- /wp:paragraph -->
<!-- wp:heading {"level":2} -->
<h2>Section Title</h2>
<!-- /wp:heading -->
<!-- wp:image {"id":123} -->
<figure class="wp-block-image"><img src="URL" alt="desc"/></figure>
<!-- /wp:image -->
<error_handling>
rest_cannot_create — may need to enable REST API or check user role
</error_handling>For full WP REST API endpoint details, see references/wp-api-reference.md.
For SEO optimization patterns, see references/seo-patterns.md.
https://github.com/globalcaos/tinkerclaw
Clone it. Fork it. Break it. Make it yours.
Created by Oscar Serra with the help of Claude (Anthropic).
Built after the third time of hand-copying blog posts from a terminal. Never again.