# AgentTools for ProcessWire Tools for using AI coding agents (e.g. Claude Code) to interact directly with a ProcessWire installation, create database migrations, and apply them across environments. --- ## Getting started **Step 1:** Install the AgentTools module in your ProcessWire installation. **Step 2:** Make sure `php` is in your PATH. To verify: ~~~~~ php -v ~~~~~ If `php` is not found, add it to your PATH. On most Linux/macOS systems this means adding the PHP binary directory to `$PATH` in your shell profile (`.bashrc`, `.zshrc`, etc.). **Step 3:** Start an AI coding agent session in your ProcessWire root directory and say: > *"Please read the `site/modules/AgentTools/agent_cli.md` file before we begin."* **Step 4:** Describe what you want to create. --- ## CLI commands All commands are run from the ProcessWire root directory (where `index.php` lives). | Command | Purpose | |---------|---------| | `php index.php --at-cli` | Opens the agent CLI for interactive API access | | `php index.php --at-eval [--readonly] 'CODE'` | Evaluate a PHP expression inline | | `echo 'CODE' \| php index.php --at-stdin [--readonly]` | Evaluate multi-line PHP code from stdin | | `php index.php --at-migrations-apply [--file=FILE\|--name=NAME] [--limit=N] [--dry-run] [--force]` | Apply pending migrations, optionally filtered | | `php index.php --at-migrations-list [--file=FILE\|--name=NAME]` | List migrations and their status | | `php index.php --at-migrations-test [--file=FILE\|--name=NAME] [--limit=N]` | Preview pending migrations without applying | | `php index.php --at-migrations-lint [--file=FILE\|--name=NAME]` | Check migration syntax and AgentTools conventions without applying | | `php index.php --at-migrations-rerun --file=FILE\|--name=NAME [--dry-run]` | Re-run one migration even if already applied | | `php index.php --at-sitemap-generate` | Generate a JSON site map to `site/assets/at/site-map.json` | | `php index.php --at-sitemap-generate-schema` | Generate a schema JSON to `site/assets/at/site-map-schema.json` | | `php index.php --at-engineer "REQUEST"` | Ask the Engineer a question or request a change | | `php index.php --at-engineer-migrate "REQUEST"` | Have the Engineer create a migration; outputs the migration file path | | `php index.php --at-engineer-site-info pages\|schema\|modules [--refresh]` | Print generated site info JSON without calling an AI provider | | `php index.php --at-engineer-api-docs-list` | List available ProcessWire API.md documentation without calling an AI provider | | `php index.php --at-engineer-api-docs-get NAME` | Print a ProcessWire API.md documentation file without calling an AI provider | | `php index.php --at-engineer-api-docs-search TERM` | Search ProcessWire API.md documentation without calling an AI provider | | `php index.php --at-engineer-read-file PATH` | Read a local site file without calling an AI provider | | `php index.php --at-cron` | Process one pending AgentTools background job; intended for system cron | | `php index.php --at-mcp` | Run the local AgentTools MCP server over stdio | ### When to use `--at-eval` vs `--at-stdin` `--at-eval` is convenient for simple expressions but is subject to shell escaping rules — single quotes, double quotes, dollar signs, and backticks in the PHP code can conflict with the shell. For anything beyond a simple one-liner, prefer `--at-stdin` with a single-quoted heredoc, which passes PHP code verbatim: ~~~~~ cat <<'PHP' | php index.php --at-stdin $items = $pages->find("template=blog-post, sort=-modified, limit=10"); foreach($items as $item) { $date = date('Y-m-d', $item->modified); echo "{$date} | {$item->title} | {$item->url}\n"; } PHP ~~~~~ The single-quoted delimiter (`<<'PHP'`) prevents the shell from interpreting `$variables`, so PHP variables pass through untouched. `--at-stdin` also accepts normal PHP file contents with an opening `count() . " pages\n";' cat <<'PHP' | php index.php --at-stdin --readonly echo $templates->get('home')->name . " template\n"; PHP ~~~~~ Read-only mode validates code before it runs and blocks common ProcessWire, database, and filesystem mutation calls. --- ## Site map Running `--at-sitemap-generate` writes a JSON file to `site/assets/at/site-map.json` covering the full scope of the installation: - **templates** — name, label, fields list, parent/child template restrictions - **fields** — name, label, fieldtype - **pages** — hierarchical tree (3 levels deep by default), with `children_count` at each node so deeper branches are visible without being expanded - **modules** — all installed non-core modules Run this at the start of a session on an unfamiliar site to get a complete picture of its structure before making any changes. If you need the same data printed directly to stdout, use: ~~~~~ php index.php --at-engineer-site-info pages --refresh ~~~~~ For full field/template configuration JSON in stdout, use: ~~~~~ php index.php --at-engineer-site-info schema --refresh ~~~~~ --- ## Engineer helper commands The Engineer can be called from the CLI when AgentTools has an AI provider configured: ~~~~~ php index.php --at-engineer "How many published pages does this site have?" php index.php --at-engineer-migrate "Add a subtitle text field to the blog-post template" ~~~~~ Use `--at-engineer` for questions or requested changes. Use `--at-engineer-migrate` when the response must create a migration file; successful output includes a `Migration: /full/path/to/file.php` line. Several read-only helper commands are available without calling an AI provider: | Command | Purpose | |---------|---------| | `php index.php --at-engineer-site-info pages [--refresh]` | Print `site/assets/at/site-map.json`; with `--refresh`, regenerate first | | `php index.php --at-engineer-site-info schema [--refresh]` | Print `site/assets/at/site-map-schema.json`; with `--refresh`, regenerate first | | `php index.php --at-engineer-site-info modules` | Print installed module class names as JSON | | `php index.php --at-engineer-api-docs-list` | Print available API docs as JSON with `name`, `description`, and `file` | | `php index.php --at-engineer-api-docs-get NAME` | Print the contents of an API.md file by name | | `php index.php --at-engineer-api-docs-search TERM` | Search API docs and print JSON matches with `name`, `file`, `line`, and `snippet` | | `php index.php --at-engineer-read-file PATH` | Print a file inside the ProcessWire root; paths outside the root are denied | | `php index.php --at-mcp` | Run the local AgentTools MCP server over stdio | Use these local helper commands when an external coding agent needs structured site context, ProcessWire API documentation, or a small local file without spending provider tokens. ### MCP server AgentTools can run as a local stdio MCP server: ~~~~~ php index.php --at-mcp ~~~~~ The initial MCP tool set is read-only and uses the `at_` prefix to avoid naming collisions when clients connect to multiple MCP servers: | Tool | Purpose | |------|---------| | `at_status` | Show AgentTools, ProcessWire, PHP, site path, generated files, and tool names | | `at_site_info` | Retrieve page tree, schema, or installed module info | | `at_api_docs` | List, retrieve, or search ProcessWire API.md documentation | | `at_read_file` | Read a file inside the ProcessWire installation | | `at_migrations_list` | List migrations and applied/pending status | | `at_migrations_lint` | Check migration syntax and conventions without executing files | | `at_eval_readonly` | Run read-only ProcessWire eval with mutation validation | If an MCP client disconnects idle stdio servers, set `AGENTTOOLS_MCP_HEARTBEAT=30` in that server command to send periodic JSON-RPC `ping` heartbeats. `--at-cron` is for queued background jobs from the admin Engineer, Page Engineer, and Tasks screens. It should be run by system cron from the ProcessWire root. Do not install or modify cron for the user unless they explicitly ask you to. --- ## agent_cli.php ### Purpose Gives the agent direct access to the ProcessWire API so it can perform any create, update, or delete (CUD) operation, run tests, or create migration files. ### How it works The agent may modify anything in `agent_cli.php` **after** the marker line: ~~~~~ /* ~~~ AGENT ~~~ */ ~~~~~ After that marker, ProcessWire is fully booted and all API variables are available. They are documented with PHPDoc at the top of the file. When `--at-cli` starts, the first line of output indicates which file is active: ~~~~~ // agent_cli.php: /path/to/site/modules/AgentTools/agent_cli.php ~~~~~ Always edit the file indicated in that output. On servers where the modules directory is not writable, the active file will be in `site/assets/at/` instead. ### Notes - If something doesn't appear to be working correctly, report the error and ask before attempting to fix it. - For quick one-off operations, prefer `--at-eval` or `--at-stdin` over editing `agent_cli.php`. --- ## Migrations ### What is a migration? A migration is a PHP file that makes one or more CUD changes to a ProcessWire installation using the ProcessWire API. It is designed to be repeatable (idempotent) and transferable across environments. ### Workflow Always use **migration-first**: write the migration file first, apply it on the development environment, then transfer and apply it on other environments. 1. Write the migration file in `site/assets/at/migrations/` 2. Apply it on the development environment to verify it works: `php index.php --at-migrations-apply` 3. Report the output to the user and confirm success before proceeding 4. The user transfers the file to other environments (e.g. via rsync) 5. The user applies it there via CLI or the admin UI (Setup > Agent Tools) Do not make changes directly to the development environment and then write a migration after the fact. Migration-first keeps all environments consistent, and means any failure is caught on development — where it is easiest to fix — before the migration is transferred anywhere. ### File locations | Path | Purpose | |------|---------| | `site/assets/at/migrations/` | Migration PHP files — safe to rsync to other servers | The applied migrations registry is stored in the AgentTools module configuration in the database, so it is never at risk of being overwritten by an rsync. ### Migration file naming ~~~~~ YYYYMMDDhhmmss_name.php ~~~~~ - `YYYY` — 4-digit year - `MM` — 2-digit month - `DD` — 2-digit day - `hh` — 2-digit hour (24-hour) - `mm` — 2-digit minute - `ss` — 2-digit second - `name` — short description in ProcessWire page-name format (e.g. `add-blog-template`) Unless the name is obvious from context, the agent should ask what to call it and sanitize it with `$sanitizer->pageNameTranslate('...')`. ### Migration structure Every migration should follow this structure: ~~~~~ migrations->getName(__FILE__); echo "# $name\n\n"; // Secondary state check (optional but recommended) if($templates->get('blog-post')) { echo "- Skipped: template 'blog-post' already exists.\n"; return; } // ... migration operations ... echo "- $name has been applied\n"; ~~~~~ The `migrations->getName()` method is available via the `$at` API variable (`wire('at')->migrations->getName(__FILE__)`). ### Dependency check If a migration depends on a previous one having been applied, check for it explicitly. For example, if creating a `/blog/` page that requires the `blog` template to exist: ~~~~~ if(!$templates->get('blog')) { echo "- Error: the 'blog' template does not yet exist.\n"; return; } ~~~~~ ### Defensive migration recipes Use these small patterns when building migrations. They keep generated files safe to re-run and easier to review. **Create a field only when missing:** ~~~~~ $field = $fields->get('subtitle'); if(!$field) { $field = new Field(); $field->type = $modules->get('FieldtypeText'); $field->name = 'subtitle'; $field->label = 'Subtitle'; $field->save(); echo "- Created field: subtitle\n"; } else { echo "- Skipped existing field: subtitle\n"; } ~~~~~ **Add a field to a template in order:** ~~~~~ $template = $templates->get('blog-post'); $field = $fields->get('subtitle'); if(!$template) { echo "- Error: template 'blog-post' does not exist.\n"; return; } if(!$field) { echo "- Error: field 'subtitle' does not exist.\n"; return; } $fieldgroup = $template->fieldgroup; if($fieldgroup->hasField($field)) { echo "- Skipped existing field on template: subtitle\n"; } else { $fieldgroup->add($field); if($fieldgroup->hasField('summary')) { $fieldgroup->insertAfter($field, $fieldgroup->getField('summary')); } $fieldgroup->save(); echo "- Added field 'subtitle' to template 'blog-post'\n"; } ~~~~~ **Create a template with its fieldgroup only when missing:** ~~~~~ if($templates->get('event')) { echo "- Skipped existing template: event\n"; } else { $fieldgroup = new Fieldgroup(); $fieldgroup->name = 'event'; $fieldgroup->add($fields->get('title')); $fieldgroup->add($fields->get('body')); $fieldgroup->save(); $template = new Template(); $template->name = 'event'; $template->fieldgroup = $fieldgroup; $template->save(); echo "- Created template: event\n"; } ~~~~~ **Create a page only when missing under the expected parent:** ~~~~~ $parent = $pages->get('/blog/'); $template = $templates->get('blog-post'); if(!$parent->id || !$template) { echo "- Error: required parent or template is missing.\n"; return; } $page = $pages->get("parent_id={$parent->id}, name=hello-world, include=all"); if($page->id) { echo "- Skipped existing page: {$page->path}\n"; } else { $page = new Page(); $page->template = $template; $page->parent = $parent; $page->name = 'hello-world'; $page->title = 'Hello World'; $page->save(); echo "- Created page: {$page->path}\n"; } ~~~~~ ### Output format Migrations output plain text in markdown format. Rules: - Start with `echo "# $name\n\n";` - Use `- ` bullet prefix for each line of output - Do not use `#` headings within the migration body - End a successful migration with `echo "- $name has been applied\n";` Example: ~~~~~ echo "- Creating field: `date`\n"; echo "- Creating template: `blog-post`\n"; echo "- $name has been applied\n"; ~~~~~ ### Considerations for creating migrations - **Use the ProcessWire API** wherever possible — avoid raw SQL. - **Never use database IDs** — IDs differ between installations. Refer to items by `name` (Templates, Fields, Fieldgroups, Roles, Users, Permissions) or by `name` + `parent` (Pages). - **Group related operations** into a single migration file where it makes logical sense. - **Migrations are forward-only** — rollback is not supported (yet). ### Applying migrations ~~~~~ php index.php --at-migrations-apply ~~~~~ By default this applies every pending migration in timestamp order. To apply or preview a smaller set, use the documented migration flags: | Flag | Commands | Purpose | |------|----------|---------| | `--file=FILENAME.php` | `apply`, `test`, `list`, `lint`, `rerun` | Select one exact migration filename | | `--name=NAME` | `apply`, `test`, `list`, `lint`, `rerun` | Select one migration by unique full or partial name | | `--limit=N` | `apply`, `test` | Limit to the next N selected pending migrations | | `--dry-run` | `apply`, `rerun` | Preview selected migrations without applying them | | `--force` | `apply` | Re-run the selected migration even if it is already applied; requires `--file` or `--name` | Examples: ~~~~~ php index.php --at-migrations-test --limit=1 php index.php --at-migrations-lint php index.php --at-migrations-lint --file=20260617123000_add-blog-fields.php php index.php --at-migrations-apply --file=20260617123000_add-blog-fields.php php index.php --at-migrations-apply --name=add-blog-fields --dry-run php index.php --at-migrations-rerun --name=add-blog-fields php index.php --at-migrations-apply --file=20260617123000_add-blog-fields.php --force ~~~~~ Agents should use only the documented migration flags above. Do not invent custom migration flags or ad-hoc arguments.