Guía para agentes de IA: crear sistemas y páginas
Este documento está dirigido a un agente de IA que debe construir funcionalidades completas con el framework desde la CLI: una sección de datos (tabla + administración CRUD) y/o una página pública (con su HTML, CSS y JavaScript) que la muestre.
Con dos comandos el agente arma un sistema entero:
| Comando | Crea |
|---|---|
tools/make-table.php | Una sección de datos: tabla MySQL + CRUD en el admin (gestionar registros, stock, etc.). |
tools/make-page.php | Una página pública que lista/usa esos datos (precio, stock, carrito, formularios…). |
Ejemplo end-to-end: "crea una sección de productos para manejar stock y una página que los liste con precio, stock y carrito" →
make-table.php(tablaproductos) +make-page.php(páginatiendacon carrito). Ver el ejemplo al final.
Principio clave (lee esto primero)
Las páginas públicas son archivos en web/pages/<nombre>.php. Para que una página
aparezca en el panel de administración (CMS → "Páginas Web") y se pueda
editar ahí después, debe generarse con el motor del framework, que incrusta
la configuración de la página como base64 en una línea:
$wpbConfig = '<base64...>';
El CMS lista una página solo si encuentra esa línea. No escribas el .php a
mano: usa el comando CLI de abajo, que genera ese formato por ti. Así la página:
- aparece en la lista "Páginas creadas",
- es editable desde el CMS (HTML/CSS/JS por separado),
- queda con URL limpia (
/<nombre>).
Paso 1 — Crear la sección de datos (tools/make-table.php)
Crea la tabla MySQL y la registra en el admin como sección Modular, con CRUD automático (crear/editar/eliminar registros). No requiere escribir archivos.
php tools/make-table.php config.json
# o JSON inline:
php tools/make-table.php '{"name":"productos","title":"Productos","icon":"bi bi-box-seam","fields":[{"name":"nombre","type":"text"},{"name":"precio","type":"money"},{"name":"stock","type":"int"},{"name":"imagen","type":"image"}]}'
Imprime un JSON con los nombres reales de las columnas (úsalos en la página):
{ "table": "productos", "idColumn": "id_producto",
"columns": ["nombre_producto","precio_producto","stock_producto","imagen_producto"] }
Config de la sección
| Clave | Descripción |
|---|---|
name (req.) | Nombre de la tabla y URL de la sección (a-z 0-9 _). |
title | Título en el menú del admin (default: name). |
icon | Clase de Bootstrap Icons, ej. bi bi-box-seam. |
fields (req.) | Lista de campos [{ "name", "type", "alias?", "visible?" }]. |
El framework agrega solos la PK (id_<suffix>) y las fechas de creación/edición.
A cada campo le añade el sufijo de la tabla (precio → precio_producto).
Tipos de campo (type)
text, textarea, int, double, money, measure (número + unidad),
boolean, date, time, email, link, color, select, image,
multiimage, file, json, object.
measuremuestra un número con su unidad en una sola celda y se guarda comoDOUBLE. La unidad sale del matrix de la columna: una unidad literal (kg) o el nombre de una columna hermana con la unidad por fila (unidad_insumo).
Tras crearla, la sección aparece en el menú del CMS y el usuario puede cargar
registros (o cárgalos tú vía la API REST: POST /api/<tabla>).
¿Esta tabla necesita página pública?
No siempre. El Paso 2 (crear página) es opcional. Decide por cada tabla:
- Tabla con página → usa
make-page.phppara una vista pública (catálogo, listado, tienda…). - Tabla solo-datos (sin página) → se gestiona en el admin y se consume desde una aplicación web (o app móvil) por la API REST. Muchas secciones son así: configuraciones, inventario, usuarios de la app, pedidos, etc.
Si el usuario no especifica, pregunta qué tablas requieren página y cuáles son solo-datos.
Consumir una sección solo-datos por la API
La tabla queda disponible de inmediato en la API dinámica (header
Authorization: <api-key>):
GET /api/<tabla> # listar
GET /api/<tabla>?linkTo=col&equalTo=valor # filtrar
POST /api/<tabla> # crear (body JSON)
PUT /api/<tabla>?id=5&nameId=id_<suffix> # actualizar
DELETE /api/<tabla>?id=5&nameId=id_<suffix> # eliminar
Detalles completos en API.md.
Paso 2 — Crear la página pública (tools/make-page.php)
php tools/make-page.php config.json
# o JSON inline:
php tools/make-page.php '{"name":"contacto","heading":"Contacto","template":"<h1>Hola</h1>"}'
# o por stdin:
cat config.json | php tools/make-page.php
Imprime un JSON con el resultado (success, file, slug). Código de salida 0
si todo bien, 1 si hubo error (el mensaje va a stderr).
El comando se encarga de: buscar la PK/columnas reales de la tabla (si se indica),
generar el .php con la config embebida, crear web/config.php y el header/footer
si faltan, y dejar la página lista.
Esquema del JSON de configuración
| Clave | Tipo | Descripción |
|---|---|---|
name | string (requerido) | Nombre del archivo (a-z 0-9 _ -). La página será web/pages/<name>.php y su URL /<name>. |
heading | string | Título (pestaña del navegador + nombre en la lista del CMS). |
template | string | Tu HTML (admite las etiquetas de abajo). La página se abre en el editor de código. |
blocks | array | Un árbol de bloques del editor visual. La página se abre en el editor visual y se edita bloque por bloque. Excluyente con template. Ver Páginas con el editor visual. |
customCss | string | CSS que se inyecta en la página. |
customJs | string | JavaScript que se inyecta en la página. |
table | string | Tabla o vista (VIEW) de datos a vincular. Opcional: si la omites, es una página estática (sin datos). Vincular una vista permite páginas basadas en datos curados/filtrados (p. ej. "solo productos activos"). |
metaTitle, metaDesc | string | SEO (título y descripción para buscadores). |
ogTitle, ogType, ogDesc, ogImage | string | Open Graph (al compartir en redes). |
private | bool | Requiere login para verla (default false). |
accessRoles | array | Roles permitidos (rol_admin) cuando es privada. |
accessUsers | array | IDs de usuarios permitidos cuando es privada. |
isHome | bool | Además marca esta página como inicio del sitio (raíz del dominio). |
confirmedStatic | bool | Marca explícita de página estática (sin table). Opcional; deja constancia de que la página es de contenido puro y no una a la que le falta la tabla. No se muestra en la lista: una página sin tabla es el caso normal. Se ignora si pasás table. |
Páginas con el editor visual
Una página hecha con template es HTML: quien la reciba puede leerla y cambiarla
en el editor de código, pero no puede mover una sección, cambiar un color o
agregar una columna con el editor de bloques sin reescribirla entera.
Pasando blocks en vez de template, la página nace en modo visual: se abre
con «Editar visual» y cada elemento se selecciona, se arrastra y se estiliza
igual que si alguien lo hubiera puesto ahí a mano. Es lo que hay que usar cuando
el resultado se lo entregas a otra persona.
php tools/make-page.php --blocks-help # los tipos válidos + un ejemplo completo
Requiere Node en el equipo: el compilador del editor visual es JavaScript y
se reutiliza tal cual (tools/wpb-compile.js), en vez de duplicarlo en PHP. Dos
compiladores producirían páginas distintas según quién las creó.
La forma de un bloque
{ "type": "heading", "props": { "level": 1, "text": "Nuestro catálogo" } }
Los contenedores llevan además children. No pongas id: se asignan solos,
y son la identidad de cada bloque para el editor y el alcance de su CSS.
El vocabulario
Contenido
type | props |
|---|---|
heading | level (1-6), text |
paragraph | text |
richText | text (una línea en blanco separa párrafos; un párrafo cuyas líneas empiezan con - se publica como lista de viñetas) |
image | src, alt, href — la alineación, las esquinas y la sombra van en style, no acá |
button | text, href, style (primary/secondary) |
iconBox | icon (clase Bootstrap Icons, p. ej. bi-truck), title, text — el color y la alineación van en style |
alert | tone (info/success/warning/danger/dark), title, text |
video | url, title |
divider | height (px) |
spacer | height (px) |
rawHtml | html, css, js — el bloque HTML libre. En css, la palabra selector se reemplaza por la clase propia del bloque |
Estructura (llevan children)
type | props | Qué acepta dentro |
|---|---|---|
section | gap (0-5), align (start/center/end) | sólo bloques column |
layout lo completa el CLI a partir de los anchos de las columnas — no hace falta escribirlo | ||
column | width (1-12, sobre una grilla de 12) | cualquier bloque |
Datos de la tabla (requieren table en la config)
type | props |
|---|---|
field | column, tag (span/div/strong/em/small/h1…h4) |
fieldImage | column, width (100%, 320px, auto) |
fieldGallery | column (campo multi-imagen), itemWidth (px) |
list | wrapper, wrapperClass · children se repite una vez por registro |
form | submitText · dentro van formInput / formTextarea / formFile |
formInput, formTextarea, formFile | column, label |
image aceptó en su día align, fit, radius, shadow y width, y
iconBox aceptó align y color. El compilador los sigue respetando para que
las páginas guardadas antes se vean igual, pero salen como estilo en línea,
que le gana a la hoja de estilos: quien reciba la página moverá el control en la
pestaña Estilo y no pasará nada, sin nada en pantalla que lo explique.
Escríbelos siempre en style. El CLI avisa si los encuentra.
Estilo por bloque
Cualquier bloque acepta props.style, con una capa por pantalla y estado:
{
"type": "heading",
"props": {
"level": 2, "text": "Hola",
"style": {
"base": { "color": "#112233", "fontSize": { "size": 32, "unit": "px" } },
"tablet": { "fontSize": { "size": 24, "unit": "px" } },
"mobile": { "fontSize": { "size": 18, "unit": "px" } },
"hover": { "color": "#cc0000" }
}
}
}
Las claves más usadas de una capa: color, fontSize, fontWeight,
lineHeight, letterSpacing, textTransform, align, bgColor, bgImage,
margin, padding, radius, shadow, borderWidth, borderColor,
elWidth, minHeight, hideDesktop / hideTablet / hideMobile, cssId,
cssClass y customCss. Las de tamaño son { "size": n, "unit": "px" } y las
de cuatro lados { "top": n, "right": n, "bottom": n, "left": n, "unit": "px" }.
base vale siempre; tablet y mobile sólo bajo su ancho; hover al pasar el
mouse. Lo que dejes fuera se hereda. Son los mismos controles que muestra la
pestaña Estilo del editor, así que quien reciba la página puede cambiarlos ahí.
Centrar el título de una sección no centra el botón que va debajo: cada bloque
lleva su propia style.base.align. Es la trampa más fácil de pisar al escribir
un árbol a mano — el resultado se publica bien y se ve descuadrado.
Un ejemplo completo
{
"name": "catalogo",
"heading": "Catálogo",
"table": "productos",
"blocks": [
{ "type": "heading", "props": { "level": 1, "text": "Nuestro catálogo" } },
{ "type": "list", "props": { "wrapper": "div", "wrapperClass": "row" }, "children": [
{ "type": "section", "props": { "gap": 4 }, "children": [
{ "type": "column", "props": { "width": 4 }, "children": [
{ "type": "fieldImage", "props": { "column": "foto_producto", "width": "100%" } }
]},
{ "type": "column", "props": { "width": 8 }, "children": [
{ "type": "field", "props": { "column": "name_producto", "tag": "h3" } },
{ "type": "field", "props": { "column": "detalle_producto", "tag": "div" } }
]}
]}
]}
]
}
Reglas que el CLI hace cumplir
- Un
typemal escrito es un error, no un bloque que desaparece: el comando lo nombra y lista los válidos. Compilar a nada y entregar media página es lo peor que puede pasarle a quien la recibe, porque se entera el usuario final. template,customCssycustomJsno se pueden combinar conblocks. Una página visual recompila esos tres campos desde el árbol cada vez que alguien pulsa Guardar en el editor, así que lo que llegue por fuera se perdería en el primer guardado. Si necesitas CSS o JS propios, van dentro de un bloquerawHtml, que sí viaja en el árbol.- Los archivos del sitio se nombran
views/assets/…, sin barra ni dominio: una foto (src), una hoja (href) o un fondo o una tipografía dentro del CSS (url('views/assets/…')). Al servir la página, el sitio los re-enraiza en la carpeta de la instalación, así que funciona igual en la raíz del dominio, en/web/…y bajo una subcarpeta. Hasta 2026-09 sólo se re-enraizabansrcyhref, y una portada abierta en la raíz perdía sus fondos y sus fuentes. - Un aviso del compilador (un
fieldsincolumn, por ejemplo) sale enwarningsen el JSON de respuesta: ese bloque no se dibujó.
El header y el footer (tools/make-theme-doc.php)
Una página entregada así es editable; el marco que la rodea no lo era. El header y el footer son lo único que el visitante ve en todas las páginas, y la única forma de producir uno era armarlo a mano en el editor, bloque por bloque. Un sitio entregado completo llegaba con páginas editables dentro de un marco que nadie podía tocar.
php tools/make-theme-doc.php config.json
php tools/make-theme-doc.php '{"kind":"footer","name":"Pie","blocks":[…]}'
php tools/make-theme-doc.php --blocks-help # el mismo vocabulario de bloques
| Clave | Descripción |
|---|---|
kind (req.) | header o footer. |
blocks (req.) | El árbol de bloques, igual que en una página visual. |
name | Cómo se llama en la lista de headers y footers del panel. |
id | Para reemplazar uno existente en vez de crear otro. |
status | published (por defecto) o draft. |
conditions | Dónde se muestra: ["include/entire-site"] (por defecto), ["include/page/inicio"], ["exclude/page/contacto"]. |
Mismo compilador que las páginas (tools/wpb-compile.js) y mismo almacén que el
editor (tools/web-theme.php): el documento que sale de acá es indistinguible de
uno dibujado en pantalla, que es lo único que lo mantiene editable ahí.
template, customCss y customJs no se pueden pasar junto con blocks,
por la misma razón que en una página: el editor los recalcula desde el árbol en
cada guardado.
Etiquetas de plantilla (en template)
Solo aplican si vinculaste una table.
| Etiqueta | Qué hace |
|---|---|
{{campo}} | Inserta el valor de una columna (escapado). Usa el registro único (el de ?id= o el primero). |
{{html campo}} | Un campo de texto enriquecido con su formato, saneado (lista blanca: sin scripts ni enlaces javascript:). |
{{#cada}} ... {{/cada}} | Repite el HTML interior por cada registro de la tabla. |
{{#imagenes campo}}<img src="{{url}}">{{/imagenes}} | Recorre un campo multi-imagen (JSON de URLs) y repite por cada imagen. |
{{#form}} ... {{/form}} | Un formulario para crear/editar registros. |
{{input campo}} / {{textarea campo}} / {{file campo}} | Campos del formulario (texto / área / archivo). |
{{submit Texto}} | Botón de envío. |
El formulario, en páginas públicas, solo crea registros. Editar (
?id=5) solo está permitido en páginas privadas autorizadas (seguridad).
Ejemplos
1) Página estática (sin datos) con CSS y JS
{
"name": "inicio",
"heading": "Bienvenido",
"template": "<section class=\"hero\"><h1>Mi Empresa</h1><button id=\"cta\">Contáctanos</button></section>",
"customCss": ".hero{padding:5rem 1rem;text-align:center}.hero h1{font-size:3rem}",
"customJs": "document.getElementById('cta').addEventListener('click',()=>alert('¡Gracias!'));",
"isHome": true
}
2) Página con datos de una tabla (listado)
{
"name": "propiedades",
"heading": "Propiedades",
"table": "propiedades",
"template": "<div class=\"row\">{{#cada}}<div class=\"col-md-4\"><div class=\"card\"><img src=\"{{imagen}}\" class=\"card-img-top\"><div class=\"card-body\"><h5>{{titulo}}</h5><p>{{precio}}</p></div></div></div>{{/cada}}</div>",
"customCss": ".card{margin-bottom:1rem}"
}
3) Página privada con formulario (ej. solicitudes de RRHH)
{
"name": "solicitudes",
"heading": "Solicitar permiso",
"table": "solicitudes",
"template": "<div class=\"container py-4\">{{#form}}<label>Motivo</label>{{textarea motivo}}<label>Fecha</label>{{input fecha}}{{submit Enviar}}{{/form}}</div>",
"private": true,
"accessRoles": ["empleado"]
}
Cómo se ve en el admin
Tras ejecutar el comando, abre el CMS → "Páginas Web". La página aparece en la lista izquierda "Páginas creadas". Al hacer clic se carga en el editor con su HTML, CSS y JS por separado — el agente y un humano editan la misma página.
El header y el footer del sitio son compartidos (uno para todas las páginas) y también se editan ahí (items fijados arriba de la lista); no los toques por página.
URLs resultantes
Con el document root del dominio apuntando a web/:
www.tu-dominio.cl/<name>→ la página (web/pages/<name>.php).www.tu-dominio.cl/→ la página marcada conisHome: true.
En local (subcarpeta): …/web/<name>. Ver GENERADOR-PAGINAS.md.
Alternativa: API / AJAX del CMS
La creación por CLI es la vía recomendada para un agente. Existe también el endpoint
del CMS cms/ajax/web-pages.ajax.php (acción generate), pero requiere sesión de
administrador y token CSRF, por lo que solo es práctico desde el navegador del CMS.
La API REST (api/) opera sobre tablas de datos, no sobre las páginas
(las páginas son archivos). Por eso, para crear páginas programáticamente, usa la
CLI tools/make-page.php.
Ejemplo completo: productos + tienda con carrito
1. Crear la sección de datos (tabla + CRUD en el admin):
php tools/make-table.php '{"name":"productos","title":"Productos","icon":"bi bi-box-seam","fields":[{"name":"nombre","type":"text"},{"name":"descripcion","type":"textarea"},{"name":"precio","type":"money"},{"name":"stock","type":"int"},{"name":"imagen","type":"image"}]}'
Devuelve las columnas: nombre_producto, precio_producto, stock_producto,
imagen_producto (úsalas en la plantilla).
2. Crear la página pública que los lista con precio, stock y carrito. El
carrito es JavaScript en customJs (estado en localStorage), y el stock 0
deshabilita el botón. Esqueleto:
{
"name": "tienda",
"heading": "Tienda",
"table": "productos",
"template": "<div class=\"grid\">{{#cada}}<article><img src=\"{{imagen_producto}}\"><h3>{{nombre_producto}}</h3><span class=\"price\" data-price=\"{{precio_producto}}\"></span><span class=\"stock\" data-stock=\"{{stock_producto}}\"></span><button class=\"add\" data-name=\"{{nombre_producto}}\" data-price=\"{{precio_producto}}\" data-stock=\"{{stock_producto}}\">Agregar</button></article>{{/cada}}</div>",
"customCss": ".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem} /* … */",
"customJs": "var KEY='cart';function get(){return JSON.parse(localStorage.getItem(KEY)||'[]')}function add(n,p){var c=get();var i=c.find(x=>x.name===n);i?i.qty++:c.push({name:n,price:+p,qty:1});localStorage.setItem(KEY,JSON.stringify(c));} document.querySelectorAll('.add').forEach(b=>{if(+b.dataset.stock<=0){b.disabled=true;b.textContent='Sin stock';}else{b.onclick=()=>add(b.dataset.name,b.dataset.price);}});"
}
La página queda en web/pages/tienda.php, aparece en el admin y se ve en
/tienda. Los datos los gestiona el usuario en la sección Productos del CMS
(o el agente vía POST /api/productos).
Reglas para el agente
-
Elige el caso según lo que pida el usuario:
- Datos + página pública:
make-table.php(sección) →make-page.php(página con esatable). - Solo datos (app web / sin página pública): solo
make-table.php; la app consume la tabla por la API REST. - Solo contenido (landing, info): solo
make-page.phpsintable, marcando"confirmedStatic": truepara indicar que la falta de tabla es intencional.
Si no está claro, pregunta qué tablas necesitan página y cuáles son solo-datos.
Cuando se usa el MCP,
create_pagebloquea las llamadas sintabley sinconfirmedStaticdevolviendostatus: "needs_confirmation"con las tres opciones (vincular sección existente, crear una nueva, o confirmar estática). El agente debe presentarle las opciones al usuario antes de reintentar. - Datos + página pública:
-
Siempre genera con
tools/make-page.php(nunca escribas el.phpa mano), o la página no aparecerá en el admin ni será editable. -
nameen minúsculas, sin espacios ni acentos (a-z 0-9 _ -). -
Si la página usa datos, indica
tabley usa las etiquetas; si es de contenido, omitetable. -
CSS va en
customCss, JS encustomJs(no metas<style>/<script>dentro detemplate; el motor los inyecta correctamente). -
Para la página de inicio, usa
"isHome": true(solo una a la vez).