Agency Operations & Growth

How to Connect Your AI to a WordPress Site (Without Migrating It)

Give ChatGPT, Claude, an automation tool or your own code a limited, revocable key to a WordPress site using the Application Passwords built into WordPress. The setup, the ways to connect, and the host problems that stop it working.

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.

Not technical? Let your AI walk you through it.

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.

RoleWhat the AI can do
AuthorCreate and publish its own posts, and upload media
EditorCreate, edit and publish any posts and pages; manage categories, tags, media and comments
AdministratorEverything, 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.

  1. In wp-admin, go to Users → Add New User.
  2. Give it a username that says what it is, like chatgpt or automation. Use any email address you control.
  3. Pick the lowest role that does the job (see the table above). Editor covers most content work.
  4. 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

  1. Open that user's profile (Users → All Users → the user).
  2. Scroll to Application Passwords, type a name that identifies the system (like "ChatGPT" or "n8n") and click Add New Application Password.
  3. 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.

  1. In ChatGPT, open GPTs → + Create, then switch to the Configure tab. Give it a name.
  2. 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.
  3. 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 servers address to your site.
  4. Under Authentication, choose API Key, set Auth Type to Basic, and paste your username and password combined and Base64-encoded (commands below).
  5. 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 backWhat it means
Data, even an empty listIt'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 dataA 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 requestThe site's permalinks are set to "Plain". Go to Settings → Permalinks, choose Post name and save.
404 only on /wp/v2/users routesThe 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.

See it running on your accounts.

A 20-minute walkthrough of the whole system.

Book a demo →