2 de enero de 2026 • 25 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.
Construir experiencias conversacionales inteligentes en WhatsApp pide decisiones de arquitectura cuidadas y una buena integración de tools. Esta guía muestra cómo armar un asistente de WhatsApp con IA sobre Twilio, listo para producción. Procesa mensajes de voz, maneja eventos de calendario, hace búsquedas web y mantiene el contexto de la conversación entre sesiones.
La implementación usa Python, FastAPI, LangGraph y los modelos GPT-4 de OpenAI. Esto no es una exploración teórica. La arquitectura que describo acá sale de una implementación que funciona, disponible en github.com/GonzaGomezDev/whatsapp-ai-assistant-starting-setup, y cada decisión técnica que menciono refleja trade-offs reales.
La arquitectura del sistema incluye estas capacidades:
Este es un sistema de IA agéntico. La distinción importa: los chatbots tradicionales siguen flujos de conversación predefinidos, mientras que los sistemas agénticos razonan qué tools usar según la intención del usuario. Las implicancias de esa elección tocan todo, desde el diseño de la base de datos hasta el manejo de errores.
La WhatsApp Business API de Twilio tiene ventajas técnicas concretas para sistemas conversacionales con IA:
Escala y alcance: WhatsApp tiene una base de 2+ mil millones de usuarios. Tu implementación llega a todo el mundo sin pedirle a nadie que instale otra app ni aprenda otra interfaz.
Multimedia: a diferencia de los sistemas basados en SMS, WhatsApp soporta mensajes de voz, imágenes y documentos. Para un asistente de IA eso importa, porque habilita transcripción de audio, análisis de imágenes y procesamiento de documentos en un solo canal.
Integración con sistemas de negocio: la API de Twilio maneja los mensajes por webhooks, y eso integra limpio con los frameworks web modernos. Conectás CRMs, bases de datos y herramientas de automatización con interfaces HTTP estándar.
Control programable: con acceso completo a la API controlás el ruteo de mensajes, la transformación de contenido y la lógica de integración. No te limitan las restricciones típicas de los constructores de chatbots no-code.
El sistema usa estos componentes centrales:
El sistema sigue un patrón conversacional con estado. Cada usuario, identificado por su número de teléfono, mantiene un thread de conversación independiente, con su propio checkpoint de estado en PostgreSQL. Esa elección tiene implicancias concretas en concurrencia, aislamiento de datos y manejo de memoria.
Antes de meternos en la implementación, asegurate de tener:
git clone https://github.com/GonzaGomezDev/whatsapp-ai-assistant-starting-setup.git
cd whatsapp-ai-assistant-starting-setup/backendpip install -r requirements.txt
Dependencias clave de requirements.txt:
fastapi y uvicorn para el server weblangchain, langgraph y langchain-openai para orquestar la IAlanggraph-checkpoint-postgres para persistir la conversaciónopenai para integrar GPT-4 y Whispertwilio para la API de WhatsApppsycopg2-binary para conectarse a PostgreSQLgoogle-api-python-client para la integración con Calendarlangchain_tavily para la búsqueda websqlalchemy como ORM del historial de mensajesCreá un archivo .env.development en el directorio backend:
# OpenAI Configuration
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx
# Twilio WhatsApp Configuration
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxx
# PostgreSQL Database
DB_USER=postgres
DB_PASSWORD=your_secure_password
DB_HOST=localhost
DB_PORT=5432
DB_NAME=langgraph
DB_DRIVER=postgresql+psycopg2
# Tavily Search API
TAVILY_API_KEY=tvly-xxxxxxxxxxxxx
# Google Calendar OAuth
GOOGLE_CALENDAR_SCOPES=https://www.googleapis.com/auth/calendar.events
GOOGLE_CALENDAR_CREDENTIALS_FILE=./credentials.json
GOOGLE_CALENDAR_TOKEN_FILE=./token.json
GOOGLE_CALENDAR_DEFAULT_CALENDAR_ID=primaryEl asistente usa dos mecanismos de base de datos separados:
Una tabla propia de mensajes para registrar el historial
Crear la base en PostgreSQL
psql -U postgres
CREATE DATABASE langgraph; \qLa implementación en models.py define una tabla Message:
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
_from = Column(String(50), nullable=False)
_to = Column(String(50), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(String(50), nullable=False)
message_type = Column(String(20), nullable=False) # "user" or "ai"
Esta tabla guarda el historial completo de mensajes, aparte del sistema de checkpointing de LangGraph. Esa decisión de diseño te da:
La tabla se crea sola cuando arranca la aplicación, con Base.metadata.create_all() de SQLAlchemy.
El PostgresSaver de LangGraph crea sus propias tablas para el estado:
La implementación en assistant.py inicializa PostgresSaver con lógica de fallback:
# Try URL form first, then DSN
for candidate in (connection_string, dsn_fallback):
try:
cm = PostgresSaver.from_conn_string(candidate)
self.memory = self._exit_stack.enter_context(cm)
break
except Exception as e:
last_err = e
Este enfoque de doble conexión cubre distintos métodos de autenticación de PostgreSQL. El ExitStack se asegura de limpiar bien el context manager de la conexión.
Importante: PostgresSaver.from_conn_string() devuelve un context manager, no una instancia de saver. Si no entrás al contexto, vas a tener errores en runtime. La implementación usa ExitStack para manejar bien ese ciclo de vida.
La integración con el calendario muestra cómo una interfaz de lenguaje natural puede controlar lógica de negocio estructurada. El usuario expresa su intención conversando, y el sistema lo traduce a llamadas concretas a la API.
credentials.json en tu directorio backendLa implementación en tools/calendar.py expone tres funciones como tools de LangChain:
1. Crear un evento
def create_calendar_event(
summary: str,
start: str,
end: str,
description: Optional[str] = None,
attendees: Optional[List[str]] = None,
location: Optional[str] = None,
calendar_id: Optional[str] = None,
) -> dict:
Esta función crea el evento y notifica automáticamente a los invitados. Detalles clave de la implementación:
end sea posterior al de startsendUpdates="all"
2. Traer los eventos
def get_calendar_events(
time_min: str,
time_max: str,
calendar_id: Optional[str] = None
) -> List[dict]:
Trae los eventos dentro de un rango de tiempo. La implementación:
singleEvents=True para expandir los eventos recurrentesstartTime para mostrarlos cronológicamente
3. Borrar un evento
def delete_calendar_event(
start_time: str,
calendar_id: Optional[str] = None
) -> None:
Borra eventos haciendo match por hora de inicio. El enfoque:
Este diseño asume que el usuario no tiene varios eventos con exactamente la misma hora de inicio. En producción puede hacer falta lógica extra de desambiguación para los casos borde.
La función _load_credentials() en calendar.py implementa OAuth con cache del token:
def _load_credentials() -> Credentials:
creds: Optional[Credentials] = None
# Load existing token if present
if TOKEN_FILE and os.path.exists(TOKEN_FILE):
try:
creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
except Exception:
creds = None
# Refresh if expired
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
except Exception as e:
creds = None
# If no valid creds available, start browser-based flow
if not creds:
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES)
try:
creds = flow.run_local_server(port=0)
except Exception:
creds = flow.run_console()
La implementación prueba primero run_local_server() por una UX más cómoda, y cae a run_console() si el server local falla. El token queda guardado en token.json para las corridas siguientes.
El asistente usa LangGraph para implementar un workflow agéntico. Esta sección mira la implementación real para entender cómo maneja el razonamiento en varios pasos y la ejecución de tools.
El estado de la conversación en assistant/state.py usa un TypedDict mínimo:
class State(TypedDict):
messages: Annotated[list, add_messages]
El diseño es deliberadamente mínimo. El estado contiene solo el historial de mensajes, y el reducer add_messages se encarga de acumularlos. Eso significa:
La implementación en assistant.py arma un grafo con dos nodos principales:
self.graph_builder = StateGraph(State)
self.graph_builder.add_node("chat", self.chat)
self.graph_builder.add_node("tools", self.tool_node)
Nodo de chat: procesa los mensajes e invoca al modelo:
def chat(self, state: State):
"""Chat node that processes messages and generates responses."""
return {
"messages": [self.agent.invoke(state["messages"])]
}
El nodo de chat es simple. Toma el historial de mensajes actual, se lo pasa al modelo vinculado y devuelve su respuesta.
Nodo de tools: ejecuta las tools pedidas con BasicToolNode:
class BasicToolNode:
def __call__(self, inputs: dict):
if messages := inputs.get("messages", []):
message = messages[-1]
else:
raise ValueError("No message found in input")
outputs = []
for tool_call in message.tool_calls:
tool_result = self.tools_by_name[tool_call["name"]].invoke(
tool_call["args"]
)
outputs.append(
ToolMessage(
content=json.dumps(tool_result),
name=tool_call["name"],
tool_call_id=tool_call["id"],
)
)
return {"messages": outputs}
El nodo de tools extrae las llamadas del último mensaje de la IA, ejecuta cada una con los argumentos recibidos y devuelve objetos ToolMessage con los resultados.
El ruteo entre nodos usa BasicToolNode.route_tools():
@staticmethod
def route_tools(state: State):
if isinstance(state, list):
ai_message = state[-1]
elif messages := state.get("messages", []):
ai_message = messages[-1]
else:
raise ValueError(f"No messages found in input state")
if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
return "tools"
return END
Esta función revisa si el último mensaje tiene llamadas a tools. Si las tiene, rutea al nodo de tools. Si no, termina la ejecución del grafo.
Las conexiones del grafo:
self.graph_builder.add_conditional_edges(
"chat",
BasicToolNode.route_tools,
{"tools": "tools", END: END},
)
self.graph_builder.add_edge("tools", "chat")
self.graph_builder.add_edge(START, "chat")
Eso arma un loop: START → chatbot → tools (opcional) → summarize → END

El sistema no tiene un máximo de iteraciones hardcodeado. El modelo decide cuándo dejar de pedir tools. En la práctica, GPT-4o-mini rara vez necesita más de 2-3 invocaciones por request, pero en teoría un loop infinito es posible.
Las tools se registran en el modelo con bind_tools de LangChain:
self.tools = [
TavilySearch(max_results=5),
create_calendar_event,
get_calendar_events,
delete_calendar_event,
]
self.agent = init_chat_model(
"gpt-4o-mini",
temperature=0.5,
use_responses_api=True
).bind_tools(self.tools)
El modelo aprende a usar las tools desde las firmas de función, los docstrings y los type hints de los parámetros. La implementación usa temperature=0.5, que mete algo de aleatoriedad controlada sin perder consistencia al elegir tools.
El parámetro use_responses_api=True habilita el parseo de salida estructurada para las llamadas a tools, que es lo que necesita la ejecución para funcionar bien.
La implementación del webhook con FastAPI en main.py recibe los mensajes de WhatsApp y los rutea por el asistente.
@app.post("/message")
async def receive_message(request: Request):
form_data = await request.form()
from_number = form_data.get("From")
to_number = form_data.get("To")
if not from_number or not to_number:
raise HTTPException(
status_code=400,
detail="Missing From or To fields in webhook payload"
)
body: str | None = None
assistant = Assistant()
# Detect audio media attachment and attempt transcription
media_content_type = form_data.get("MediaContentType0")
if media_content_type and media_content_type.startswith("audio/"):
media_url = form_data.get("MediaUrl0")
# ... audio handling code
else:
body = form_data.get("Body") or ""
try:
message = await assistant.generate_response(
prompt=body,
from_number=from_number,
to_number=to_number
)
except Exception as e:
print(f"[receive_message] Error generating response: {e}")
raise HTTPException(status_code=500, detail="Failed to generate response")
return {"status": "Message sent"}
El endpoint extrae los números de teléfono y el contenido del mensaje del payload de Twilio, detecta los mensajes de voz y los procesa según corresponda.
El código que maneja el audio descarga el archivo y lo transcribe:
# Twilio media URLs require basic auth
account_sid = os.getenv("TWILIO_ACCOUNT_SID")
auth_token = os.getenv("TWILIO_AUTH_TOKEN")
try:
resp = requests.get(
media_url,
auth=(account_sid, auth_token),
timeout=30
)
resp.raise_for_status()
audio_bytes = resp.content
# Derive filename extension from content-type
ext = "ogg" if media_content_type == "audio/ogg" else media_content_type.split("/")[-1][:5]
transcript = await assistant.transcribe_audio(audio_bytes, filename=f"voice.{ext}")
body = transcript.strip() or "(Unintelligible audio or empty transcription)"
except Exception as e:
print(f"[receive_message] Audio transcription failed: {e}")
body = "(Error transcribing audio message)"
La implementación usa HTTP Basic Auth para bajar el archivo del storage temporal de Twilio. Los bytes del audio van al método de transcripción del asistente.
La implementación de la transcripción en assistant.py:
async def transcribe_audio(self, audio_bytes: bytes, filename: str = "audio.ogg") -> str:
# Wrap bytes in a file-like object with a name attr
audio_file_obj = io.BytesIO(audio_bytes)
audio_file_obj.name = filename
try:
model = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
transcription = await model.audio.transcriptions.create(
model="whisper-1",
file=audio_file_obj,
response_format="text",
)
return transcription or ""
except Exception as e:
print(f"[transcribe_audio] Failed to transcribe audio: {e}")
return ""
Whisper tiene un límite de 25MB por archivo. La implementación envuelve los bytes en un objeto BytesIO con atributo name, porque la librería de OpenAI necesita objetos tipo archivo con nombre para inferir el formato.
El parámetro response_format="text" devuelve el texto transcripto plano en vez de metadata JSON. Para aplicaciones conversacionales es más simple de manejar.
El método generate_response() en assistant.py orquesta toda la interacción:
async def generate_response(self, prompt: str, from_number: str, to_number: str) -> str:
# Store prompt in DB
db = SessionLocal()
try:
msg_record = Message(
_from=from_number,
_to=to_number,
content=prompt,
created_at=date.today().isoformat(),
message_type="user"
)
db.add(msg_record)
db.commit()
except Exception as e:
print(f"Error saving incoming message to DB: {e}")
finally:
db.close()
# Create messages with system instructions
messages = [
{
"role": "system",
"content": self.assistant_instructions +
f"\n\nCurrent date is {date.today().isoformat()} and default timezone is UTC -3 (ART)."
}
]
# Add current user message
messages.append({"role": "user", "content": prompt})
# Use phone number as thread ID for persistent memory
config = {"configurable": {"thread_id": from_number}}
final_response = ""
for step in self.graph.stream({"messages": messages}, config, stream_mode="messages"):
# Process streaming responses
if isinstance(step, tuple) and len(step) == 2:
message_chunk, metadata = step
if (hasattr(message_chunk, 'content') and message_chunk.content):
content = message_chunk.content
# Handle both string and list content
if isinstance(content, list):
text_content = ""
for item in content:
if isinstance(item, dict) and 'text' in item:
text_content += item['text']
elif isinstance(item, str):
text_content += item
elif hasattr(item, 'text'):
text_content += item.text
content = text_content
# Filter out JSON responses and empty content
if content and not str(content).startswith('{'):
final_response += content
Las decisiones de arquitectura clave:
from_number como identificador del thread, así cada usuario tiene su propio thread persistente en el sistema de checkpoints de LangGraph.prompts/_evo_001 y le suma la fecha y la timezone actuales. El system prompt se carga una sola vez al inicializar, no en cada request.stream_mode="messages", que va devolviendo los chunks del mensaje a medida que se generan. La Responses API puede devolver el contenido como lista de bloques, así que la implementación maneja strings y listas.
Una vez armada la respuesta completa, la implementación la manda por Twilio y la guarda en la base de historial.
Para recibir mensajes de usuarios de WhatsApp, configurá Twilio para que mande los webhooks a tu aplicación.
Como Twilio necesita una URL pública para mandar los webhooks, usá ngrok para exponer tu server local:
ngrok http 8000
ngrok te da una URL pública tipo https://abc123.ngrok.io
En la Twilio Console:
https://your-ngrok-url.ngrok.io/messageHTTP POST
Ahora, cuando alguien le mande un mensaje a tu número de WhatsApp de Twilio, va a llegar a tu aplicación FastAPI.
Para producción:
cd backend
uvicorn main:app --reload --host 0.0.0.0 --port 8000
Tenés que ver:
INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Application startup complete.1. Conversación simple
You: Hello!
Assistant: [Responds with greeting and capabilities]
2. Búsqueda web
You: What's the latest AI news?
Assistant: [Searches via Tavily and provides current information]
3. Manejo del calendario
You: Schedule a meeting tomorrow at 2 PM with john@example.com
Assistant: [Creates calendar event and confirms]
4. Mensaje de voz
You: [Sends voice message]
Assistant: [Transcribes and responds to content]
5. Retención de contexto
You: Schedule a meeting with Sarah tomorrow at 3 PM
Assistant: [Creates event]
You: Actually, make that 4 PM
Assistant: [Understands "that" refers to Sarah's meeting and updates]
La retención de contexto muestra el checkpointing de LangGraph manteniendo el estado entre turnos.
La implementación tiene varias fuentes de latencia:
El tiempo total de respuesta suele ir de 2-8 segundos, según si se invocan tools o no.
La implementación usa el connection pooling de SQLAlchemy para la base de historial:
engine = create_engine(
url,
connect_args={"options": "-c client_encoding=UTF8"},
pool_pre_ping=True
)
El parámetro pool_pre_ping=True valida las conexiones antes de usarlas, y así evita errores por conexiones viejas.
Para el sistema de checkpointing de LangGraph, mirá:
thread_id para recuperar checkpoints más rápidoDesglose de costo por turno de conversación:
La implementación usa temperature=0.5, que da respuestas consistentes sin disparar el uso de tokens. Para bajar más el costo, mirá:
La implementación actual crea una instancia nueva de Assistant por cada request del webhook:
assistant = Assistant()
Es seguro para requests concurrentes porque cada instancia maneja sus propias conexiones con ExitStack. Igual, el enfoque tiene trade-offs:
Para más tráfico, mirá:
Extendé el asistente sumando tools nuevas. Ejemplo de una tool de clima:
def get_weather(location: str) -> dict:
"""
Get current weather for a location.
Args:
location: City name or ZIP code
Returns:
Dictionary with weather information
"""
# Implement weather API call
return {
"location": location,
"temperature": 72,
"condition": "Sunny"
}
Sumala a la lista de tools en assistant.py:
self.tools = [
TavilySearch(max_results=5),
create_calendar_event,
get_calendar_events,
delete_calendar_event,
get_weather, # New tool
]El modelo aprende solo a usar las tools nuevas a partir de sus docstrings y type hints. Asegurate de que los docstrings expliquen claro cuándo usar cada tool y qué parámetros necesita.
El system prompt se carga desde prompts/_evo_001. Editá ese archivo para:
La implementación le suma la fecha y la timezone actuales al prompt:
content = self.assistant_instructions +
f"\n\nCurrent date is {date.today().isoformat()} and default timezone is UTC -3 (ART)."
Ajustá la timezone según tus usuarios.
Problema: AttributeError: '_GeneratorContextManager' object has no attribute 'get_next_version'
Solución: pasa cuando le pasás el context manager directo en vez de entrar en él. La implementación usa bien ExitStack.enter_context():
cm = PostgresSaver.from_conn_string(candidate) self.memory = self._exit_stack.enter_context(cm)Problema: psycopg2.OperationalError: could not connect to server
Solución: verificá que PostgreSQL esté corriendo y que las credenciales sean correctas. La implementación prueba los formatos URL y DSN:
connection_string = f"postgresql://{_db_user}:{_db_pass}@{_db_host}:{_db_port}/{_db_name}" dsn_fallback = f"host={_db_host} port={_db_port} dbname={_db_name} user={_db_user} password={_db_pass}"Problema: las operaciones de calendario fallan con errores de autenticación
Solución: borrá token.json y volvé a autenticarte. Verificá que la Calendar API esté habilitada en Google Cloud Console. La implementación cae a OAuth por consola si falla el flujo con server local.
Problema: Twilio muestra errores de timeout del webhook
Solución: los webhooks de Twilio tienen un timeout de 10 segundos. La implementación hace streaming de la respuesta pero no procesa en async. Para operaciones largas, mirá:
@app.post("/message")
async def receive_message(request: Request):
# Send immediate acknowledgment
threading.Thread(
target=process_message_async,
args=(from_number, to_number, body)
).start()
return {"status": "Processing"}Problema: la transcripción falla o devuelve texto vacío
Solución: revisá que:
filename tenga la extensión correctaexcept Exception as e:
print(f"[transcribe_audio] Failed to transcribe audio: {e}")
return ""Desplegá este asistente como primera línea de soporte al cliente:
Convertí el sistema en un asistente personal:
Adaptalo a flujos de negocio:
Esta implementación muestra un asistente de WhatsApp con IA capaz de ir a producción. Está construido sobre GPT-4 de OpenAI, los workflows agénticos de LangGraph y la infraestructura de mensajería de Twilio. La arquitectura resuelve varios requerimientos técnicos:
Las decisiones de arquitectura clave tienen impactos medibles. El checkpointing de LangGraph cambia almacenamiento por confiabilidad. GPT-4o-mini equilibra costo y capacidad. Manejar los mensajes por webhook mete restricciones de latencia que afectan la experiencia.
Entender esos trade-offs te deja decidir con información cuando adaptes esta arquitectura a tus requerimientos. La implementación es una base que podés extender con más tools, otros modelos, o integrar en sistemas más grandes.
Para extender este asistente:
Descubre cuánto te está costando tu sistema de comunicaciones.
Obtén la auditoría de comunicaciones