Docs
Working with AI agents
This project is set up so a coding agent can extend it safely. The rules below are the ones that stop an agent producing something that looks right and silently isn't.
Point your agent at AGENTS.md in the repository root. Claude Code, Cursor and Copilot read it automatically. It contains these rules in a form written for a machine, plus the exact verification commands.
Packaged skills
.claude/skills/ holds step-by-step procedures for the tasks that touch several files at once. In Claude Code, invoke one by name:
| Skill | Use it to |
|---|---|
/add-content-type | Add a post type end to end: WordPress registration, fields, GraphQL, REST, Astro collection, routes, tests |
/add-field | Add one Meta Box field across all five places it has to appear |
/verify-stack | Run every check and interpret the failures |
The five rules
1. A field exists in five places or not at all
Meta Box names are derived, not written down: the bridge strips the shared prefix, soapp_project_client is queried as client. Nothing records that mapping, which is why changing one file and not the others produces a field that is present in the schema and absent from the page.
wordpress/mu-plugins/app-fields.php # define
web/src/lib/wp/queries.ts # request
web/src/lib/wp/schema.ts # validate + normalise
web/scripts/mock-wp.mjs # keep the offline mock honest
wordpress/tests/schema-contract.php # assert the names still match2. Verify with commands, not by reading
Every claim about this stack is checkable. Run these before saying something works:
php wordpress/tests/schema-contract.php # PHP names vs the queries
cd web
npm run mock:validate # queries vs the schema
npm run check # types
npm run build # the whole thing3. Prerendered pages have no request
Public pages are prerendered. They have no session, no cookies and no user — inastro dev as well as in production. An agent that adds a personalised element to a public page will get an empty result, not an error. Anything per-user needs export const prerender = false, which makes that route render on demand.
4. Webhooks grant access; redirects do not
Subscription state is written only by the Stripe and PayPal webhook handlers. A browser returning from checkout proves nothing — the tab can be closed and the redirect is forgeable. Never add code that marks a user subscribed because they reached a success URL.
5. Secrets belong in the environment
Read configuration through setting() in web/src/lib/settings.ts, which checks the environment first and WordPress second. Never hardcode a key, never log one, and never send one to the browser. The X-App-Secret header is server-to-server only.
Where things live
| To change | Edit |
|---|---|
| Post types, taxonomies | wordpress/mu-plugins/app-post-types.php |
| Custom fields | wordpress/mu-plugins/app-fields.php |
| Meta Box → GraphQL | app-graphql-metabox.php (generic; rarely needs edits) |
| Meta Box → REST | app-rest-fields.php (generic; rarely needs edits) |
| Queries | web/src/lib/wp/queries.ts |
| Collections and validation | web/src/content.config.ts, web/src/lib/wp/schema.ts |
| Pages and routes | web/src/pages/ |
| Brand, plans | web/src/config.ts |
| Auth and route guards | web/src/middleware.ts, web/src/lib/auth/ |
Building a new site on this
- Clone, then
cp .env.example .envand run./scripts/wp-bootstrap.sh. - Rename the prefix if you want one of your own: change
app_in the mu-plugins andweb/src/lib/wp/, then run./scripts/migrate-prefix.sh app yourprefixso existing rows follow. - Set
SITE_NAMEandSITE_TAGLINEinweb/.env. - Replace the demo content types with yours using
/add-content-type. - Run the checks in rule 2, then open Health and confirm every row is green.
Traps that look like bugs
| Symptom | Cause |
|---|---|
| A collection loads zero items | WPGraphQL treats a post type as private unless public or publicly_queryable is true. It returns an empty list, not an error. |
| Edits in WordPress don't appear | Content loaders run once at dev-server start. The dev integration polls every 5s; a build needs rerunning. |
rest_cannot_create | Application passwords are refused over plain HTTP unless WP_ENVIRONMENT_TYPE is local. |
| REST writes ignore custom fields | Post meta is invisible to REST until registered — that is what app-rest-fields.php does. |
| A number field errors in GraphQL | Meta Box stores an untouched number as '', which cannot coerce to Int. Resolve numerics to null when empty. |
| The whole site is a "coming soon" page | It was built while the site mode was not Live. The gate is baked in until the next build. |
wp db query fails | The wp-cli image ships a MariaDB client that cannot authenticate to MySQL 8. Use wp eval. |