September 11, 2026 • 13 min read
AI & Automation Specialist
I design AI-powered communication systems. My work focuses on voice agents, WhatsApp chatbots, AI assistants, and workflow automation built primarily on Twilio, n8n, and modern LLMs like OpenAI and Claude. Over the past 7 years, I've shipped 30+ automation projects handling 250k+ monthly interactions.
If you enjoy the content that I make, you can subscribe and receive insightful information through email. No spam is going to be sent, just updates about interesting posts or specialized content that I talk about.
The first thing my agent did on camera was fail a tool call. It called create_client with arguments my function did not accept, got an error back, loaded the guide of that skill, retried once the way the guide says, created the client with name: null, and went on to answer the customer. I did not touch anything. That sequence is the reason this post exists, because it is the difference between a WhatsApp agent that works in dev and one that survives production, and it was not produced by a better model. It was produced by where the error handling lives.
The system is a customer service agent on WhatsApp: Twilio in front, Claude in the middle, Supabase behind. Five capabilities (identify the client, tickets, knowledge base, payments, escalate to a human), 12 tools, 141 tests that run without credentials. The boilerplate is public at github.com/GonzaGomezDev/claude-whatsapp-chatbot-skills and it is the same shape I put in production for clients. The numbers below come from it.
A tool is a function with a JSON schema. A skill is a folder:
skills/client-management/
├── SKILL.md frontmatter (name, description) + the guide
└── tools.py the functions, decorated with @skill_tool
The frontmatter description is the only part of the skill that goes into the system prompt on every request. The body of SKILL.md (preconditions, order of operations, error handling, what not to do) is loaded on demand through a built-in tool:
@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}
That is progressive disclosure, and it is the actual mechanism behind Agent Skills. The fixed cost per request is five short descriptions, not five full guides.
This is the part of the client-management guide the model read when it failed:
## 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.
One line for bad_arguments. It did exactly that. In my previous systems the equivalent was a chain of try/except in the orchestrator that nobody maintained, because the person who knew why each branch existed had left the project, which in a solo shop means me six months later.
And the tool underneath is deliberately boring. Short description, strict schema, nothing about when to call it:
@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")}
The decorator gives every tool the same things: a timeout, a per-tool rate limit, a circuit breaker and structured logging. find_client hits the database and gets 5 per minute; knowledge_search runs on a GIN index and gets 50 per minute. One global budget for everything treats an expensive query and a cheap one the same, which is how you end up rate limiting the wrong thing.
You will read in many places that packaging five tools into a skill lowers the token overhead. Said like that it is false. Grouping files into folders changes nothing about what goes over the wire.
Two things do lower the cost, and the repo does both:
The first is taking the prose out of the schemas. Tool descriptions are paid on every request. The long guides (when to use each tool, in which order, what to do if it fails) live in SKILL.md and are loaded when needed. The second is deferring the definitions: with defer_loading: true and the server-side tool_search tool, Claude discovers the tools it needs instead of receiving all of them up front.
Because these are claims about tokens, the repo ships a script that measures them with count_tokens on the same message across four configurations: everything inline in the system prompt, the 12 tools with descriptions only, deferred, and the 11 business tools with no skill layer at all. Run it before believing anyone, including this post. With 12 tools the saving from deferring is moderate; it becomes the difference between fitting or not fitting the budget around 40 or 50 tools.
What you get from skills on day one, without discussion, is separation of responsibilities. Each skill has its own domain, its own error handling, its own rate limit budget. When one goes down, the other four keep working.
A real quotation message, for scale: in=4821 · out=180 · cache_read=3902. The cache only hits if the system prompt is split in two, a static block (persona, rules, skill descriptions) that is byte-identical across requests, and a dynamic block (customer name, open tickets) placed after the cache breakpoint. Mix them and the hit rate goes to zero silently. One more thing to check: the minimum cacheable prefix is around 1024 tokens, so with five short descriptions the static block can fall under that and cache nothing. Verify usage.cache_read_input_tokens before assuming the saving is there.
The production backend runs a hand-written loop on the Messages API instead of the SDK's tool runner. The runner generates schemas from function signatures and gives you no place to set defer_loading, no hook for a per-tool timeout, breaker, rate limiter or latency log. All the error handling that makes this architecture interesting lives at exactly that point, so I keep the point.
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)
Three decisions in there. Tools that do not depend on each other run in parallel (searching the knowledge base does not need to wait for the client to be created). All the results go back in a single user message, because splitting them across messages trains the model to stop parallelizing. And the cap is 8 iterations per message. I treat hitting it as a bug in a skill, not as a reason to raise it: if a WhatsApp reply needs more than 8 tool calls, the guide is badly written.
@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 kills the webhook request at 15 seconds and the agent takes around 8. Without answering first and working after, any latency spike leaves the customer without a reply and fires a retry at the same time. And since Twilio retries on any non-2xx, messages.twilio_sid has a unique index; a duplicate MessageSid is dropped before it reaches the model, otherwise a retry creates the ticket twice.
The signature is computed over the exact public URL. If you get 403 on every message, it is almost always PUBLIC_BASE_URL not matching what is configured in the console: http versus https, a trailing slash, or last week's ngrok subdomain.
Before any of this reaches the model there is a router that costs microseconds. A message that is only «gracias», «ok» or «dale» gets a fixed reply; an opt-out gets acknowledged and dropped; an audio gets a fixed «I can only read text for now». Every message that does not reach the agent is a whole request you do not pay and eight seconds the customer does not wait. It is the least interesting optimization in the system and the most effective one.
A new number writes «hola». Not in the database yet. «Me llamo Gonzalo, quiero saber qué productos»: two products, discount from 500 units. A quote for 15 units of product X, with its validity. Then I ask for a discount anyway, no volume, no reason. The agent answers that the only standard discount is by volume, anything outside that grid is approved by the sales team, and gives me a tracking number. On my phone, a push: new client, no name yet (correct, I had not given it), reason commercial_exception, the policy that applied (bulk_pricing.md), a summary and the context for the human.
The escalation guide is where most of the product decisions live. It has a section called «when NOT to escalate»: a knowledge search with no results is a quotation ticket, not an escalation; a tool that failed once is a retry; a question the agent can answer, it answers. And the opposite: the customer asks for a person, escalate immediately, do not try to convince them. The summary has to answer three things a human can read in ten seconds: what the customer wants, what was tried and what happened, what the person has to do now. The reasons are an enum (client_requested, client_lookup_failed, tool_failure, angry_customer, out_of_scope, commercial_exception, no_progress) so the metrics mean something later.
Prices, delivery times and conditions come out of the knowledge base or they do not come out. That base is three markdown documents in Postgres with full-text search and Spanish stemming, not embeddings. «presupuesto» does not find «cotización» and I accepted that: the documents are three, the synonyms are listed in the guide, and the search does two passes (all terms first, any term second) telling the model which one it used so a partial match gets read before it gets quoted. pgvector is for a domain with a real synonym problem, not for a policy file. Documents are truncated at 1500 characters per search and the tool says so, so the model says it may not have the full information instead of claiming it does.
There are two backends behind one Protocol. Production uses the Messages API. For iterating on the guides I use a cli backend that shells out to claude -p and runs on the Claude Code subscription, no API key, with the same tools exposed through an MCP server that walks the same registry. Both backends execute through the same registry.dispatch, so timeout, breaker, rate limiter and logs are identical, and the tests check that both expose exactly the same tools with the same required and enum.
The cli backend costs 1 to 3 seconds of startup per message and it is only for development, for a reason that is not the latency. A WhatsApp message is untrusted input. Claude Code brings around 23 built-in tools (Read, Bash, WebFetch, SendMessage) and --allowedTools is not an exclusive list: with --permission-mode dontAsk it pre-approves, it does not restrict. An agent running with the repo as working directory can read your .env. Three layers close it: an explicit deny of every built-in, a subprocess that runs in an empty temporary directory, and a check on the system event of the stream that logs an error if a tool we did not declare shows up. The surface went from 35 tools to 12, all from the MCP. If you ever see unexpected_tools_available in the logs, stop and look at that before anything else.
The five skills. Yours will be different, your business is not quotes and tickets. What is worth copying is the pattern: the registry as the single execution path, the guide separated from the schema, the error handling declared per skill, the backend behind a Protocol.
And the things this repo does not do, said plainly: no skill versioning (change a SKILL.md mid-conversation and the next call uses the new one; real production wants skill.v1 and a migration), breaker and rate limiter in-process (several replicas means Redis), lexical search only, and the 24 hour WhatsApp window still applies when a human replies a ticket the next day, so the inbox script checks the window and refuses with an explanation instead of letting Twilio return 63016.
Skills are not a rename of tools. They are the decision of where the «what to do when this fails» lives, and I want it next to the function, in a file the model reads when it needs it and anyone on the team can fix without a deploy. That holds for a voice agent and an SMS agent the same as it holds here.
-Gonza
Building something like this on Twilio? I help companies design and run these systems in production. Explore my Twilio consulting and development services.
Find out what your communication setup is costing you.
Get the communication audit