More and more trades businesses want their AI to do real work on their website: write and update content, pull form leads into a follow-up process, update listings, run a custom workflow they've designed. When the site is on WordPress, the usual advice is to rebuild it somewhere else first. You don't have to.
WordPress has a built-in way for outside systems to sign in: Application Passwords, part of WordPress since version 5.6. One gives an AI system, an automation tool or your own script its own key to the WordPress REST API. The key can be limited to what that system needs and revoked in one click. This guide covers the setup, the main ways to connect, and the hosting problems that most often get in the way.
Copy this prompt and paste it into ChatGPT, Claude or whichever AI you use. It will guide you through this whole setup one step at a time, and it never asks for your passwords.
How it works
Everything runs through the WordPress REST API, the part of WordPress that other software talks to at https://your-site.com/wp-json/. Anything you can do in wp-admin has an equivalent there for core content, and many plugins add their own sections.
Your AI system sends requests to that address with a username and Application Password attached. WordPress treats each request as coming from that user, so the user's role decides what the AI can do. That's the single most important setting in this whole setup.
| Role | What the AI can do |
|---|---|
| Author | Create and publish its own posts, and upload media |
| Editor | Create, edit and publish any posts and pages; manage categories, tags, media and comments |
| Administrator | Everything, including users, plugins and settings. Only use this if a process genuinely needs it, and understand that the key is then as powerful as your own login. |
What's available beyond core content depends on the site's plugins:
- WooCommerce has its own REST API (
/wp-json/wc/v3/) for products, orders and customers, and recent versions accept Application Passwords. - Custom post types (listings, projects, services) are reachable if they're registered with REST support turned on (
show_in_rest). - Other plugins vary. Some add REST routes, some don't. Ask for
https://your-site.com/wp-json/while signed in and you'll get a list of every route the site offers.
One limit to know up front: pages built with Elementor, Divi or another page builder store their layout outside the normal page content. An AI can read and change a page's content field through the API, but on a page-builder page that change won't appear. Blog posts, custom post types and data are fine; redesigning page-builder pages is not what this is for.
What you need
- A WordPress site on HTTPS. WordPress switches Application Passwords off on plain HTTP.
- An administrator login, for the one-time setup
- The AI system or tool you're connecting, and a clear idea of what it needs to do, so you can pick the right role
Step 1: Give each AI system its own WordPress user
Never hand an AI your own login. Create a separate user for it, so its access is limited and can be cut off without affecting anyone else.
- In wp-admin, go to Users → Add New User.
- Give it a username that says what it is, like
chatgptorautomation. Use any email address you control. - Pick the lowest role that does the job (see the table above). Editor covers most content work.
- Click Add New User.
If you're connecting more than one system, give each its own user, or at least its own Application Password. Then you can see which one did what, and switch one off without touching the others.
Step 2: Create an Application Password
- Open that user's profile (Users → All Users → the user).
- Scroll to Application Passwords, type a name that identifies the system (like "ChatGPT" or "n8n") and click Add New Application Password.
- Copy the password it shows. It looks like
abcd EFGH 1234 ijkl MNOP 5678, and it's only shown once.
Keep it in a password manager. If you're setting this up for someone else, send the password separately from the instructions (a password-manager share link or a text), never in the same email.
If the button does nothing or throws an error: some managed WordPress hosts block the part of the API this button uses, because their firewall treats it as a way to list usernames. Ask the host to allow the /wp-json/wp/v2/users/{id}/application-passwords route for your site, or have your web person create the password on the server instead.
Step 3: Test the key before connecting anything
Check the key works on its own first, so any problem later is clearly about the AI tool and not the site. From a terminal:
curl -u "chatgpt:abcd EFGH 1234 ijkl MNOP 5678" "https://your-site.com/wp-json/wp/v2/posts?status=draft&_fields=id,title"
A list of drafts (even an empty []) means the key and the site are working. For anything else, see Troubleshooting below.
Step 4: Connect your AI system
The same username and Application Password work everywhere. What changes is where you enter them.
Automation tools (n8n, Zapier, Make and similar)
Most automation platforms have a WordPress connector or a generic HTTP step. Add a WordPress credential with the site address, the username and the Application Password. That's usually all it takes, and it's the fastest route for custom processes like "when a form lead comes in, create a draft job post" or "every Monday, publish the scheduled posts and notify the team". Where a tool has no WordPress step, use its HTTP request step with Basic auth and call the REST API directly.
Your own code or AI agent
Use HTTP Basic auth with the username and Application Password on every request. Most languages have it built in, as in this Python example:
import requests
auth = ("chatgpt", "abcd EFGH 1234 ijkl MNOP 5678")
r = requests.post(
"https://your-site.com/wp-json/wp/v2/posts",
auth=auth,
json={"title": "Draft from our agent", "content": "<p>Hello</p>", "status": "draft"},
)
print(r.status_code, r.json().get("link"))
Keep the password in an environment variable or a secret store, not in the code.
Claude and other AI assistants that use MCP
Assistants that support the Model Context Protocol (MCP), such as Claude Desktop, can connect to WordPress through an MCP server for WordPress. Both WordPress's own MCP Adapter project and community servers exist, and most take the site address, username and Application Password as settings. Follow the setup notes for whichever one you choose; the key from step 2 is what they ask for.
ChatGPT (custom GPT with an action)
ChatGPT connects through a custom GPT with an action, which needs ChatGPT Plus, Team or Enterprise.
- In ChatGPT, open GPTs → + Create, then switch to the Configure tab. Give it a name.
- In Instructions, describe the process you want it to run and the rules it must follow. For example: always save as a draft unless told otherwise, fetch before changing anything, confirm before publishing.
- Click Create new action and paste an OpenAPI schema describing the parts of the API it may use. The starter schema below covers posts, pages, categories and tags. Change the
serversaddress to your site. - Under Authentication, choose API Key, set Auth Type to Basic, and paste your username and password combined and Base64-encoded (commands below).
- Keep it set to Only me, click Create, and test it with "List my draft posts."
Show the full schema (copy all of it)
openapi: 3.1.0
info:
title: WordPress Site Editor
description: Read, draft and update posts and pages on this WordPress site through the WordPress REST API.
version: 1.0.0
servers:
- url: https://YOUR-SITE.com
paths:
/wp-json/wp/v2/posts:
get:
operationId: listPosts
summary: List or search posts. Returns summaries only, not full content. Also used to test the connection.
parameters:
- $ref: "#/components/parameters/search"
- $ref: "#/components/parameters/status"
- $ref: "#/components/parameters/perPage"
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/listFields"
responses:
"200": { description: A list of posts. }
post:
operationId: createPost
summary: Create a new post. Always create as a draft unless the user explicitly says to publish.
x-openai-isConsequential: true
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PostInput" }
responses:
"201": { description: The created post. }
/wp-json/wp/v2/posts/{id}:
get:
operationId: getPost
summary: Get one post, including its raw editable content.
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/editContext"
- $ref: "#/components/parameters/singleFields"
responses:
"200": { description: The post. }
post:
operationId: updatePost
summary: Update an existing post. Only send the fields that change.
x-openai-isConsequential: true
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PostInput" }
responses:
"200": { description: The updated post. }
/wp-json/wp/v2/pages:
get:
operationId: listPages
summary: List or search pages. Returns summaries only, not full content.
parameters:
- $ref: "#/components/parameters/search"
- $ref: "#/components/parameters/status"
- $ref: "#/components/parameters/perPage"
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/listFields"
responses:
"200": { description: A list of pages. }
post:
operationId: createPage
summary: Create a new page. Always create as a draft unless the user explicitly says to publish.
x-openai-isConsequential: true
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PageInput" }
responses:
"201": { description: The created page. }
/wp-json/wp/v2/pages/{id}:
get:
operationId: getPage
summary: Get one page, including its raw editable content.
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/editContext"
- $ref: "#/components/parameters/singleFields"
responses:
"200": { description: The page. }
post:
operationId: updatePage
summary: Update an existing page. Only send the fields that change.
x-openai-isConsequential: true
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PageInput" }
responses:
"200": { description: The updated page. }
/wp-json/wp/v2/categories:
get:
operationId: listCategories
summary: List post categories (needed to get category IDs).
parameters:
- $ref: "#/components/parameters/search"
- name: per_page
in: query
schema: { type: integer, default: 100, maximum: 100 }
- name: _fields
in: query
schema: { type: string, default: "id,name,slug,count" }
responses:
"200": { description: A list of categories. }
/wp-json/wp/v2/tags:
get:
operationId: listTags
summary: List post tags (needed to get tag IDs).
parameters:
- $ref: "#/components/parameters/search"
- name: per_page
in: query
schema: { type: integer, default: 100, maximum: 100 }
- name: _fields
in: query
schema: { type: string, default: "id,name,slug,count" }
responses:
"200": { description: A list of tags. }
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
parameters:
id:
name: id
in: path
required: true
schema: { type: integer }
search:
name: search
in: query
schema: { type: string }
status:
name: status
in: query
description: Comma-separated. Use "any" to include drafts.
schema: { type: string, default: "any" }
perPage:
name: per_page
in: query
schema: { type: integer, default: 20, maximum: 100 }
page:
name: page
in: query
schema: { type: integer, default: 1 }
listFields:
name: _fields
in: query
schema: { type: string, default: "id,title,status,link,date,modified" }
editContext:
name: context
in: query
schema: { type: string, enum: [view, edit], default: edit }
singleFields:
name: _fields
in: query
schema: { type: string, default: "id,title,content,excerpt,status,slug,link,categories,tags,date,modified" }
schemas:
PageInput:
type: object
properties:
title: { type: string }
content: { type: string, description: HTML or block markup. }
excerpt: { type: string }
status: { type: string, enum: [draft, pending, publish, future, private], default: draft }
slug: { type: string }
date: { type: string, description: "Site-local ISO 8601, e.g. 2026-10-01T09:00:00. Required with status 'future'." }
parent: { type: integer }
PostInput:
type: object
properties:
title: { type: string }
content: { type: string, description: HTML or block markup. }
excerpt: { type: string }
status: { type: string, enum: [draft, pending, publish, future, private], default: draft }
slug: { type: string }
date: { type: string, description: "Site-local ISO 8601, e.g. 2026-10-01T09:00:00. Required with status 'future'." }
categories: { type: array, items: { type: integer } }
tags: { type: array, items: { type: integer } }
security:
- basicAuth: []
Why the starter schema is built the way it is: every write is marked x-openai-isConsequential, so ChatGPT asks before creating or changing anything. List results are trimmed to a few fields so long lists don't overflow ChatGPT's response limit. And it never touches the /wp/v2/users part of the API, because many hosts block it. To build a custom process, add the routes it needs, such as a custom post type, media for uploads, or WooCommerce orders, in the same pattern. Keep the list under 30 operations.
To make the Base64 key on your own computer, so the password never goes into a website or a chat:
Windows (PowerShell):
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("chatgpt:abcd EFGH 1234 ijkl MNOP 5678"))
Mac (Terminal):
printf '%s' 'chatgpt:abcd EFGH 1234 ijkl MNOP 5678' | base64
Troubleshooting
Most failed setups come down to the host, not the AI tool. The test in step 3 shows which is which.
| What comes back | What it means |
|---|---|
| Data, even an empty list | It's working. |
| "rest_not_logged_in" or "authentication required" | The key isn't getting through. Check the username and look for stray spaces. If it's right and still failing, the host is probably stripping the login header (below). |
| "rest_forbidden" or "Sorry, you are not allowed to…" | The key works but the user's role can't do that action. Raise the role one step, or rethink whether the process needs it. |
| "Forbidden", 403, or a web page instead of data | A firewall (the host's, a security plugin's, or Cloudflare's) is blocking the request. Ask for /wp-json/* to be allowed for your AI system. |
| 404 on every request | The site's permalinks are set to "Plain". Go to Settings → Permalinks, choose Post name and save. |
404 only on /wp/v2/users routes | The host blocks that section on purpose. Avoid it; nothing in a normal content process needs it. |
If the host strips the login header: on Apache hosting, adding this line to the top of .htaccess usually fixes it:
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
On nginx-based managed hosts, ask support to pass the Authorization header through to PHP.
Security plugins that "restrict the REST API to logged-in users" can stay on. A request made with an Application Password counts as logged in.
Keeping it safe
- Least privilege. Use the lowest role that does the job, and one user or password per system.
- Drafts first. Tell AI systems to save content as drafts or pending, and have a person publish, at least until the process has earned trust.
- Watch the "Last used" date. Each Application Password on a user's profile shows when it was last used. One you don't recognise being active is a reason to revoke it.
- Turning it off: go to Users → the AI's user → Application Passwords and click Revoke. Access stops immediately. If the host's firewall blocks the Revoke button, change the user's role to No role for this site, which locks it out just as fast.
The limits of this setup
Application Passwords are the quickest way to connect an AI to WordPress without rebuilding anything, and they work with nearly every AI tool. They're only as smooth as the host allows, though. Managed hosts differ a lot in which parts of the API they block and which headers they strip, and that's where most setups get stuck. They also can't reach inside page-builder layouts.
If you manage WordPress sites for trades clients and want this set up without the troubleshooting, get in touch.