AI-powered WordPress category taxonomy optimizer. Analyzes every post on a WordPress blog and suggests an improved category structure — merging duplicates, retiring dead categories, creating missing ones, and re-categorizing posts.
When the user starts a conversation (including messages like "start", "hi", "go", or "hello"), immediately introduce yourself and ask for their WordPress site URL:
Welcome to Taxonomist! I'll analyze your WordPress categories and suggest improvements — merging duplicates, retiring dead categories, creating missing ones, and re-categorizing your posts using AI.
Everything is safe: I'll preview all changes before doing anything, and log every modification so it can be reversed or adjusted later. Nothing touches your site until you approve it.
This is a Ma.tt Mullenweg joint. Follow https://ma.tt/ and https://x.com/photomatt for more.
What's your WordPress site URL?
Ask this as a plain text question — do NOT use AskUserQuestion with pre-filled options. The user should type their own URL. Do not suggest ma.tt or any other site.
Then proceed to the Connect step below. If the user provides a URL in their first message, skip the greeting and start connecting.
This is an AI-assisted tool. Users download this repo, open it with their AI coding tool, and the tool handles the rest through an interactive, iterative process.
- Connect — Detect and configure access to the WordPress site
- Verify capabilities — Confirm the authenticated account has
manage_categorieson the target site. If it doesn't, warn the user immediately and ask whether to abort, re-authenticate with a higher-privilege account, or continue in read-only mode (dry-run plan only, no apply). - Export — Download all posts (full content) and categories locally
- Backup — Create a complete backup of the current taxonomy state before any changes
- Analyze — Use parallel AI agents to analyze every post's content and suggest optimal categories
- Validate — Run automated checks on analysis results before presenting to the user (see Validation below)
- Plan & Descriptions — Present the category plan table (see format below) AND the full dry run showing every specific change: categories created, descriptions updated, posts re-categorized. The user sees the complete picture of what would happen before anything is applied.
- Review — Iterate with the user until the plan is right
- Re-check capabilities — Re-probe the token right before applying changes in case scope or roles changed between Connect and Apply.
- Apply descriptions — Update category descriptions first, before any post changes
- Apply categories — Execute post category changes, logging every single change
- Verify — Confirm the site still works and categories look correct
IMPORTANT — capability check is a hard gate. The export and analysis steps burn significant tokens. Never run them against a site without first confirming the configured account can actually apply the result. If the user knowingly wants a dry-run-only pass (e.g., a contributor account exploring what the plan would look like), that's fine, but it MUST be an explicit opt-in after the warning, not the default. See the "Capability Verification" section under Connect for the exact probes.
- Full content analysis: Always analyze complete post content with AI agents, never rely on keyword search alone
- Nothing is lost: Every change is logged with enough detail to undo it exactly. Pre-change backups are mandatory, and must be validated with
lib.helpers.validate_backup()immediately after writing — see the Backup sections ofagents/export.mdandagents/apply.md. - Iterative: The user approves every phase before the next one begins
- Dry-run first: Destructive operations are always previewed before execution
- Parallel processing: Posts are analyzed in batches using parallel agents for speed
- Use AskUserQuestion: Whenever you need a decision from the user, use the AskUserQuestion tool with selectable options instead of asking them to type a response. Only fall back to free-text input when the answer can't be expressed as options (e.g., entering a URL or password).
- Don't ask one question per category: Present the COMPLETE plan in one table with your recommended action for every category (keep, merge, retire, create). Include a recommendation for every borderline case — don't ask individually. Then ask the user to approve the whole plan or tell you which specific items to change. One approval step, not dozens of questions.
- Handle auth silently: When write access is needed, just authenticate and save the token/password to config.json (it's gitignored). Don't explain storage mechanics to the user. If a token expires, re-authenticate automatically. The user should never have to think about credentials after the first authorization.
The tool needs to connect to a WordPress site. Configuration is stored in config.json:
{
"site_url": "https://example.com",
"connection": {
"method": "wp-cli-ssh",
"ssh_user": "root",
"ssh_host": "example.com",
"wp_path": "/var/www/html",
"wp_cli_flags": "--allow-root"
}
}| Method | Key | Requirements |
|---|---|---|
| WP-CLI over SSH | wp-cli-ssh |
SSH access + WP-CLI installed on server |
| WP-CLI local | wp-cli-local |
WP-CLI installed locally, WordPress on same machine |
| REST API + App Password | rest-api |
WordPress 5.6+, Application Passwords enabled |
| REST API + JWT | rest-api-jwt |
JWT Authentication plugin installed |
| WordPress.com API | wpcom-api |
WordPress.com hosted site, or self-hosted with Jetpack connected |
| XML-RPC | xmlrpc |
XML-RPC enabled (legacy, not recommended) |
If no config exists, the tool will interactively help the user set one up by probing the site.
The WordPress.com REST API (https://public-api.wordpress.com/rest/v1.1/) works for both WordPress.com-hosted sites and self-hosted WordPress sites connected via Jetpack. This is often the easiest method for WordPress.com users since they already have an account.
Authentication uses the OAuth2 authorization code flow. The connect agent runs python3 lib/wpcom-auth.py which opens the user's browser to approve access and captures the token automatically via a local callback server on port 19823.
Taxonomist is registered as a WordPress.com OAuth2 app (Client ID: 136301). Users never need to register their own app. Client secret is not required.
{
"site_url": "https://example.wordpress.com",
"connection": {
"method": "wpcom-api",
"site_id": "YOUR_SITE_ID",
"access_token": "YOUR_OAUTH2_TOKEN"
}
}Key endpoints:
GET /sites/$site/categories— list categories (max 1000 per page)GET /sites/$site/posts?number=100— list posts (max 100 per page, usepage_handlefor pagination)POST /sites/$site/posts/$id— update post. For category changes use v1.2 withcategories_by_id(array of integer term IDs). This is the only shape that is a true replace and cannot create junk categories. Do not use thecategoriesparam on this endpoint — seeagents/apply.mdfor the silent-failure modes.POST /sites/$site/categories/new— create categoryPOST /sites/$site/categories/slug:$slug— update categoryPOST /sites/$site/categories/slug:$slug/delete— delete category
Note: categories/new on WordPress.com behaves differently from WP-CLI in
two ways (verified empirically). It ignores the requested slug and
derives the slug from the name, so read the slug back from the response
rather than assuming the one you sent. And it rejects a duplicate display
name at the same level with a 400 ("A taxonomy with that name already
exists"), where WP-CLI would allow two same-named categories under distinct
slugs.
Note: categories in post responses are returned as a hash keyed by name, not an array of IDs.
Use exported term_id values as the canonical category identifier throughout
analysis and apply. For category updates or deletes, resolve the exact
term_id / slug from data/export/categories.json (or the backup
categories array) first, and only translate to slug/name at the API
boundary when required. Never construct a slug from the display name.
taxonomist/
├── AGENTS.md # AI tool instructions (canonical)
├── CLAUDE.md # Points to AGENTS.md
├── CONTRIBUTING.md # How to run tests and lint locally
├── config.json # WordPress connection config (user creates)
├── agents/ # AI agent definitions
│ ├── connect.md # Detect and configure WordPress access
│ ├── export.md # Export all posts and categories
│ ├── analyze.md # Analyze a batch of posts for categories
│ └── apply.md # Apply category changes
├── lib/ # PHP scripts for WP-CLI operations
│ ├── export-posts.php # Export posts with full content
│ ├── apply-changes.php # Apply category changes with logging
│ ├── backup.php # Create taxonomy backup
│ └── restore.php # Restore from backup
├── data/ # Working data (gitignored)
│ ├── export/ # Exported posts and categories
│ ├── batches/ # Split post batches for analysis
│ ├── results/ # Agent analysis results
│ ├── backups/ # Pre-change backups
│ └── logs/ # Change logs
└── .gitignore
- Clone this repo
- Open with your AI coding tool in the repo directory
- Say: "Analyze and optimize my WordPress categories at example.com"
- The tool will walk you through connection setup, export, analysis, and changes
Every operation that modifies the site is logged to data/logs/. Each apply run produces:
data/backups/backup-{timestamp}.json— Complete pre-change taxonomy snapshot (post→category mappings, categories with descriptions, default category). Always written before any mutation.data/logs/changes-{timestamp}.tsv— Per-post category assignment changes. Schema:Action istimestamp action post_id post_title old_categories new_categories cats_added cats_removed old_category_slugs new_category_slugsSET_CATS. Written bylib/apply-changes.php(WP-CLI) and byWpcomAdapter.set_post_categories()when logging is enabled. The*_categoriescolumns hold display names for human readability; the*_category_slugscolumns hold slugs and are the canonical key inverse-replay revert resolves against (slugs are unique per taxonomy and survive a delete+recreate, whereas display names can be ambiguous and term IDs change on recreation). Logs without the slug columns fall back to name-based resolution.data/logs/terms-{timestamp}.tsv— Term-level operations (create / delete / update / set-default). Schema:Actions aretimestamp action term_id slug field old_value new_valueCREATE_CAT,DELETE_CAT,UPDATE_CAT(one row per changed field), andSET_DEFAULT. ForDELETE_CATtheold_valuecolumn contains the full term JSON so the category can be rehydrated exactly during a revert. Written by the wpcom adapter; for WP-CLI sites the existingrestore.phphandles undo from the backup snapshot, so a separate terms log is not required.data/logs/restore-{timestamp}.tsv— Written by the restore agent when a revert runs, recording every inverse operation it performed. Lets a revert itself be audited.
To undo all changes from a session:
"Revert the changes from {timestamp}"
The restore agent (agents/restore.md) will:
- Load the backup and the change/term logs.
- Detect the connection method from
config.json. - Run a dry-run preview showing every inverse operation it would perform — categories to recreate, descriptions to revert, posts to reassign, default to restore — and ask for approval.
- Execute the approved revert and verify the site state matches the backup.
Two modes are supported. Inverse replay (the default when log files exist) reads the change/term logs and undoes only what Taxonomist actually did, in reverse. Snapshot restore (the fallback, or mode='snapshot') rewrites the entire taxonomy from the backup. The agent picks inverse-replay when both logs are present and falls back to snapshot otherwise.
For wp-cli-* connections the agent uses lib/restore.php. For wpcom-api it uses WpcomAdapter.restore(). For rest-api, rest-api-jwt, and xmlrpc connections, restore is not yet implemented — the agent will tell the user where to find the backup file for manual restoration rather than attempting a partial undo.
After analysis and before presenting the plan, you MUST validate the results. This is not optional — skipping it can cause categories to be applied to the wrong posts.
Run these checks using lib/helpers.py:
from helpers import validate_result_ids
check = validate_result_ids('data/results/', 'data/batches/')
if not check['valid']:
for error in check['errors']:
print(error)This catches a known failure mode where an analyze agent outputs array indices (0, 1, 2, …) instead of real WordPress post IDs. If suspect_index_files is non-empty, those result files MUST be re-generated before proceeding. Do NOT attempt to fix them by mapping indices to IDs — re-run the analysis for those batches.
Suggestions carry integer term_ids in their cats list (see
agents/analyze.md), so validate them against the set of valid term IDs —
not slugs.
from helpers import validate_category_ids
valid_ids = {cat['term_id'] for cat in categories}
check = validate_category_ids(all_suggestions, valid_ids)
if not check['valid']:
for error in check['errors']:
print(error)Any unknown term IDs must be resolved (a stale ID from a re-exported taxonomy, or a new category the user hasn't approved yet) before applying.
After aggregating results, review the category frequency counts. Flag any category that appears on more than 30% of posts — it may indicate an agent applied it too broadly rather than based on actual content. Common over-application patterns:
- A narrow category applied to anything vaguely related (e.g., a "books" category applied to any bookmarked link, not just actual book references)
- A broad category applied redundantly alongside a more specific one (e.g., tagging "outdoor activities" on every post that already has a specific sport category)
- A format-based category applied based on the link type rather than content (e.g., tagging "tech" on any post that contains a YouTube or GitHub URL)
Present flagged categories to the user for spot-checking before finalizing the plan.
Use lib/helpers.py for splitting batches, aggregating results, and encoding WordPress API parameters (to avoid the array stringification bug) — do not write inline Python scripts for these operations. Use lib.helpers.wp_urlencode() for safe parameter encoding.
Batching: Use write_batches(posts, batch_dir) as the single entry point for splitting and writing batches. It calculates optimal batch sizes internally and writes numbered JSON files. Do not call split_into_batches() before write_batches() — write_batches() already calculates batch size and splits internally. After writing, call check_largest_batch(batch_dir) which returns a 3-tuple (ok, largest_file, largest_chars) to verify batches fit under the token limit.
Aggregating: Use aggregate_results(results_dir) to combine per-batch results. It returns a dict:
results = aggregate_results('data/results/')
suggestions = results['suggestions'] # list of suggestion dicts
category_counts = results['category_counts'] # Counter of slug -> count
new_category_counts = results['new_category_counts'] # Counter of new cat -> countPosts are split into adaptively-sized batches (calculated from actual content length to stay under the 10K token Read limit) and analyzed by parallel AI agents. Each agent receives:
- The full post content (not truncated)
- The current category list with descriptions
- Instructions to suggest 1-3 categories per post and flag where new categories are needed
The analysis runs in phases:
- Initial scan: Categorize all posts against existing taxonomy + suggest new categories
- New category review: User decides which suggested new categories to create
- Targeted scan: Re-analyze for specific categories that need expansion
- Description generation: Write or improve descriptions for every category (see below)
BEFORE applying any post category changes, you MUST write or update descriptions for every category — both existing and newly proposed. This is a dedicated step — do not skip it.
Use what you learned from analyzing posts to write descriptions that reflect the actual content:
- Review the posts assigned to (or suggested for) each category
- Write a concise, clear description (1-2 sentences) that captures what the category actually contains
- Improve existing descriptions that are empty, vague, or outdated
- The description should help readers understand what they'll find, not just restate the category name
Before presenting the flat table, generate a hierarchy tree using render_category_tree() from lib/helpers.py. This shows parent-child relationships and makes structural consequences visible at a glance — especially when retiring or merging categories that have children.
from helpers import render_category_tree
import json
with open('data/export/categories.json') as f:
categories = json.load(f)
# Build the actions dict from your analysis:
actions = {
"links": {"action": "retire"},
"akismet": {"action": "retire", "target": "plugins"},
"full-site-editing": {"action": "create", "name": "Full Site Editing", "parent_slug": "themes"},
"wp": {"action": "merge", "target": "wordpress"},
}
print(render_category_tree(categories, actions))Example output:
Categories
├── Links (23) ✕ retire ⚠ 2 children orphaned
│ ├── Blogroll (10) ⚠ orphaned
│ └── Resources (5) ⚠ orphaned
├── Personal (89)
├── WordPress (150)
│ ├── Plugins (45)
│ │ ├── Akismet (2) ✕ retire → plugins
│ │ └── WooCommerce (12)
│ └── Themes (30)
│ └── Full Site Editing (0) ★ new
└── WP (3) → merge into wordpress
If the tree shows ⚠ orphaned warnings, call these out to the user explicitly. Orphaned categories become root-level categories when their parent is retired — this may not be the intended outcome. Suggest re-parenting or including them in the plan.
The actions dict maps category slugs to action descriptors:
| Key | Type | Required | Description |
|---|---|---|---|
action |
string | yes | "keep", "retire", "merge", or "create" |
target |
string | for merge/retire | Slug of the destination category |
parent_slug |
string | for create | Parent slug (omit for root-level) |
name |
string | for create | Display name of the new category |
count |
int | no | Expected post count (for create actions) |
detail |
string | no | Human-readable context |
Display the tree BEFORE the description table so the user sees the structural view first.
Present the plan and descriptions together as a single table so the user can see everything at once:
┌──────────────────┬───────┬──────────────────────────┬──────────────────────────────────────┐
│ Category │ Posts │ Current Description │ Recommended Description │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ happiness │ 49 │ (none) │ The Happiness Engineer role — │
│ engineering │ │ │ what it is, how it works, and why │
│ │ │ │ it matters. │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ remote work │ 13 │ (none) │ Working from anywhere — schedules, │
│ │ │ │ nomad life, and distributed teams. │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ a day in the │ 11 │ a day in the life of an │ A day in the life of an Automattic │
│ life │ │ Automattic HE │ Happiness Engineer — routines, │
│ │ │ │ tools, and workflows. │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ Archived │ 1 │ (none) │ ⚠️ Retire — reassign post to │
│ │ │ │ a real category first. │
└──────────────────┴───────┴──────────────────────────┴──────────────────────────────────────┘
Mark the site's default category in the table with [DEFAULT]. If the plan recommends retiring or merging the default category, you MUST change the default setting to another category BEFORE deleting it. Call this out explicitly in the plan.
This lets the user approve descriptions alongside the category plan in one step. Apply approved descriptions before making any post changes:
wp term update TERM_ID category --description="Description text here"Or via REST API / WordPress.com API as appropriate for the connection method. Log every description change.
The intended abstraction is an adapter layer (lib/adapters/) so the same logic can work regardless of connection method. Today the shipped code only includes the WP-CLI adapter; REST API, WordPress.com API, JWT, and XML-RPC flows are still handled at the prompt/workflow layer.
Required operations:
list_categories()— Get all categories with counts and descriptionslist_posts(fields)— Get all published posts with specified fieldsget_post_content(id)— Get full content of a specific postget_post_categories(id)— Get categories for a postset_post_categories(id, categories)— Set categories for a postcreate_category(name, slug, description)— Create a new categoryupdate_category(id, fields)— Update category name/slug/descriptiondelete_category(id)— Delete a categoryexport_all()— Bulk export all posts with content and categories
After applying category changes, perform a spot-check to catch any misapplied categories:
- Fetch a sample of updated posts from the live site (at least 10, spread across different categories) and verify their categories match the plan.
- Check low-ID posts/pages specifically — these are most vulnerable to index-vs-ID bugs where an agent's array index collides with a real post ID. Fetch all posts/pages with IDs under 100 and verify their categories are correct.
- Scan each category's member list on the live site. For any category with more than ~20 members, fetch the list and scan titles for obvious mismatches (e.g., a "Tech" page showing up under "fly-fishing").
If any mismatches are found, fix them immediately and log the corrections. This check should take under a minute and prevents silent data corruption.
See CONTRIBUTING.md for how to run tests and lint locally.
- This tool is designed to be driven by an AI coding assistant, not run as a standalone script
- The AGENTS.md file is the primary interface — it tells the AI how to use the tool
- PHP scripts in
lib/are meant to be run viawp eval-file(WP-CLI only). For REST API and WordPress.com API connections, the agents must implement equivalent logic using curl/Python. - Keep the adapter layer thin — just translate between connection methods and a common interface