Agentes IA programados: automatiza sin pulsar send
Construiste el agente. Funciona bien. Extrae datos, genera informes, manda emails. El problema: cada vez que quieres que actúe, alguien tiene que pulsar un botón o mandar un mensaje.
Eso no es automatización. Es un acceso directo glorificado.
El siguiente nivel no es construir un agente más inteligente — es un agente que actúa solo, en el momento correcto, aunque sean las tres de la mañana y nadie esté mirando.
El agente que vive a demanda no escala
La mayoría de implementaciones de agentes IA en producción comparten el mismo patrón: el usuario envía un prompt, el agente responde. Es útil, pero limita todo el potencial de la automatización real.
Los casos de uso más valiosos en una empresa no son los conversacionales — son los recurrentes:
- El informe de cierre del día que alguien tiene que acordarse de pedir
- La sincronización de datos entre sistemas que se hace a mano cada noche
- El resumen semanal de KPIs que se pospone porque siempre hay cosas urgentes
- La detección de anomalías en facturación que nadie ve hasta que el cliente llama
Todos estos casos tienen algo en común: el valor no está en la respuesta al usuario, está en que ocurra sin que nadie lo pida.
Para eso existen los agentes programados. Y construirlos tiene más matices de lo que parece.
La trampa del cron directo
El error más común al implementar un agente con cron: ejecutar el agente directamente dentro del handler del cron.
// ❌ El patrón que parece correcto pero rompe en producción
export async function GET() {
// Esta función muere si el agente tarda más de 60s en Vercel Hobby
// o más de 5min en Vercel Pro — sin aviso, sin retry
const result = await runFullAgentWorkflow(); // 3-8 minutos reales
return Response.json({ result });
}
El cron de Vercel dispara una petición HTTP. Esa petición tiene los mismos límites de timeout que cualquier función serverless. Si tu agente tarda cuatro minutos en procesar facturas de un mes, el cron muere silenciosamente a los 60 segundos en el plan Hobby — o a los 300 segundos en Pro — sin error visible, sin reintento, sin registro de que algo falló.
El lunes llega el responsable de contabilidad y el informe no está. Nadie sabe por qué. Tú revisas Vercel y ves que la función invocation completion rate bajó al 0%. La mañana que alguien lo nota es la mañana que algo ya se perdió.
La arquitectura correcta: cron → enqueue → worker
El cron no ejecuta el agente. El cron encola el trabajo. El worker lo procesa sin límite de tiempo.
CRON (08:00 UTC)
└─▶ API Route /api/cron/daily-report
└─▶ INSERT job_queue (status: 'pending')
└─▶ Worker externo / Edge Function larga
└─▶ Agente IA (sin límite de tiempo)
└─▶ Resultado en Supabase
Este desacoplamiento garantiza tres cosas:
- El cron siempre termina en menos de 200ms — nunca timeout
- El agente puede tardar lo que necesite
- Si el agente falla, el job queda con
status: 'error'y puedes reintentar
Configuración de cron en Vercel
// vercel.json
{
"crons": [
{
"path": "/api/cron/daily-report",
"schedule": "0 8 * * 1-5"
},
{
"path": "/api/cron/nightly-sync",
"schedule": "0 2 * * *"
}
]
}
El handler del cron — solo encola, nunca ejecuta
// app/api/cron/daily-report/route.ts
import { headers } from 'next/headers';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export const runtime = 'nodejs';
export async function GET() {
// Verifica que la llamada viene de Vercel, no de cualquiera
const authHeader = headers().get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// Evita duplicados si el cron se dispara dos veces
const { data: existing } = await supabase
.from('agent_jobs')
.select('id, status')
.eq('type', 'daily_report')
.eq('run_date', today)
.maybeSingle();
if (existing) {
return Response.json({
message: 'Job already scheduled or completed',
status: existing.status,
});
}
// Encola el trabajo — el handler termina aquí
const { error } = await supabase.from('agent_jobs').insert({
type: 'daily_report',
run_date: today,
status: 'pending',
payload: { date: today, recipients: ['cfo@empresa.com'] },
});
if (error) {
console.error('Failed to enqueue job:', error);
return Response.json({ error: 'Enqueue failed' }, { status: 500 });
}
return Response.json({ message: 'Job enqueued', date: today });
}
Idempotencia: el problema que nadie piensa hasta que ocurre
Los crons no son deterministas. Vercel puede disparar el mismo cron dos veces si hay un problema de red o de reloj. AWS hace lo mismo. Cualquier sistema de tareas distribuido hace lo mismo.
Si tu agente no es idempotente, el doble disparo produce el doble de trabajo: dos emails al CFO, dos PDFs idénticos en el bucket, dos filas en la base de datos con el mismo informe.
La solución es una combinación de clave de idempotencia (tipo + fecha) y lock en base de datos:
// lib/agent-jobs.ts
export async function claimJob(
supabase: ReturnType<typeof createClient>,
jobId: string
): Promise<boolean> {
// UPDATE atómico: solo avanza si está en 'pending'
const { data, error } = await supabase
.from('agent_jobs')
.update({ status: 'running', started_at: new Date().toISOString() })
.eq('id', jobId)
.eq('status', 'pending') // Condición de lock
.select('id')
.maybeSingle();
if (error || !data) {
// Otro worker ya reclamó el job
return false;
}
return true;
}
El worker solo procesa si el UPDATE devuelve una fila. Si dos workers compiten por el mismo job, uno obtiene data y el otro obtiene null. Sin race conditions, sin trabajo duplicado.
El agente worker — sin prisa, sin límite
El worker es una función separada que se ejecuta fuera del ciclo HTTP. Puede ser un Edge Function de larga duración, una función de Inngest, una tarea de QStash o un proceso en Railway. Lo importante es que no tiene los límites del handler del cron.
// lib/workers/daily-report.worker.ts
import { anthropic } from '@anthropic-ai/sdk';
export async function processDailyReport(job: AgentJob) {
const { date, recipients } = job.payload;
// 1. Recupera datos del día desde Supabase
const metrics = await fetchDailyMetrics(date);
// 2. Genera el análisis con el LLM
const analysis = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 2000,
messages: [{
role: 'user',
content: `Analiza estas métricas del día ${date} y genera un resumen ejecutivo en español para el equipo de dirección. Destaca anomalías y tendencias.\n\nMétricas:\n${JSON.stringify(metrics, null, 2)}`,
}],
});
const summary = analysis.content[0].type === 'text'
? analysis.content[0].text
: '';
// 3. Genera PDF y envía emails
await generateAndSendReport({ date, summary, metrics, recipients });
return { summary, metricsCount: metrics.length };
}
Observabilidad: saber que el agente de las 3AM funcionó
Si el agente falla a las 3 de la mañana y nadie lo ve, ¿falló? Para tu CFO, sí — el informe no está.
Necesitas tres cosas:
1. Registro de cada ejecución en base de datos:
create table agent_jobs (
id uuid default gen_random_uuid() primary key,
type text not null,
run_date date not null,
status text default 'pending',
payload jsonb not null default '{}',
result jsonb,
error_msg text,
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz default now(),
unique (type, run_date)
);
2. Alertas en caso de error — un webhook a Slack o un email al equipo técnico cuando status = 'error':
async function notifyJobFailure(job: AgentJob, error: Error) {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `⚠️ Agente cron fallido: *${job.type}* (${job.run_date})\n\`${error.message}\``,
}),
});
}
3. Dashboard mínimo — una query en Supabase que muestra el histórico de ejecuciones. Con esto, un vistazo de 10 segundos cada mañana confirma que todo funcionó.
Cuándo usar Vercel Cron vs servicios externos
Vercel Cron funciona bien para agentes que:
- Encolan trabajo en menos de 5 segundos
- No necesitan más de una ejecución simultánea
- Tienen granularidad de minutos (no segundos)
Para casos más complejos — múltiples intentos, colas con prioridad, workflows de varios pasos — considera Inngest o QStash de Upstash. Ambos se integran bien con el stack de integración IA en Next.js que usamos en DAILYMP.
La diferencia clave: Vercel Cron dispara una petición HTTP y no sabe si el trabajo se completó. Inngest y QStash gestionan el estado, los reintentos y los fallos de forma nativa — son la elección correcta cuando el volumen o la criticidad del proceso lo justifica.
El resultado: procesos que no dependen de que alguien recuerde
Un agente programado correctamente no requiere disciplina del equipo. El informe llega porque el sistema lo genera, no porque alguien lo pidió. La sincronización ocurre porque está en el calendario del servidor, no en la lista de tareas de una persona.
Este es exactamente el tipo de arquitectura que implementamos en los proyectos de automatización con agentes IA en DAILYMP: sistemas que funcionan solos, con observabilidad real y sin depender de que nadie pulse un botón.
Si tienes un proceso recurrente en tu empresa que todavía depende de que alguien lo recuerde, hay una versión automatizada esperando.