11 de septiembre de 2026 • 14 min de lectura
Especialista en IA y Automatización
Diseño sistemas de comunicación impulsados por IA. Mi trabajo se centra en agentes de voz, chatbots de WhatsApp, asistentes de IA y automatización de flujos, construidos principalmente sobre Twilio, n8n y LLM modernos como OpenAI y Claude. En los últimos 7 años he entregado más de 30 proyectos de automatización que manejan más de 250 000 interacciones mensuales.
Si te gusta el contenido que hago, puedes suscribirte y recibir información valiosa por correo. No se envía spam, solo novedades sobre publicaciones interesantes o contenido especializado del que hablo.
Lo primero que hizo mi agente frente a la cámara fue fallar una tool call. Llamó a create_client con argumentos que mi función no aceptaba y recibió el error. Cargó la guía de esa skill, reintentó una sola vez como dice la guía, creó el cliente con name: null y siguió atendiendo al cliente. Yo no toqué nada. Esa secuencia es la razón de este post. Es la diferencia entre un agente de WhatsApp que anda en desarrollo y uno que sobrevive a producción, y no la produjo un modelo mejor. La produjo el lugar donde vive el manejo de errores. En este sistema, ese lugar son las Claude Agent Skills.
El sistema es un agente de atención al cliente en WhatsApp: Twilio adelante, Claude en el medio, Supabase atrás. Cinco capacidades (identificar al cliente, tickets, base de conocimiento, pagos, escalar a un humano), 12 tools, 141 tests que corren sin credenciales. El boilerplate es público en github.com/GonzaGomezDev/claude-whatsapp-chatbot-skills y es la misma forma que pongo en producción para clientes. Los números que siguen salen de ahí.
Una tool es una función con un JSON schema. Una skill es una carpeta:
skills/client-management/
├── SKILL.md frontmatter (name, description) + the guide
└── tools.py the functions, decorated with @skill_tool
El description del frontmatter es la única parte de la skill que entra al system prompt en cada request. El cuerpo de SKILL.md (precondiciones, orden de operaciones, manejo de errores, qué no hacer) se carga bajo demanda con una tool built-in:
@skill_tool(
name="load_skill_guide",
description=(
"Leer la guía completa de una skill: precondiciones, orden de operaciones y "
"manejo de errores. Usala ANTES de encadenar varias tools de una skill que no "
"usaste todavía en esta conversación, o cuando una tool devuelva un error que "
"no sepas resolver."
),
input_schema={"type": "object", "properties": {"skill": {"type": "string"}}, "required": ["skill"]},
timeout_s=1.0,
skill=BUILTIN_SKILL,
)
async def load_skill_guide(ctx: SkillContext, skill: str) -> dict[str, Any]:
doc = _SKILL_DOCS.get(skill)
if doc is None:
return {"found": False, "error": f"No existe la skill {skill!r}.", "available": sorted(_SKILL_DOCS)}
return {"found": True, "skill": doc.name, "guide": doc.body}
Eso es progressive disclosure, y es el mecanismo real detrás de las Agent Skills. El costo fijo por request son cinco descripciones cortas, no cinco guías completas.
Esta es la parte de la guía de client-management que leyó el modelo cuando falló:
## Error handling
- `error_type: rate_limited`: `find_client` está limitada a 5 por minuto porque
pega a la base. Si la agotaste, seguí con lo que ya sabés y no reintentes en loop.
- `error_type: timeout` o `circuit_open`: la base no responde. **No inventes un
`client_id`.** Escalá con `escalate_to_human` usando `reason: "client_lookup_failed"`.
- `error_type: bad_arguments`: mandaste mal los argumentos. Corregí y reintentá
una sola vez.
Una línea para bad_arguments. Hizo exactamente eso. En mis sistemas anteriores el equivalente era una cadena de try/except en el orquestador que nadie mantenía. El que sabía por qué existía cada rama se había ido del proyecto, y en un taller de una persona eso significa yo seis meses después.
Y la tool que está abajo es deliberadamente aburrida. Descripción corta, schema estricto, nada sobre cuándo llamarla:
@skill_tool(
name="create_client",
description=(
"Crear un cliente nuevo. Hace upsert sobre el teléfono: es seguro llamarla "
"aunque no estés seguro de si el cliente ya existía."
),
input_schema={
"type": "object",
"properties": {
"phone": {"type": "string", "description": "Teléfono en E.164."},
"name": {"type": ["string", "null"], "description": "Nombre, si el cliente lo dijo. null si todavía no lo sabés."},
"company": {"type": ["string", "null"], "description": "Empresa, sólo si el cliente la mencionó explícitamente."},
},
"required": ["phone", "name", "company"],
"additionalProperties": False,
},
timeout_s=3.0,
rate_limit="10/minute",
)
async def create_client(ctx: SkillContext, phone: str, name: str | None, company: str | None) -> dict[str, Any]:
row = await ctx.db.create_client_row(phone=phone, name=name, company=company)
ctx.client_id = row["id"]
return {"client_id": row["id"], "created": True, "name": row.get("name"), "company": row.get("company")}
El decorator le da a cada tool las mismas cosas: un timeout, un rate limit propio, un circuit breaker y logging estructurado. find_client pega a la base y tiene 5 por minuto. knowledge_search corre sobre un índice GIN y tiene 50 por minuto. Un presupuesto global único trata igual a una query cara y a una barata, y así terminás limitando lo que no había que limitar.
Vas a leer en muchos lados que empaquetar cinco tools en una skill baja el overhead de tokens. Dicho así es falso. Agrupar archivos en carpetas no cambia nada de lo que viaja por la red.
Dos cosas sí bajan el costo, y el repo hace las dos:
La primera es sacar la prosa de los schemas. Las descripciones de las tools se pagan en cada request. Las guías largas (cuándo usar cada tool, en qué orden, qué hacer si falla) viven en SKILL.md y se cargan cuando hacen falta. La segunda es diferir las definiciones: con defer_loading: true y la tool tool_search del lado del servidor, Claude descubre las tools que necesita en vez de recibirlas todas de entrada.
Como son afirmaciones sobre tokens, el repo trae un script que las mide con count_tokens sobre el mismo mensaje en cuatro configuraciones: todo inline en el system prompt, las 12 tools solo con descripciones, diferidas, y las 11 tools de negocio sin capa de skills. Correlo antes de creerle a nadie, este post incluido. Con 12 tools el ahorro de diferir es moderado. Pasa a ser la diferencia entre entrar o no entrar en el presupuesto cerca de las 40 o 50 tools.
Lo que sí te dan las skills desde el día uno, sin discusión, es separación de responsabilidades. Cada skill tiene su dominio, su manejo de errores, su presupuesto de rate limit. Cuando una se cae, las otras cuatro siguen funcionando.
Un mensaje real de cotización, para tener escala: in=4821 · out=180 · cache_read=3902. El cache solo pega si el system prompt está partido en dos. Un bloque estático (persona, reglas, descripciones de skills) idéntico byte a byte entre requests, y un bloque dinámico (nombre del cliente, tickets abiertos) puesto después del cache breakpoint. Si los mezclás, el hit rate se va a cero en silencio. Algo más para chequear: el prefijo mínimo cacheable ronda los 1024 tokens, así que con cinco descripciones cortas el bloque estático puede quedar por debajo y no cachear nada. Verificá usage.cache_read_input_tokens antes de dar el ahorro por hecho.
El backend de producción corre un loop escrito a mano sobre la Messages API en vez del tool runner del SDK. El runner genera los schemas desde las firmas de las funciones. No te deja lugar para poner defer_loading, ni un hook para timeout por tool, breaker, rate limiter o log de latencia. Todo el manejo de errores que hace interesante a esta arquitectura vive justo en ese punto, así que me quedo con el punto.
for iteration in range(1, self.max_iterations + 1):
response = await self._create(system=system, tools=tools, messages=messages)
_accumulate(usage, response)
if response.stop_reason == "refusal":
return AgentResult(reply_text=FALLBACK_REPLY, tool_calls=calls, usage=usage,
escalated=True, stop_reason="refusal", iterations=iteration)
tool_uses = [b for b in response.content if b.type == "tool_use"]
if not tool_uses:
break
messages.append({"role": "assistant", "content": response.content})
results, iteration_calls, iteration_escalated = await self._execute_parallel(tool_uses, ctx)
calls.extend(iteration_calls)
escalated = escalated or iteration_escalated
# ALL tool_results in ONE user message. Splitting them teaches the model to stop parallelizing.
messages.append({"role": "user", "content": results})
else:
log.warning("max_iterations_reached", limit=self.max_iterations)
Tres decisiones ahí adentro. Las tools que no dependen entre sí corren en paralelo (buscar en la base de conocimiento no necesita esperar a que se cree el cliente). Todos los resultados vuelven en un solo mensaje de usuario, porque partirlos en varios mensajes le enseña al modelo a dejar de paralelizar. Y el tope son 8 iteraciones por mensaje. Si lo toca, lo trato como un bug en una skill, no como una razón para subirlo. Si una respuesta de WhatsApp necesita más de 8 tool calls, la guía está mal escrita.
@router.post("/webhook/whatsapp")
async def whatsapp_webhook(request: Request, background: BackgroundTasks) -> Response:
form = dict(await request.form())
if app_state.settings.twilio_validate_signature:
signature = request.headers.get("X-Twilio-Signature", "")
if not app_state.whatsapp.validate_signature(app_state.settings.webhook_url, form, signature):
return Response(status_code=403, content="invalid signature")
...
# Twilio retries on any non-2xx. 200 first, work after.
background.add_task(_process, request.app, phone, body, sid, num_media)
return Response(content=EMPTY_TWIML, media_type="application/xml")
Twilio mata el request del webhook a los 15 segundos y el agente tarda unos 8. Si no contestás primero y trabajás después, cualquier pico de latencia deja al cliente sin respuesta y dispara un retry al mismo tiempo. Y como Twilio reintenta ante cualquier respuesta que no sea 2xx, messages.twilio_sid tiene un índice único. Un MessageSid duplicado se descarta antes de llegar al modelo, porque si no un retry crea el ticket dos veces.
La firma se calcula sobre la URL pública exacta. Si te da 403 en cada mensaje, casi siempre es PUBLIC_BASE_URL que no coincide con lo configurado en la consola: http contra https, una barra de más al final, o el subdominio de ngrok de la semana pasada.
Antes de que nada de esto llegue al modelo hay un router que cuesta microsegundos. Un mensaje que es solo "gracias", "ok" o "dale" recibe una respuesta fija. Un opt-out se acusa y se descarta. Un audio recibe un fijo "por ahora solo puedo leer texto". Cada mensaje que no llega al agente es un request entero que no pagás y ocho segundos que el cliente no espera. Es la optimización menos interesante del sistema y la más efectiva.
Un número nuevo escribe "hola". Todavía no está en la base. "Me llamo Gonzalo, quiero saber qué productos": dos productos, descuento a partir de 500 unidades. Una cotización por 15 unidades del producto X, con su validez. Después pido descuento igual, sin volumen y sin razón. El agente responde que el único descuento estándar es por volumen, y que todo lo que se sale de esa grilla lo aprueba el equipo comercial. Y me da un número de seguimiento. En mi celular, un push: cliente nuevo, todavía sin nombre (correcto, yo no lo había dado), razón commercial_exception, la política que aplicó (bulk_pricing.md), un resumen y el contexto para el humano.
La guía de escalación es donde vive la mayoría de las decisiones de producto. Tiene una sección que se llama "cuándo NO escalar": una búsqueda en la base sin resultados es un ticket de cotización, no una escalación. Una tool que falló una vez es un reintento. Una pregunta que el agente puede responder, la responde. Y al revés: si el cliente pide una persona, escalá ya, no intentes convencerlo. El resumen tiene que responder tres cosas que un humano lee en diez segundos: qué quiere el cliente, qué se intentó y qué pasó, qué tiene que hacer ahora esa persona. Las razones son un enum (client_requested, client_lookup_failed, tool_failure, angry_customer, out_of_scope, commercial_exception, no_progress) para que después las métricas signifiquen algo.
Los precios, los plazos de entrega y las condiciones salen de la base de conocimiento o no salen. Esa base son tres documentos markdown en Postgres con full-text search y stemming en español, no embeddings. "presupuesto" no encuentra "cotización" y lo acepté. Los documentos son tres y los sinónimos están listados en la guía. La búsqueda hace dos pasadas: primero todos los términos, después cualquiera. Le dice al modelo cuál usó, así una coincidencia parcial se lee antes de que se cotice. pgvector es para un dominio con un problema real de sinónimos, no para un archivo de políticas. Los documentos se truncan a 1500 caracteres por búsqueda y la tool lo avisa. Así el modelo dice que puede no tener la información completa, en vez de afirmar que la tiene.
Hay dos backends detrás de un mismo Protocol. Producción usa la Messages API. Para iterar sobre las guías uso un backend cli que invoca claude -p. Corre sobre la suscripción de Claude Code, sin API key, con las mismas tools expuestas por un servidor MCP que recorre el mismo registry. Los dos backends ejecutan por el mismo registry.dispatch, así que timeout, breaker, rate limiter y logs son idénticos. Y los tests chequean que los dos expongan exactamente las mismas tools con el mismo required y el mismo enum.
El backend cli cuesta de 1 a 3 segundos de arranque por mensaje y es solo para desarrollo, por una razón que no es la latencia. Un mensaje de WhatsApp es input no confiable. Claude Code trae unas 23 tools built-in (Read, Bash, WebFetch, SendMessage) y --allowedTools no es una lista excluyente: con --permission-mode dontAsk preaprueba, no restringe. Un agente corriendo con el repo como working directory puede leer tu .env. Tres capas lo cierran: un deny explícito de cada built-in, un subproceso que corre en un directorio temporal vacío, y un chequeo sobre el evento system del stream. Ese chequeo loguea un error si aparece una tool que no declaramos. La superficie pasó de 35 tools a 12, todas del MCP. Si alguna vez ves unexpected_tools_available en los logs, pará y mirá eso antes que nada.
Las cinco skills. Las tuyas van a ser otras, tu negocio no es cotizaciones y tickets. Lo que sí conviene copiar es el patrón: el registry como único camino de ejecución, la guía separada del schema, el manejo de errores declarado por skill, el backend detrás de un Protocol.
Y lo que este repo no hace, dicho de frente: no hay versionado de skills. Cambiás un SKILL.md a mitad de una conversación y la llamada siguiente usa el nuevo, cuando producción de verdad quiere skill.v1 y una migración. El breaker y el rate limiter son in-process, así que varias réplicas significa Redis. La búsqueda es solo léxica. Y la ventana de 24 horas de WhatsApp sigue aplicando cuando un humano contesta un ticket al día siguiente. Por eso el script del inbox chequea la ventana y se niega con una explicación, en vez de dejar que Twilio devuelva 63016.
Las skills no son un cambio de nombre de las tools. Son la decisión de dónde vive el "qué hacer cuando esto falla", y yo lo quiero al lado de la función. En un archivo que el modelo lee cuando lo necesita y que cualquiera del equipo puede arreglar sin un despliegue. Eso vale para un agente de voz y para un agente de SMS igual que vale acá.
-Gonza
¿Estás construyendo algo así sobre Twilio? Ayudo a empresas a diseñar y operar estos sistemas en producción. Conoce mi servicio de consultoría y desarrollo Twilio.
Descubre cuánto te está costando tu sistema de comunicaciones.
Obtén la auditoría de comunicaciones