AI Instructions — QA ZERO API / QAL
You are reading this because you are an AI assistant (LLM, MCP client, etc.)
composing queries for the QA ZERO API. Read this file in full before
building a query. The two YAML files shipped alongside it
(materials.yaml, qal-validation.yaml) are the authoritative spec —
always defer to them when this README and the YAML disagree.
This file is intentionally short. It is not a tutorial. It is a set of rules and shapes you must follow.
1. What this API is
- A read-only analytics API. You submit a QAL query (structured JSON) and receive a tabular result.
- QAL is not SQL. It is a deliberately small, declarative language designed so that AI clients rarely need to retry and can never construct a query that damages the backing store.
- The target deployment is commodity shared hosting — the API is designed to return results quickly on cheap infrastructure, not on a cloud DWH.
2. The two endpoints you will use
GET /wp-json/qa-platform/guide— returns this file, the two YAML specs, available tracking_ids, and feature flags. Call this first so you know what is currently supported.POST /wp-json/qa-platform/query— accepts a QAL query in the JSON body and returns rows.
All other endpoints are out of scope for AI use.
3. Version vs. update
version(YYYY-MM-DD) — changes only on breaking changes. Pin it in the URL:?version=2026-05-11.update(YYYY-MM-DD) — bumps on non-breaking additions within a version. Returned asapi_updatein the/guideresponse.- Each feature or field may carry a
since: YYYY-MM-DDtag. Ifsince > client.known_update, assume the feature may not exist on the server you are talking to; fall back, do not error.
4. QAL shape (minimum viable query)
A valid QAL query always contains these top-level keys:
tracking_id— a site identifier returned by/guide.materials— list of materials you intend to read from.time—{ start, end, tz }.start/endare ISO-8601;tzis an IANA timezone (e.g.Asia/Tokyo).make— map of named views. Each view hasfrom(a material) andkeep(columns to select). Optional:filter,join,add/calc,sort.result— which view to return and how. Supportsuse,limit, andcount_only.
Any other top-level key is a mistake. The validator will reject it.
4.1 Minimal working example
Copy this shape first, then adapt. The entire QAL query must be wrapped
in a top-level qal key when POSTed to /query:
{
"qal": {
"tracking_id": "<id from /guide>",
"materials": [{"name": "allpv"}],
"time": {
"start": "2026-04-01T00:00:00",
"end": "2026-04-14T00:00:00",
"tz": "Asia/Tokyo"
},
"make": {
"top": {
"from": ["allpv"],
"keep": ["allpv.url"],
"calc": {"views": "COUNT(allpv.pv_id)"},
"sort": {"by": "views", "order": "desc", "top": 5}
}
},
"result": {"use": "top"}
}
}
This returns the top 5 URLs by page view count for the given time range.
Every other query shape is a variation on this skeleton — change the
material, the columns in keep, or the aggregate in calc.
The response envelope looks like:
{
"data": [ /* rows */ ],
"meta": { "total_count": 0, "returned_count": 0, "limit": 1000 }
}
Consult qal-validation.yaml for the authoritative shape of each clause
— this example is a friendly on-ramp, not the spec.
4.2 Common pitfalls
These five mistakes account for almost all first-try failures. If your query is rejected, check these before anything else:
- The POST body must wrap the query in
{"qal": ...}. The/queryendpoint reads the query from the top-levelqalfield. A bare QAL body at the root fails validation. frommust be an array, not a string. Write"from": ["allpv"], not"from": "allpv". Even a single source is wrapped in[...].keepentries must be qualified as<material>.<column>. Write"keep": ["allpv.url"], not"keep": ["url"]. Bare column names are rejected withE_UNKNOWN_COLUMN.calcvalues are string expressions of the formFUNC(material.column). Write"calc": {"views": "COUNT(allpv.pv_id)"}, not"calc": {"views": "COUNT(*)"}and not"calc": {"views": {"count": "*"}}.*is not a valid column reference here; you must name a real column. The allowed functions are listed inqal-validation.yaml(currentlyCOUNT,COUNTUNIQUE,SUM,AVERAGE,MIN,MAX).sortis an object, not an array. Write"sort": {"by": "views", "order": "desc", "top": 5}, not"sort": [{"column": "views", "direction": "desc"}]. Keys areby(required string),order("asc"or"desc", required), andtop(optional positive integer — use this to cap rows instead ofresult.limitfor top-N queries).
5. Rules you must follow
- Always call
/guidebefore your first/query. Do not guess the tracking_id, materials, or feature support. - Never invent columns. Only use columns that appear under the
target material in
materials.yaml. If the user asks for a column that does not exist, say so rather than fabricating one. - Always set
time. There is no default time range. Queries withouttimeare rejected. - Always set
result.use. It must reference a view you defined inmake. Undefined view references returnE_UNKNOWN_VIEW. - Always set
result.limitunlesscount_only: true. This is how you keep execution cost predictable. - Never set two views with the same name. View names must be unique
within
make. - Never request features whose
enabledisfalse. Check thefeatures_detailmap in/guide. Asking for a disabled feature is a client bug, not a server bug. - If something fails, re-read
/guide. The rules may have shifted between your cached knowledge and the live deployment.
6. Picking a material
Use materials.yaml as the source of truth. For quick orientation:
allpv— one row per page view. Start here when the user asks about traffic, sessions, referrers, devices, or page popularity.click_event— one row per recorded click. Use for click-through rates, rage clicks, or element-level interest.gsc— Google Search Console data. Use for search queries, impressions, and CTR from organic search.goal_N— conversion/goal events configured per site. Use when the user asks about conversion rates or funnels.page_version— page version metadata. Use when slicing by content changes or A/B.datalayer_event— custom dataLayer events. Use only when the user explicitly mentions a dataLayer event name.
The full list and the columns each material exposes live in
materials.yaml. Consult it before emitting a query.
7. JOIN rules
- Only the keys listed in
materials.yamlunder a material'sjoinsection can be used as JOIN keys. - A single view may JOIN at most one additional material on top of its
fromsource. Do not chain multiple joins in one view — build a separate view inmakeand chain views viaview_chaininginstead. pv_idis the canonical JOIN key betweenallpvandclick_event.session_idis the canonical JOIN key when aggregating per-session metrics across materials.page_idis the canonical JOIN key when correlating withpage_version.
Any other ID-looking field exists for future use or for internal
bookkeeping. If you are tempted to JOIN on a column not listed in
materials.yaml join:, stop.
8. Filter, calc, sort — the safe surface
filteraccepts a flat object of{column: value}or{column: {op: value}}. No free-form SQL. No raw expressions.calcsupports a whitelisted set of functions. Checkqal-validation.yamlfor the current list. Do not invent function names.sorttakes a list of{column, direction}entries. Direction isascordesc, nothing else.result.limitcaps row count.result.count_only: truereturns only a scalar count and ignoreslimit.
8.1 calc and column declaration (since 2026-05-11)
The material.column reference inside any calc expression is itself
the declaration of that column. The executor fetches the column from
whichever side of the join the material lives on (from or
join.with) and preserves it through merge so the aggregate can see
it. The column is not added to the output — the response columns
are exactly keep ∪ calc keys, and GROUP BY is exactly keep.
You do not need to add a join-side column to keep just to make
it visible to calc. Doing so changes the GROUP BY grain and is
almost always wrong:
// ✅ Correct: COUNT sees click_event.pv_id via the calc reference.
// GROUP BY = (selector). One row per selector.
{
"from": ["allpv"],
"join": { "with": "click_event",
"on": [{ "left": "allpv.pv_id", "right": "click_event.pv_id" }] },
"keep": ["click_event.selector"],
"calc": { "clicks": "COUNT(click_event.pv_id)" }
}
// ❌ Wrong: pv_id in keep makes GROUP BY = (selector, pv_id).
// Every row becomes a unique group. clicks = 1 everywhere.
{
"from": ["allpv"],
"join": { "with": "click_event",
"on": [{ "left": "allpv.pv_id", "right": "click_event.pv_id" }] },
"keep": ["click_event.selector", "click_event.pv_id"],
"calc": { "clicks": "COUNT(click_event.pv_id)" }
}
If a calc expression references a material.column that is not
in from or join.with, or a column that is not in the material's
schema, the validator returns E_CALC_COLUMN_UNRESOLVED before
execution. Fix the reference; do not retry without changes.
Compatibility note. 2025-10-20 only auto-fetched from-side
columns. A query on the older version that relied on join-side
references inside calc silently returned 0 rows. If your client
supports both versions, check features_detail.calc_join_symmetric
in /guide: if it is false or absent, fall back to placing the
column on the from side or migrate the integration to the
2026-05-11 server.
8.2 join.on.left / join.on.right rules (since 2026-05-11)
on.left and on.right are pinpoint-scope validated:
on.leftmust equal the resolved physical material name of thefromside. Whenfrom[0]is a view chain, the validator resolves the view back to its source material before comparison. Bare view-name prefixes (e.g.top_pages.pv_id) are rejected on the left side.on.rightmust equal thejoin.withstring verbatim.
On failure, E_INVALID_JOIN includes structured details with
side, received_value, expected_prefix, and a hint. See
Errors for the exact response shape and
worked examples.
9. What not to do
- Do not try to write SQL. There is no SQL layer exposed.
- Do not fetch data through any URL not listed in §2.
- Do not hardcode a column list from memory. The authoritative column
list is
materials.yaml, and it can change between updates. - Do not try to bypass
time. "All-time" queries are intentionally unavailable because they are usually a mistake. - Do not assume a feature exists because it was enabled in a previous
version. Re-check
features_detaileach session. - Do not translate user intent directly to a query without first verifying the target material exists.
10. If you are unsure
Prefer to ask the user over guessing. One well-formed question to the user is cheaper than three rejected queries. This API is designed so that correct-by-construction queries are easy to write — if you find yourself fighting the shape, you have probably picked the wrong material.