Mi blog ahora publica en LinkedIn solo — pero no sin mí

Cómo extendí un blog Laravel para generar y publicar contenido en LinkedIn con Claude, y por qué la decisión más importante fue dejar a un humano en el medio.


Tengo un blog y un problema conocido: escribir un artículo es la mitad del trabajo. La otra mitad —adaptarlo a LinkedIn, publicarlo, no procrastinarlo— es la que se queda sin hacer. Así que automaticé esa mitad. Este artículo cuenta las decisiones de arquitectura, incluyendo dos o tres que me hubiera gustado leer antes de empezar.

Spoiler del final: el post de LinkedIn que probablemente te trajo hasta aquí lo redactó Claude y lo publicó este pipeline. Yo solo lo aprobé.

El flujo en 30 segundos

Mi blog corre en Laravel 10 y publica posts mediante queues. La extensión hace esto:

  1. Al publicarse un artículo, un observer lo detecta y encola un job.
  2. El job envía título, cuerpo y URL a la API de Claude con un prompt fijo, y guarda el borrador resultante como pendiente de revisión. Me llega un email.
  3. En un panel de admin reviso el borrador: puedo editarlo, aprobarlo, rechazarlo o pedir una regeneración con feedback.
  4. Solo si apruebo, otro job publica en LinkedIn vía su API oficial.

Nada de esto es exótico. Lo interesante está en lo que decidí no hacer.

Decisión 1: el humano no es opcional

La versión totalmente automática era más fácil de construir: generar y publicar, sin panel ni estados intermedios. La descarté por una razón simple: el costo de un error no es simétrico. Un borrador mediocre que nunca se publica cuesta cero. Un post publicado con un dato inventado, un tono que no es el mío o un error técnico expuesto en mi perfil profesional cuesta credibilidad — exactamente lo que el sistema intenta construir.

Ya había usado este patrón antes. En mi trabajo anterior integré un LLM al flujo de reembolsos de pagos: el modelo proponía la resolución, una persona aprobaba, el sistema ejecutaba. Automatizó el 70% del esfuerzo sin ceder la decisión. La lección que me llevé y que apliqué aquí: automatiza la propuesta, nunca la aprobación — al menos mientras el costo del error lo pague tu reputación.

En la práctica, el "human in the loop" es un estado más en la máquina de estados: generating → pending_review → approved → published, con salidas a rejected y failed. El sistema entero es un workflow determinista con una pausa humana en el medio.

Decisión 2: no tocar el código que ya funciona

El blog ya publicaba bien. La regla que me impuse: extender sin modificar (sí, Open/Closed aplicado a mi propio código legacy). El único punto de acople es un observer de Eloquent que reacciona cuando un post pasa a publicado:

public function updated(Post $post): void
{
    if ($post->wasChanged('published_at')) {
        $this->syncIfPublished($post);
    }
}

Cero líneas cambiadas en el flujo original. Si mañana elimino la integración, borro archivos y una línea de registro del observer. Esa reversibilidad barata es lo que te permite experimentar sin miedo.

Decisión 3: una llamada API, no un "agente"

Tentación de época: ponerle un agente a todo. Aquí no hay ninguno. La generación es una petición HTTP a la API de Anthropic — prompt de sistema fijo, el artículo como entrada, texto como salida. Sin herramientas, sin iteraciones, sin decisiones del modelo.

¿Por qué insisto en la distinción? Porque la complejidad agéntica se justifica cuando la tarea requiere pasos variables u acceso a sistemas. Redactar un post a partir de un artículo no lo requiere. Una llamada simple es más barata, más rápida, trivial de testear (una interfaz ContentGenerator mockeada) y, sobre todo, predecible. Elegir la herramienta aburrida también es una decisión de arquitectura.

El prompt sí tiene trabajo fino: pide un post nativo que aporte valor por sí solo, no un teaser con link. LinkedIn penaliza el alcance de publicaciones cuyo único contenido es una URL externa; el algoritmo premia que el valor esté en el post. La URL del artículo va al final, para quien quiera profundizar.

Decisión 4: asumir la fricción de la API de LinkedIn

Dos cosas que conviene saber antes de integrarse con LinkedIn:

No hay refresh tokens para apps self-serve. El access token dura 60 días y la renovación programática está reservada a partners aprobados. Mi solución: un comando programado que revisa la expiración a diario y me avisa por email con una semana de antelación; renovar es un click en el panel. ¿Elegante? No. ¿Suficiente para una persona publicando dos veces por semana? Completamente. Sobre-ingeniería evitada.

La API self-serve es la de siempre. Para publicar como miembro, la vía sigue siendo POST /v2/ugcPosts con el scope w_member_social (producto "Share on LinkedIn"). La Posts API versionada más moderna es territorio de partners. Saberlo de antemano ahorra una tarde de leer documentación que no aplica.

Detalles defensivos que sí valieron la pena: el job de publicación es idempotente (verifica que el estado siga siendo approved antes de publicar, así un reintento de cola no duplica posts), los fallos quedan registrados con su error visible en el panel, y el token se guarda cifrado en base de datos.

Lo que esto NO resuelve

Honestidad ante todo: el pipeline elimina la fricción logística, no el trabajo de fondo. Claude redacta borradores sorprendentemente decentes, pero la voz definitiva sigo poniéndola yo en la revisión — y el artículo de origen hay que escribirlo igual. La automatización te quita excusas, no esfuerzo creativo.

También dejé fuera, conscientemente, la programación de horarios óptimos, los análisis de engagement y el soporte multi-red. Cada uno es una rama de complejidad que no necesito hoy. Cuando Instagram entre en mi plan, el diseño con interfaces (ContentGenerator, cliente por plataforma) ya tiene el enchufe preparado.

Cierre

Si este artículo te llegó por LinkedIn, ya viste el sistema funcionando: ese post salió de este pipeline, con mi aprobación como único paso manual. La arquitectura completa cabe en una frase: eventos de dominio + colas + un LLM como redactor + un humano como editor.

Si estás construyendo algo parecido —o integrando LLMs a cualquier flujo donde el error tenga costo— me interesa saber cómo lo estás resolviendo. La conversación está abierta en el post de LinkedIn, o en los comentarios aquí abajo.


Stack: PHP 8.1, Laravel 10 (queues, observers), API de Anthropic (Claude), LinkedIn API (OAuth 2.0 + ugcPosts).