Quick setup guide

Connect your AI agent in minutes

Create one local folder that ChatGPT Codex, Claude Code or another AI tool can access, then connect it securely to WordPress.

Before you start

Install and activate Elementor REST Bridge in WordPress, then activate your license or trial. You need WordPress 6.0+, PHP 7.4+, Elementor and HTTPS. The bridge registers no REST fields without an active license or trial.

1. Create a local working folder

Create a dedicated folder on your computer inside the workspace your AI agent can access. Add these three files to that same folder:

1

.env

Stores the secret Application Password outside your code.

.env
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
2

wp_client.py

Place the supplied Python client file here. It contains no hardcoded credentials and reads the password from .env.

Do not paste the Python source into prompts or public pages.
3

wp_sites.json

Replace the example URL and email with your own WordPress details.

wp_sites.json
{
  "site": {
    "url": "https://yourdomain.com",
    "username": "email@example.com",
    "app_password": "env:WP_APP_PASSWORD"
  }
}
WordPress

2. Create an Application Password

This is a separate, revocable password for the AI connection. Never use or share your normal WordPress login password.

  1. Sign in to WordPress Admin.
  2. Open Users → Profile.
  3. Scroll to Application Passwords.
  4. Enter a name such as Claude – Elementor editing.
  5. Click Add New Application Password.
  6. Copy the generated password immediately—it is only shown once.

Copy wp_client.py

Expand the verified source code below, copy it and save it as wp_client.py in the same local folder as your other two files. The client contains no hardcoded domain, username, password, token or API key.

Show the complete Python client
wp_client.py · verified without embedded credentials
#!/usr/bin/env python3
"""
WordPress REST API client op basis van application passwords.
Werkt met meerdere zelf-gehoste WordPress-sites via wp_sites.json.
Ondersteunt posts, pagina's, media en (met de meegeleverde plugin)
Elementor-paginadata.

Vereisten:
    pip install requests

Gebruik:
    python3 wp_client.py test <site>
    python3 wp_client.py list <site> [posts|pages] [status]
    python3 wp_client.py get <site> [posts|pages] <id>
    python3 wp_client.py create <site> [posts|pages] "<titel>" "<inhoud>" [status]
    python3 wp_client.py update <site> [posts|pages] <id> field=value [field2=value2 ...]
    python3 wp_client.py delete <site> [posts|pages] <id>
    python3 wp_client.py upload-media <site> <bestandspad>
    python3 wp_client.py get-elementor <site> <id> [pages|posts]
    python3 wp_client.py backup-elementor <site> <id> <bestandspad> [pages|posts]
    python3 wp_client.py set-elementor <site> <id> <pad-naar-json-bestand> [pages|posts]

Voorbeelden:
    python3 wp_client.py test vynd
    python3 wp_client.py list vynd pages publish
    python3 wp_client.py create vynd pages "Nieuwe pagina" "<p>Tekst</p>" draft
    python3 wp_client.py get-elementor vynd 123 pages

BEWUST NIET ONDERSTEUND (en dat moet zo blijven, tenzij expliciet anders gevraagd):
    - Het wijzigen van de site-URL / domeininstellingen
    - Het installeren, activeren of verwijderen van thema's
    - Het installeren, activeren of verwijderen van plugins
Dit script heeft hier simpelweg geen functies voor. Voeg die ook niet toe zonder
expliciete, uitdrukkelijke toestemming van de klant/eigenaar van dit script.
"""

import json
import mimetypes
import os
import sys
from pathlib import Path

import requests

SITES_FILE = Path(__file__).parent / "wp_sites.json"
ENV_FILE = Path(__file__).parent / ".env"

# Metavelden die Elementor gebruikt om paginadata op te slaan.
ELEMENTOR_META_KEYS = [
    "_elementor_data",
    "_elementor_edit_mode",
    "_elementor_template_type",
    "_elementor_version",
    "_elementor_page_settings",
]


def _load_env_file():
    """Leest .env (KEY=waarde per regel) in en zet ze als omgevingsvariabele,
    zonder een extra package (python-dotenv) nodig te hebben."""
    if not ENV_FILE.exists():
        return
    with open(ENV_FILE, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, _, value = line.partition("=")
            os.environ.setdefault(key.strip(), value.strip())


_load_env_file()


def load_sites():
    with open(SITES_FILE, "r", encoding="utf-8") as f:
        return json.load(f)


def get_site(name):
    sites = load_sites()
    if name not in sites:
        raise SystemExit(f"Site '{name}' niet gevonden in {SITES_FILE.name}. "
                          f"Beschikbaar: {', '.join(sites.keys())}")
    site = dict(sites[name])  # kopie, zodat we 'm hieronder mogen aanpassen

    pw = site.get("app_password", "")
    if pw.startswith("env:"):
        env_key = pw[4:].strip()
        real_pw = os.environ.get(env_key)
        if not real_pw:
            raise SystemExit(
                f"Omgevingsvariabele '{env_key}' niet gevonden. "
                f"Zet 'm in {ENV_FILE.name} als: {env_key}=je-application-password"
            )
        site["app_password"] = real_pw

    return site


def _auth(site):
    return (site["username"], site["app_password"])


def _base(site):
    return site["url"].rstrip("/") + "/wp-json/wp/v2"


def _check(r):
    if not r.ok:
        try:
            detail = r.json()
        except ValueError:
            detail = r.text
        raise SystemExit(f"Fout {r.status_code}: {detail}")
    return r


# ---------- Algemeen (werkt voor elk contenttype: posts, pages, ...) ----------

def test_connection(name):
    site = get_site(name)
    r = _check(requests.get(f"{_base(site)}/users/me", auth=_auth(site), timeout=15))
    data = r.json()
    print(f"Verbonden als '{data.get('name')}' (id {data.get('id')}) op {site['url']}")
    return data


def list_items(name, content_type="posts", status="publish", per_page=20):
    site = get_site(name)
    r = _check(requests.get(
        f"{_base(site)}/{content_type}",
        auth=_auth(site),
        params={"per_page": per_page, "status": status},
        timeout=15,
    ))
    for item in r.json():
        title = item.get("title", {})
        if isinstance(title, dict):
            title = title.get("rendered") or title.get("raw") or "(zonder titel)"
        link = item.get("link") or item.get("url") or ""
        suffix = f" - {link}" if link else ""
        print(f"[{item['id']}] {title} ({item.get('status', 'onbekend')}){suffix}")
    return r.json()


def get_item(name, content_type, item_id):
    site = get_site(name)
    r = _check(requests.get(
        f"{_base(site)}/{content_type}/{item_id}",
        auth=_auth(site),
        params={"context": "edit"},
        timeout=15,
    ))
    return r.json()


def create_item(name, content_type, title, content, status="draft", **extra_fields):
    site = get_site(name)
    payload = {"title": title, "content": content, "status": status, **extra_fields}
    r = _check(requests.post(
        f"{_base(site)}/{content_type}",
        auth=_auth(site),
        json=payload,
        timeout=15,
    ))
    data = r.json()
    print(f"Aangemaakt: [{data['id']}] {data['title']['rendered']} - {data['link']}")
    return data


def update_item(name, content_type, item_id, **fields):
    site = get_site(name)
    r = _check(requests.post(
        f"{_base(site)}/{content_type}/{item_id}",
        auth=_auth(site),
        json=fields,
        timeout=15,
    ))
    data = r.json()
    print(f"Bijgewerkt: [{data['id']}] {data['title']['rendered']}")
    return data


def delete_item(name, content_type, item_id, force=False):
    site = get_site(name)
    r = _check(requests.delete(
        f"{_base(site)}/{content_type}/{item_id}",
        auth=_auth(site),
        params={"force": force},
        timeout=15,
    ))
    print(f"Verwijderd: {item_id} ({'permanent' if force else 'naar prullenbak'})")
    return r.json()


def upload_media(name, file_path):
    site = get_site(name)
    file_path = Path(file_path)
    content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
    with open(file_path, "rb") as f:
        r = _check(requests.post(
            f"{_base(site)}/media",
            auth=_auth(site),
            headers={
                "Content-Disposition": f'attachment; filename="{file_path.name}"',
                "Content-Type": content_type,
            },
            data=f.read(),
            timeout=60,
        ))
    data = r.json()
    print(f"Geupload: [{data['id']}] {data['source_url']}")
    return data


# ---------- Elementor-specifiek ----------
# Vereist de meegeleverde plugin 'elementor-rest-bridge.php' actief op de site,
# anders geeft de API deze metavelden niet vrij.

def get_elementor_data(name, item_id, content_type="pages"):
    item = get_item(name, content_type, item_id)
    meta = item.get("meta", {})
    raw = meta.get("_elementor_data")
    parsed = json.loads(raw) if raw else None
    return {
        "elementor_data": parsed,
        "edit_mode": meta.get("_elementor_edit_mode"),
        "template_type": meta.get("_elementor_template_type"),
        "version": meta.get("_elementor_version"),
        "page_settings": meta.get("_elementor_page_settings"),
    }


def set_elementor_data(name, item_id, elementor_data, content_type="pages"):
    """elementor_data: python object (list/dict) zoals Elementor het verwacht,
    of al een JSON-string."""
    if not isinstance(elementor_data, str):
        elementor_data = json.dumps(elementor_data)
    return update_item(name, content_type, item_id, meta={"_elementor_data": elementor_data})


# ---------- CLI ----------

def _parse_kv(args):
    fields = {}
    for a in args:
        key, _, value = a.partition("=")
        fields[key] = value
    return fields


def _main():
    if len(sys.argv) < 3:
        print(__doc__)
        sys.exit(1)

    cmd, site_name, *rest = sys.argv[1:]

    if cmd == "test":
        test_connection(site_name)
    elif cmd == "list":
        content_type = rest[0] if len(rest) > 0 else "posts"
        status = rest[1] if len(rest) > 1 else "publish"
        list_items(site_name, content_type, status)
    elif cmd == "get":
        content_type, item_id = rest[0], rest[1]
        print(json.dumps(get_item(site_name, content_type, item_id), indent=2))
    elif cmd == "create":
        content_type, title, content = rest[0], rest[1], rest[2]
        status = rest[3] if len(rest) > 3 else "draft"
        create_item(site_name, content_type, title, content, status)
    elif cmd == "update":
        content_type, item_id, *kv = rest
        update_item(site_name, content_type, item_id, **_parse_kv(kv))
    elif cmd == "delete":
        content_type, item_id = rest[0], rest[1]
        delete_item(site_name, content_type, item_id)
    elif cmd == "upload-media":
        upload_media(site_name, rest[0])
    elif cmd == "get-elementor":
        item_id = rest[0]
        content_type = rest[1] if len(rest) > 1 else "pages"
        print(json.dumps(get_elementor_data(site_name, item_id, content_type), indent=2))
    elif cmd == "backup-elementor":
        item_id, json_path = rest[0], rest[1]
        content_type = rest[2] if len(rest) > 2 else "pages"
        data = get_elementor_data(site_name, item_id, content_type)
        path = Path(json_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f"Elementor-back-up geschreven: {path}")
    elif cmd == "set-elementor":
        item_id, json_path = rest[0], rest[1]
        content_type = rest[2] if len(rest) > 2 else "pages"
        with open(json_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        set_elementor_data(site_name, item_id, data, content_type)
    else:
        print(f"Onbekend commando: {cmd}")
        print(__doc__)
        sys.exit(1)


if __name__ == "__main__":
    _main()

3. Test the connection with your AI tool

Open the local folder as a new session in ChatGPT Codex, Claude Code or another AI tool that can work with files and a terminal. Replace the example URL with your own WordPress domain, then give the AI this instruction:

Prompt for your AI agent
Test whether you have access to my WordPress site https://examplesite.com. Confirm that you can connect successfully and that you can read and edit Elementor page data. Start with a safe test and do not change live content without asking me first.

The AI agent can install the required Python requests package if it is not already available. Once it confirms WordPress access and verifies that Elementor data can be read and edited, you’re ready to go.

Keep the credentials safe

  • Never commit .env to Git or upload it to a shared drive.
  • Never paste the Application Password into chat messages or documentation.
  • Use a dedicated WordPress user with only the permissions it needs.
  • Create a separate Application Password for each tool.
  • Revoke a password immediately when a tool no longer needs access.
  • Test bulk updates on staging or draft pages first.
Multisite: Only enable the network Editor opt-in when every Editor receiving access is trusted with raw Elementor data.

Ready to give your workflow an API?

Start your 14-day free trial. No credit card required.

Start your free trial →