El problema no era la creatividad, era la cadencia

Lemus Digital LLC la operamos dos personas, y ninguna de las dos tiene esto como empleo principal — Aiuda Labs y Assilek se construyen en las horas que quedan alrededor de un trabajo de tiempo completo cada uno. En ese contexto, la parte del negocio que primero se cae no es el producto. Es el contenido. Escribir un post técnico decente toma horas de foco que casi nunca aparecen dos semanas seguidas. Diseñar una tarjeta para Instagram, programarla, subirla a Meta Business Suite, repetir el jueves — es trabajo mecánico que compite directamente con el tiempo que debería ir a clientes o a código.

La respuesta no fue contratar a alguien de marketing ni bajar el ritmo. Fue tratar la publicación de contenido como un problema de ingeniería: si la parte mecánica (generar la imagen, renderizarla, subirla, publicarla en la fecha correcta) se puede describir como un flujo determinista, no necesita que un humano la ejecute cada vez. Solo necesita que un humano decida qué se dice, no que ejecute cómo se dice.

Este post documenta esa separación: qué corre solo, qué decisiones siguen siendo humanas (o de un agente que actúa en nuestro nombre con reglas explícitas), y los bugs concretos que costaron horas resolver.

Tres piezas, tres cadencias

El repositorio de este sitio (lemusdigital_site) tiene tres GitHub Actions corriendo con disparadores distintos:

Y por fuera del repositorio, dos rutinas programadas de Claude Code alimentan a las dos últimas: una que arma la cola de contenido de la semana (domingos) y otra que escribe un post nuevo del blog (miércoles). Esa distinción importa: las Actions ejecutan, las rutinas deciden. Nunca al revés.

La pieza más simple: deploy automático

Antes, publicar un cambio en el sitio significaba correr deploy.cmd a mano desde una máquina local con las credenciales de Firebase cargadas. Hoy, cualquier push a main que toque contenido del sitio dispara un workflow que despliega directo a Firebase Hosting usando un service account (firebase-deploy@lemus-digital-site-74d20) cuya llave vive como secreto del repo. El workflow ignora explícitamente marketing/, .github/ y archivos *.md — cambios en la cola de contenido o en documentación no deberían gatillar un redeploy del sitio público. deploy.cmd sigue existiendo para el caso raro de necesitar un deploy manual, pero dejó de ser el camino normal.

La cola de posts: JSON declarativo, no prompts sueltos en el momento

El workflow de publicación social no genera contenido en el momento — lee un archivo marketing/queue/YYYY-MM-DD.json que ya existe en el repo cuando el cron dispara. Esa separación es deliberada: si algo en la cola está mal, se ve en el diff del commit antes de que se publique nada, no después.

Para cada entrada de la cola, el pipeline hace tres cosas en orden: genera un fondo con Vertex AI (best-effort — si falla, sigue sin fondo generado), renderiza una tarjeta de 1080×1080 con Playwright a partir de una plantilla HTML (marketing/templates/post-card.html, con la paleta navy/cian del sitio) y publica el resultado en la página de Facebook Lemus Digital y en Instagram vía Graph API.

Un detalle de infraestructura que vale la pena explicar: la generación de imagen corre contra el proyecto GCP photographers-488601, no contra lemus-digital-site-74d20 — el proyecto de este sitio no tiene billing habilitado. Es más simple reutilizar el proyecto de Assilek (que sí lo tiene) que activar billing en un proyecto que hoy solo sirve el sitio estático. No es elegante, pero es honesto: no vale la pena resolver un problema de billing que no bloquea nada.

El bug que costó una tarde: bytes contra URL

La primera versión del publicador subía la tarjeta a Meta como adjunto multipart directo en la llamada a la Graph API. Con PNG (~1.2 MB), Meta devolvía el error genérico código 1 ("reduce the amount of data"), sin más contexto. El error no menciona tamaño de archivo en ningún lado de la documentación pública, así que el diagnóstico fue por descarte: reducir la imagen, cambiar el formato, comparar payloads.

La solución terminó siendo dos cambios combinados: exportar la tarjeta como JPEG en vez de PNG (~72 KB en vez de ~1.2 MB) y, más importante, dejar de mandar los bytes por multipart y en su lugar subir la tarjeta a un bucket público de GCS (cards/lemus/) y pasarle a Meta la URL pública (url= en Facebook, image_url= en Instagram). La lección generaliza más allá de Meta: cuando una API de terceros da un error genérico de "demasiados datos" sin especificar un límite, vale la pena asumir que prefiere una URL que resolver del lado del servidor, no un binario que tiene que aceptar y procesar en la misma request.

Reintentos con criterio, no reintentos ciegos

El publicador reintenta, pero solo códigos de error que Meta documenta como transitorios (1, 2, 4, 17, 32, 341, 613) más cualquier 5xx de HTTP. Un error de autenticación (token vencido) o de contenido rechazado no se reintenta — reintentar eso solo quema tiempo de ejecución del Action sin cambiar el resultado. Es una distinción simple pero fácil de saltarse cuando se escribe el primer borrador de un retry loop: no todos los fallos son iguales, y tratarlos igual es la forma más común de convertir un bug puntual en un timeout de 20 minutos.

El reel semanal: más piezas móviles, más superficie de fallo

El reel de los viernes tiene más pasos que el post de imagen, y cada paso extra fue una fuente de bugs propia. El flujo: la cola marketing/queue/reel-YYYY-MM-DD.json describe 4 a 6 escenas; generate-scenes.mjs genera cada escena con Vertex AI usando los personajes de marca como referencia; reel-frame.html (1080×1920) aplica overlays de texto sobre cada imagen; y build-reel.mjs arma el video final con FFmpeg, aplicando Ken Burns y crossfades entre escenas. El resultado se publica en Facebook (upload resumable en tres pasos) y en Instagram como contenedor de tipo REELS.

Un bug que no tenía nada que ver con el video

El primer intento de publicar en Instagram usaba la URL del CDN de Facebook que devuelve el paso de subida a FB (reutilizar esa URL para el contenedor de IG parecía razonable — es la misma empresa). Instagram la rechazaba con el error 2207076. La causa no es un problema de formato ni de tamaño: Meta no garantiza interoperabilidad entre las URLs internas de sus propios productos, aunque compartan compañía matriz. La solución fue dejar de depender de esa URL por completo: el MP4 se sube directo a un bucket público propio (photographers-488601-marketing-public, bajo reels/lemus/, con permisos storage.objectAdmin para el mismo service account que hace el deploy) y esa es la URL que se le pasa tanto a Facebook como a Instagram. Menos elegante que encadenar las respuestas de la propia API de Meta, pero más confiable — depender de una sola fuente de verdad (nuestro bucket) en vez de dos APIs que asumen que interoperan entre sí.

Otros dos bugs de esta pieza fueron más aburridos pero igual de bloqueantes: el runner de GitHub Actions (ubuntu-latest) no trae ffmpeg preinstalado, así que el workflow se quedaba sin comando a mitad de ejecución hasta que se agregó el paso de instalación explícito. Y en algún punto la autenticación contra GCP fallaba porque el flujo pasaba por la IAM Credentials API para generar un token de corta duración a partir de la llave del service account; la solución fue mintear el token directamente desde la llave, sin ese salto intermedio — un dependencia menos entre el workflow y un fallo transitorio de una API que no necesitábamos usar.

Los personajes de marca: consistencia visual sin diseñador

Tanto los posts de imagen como los reels pueden incluir dos personajes recurrentes, cada uno generado con una imagen de referencia fija para mantener consistencia entre publicaciones: Lía, la asistente de IA (la misma que atiende el chat del sitio — blazer navy, auricular con un brillo cian que la identifica), y un dueño de negocio genérico, sin rubro específico. En los posts de imagen, el campo image_style de la cola controla si aparece un personaje o una escena editorial ambiental sin caras (el default). En los reels, cada escena tiene su propio campo character (lia, owner, both o none) — no todos los reels necesitan a los dos personajes, ni al mismo tiempo.

Un ajuste que tuvo que hacerse a mano en los prompts: el modelo generativo tiende a meter texto en inglés dentro de pantallas o burbujas de diálogo que aparecen en la escena, incluso con un guardrail general pidiendo que no lo haga. La solución no fue un guardrail más fuerte sino uno más específico: en el image_prompt de cualquier escena que incluya una pantalla o interfaz visible, pedir explícitamente que esa pantalla esté vacía o sin palabras. Instrucciones genéricas ("no pongas texto en inglés") se ignoran con más frecuencia que instrucciones puntuales sobre el elemento exacto que hay que vaciar.

Quién decide qué se publica

Todo lo anterior es ejecución. La decisión de qué se dice cada semana vive en dos rutinas programadas de Claude Code, separadas de las GitHub Actions:

Vale decirlo con la misma honestidad con la que pedimos que se escriba todo lo demás en este blog: este mismo post lo generó esa segunda rutina. No es un dato incómodo — es el punto. El agente que lo escribió tiene instrucciones explícitas de leer primero el documento de contexto de marca y las reglas de copy antes de escribir una sola palabra, de no inventar cifras ni clientes, y de no atribuirle a Lemus Digital trabajo que no le pertenece. La revisión editorial no desaparece; se mueve de "un humano relee cada borrador antes de publicar" a "las reglas de veracidad están escritas en las instrucciones que ejecuta el agente, y se aplican todas las semanas sin excepción". Es un trade-off consciente: gana consistencia de cadencia, pierde el filtro de una segunda lectura humana antes del commit. Para el volumen y el riesgo de un blog técnico sin captación de leads activa todavía, es un trade-off que aceptamos.

Las Actions, en cambio, no deciden nada — solo ejecutan lo que ya está en el repo. Esa frontera es la que hace que el sistema sea auditable: si algo publicado está mal, el commit que lo introdujo está en el historial de git con su fecha y su autor (humano o rutina), no perdido en el estado interno de un proceso que corrió una vez y desapareció.

El mismo patrón, otro repo

Este pipeline no se inventó desde cero para Lemus Digital — es una réplica del que ya corría para Assilek, en el repositorio hermano photographers: mismo patrón de marketing/ + social-publish.yml + reel-publish.yml, mismo stack de Playwright + FFmpeg + Vertex AI + Graph API. Cuando un cambio estructural tiene sentido para los dos (como el fix de JPEG + URL en vez de bytes por multipart), se mantiene en espejo entre ambos repos. Reutilizar el patrón, en vez de reinventarlo por producto, es lo que hace viable automatizar el marketing de dos marcas con el mismo tiempo disponible que antes apenas alcanzaba para una.

Lo que no automatizamos, a propósito

Nada de esto reemplaza responder un comentario o un DM — eso sigue siendo humano, porque es la parte donde de verdad importa que haya una persona del otro lado. Tampoco automatizamos la decisión de si un tema es apropiado para hablar en público (los pilares editoriales y las reglas de qué no decir se definen antes, no las decide el modelo generativo en el momento). Y no tratamos ningún error de publicación como silencioso: si un post o un reel falla, el workflow falla visiblemente en GitHub Actions — no hay un "reintentar en silencio y ya si sale, sale" que esconda un problema real de configuración.

Cierre

El objetivo de este pipeline nunca fue "tener un bot que publique solo" como fin en sí mismo. Fue liberar el tiempo humano que antes se iba en la parte mecánica de mantener presencia consistente, para que ese tiempo pueda ir a clientes, a código, o a decidir mejor qué vale la pena contar. La cadencia importa más que la perfección de cada pieza individual — un post decente publicado cada semana construye más autoridad que un post perfecto publicado cuando alcance el tiempo. Automatizar la ejecución es lo que hizo posible sostener la cadencia sin sostener también el burnout.