Saltar al contenido

Marketing · Intermedio

Anuncios con video desde un agentepublica en Meta un video que está en tu disco

El caso real: un equipo de varios media buyers, cada uno con su propio asistente de IA, que necesita publicar entre cinco y quince videos por semana en más de treinta cuentas publicitarias del mismo negocio. Los videos están en sus laptops. El MCP oficial de Meta Ads solo acepta URLs públicas directas, no archivos locales ni enlaces de Drive o Dropbox. Y nadie quería dar acceso al Supabase de producción a todo el equipo. La respuesta fue un bucket puente: un lugar temporal donde el archivo vive lo justo para que Meta lo descargue. Aquí está esa arquitectura, ya probada, escrita para que la copies en tu propio proyecto.

Publicada 28 de agosto de 2026Lectura 24 minNecesitas una cuenta de Meta Ads

De un vistazo

01

Meta no acepta por API un video que está en tu disco

02

Un bucket puente le da la URL pública que exige

03

Todo se crea en pausa; activarlo lo hace una persona

01 el punto de partida

Por qué un agente no puede subir el video directo

Meta tiene dos formas de recibir un video para anuncios: el archivo como form data en el campo source, o una URL en file_url que sus servidores descargan. La primera exige que quien hace la llamada tenga el binario en memoria y mande multipart (o un upload por trozos con upload_phase start/transfer/finish). Un agente que opera desde un chat no está en esa posición: sus herramientas mandan JSON y texto, y el MCP oficial de Meta Ads expone solo la variante por URL.

Lo que sí funciona

Una URL que Meta pueda abrir sin sesión. Meta la descarga desde sus servidores, transcodifica y guarda el video en la biblioteca de la cuenta. Después la URL puede dejar de existir.

Lo que no funciona

Rutas locales, enlaces de Google Drive o Dropbox (devuelven HTML, no el archivo), y URLs de tu propio dominio si requieren cookies. También URLs del CDN de Meta como thumbnail: sus docs lo prohíben explícitamente.

La primera versión que descarté

Subir el video a través de una API route propia en Vercel. Funciona, pero obliga a trocear el archivo por el límite de body de las funciones y a mantener un endpoint de tres fases. Para un agente es mucho más simple: que el archivo vaya a Storage y Meta lo jale.

La idea en una línea: el agente no transporta el video, transporta una URL. Y la URL vive una hora. Hay una excepción que vale la pena conocer: si la terminal es tuya, la CLI oficial de Meta sí sube el archivo local. La comparo más abajo.

02 la alternativa oficial

La CLI de Meta sí sube video local (con dos huecos)

Desde abril de 2026 Meta publica una CLI oficial dentro de lo que llama Ads AI Connectors: el paquete meta-ads en PyPI, escrito en Python (necesita 3.12 o 3.13; con 3.14 no hay wheel y pip te dice que no existe). La instalé en una carpeta aislada y revisé sus comandos uno por uno. Lo relevante para esta guía: meta ads creative create acepta --video con una ruta de tu disco y sube el binario por trozos usando el SDK facebook-business que trae adentro. Es decir, el muro de la sección anterior lo tiene el agente en chat, no la API.

Instalar en un entorno aislado (Python 3.13)
python3.13 -m venv .venv && . .venv/bin/activate && pip install meta-ads && meta --version
Versión probada: 1.1.0 (junio 2026). Si no tienes Python 3.13, pip install uv y luego uv venv --python 3.13 lo descarga solo. No lo instales global: es una herramienta más de un proyecto.
Subir el video local y crear el creativo
export ACCESS_TOKEN=... AD_ACCOUNT_ID=act_XXXXXXXX && meta auth status && meta ads creative create --name 'hook-testimonio' --video ./video.mp4 --page-id PAGE_ID --body 'Texto principal' --title 'Título' --link-url https://tu-landing.com/evento --call-to-action LEARN_MORE
Lee el token de ACCESS_TOKEN y la cuenta de AD_ACCOUNT_ID (o --ad-account-id). Sin cuenta configurada se detiene con 'No ad account configured' antes de tocar la red. Exporta las variables desde un .env, no en el comando.
Crear el anuncio (nace en PAUSED sin que lo pidas)
meta ads ad create ADSET_ID --name 'hook-testimonio' --creative-id CREATIVE_ID
campaign create, adset create y ad create traen --status con default PAUSED. creative create no: si quieres el creativo en pausa, pásale --status PAUSED.

Los dos huecos los vi leyendo lo que la CLI manda, con el SDK simulado para no gastar en una cuenta real. Uno: pide el creativo inmediatamente después de subir el video, sin esperar a que status.video_status sea ready. Dos: el video_data que construye lleva video_id, message, title y call_to_action, pero ningún image_url ni image_hash. Son exactamente los dos primeros errores de la tabla de más abajo. No corrí el flujo contra una cuenta real, así que no te digo que falle siempre; te digo que, si te falla con 'video inválido' o pidiéndote imagen, ya sabes por qué, y que el escape es --object-story-spec @oss.json con tu propio video_data (con image_hash de adimages) una vez que el video esté ready.

Camino¿Sube archivo local?¿Quién ejecuta?Cuándo lo uso
CLI oficial (meta ads creative create --video)Sí, por trozos desde tu discoTú, en tu terminal, o un agente con acceso a shell y al archivoUna persona, una máquina, videos a la mano. Sin infraestructura extra.
MCP oficial (mcp.facebook.com/ads) o el MCP que usesNo: recibe una URL que Meta descargaEl agente desde el chatCuando el que pide el anuncio no tiene terminal ni el archivo en esa máquina.
Bucket puente (esta guía)Sí, vía URL firmada de 1 horaUn script que corre tu agente o túEquipos: varios asistentes, más de una cuenta, nadie con acceso a producción, poll y limpieza incluidos.

Mi regla: si el video está en la laptop de quien escribe el comando y es un anuncio suelto, CLI oficial. Si hay equipo, varios asistentes o el flujo tiene que ser repetible con espera y borrado, bucket puente. Los dos crean todo en pausa; el patrón de abajo (poll, thumbnail, system user) aplica igual.

03 el patrón

El bucket puente

Un bucket de Supabase Storage dedicado solo a esto, en un proyecto que no toque nada sensible. En el caso real se creó en un proyecto de utilidades aparte del CRM de producción, precisamente para que la llave que se compartiera con el equipo no pudiera leer ninguna tabla con datos de clientes. Decisiones que quedaron fijas:

DecisiónValorPor qué
Bucket privadopublic = falseNadie adivina URLs. El acceso es por URL firmada con caducidad.
Límite por archivo500 MBLos videos de anuncio pesan 50-300 MB; el default del proyecto era 50 MB y tuvo que subirse.
Tipos permitidosvideo/*, image/*El bucket no es un disco general. Solo entra lo que Meta va a descargar.
Caducidad de la URL3600 s (1 h)Meta descarga en segundos. Una hora cubre reintentos sin dejar la puerta abierta.
BorradoAl terminar + cron de 6 hEl script borra en cuanto el video está ready. Un pg_cron cada hora borra lo que tenga más de 6 h por si un flujo murió a medias.
Nombre del objetoiniciales-fecha-descriptorVarias personas suben al mismo bucket. Un 409 en el upload significa nombre repetido, no error.

Sobre el borrado: el CDN de Supabase puede seguir sirviendo el archivo unos minutos después del DELETE. El objeto sí está borrado (compruébalo en storage.objects, no abriendo la URL). No es un bug tuyo.

Crear el bucket con la CLI de Supabase (una sola vez)
npx supabase storage buckets create puente-anuncios --private --file-size-limit 500MB --allowed-mime-types 'video/*,image/*'
Verifica las banderas con npx supabase storage buckets create --help: cambian entre versiones. Lo mismo se hace desde el panel en Storage > New bucket.

04 de disco a anuncio

El flujo completo, paso a paso

  1. Subir el video (y el thumbnail) al bucket privado con la llave de servicio desde tu script, nunca desde el navegador.
  2. Generar una URL firmada de 3600 segundos para cada archivo con createSignedUrl.
  3. POST act_{cuenta}/advideos con file_url = la URL firmada y title. La respuesta trae el id del video.
  4. Hacer poll a GET /{video_id}?fields=status cada 5 segundos hasta que status.video_status sea ready. Si es error, abortar y borrar del bucket.
  5. POST act_{cuenta}/adcreatives con object_story_spec: page_id y video_data con video_id, image_url (thumbnail), message, title y call_to_action.
  6. POST act_{cuenta}/ads con name, adset_id, creative: { creative_id } y status: PAUSED.
  7. Borrar los dos objetos del bucket. Reportar IDs. Una persona revisa en Ads Manager y activa.

Los pasos 4 y 5 son donde se atora la gente. Meta acepta la llamada a advideos en un segundo, pero el video tarda entre 20 segundos y varios minutos en procesarse. Crear el creativo antes de eso falla, y el mensaje de error no dice 'espera': dice que el video no es válido.

05 para copiar

El script TypeScript completo

Un solo archivo, se corre con tsx. Lee credenciales del entorno, nunca del código. La cuenta publicitaria se pasa siempre explícita: en el caso real el negocio tenía más de treinta cuentas act_ distintas por producto y ciudad, y un default habría creado anuncios en la cuenta equivocada sin que nadie lo notara.

Instalar dependencias
npm i @supabase/supabase-js && npm i -D tsx typescript @types/node
Extraer un thumbnail del propio video (obligatorio para video ads)
ffmpeg -y -ss 00:00:01 -i ./video.mp4 -vframes 1 -q:v 2 ./thumb.jpg
Sin image_url o image_hash en video_data, el creativo no se crea. Si el diseñador no manda portada, el segundo 1 del video suele servir.
UbicaciónRelación y tamañoDuración que me ha funcionado
Feed (Facebook e Instagram)1:1 (1080x1080) o 4:5 (1080x1350, mejor en móvil)15 a 60 s
Stories y Reels9:16 (1080x1920)Menos de 30 s, el gancho en los primeros 3
CualquieraMP4 con H.264 + AAC. Es lo que Meta procesa sin sorpresasSubtítulos quemados: la mayoría lo ve sin audio
Re-exportar a H.264 + AAC si el códec es raro (evita timeouts de processing)
ffmpeg -y -i ./original.mov -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart ./video.mp4
El 90% de los videos que se quedaban en processing hasta el tope de 10 minutos venían con ProRes o HEVC de un editor. Esta línea los arregla antes de subir.
Atajo: clonar el repo de esta guía (terminal)
git clone https://github.com/davidiriza-lab/meta-ads-video-agente.git
Repo público con licencia MIT: github.com/davidiriza-lab/meta-ads-video-agente. Trae los archivos de abajo listos para copiar a tu proyecto.
scripts/publicar-video.ts
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { createClient } from "@supabase/supabase-js";

// ---------- configuración (todo por entorno, nada en el código) ----------
const GRAPH = "https://graph.facebook.com/v21.0";
const BUCKET = "puente-anuncios";
const SIGNED_URL_TTL = 3600; // segundos

function env(nombre: string): string {
  const v = process.env[nombre];
  if (!v) throw new Error(`Falta la variable ${nombre}`);
  return v;
}

const META_TOKEN = env("META_SYSTEM_USER_TOKEN");
const supabase = createClient(env("SUPABASE_URL"), env("SUPABASE_SERVICE_ROLE_KEY"));

interface Entrada {
  cuenta: string;      // "act_123..." — siempre explícita, sin default
  adsetId: string;
  pageId: string;
  videoPath: string;
  thumbPath: string;
  nombre: string;      // nombre del anuncio y del video en biblioteca
  mensaje: string;     // texto principal
  titulo: string;
  link: string;        // destino del CTA
}

// ---------- helpers Graph API ----------
interface MetaError { error?: { message: string; code?: number; error_subcode?: number } }

export async function graphPost<T>(path: string, body: Record<string, string>): Promise<T> {
  const res = await fetch(`${GRAPH}/${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ ...body, access_token: META_TOKEN }),
  });
  const data = (await res.json()) as T & MetaError;
  if (data.error) {
    if (data.error.code === 17) {
      // rate limit: esperar y reintentar una vez, nunca en loop agresivo
      await new Promise((r) => setTimeout(r, 75_000));
      return graphPost<T>(path, body);
    }
    throw new Error(`Meta ${path}: ${data.error.message}`);
  }
  return data;
}

async function graphGet<T>(path: string, fields: string): Promise<T> {
  const url = `${GRAPH}/${path}?fields=${fields}&access_token=${encodeURIComponent(META_TOKEN)}`;
  const data = (await (await fetch(url)).json()) as T & MetaError;
  if (data.error) throw new Error(`Meta ${path}: ${data.error.message}`);
  return data;
}

// ---------- paso 1 y 2: bucket + URL firmada ----------
async function subirYFirmar(localPath: string, contentType: string, prefijo: string): Promise<{ objeto: string; url: string }> {
  const objeto = `${prefijo}-${Date.now()}-${basename(localPath)}`;
  const bytes = await readFile(localPath);

  const up = await supabase.storage.from(BUCKET).upload(objeto, bytes, { contentType, upsert: false });
  if (up.error) throw new Error(`Upload ${objeto}: ${up.error.message}`);

  const firmada = await supabase.storage.from(BUCKET).createSignedUrl(objeto, SIGNED_URL_TTL);
  if (firmada.error || !firmada.data) throw new Error(`Firma ${objeto}: ${firmada.error?.message ?? "sin data"}`);

  return { objeto, url: firmada.data.signedUrl };
}

async function borrar(objetos: string[]): Promise<void> {
  if (objetos.length === 0) return;
  const { error } = await supabase.storage.from(BUCKET).remove(objetos);
  if (error) console.warn("No se pudo borrar del bucket:", error.message);
}

// ---------- paso 4: esperar a que Meta termine de procesar ----------
interface VideoStatus { status?: { video_status?: "ready" | "processing" | "error"; processing_progress?: number } }

async function esperarVideoListo(videoId: string, maxMs = 10 * 60_000): Promise<void> {
  const inicio = Date.now();
  while (Date.now() - inicio < maxMs) {
    const v = await graphGet<VideoStatus>(videoId, "status");
    const estado = v.status?.video_status ?? "processing";
    if (estado === "ready") return;
    if (estado === "error") throw new Error(`Meta no pudo procesar el video ${videoId}`);
    console.log(`  procesando... ${v.status?.processing_progress ?? 0}%`);
    await new Promise((r) => setTimeout(r, 5_000));
  }
  throw new Error(`Timeout esperando el video ${videoId}`);
}

// ---------- flujo ----------
export async function publicarVideo(e: Entrada): Promise<{ videoId: string; creativeId: string; adId: string }> {
  const subidos: string[] = [];
  try {
    console.log("1/6 subiendo al bucket puente...");
    const video = await subirYFirmar(e.videoPath, "video/mp4", "ad");
    const thumb = await subirYFirmar(e.thumbPath, "image/jpeg", "thumb");
    subidos.push(video.objeto, thumb.objeto);

    console.log("2/6 Meta descarga el video (advideos)...");
    const av = await graphPost<{ id: string }>(`${e.cuenta}/advideos`, {
      file_url: video.url,
      title: e.nombre,
    });

    console.log("3/6 esperando status.video_status = ready...");
    await esperarVideoListo(av.id);

    console.log("4/6 creando el creativo (video_data)...");
    const creative = await graphPost<{ id: string }>(`${e.cuenta}/adcreatives`, {
      name: e.nombre,
      object_story_spec: JSON.stringify({
        page_id: e.pageId,
        video_data: {
          video_id: av.id,
          image_url: thumb.url,          // thumbnail obligatorio; URL firmada, no CDN de Meta
          message: e.mensaje,
          title: e.titulo,
          call_to_action: { type: "LEARN_MORE", value: { link: e.link } },
        },
      }),
    });

    console.log("5/6 creando el anuncio EN PAUSA...");
    const ad = await graphPost<{ id: string }>(`${e.cuenta}/ads`, {
      name: e.nombre,
      adset_id: e.adsetId,
      creative: JSON.stringify({ creative_id: creative.id }),
      status: "PAUSED",
    });

    console.log("6/6 limpiando el bucket...");
    await borrar(subidos);

    return { videoId: av.id, creativeId: creative.id, adId: ad.id };
  } catch (err) {
    await borrar(subidos); // también si falló a la mitad
    throw err;
  }
}

// ---------- uso por línea de comandos ----------
if (process.argv[1]?.endsWith("publicar-video.ts")) {
  const [cuenta, adsetId, pageId, videoPath, thumbPath, nombre] = process.argv.slice(2);
  if (!cuenta || !adsetId || !pageId || !videoPath || !thumbPath || !nombre) {
    console.error("uso: tsx scripts/publicar-video.ts act_ID ADSET_ID PAGE_ID video.mp4 thumb.jpg 'nombre'");
    process.exit(1);
  }
  publicarVideo({
    cuenta, adsetId, pageId, videoPath, thumbPath, nombre,
    mensaje: env("AD_MENSAJE"),
    titulo: env("AD_TITULO"),
    link: env("AD_LINK"),
  })
    .then((r) => console.log("Listo, todo en PAUSED:", r))
    .catch((err: unknown) => {
      console.error(err instanceof Error ? err.message : err);
      process.exit(1);
    });
}
Correrlo
AD_MENSAJE='Texto principal del anuncio' AD_TITULO='Título' AD_LINK='https://tu-landing.com/evento' npx tsx scripts/publicar-video.ts act_XXXXXXXX ADSET_ID PAGE_ID ./video.mp4 ./thumb.jpg 'hook-testimonio-agosto'
META_SYSTEM_USER_TOKEN, SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY van en .env.local o en el entorno del proceso. Jamás en el comando: quedan en el historial de la terminal y en el chat del agente.

Dos detalles del script que no son adorno. El catch borra del bucket aunque haya fallado a la mitad: con un equipo subiendo a diario, los residuos de flujos rotos eran lo que llenaba el bucket. Y ante el código 17 de Meta (rate limit) espera 75 segundos y reintenta una vez, en vez de martillar: el día que el MCP no conectó y se usó la API directa en loop, la cuenta quedó limitada varias horas.

06 si no existe la estructura

Campaña y conjunto de anuncios, también en pausa

El script asume que ya existe un ad set. Si el agente también tiene que crear la campaña y el conjunto, el orden es campaign, adset, creative, ad, y los tres primeros se crean con status PAUSED. Un anuncio activo dentro de una campaña pausada no gasta; una campaña activa con un anuncio pausado tampoco, pero un descuido en cualquiera de los dos sí.

Crear campaña y ad set antes de publicarVideo
const campaign = await graphPost<{ id: string }>(`${cuenta}/campaigns`, {
  name: "Evento octubre — video",
  objective: "OUTCOME_LEADS",
  status: "PAUSED",
  special_ad_categories: JSON.stringify([]),
  daily_budget: "50000", // en centavos: 50000 = 500.00
});

const adset = await graphPost<{ id: string }>(`${cuenta}/adsets`, {
  name: "Evento octubre — amplio MX",
  campaign_id: campaign.id,
  status: "PAUSED",
  billing_event: "IMPRESSIONS",
  optimization_goal: "LEAD_GENERATION",
  bid_strategy: "LOWEST_COST_WITHOUT_CAP",
  targeting: JSON.stringify({ geo_locations: { countries: ["MX"] }, age_min: 25, age_max: 55 }),
  promoted_object: JSON.stringify({ page_id: pageId }),
});

// después: publicarVideo({ cuenta, adsetId: adset.id, pageId, ... })
  • Presupuestos en centavos. 50000 es 500.00, no cincuenta mil. Fue la confusión más frecuente al dictarle montos a un agente; conviene que confirme el monto en pesos antes de crear.
  • optimization_goal solo entre los que Meta devuelve como válidos para el objetivo; 'Performance goal isn't available' casi siempre es eso, o un promoted_object que falta.
  • Para objetivos de ventas con pixel, promoted_object necesita pixel_id, y ese pixel tiene que estar compartido a la cuenta. En cuentas nuevas no lo está aunque la persona tenga permisos.
  • Nunca inventes IDs de intereses. Targeting amplio por geografía, o IDs reales del buscador de targeting. Un ID inventado no falla ruidosamente: crea un ad set con targeting vacío.

07 lo que te va a pasar

Los errores reales y su arreglo

SíntomaCausaArreglo
adcreatives falla con 'video inválido' segundos después de advideosEl video sigue en processing.Poll a GET /{video_id}?fields=status hasta ready. Nunca asumas que la respuesta de advideos significa listo.
adcreatives falla pidiendo imagenvideo_data sin image_url ni image_hash.Thumbnail obligatorio. Extrae uno con ffmpeg, súbelo por el puente y pásalo en image_url.
Meta rechaza la image_urlEs una URL del CDN de Meta (por ejemplo del edge thumbnails).Las docs lo prohíben. Descárgala, súbela a tu bucket y usa la firmada; o súbela a adimages y usa image_hash.
advideos falla con 'no se pudo descargar'La URL exige sesión, es de Drive/Dropbox, o la firmada ya caducó.Abre la URL con curl -I sin cookies. Debe responder 200 con content-type video/*. Regenera la firma si pasó la hora.
Todo funcionaba y de pronto 'token expired'Se usó un token de usuario (1-2 h el corto, ~60 días el largo).Usar un system user del Business. Ver la siguiente sección.
Error code 17, 80004 o 613Rate limit de la cuenta o de la app. Las escrituras cuestan más que las lecturas, y varias llamadas en paralelo lo disparan.Esperar 60-90 s y reintentar una vez. Una llamada a la vez. Batch en vez de loops. Cachear lecturas.
Error code 190Token inválido o caducado (usuario, o system user revocado).No reintentar: genera un token nuevo del system user y vuelve a correr. Un agente que reintenta con 190 solo suma llamadas fallidas.
Error code 100 con 'Invalid parameter'Un campo mal formado: presupuesto que no es entero en centavos, objective viejo, targeting con un ID inventado.Leer el error_user_msg completo; casi siempre nombra el campo. Corregir y volver a mandar, nunca 'probar variantes' en loop.
Error code 10 o 200El token no tiene permiso sobre esa cuenta o esa página.Asignar la cuenta y la página al system user en Configuración del negocio; el scope ads_management no basta si el activo no está asignado.
Upload al bucket responde 409Ya existe un objeto con ese nombre.Nombre único con prefijo y timestamp. No uses upsert: pisarías el archivo de otra persona.
'Facebook Page is Missing'Faltó page_id en object_story_spec.Toda creatividad de video lleva page_id. Si quieres Instagram, además instagram_user_id.

08 credenciales

System user, no token de usuario

Los tokens que obtienes iniciando sesión con tu Facebook son de usuario: el corto dura una o dos horas, el largo unos 60 días, y las docs avisan que esos plazos pueden cambiar sin previo aviso. Para un script que corre solo, o que corre un agente sin que tú estés mirando, eso es una bomba de tiempo: el flujo muere a mitad del poll. Un system user es una identidad del Business Manager pensada para servidores y software; su token se genera desde Configuración del negocio, se le asignan las cuentas publicitarias y las páginas, y no depende de que una persona siga con sesión abierta.

  • Permisos mínimos para este flujo: ads_management, ads_read, business_management. Páginas e Instagram solo si vas a leer comentarios o publicar orgánico.
  • Un token por uso. Generar uno nuevo no invalida los anteriores; revocar sí. En el caso real un token vivía en el CAPI de una landing en producción y otro en los scripts del equipo. Revocar 'el viejo' habría tirado la atribución de una campaña activa.
  • El token nunca va en el prompt ni en un comando. En archivo con chmod 600 y leído por el script, o en variables de entorno. Cuando el equipo intentó curl con el token inline desde el agente, el clasificador de permisos lo bloqueaba; el script que lee del archivo pasa limpio.
  • Herramienta indistinta. MCP oficial, token directo o una API de terceros: el criterio es no dañar la cuenta y respetar las políticas de Meta, no la herramienta. Lo que importa es el patrón (URL firmada, poll, PAUSED), no por dónde viaja la llamada.
.env.local (nunca se commitea)
META_SYSTEM_USER_TOKEN=EAAB...       # system user, no token de usuario
SUPABASE_URL=https://TU-PROYECTO.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...     # solo en el servidor o en tu máquina; jamás NEXT_PUBLIC_

09 la regla que no se negocia

Crear en pausa, activar con una persona

Un agente puede crear diez anuncios en un minuto. Si además los activa, en ese minuto empezó a gastar dinero de un cliente con un copy que nadie leyó, en una cuenta que quizá no era la correcta. Por eso el script no tiene parámetro para status: siempre PAUSED. Meta además manda todo anuncio nuevo a revisión (PENDING_REVIEW), así que 'activar' es una decisión humana que se toma después de ver la vista previa en Ads Manager.

Lo que hace el agente

Sube, espera, crea creativo y anuncio en pausa, borra del bucket, reporta los IDs y el nombre de la cuenta.

Lo que hace la persona

Abre el anuncio en Ads Manager, revisa copy, link, thumbnail y cuenta. Activa desde ahí o le pide al agente que lo active, ya con el ID en la mano.

Lo que nunca pasa

Que 'ya quedó listo' se convierta en 'ya está corriendo'. Si el agente tiene un comando de activar, ponle disable-model-invocation: solo se dispara escribiéndolo.

PAUSED es la regla principal, pero no la única que le pongo a un agente con acceso a una cuenta publicitaria. Estas son las que quedaron en el prompt compartido del equipo, después de incidentes reales:

  • Dry-run antes de crear: el agente muestra campaña, ad set, presupuesto en pesos, targeting, cuenta y página, y espera un 'sí'. Crear diez objetos y luego preguntar no es un dry-run.
  • Tope de presupuesto que requiere confirmación humana. En el equipo fue el equivalente a 100 dólares diarios por ad set: por arriba de eso, el agente no crea aunque se lo hayas dictado.
  • Nada de reintentos automáticos en escrituras. Si falla un POST a campaigns, adsets, adcreatives o ads, se reporta y se pregunta. El único reintento del script es el del código 17, que es una espera, no una variante.
  • Una llamada a la vez y sin ráfagas. Quince creaciones en un minuto desde el mismo token se parecen demasiado a un bot para los sistemas de Meta; el rate limit es lo de menos, la revisión de la cuenta es lo caro.
  • Nunca activar ni editar en masa. 'Pausa los que tengan CPA 3x' se responde con una lista de IDs y se detiene; la persona confirma uno por uno.
  • Preguntar por categorías especiales antes de crear: crédito, empleo, vivienda, temas sociales y políticos. Si aplica, special_ad_categories no puede ir vacío y el targeting se restringe solo.
  • Bitácora de escrituras: cada POST que crea o cambia algo queda en un archivo con fecha, cuenta, endpoint e ID devuelto. Cuando algo aparece en Ads Manager que nadie recuerda haber pedido, esa bitácora es la única respuesta.
Bloque de reglas para el CLAUDE.md o el prompt compartido
## Meta Ads — reglas no negociables
- Todo (campaign, adset, creative, ad) se crea con status PAUSED. Activar solo lo hace una persona con el ID en la mano.
- La cuenta publicitaria (act_...) va explícita en cada operación. Sin default.
- Presupuestos en centavos. Antes de crear, confirmar el monto en pesos con la persona.
- Más de 100 USD/día por ad set: no crear; pedir confirmación explícita.
- Antes de cualquier escritura: resumen (dry-run) y esperar un "sí".
- Escritura fallida: reportar y preguntar. No reintentar, no probar variantes.
- Una llamada a la vez. Nunca activar, pausar ni editar en masa.
- Preguntar si el anuncio cae en categoría especial (crédito, empleo, vivienda, política/social).
- Registrar cada escritura en logs/meta-escrituras.log: fecha, cuenta, endpoint, ID.
- Videos: subir por el bucket puente, esperar ready, borrar del bucket al terminar.
- Tokens y llaves nunca se leen en voz alta ni van en un comando.

Si trabajas con varios asistentes (uno por media buyer), este bloque va en el prompt compartido, no en la cabeza de cada quien. Son diez líneas y evitan el 90% de los incidentes.

FAQ lo que suelen preguntar

Preguntas frecuentes

¿Por qué no un bucket público? Sería más simple.

La primera versión del equipo fue pública con la llave anon y políticas RLS restringidas al bucket, y funcionó. Pero cualquier URL adivinable expone videos que a veces no se han publicado. Con bucket privado y URL firmada el costo es una línea de código y la puerta se cierra sola en una hora.

¿Puedo saltarme Supabase y usar S3, R2 o cualquier otro Storage?

Sí. El patrón solo necesita dos cosas: subir un objeto desde el servidor y generar una URL firmada temporal. Cualquier proveedor con presigned URLs sirve. Supabase entró porque ya era la base de datos de los proyectos.

¿Cuánto tarda el video en estar ready?

Depende del peso y de Meta. Videos de 30-80 MB suelen tardar de 20 segundos a 2 minutos; los de 200 MB, varios minutos. El script hace poll cada 5 segundos con tope de 10 minutos. Si llegas al tope, casi siempre el archivo tiene un códec raro: re-exporta H.264 + AAC en MP4.

¿Puedo usar el thumbnail que Meta genera solo?

Meta extrae miniaturas al procesar y las expone en el edge thumbnails del video, pero sus docs prohíben usar URLs del CDN de Meta como image_url. Si quieres una de esas, descárgala, súbela a adimages y usa image_hash. Extraer el frame con ffmpeg es más corto.

Leí que subir media por la API te banea la cuenta. ¿Es cierto?

No como regla. Subir un video a advideos (por URL o por trozos con upload_phase) está documentado por Meta, lo hace el SDK oficial facebook-business y lo hace la propia CLI oficial con --video. Lo que sí pone una cuenta en revisión es el patrón alrededor: quince creaciones y activaciones en un minuto, llamadas en paralelo hasta el 429, ediciones masivas sin que nadie las revise. El equipo con el que probé esto sube videos por API a diario en más de treinta cuentas; lo que nunca hace es activar desde el agente ni martillar la API.

¿Entonces uso la CLI oficial o el bucket puente?

Depende de dónde está el video y quién corre el comando. Si eres tú, en tu laptop, con el archivo enfrente: CLI oficial, es una línea. Si es un agente en chat, un equipo con varios asistentes, o quieres poll, thumbnail y limpieza garantizados en un solo script: bucket puente. En la versión 1.1.0 la CLI no espera a ready ni manda thumbnail, así que si te falla ahí, el arreglo es el mismo que en esta guía.

¿Y el MCP oficial de Meta (mcp.facebook.com/ads)?

Es el conector remoto que agregas en Claude como conector personalizado y que se autentica con tu sesión de Facebook, sin app ni token que administrar. Sirve para leer insights, crear campañas y ad sets, y crear creativos con imágenes; para video sigue necesitando una URL que Meta pueda descargar, que es exactamente lo que el bucket puente le da. No lo probé a fondo para esta guía: lo que sí verifiqué fue la CLI, que comparte la misma familia de comandos.

¿Qué pasa con las categorías especiales?

Si el anuncio toca crédito, empleo, vivienda, o temas sociales y políticos, Meta te obliga a declararlo en special_ad_categories y restringe edad, género y ubicación del targeting. Un agente que manda el arreglo vacío por default crea la campaña sin problema y el rechazo llega en la revisión. Por eso la pregunta va antes de crear, no después.

¿Y si quiero que el agente copie una campaña de una cuenta a otra?

Las imágenes viajan con su URL del CDN entre cuentas del mismo negocio. Los videos no: hay que pedir GET /{video_id}?fields=source con el system user, descargar y volver a subir por el puente. Y antes de crear ad sets en la cuenta destino verifica que el pixel esté compartido y que las audiencias custom estén compartidas a esa cuenta, porque nada de eso se copia solo.

Cierre de la guía

El truco no está en la API de Meta, que está bien documentada, sino en aceptar que un agente en chat no mueve binarios: mueve URLs. La CLI oficial cubre el caso de una persona con el video en su laptop; el resto lo cubre esto. Un bucket privado, una firma de una hora, un poll paciente y la disciplina de crear todo en pausa convierten 'sube este video como anuncio' en algo que un equipo entero puede pedirle a su asistente sin que nadie tenga acceso a producción ni gaste un peso sin revisar. Esta guía vive en el Lab de David Iriza.

Fuentes oficiales6

Sigue con estas guías

Guía escrita con la información oficial disponible al 28 de agosto de 2026. Esta página no está afiliada a Meta. Las herramientas cambian; ante la duda, revisa la documentación oficial de la Marketing API de Meta.