Tu agente ya sabe de música. Ahora va a controlar Spotify, crear playlists reales y delegar a sub-agentes especializados.
En el artículo anterior construimos un agente DJ desde cero. Cuatro capas: un modelo que habla, herramientas para buscar en una biblioteca local, múltiples tools que el modelo orquesta solo, y memoria para recordar tus gustos entre sesiones.
Todo local. Todo open source. Todo en tu laptop.
Pero hay un problema.
Tu biblioteca local tiene 30 canciones. Spotify tiene más de 100 millones. Tu agente puede recomendar jazz para trabajar, pero no puede reproducir esa canción en tu parlante. Puede armar una playlist en texto, pero no puede crearla en tu cuenta.
Un agente que solo consulta datos locales es útil. Un agente que controla servicios reales es poderoso.
Y aquí es donde se pone interesante: ¿qué pasa cuando un solo agente no es suficiente? ¿Cuando necesitas un especialista en emociones, otro en eventos, y otro en gustos personales? La respuesta es un patrón que suena complejo pero es elegante: agent as a tool.
En este artículo vamos a construir las capas 5 y 6 del DJ:
- Capa 5: Tools que se conectan a una API externa real (Spotify)
- Capa 6: Un agente orquestador que delega a sub-agentes especializados
El flujo completo se ve así: tú le hablas al agente, el agente razona con Bedrock, invoca tools que llaman a Spotify, y la música suena en tu dispositivo.
Lo que necesitas para seguir este artículo
No necesitas haber implementado las capas anteriores. Este artículo es autocontenido, puedes clonar el repo y correr las capas 5 y 6 directamente. Pero sí te recomiendo leer el artículo anterior para entender los conceptos de @tool, agent loop y model-driven que usamos aquí.
git clone https://github.com/hsaenzG/OpenSource-agents-demo.git
cd OpenSource-agents-demo
python3 -m venv .venv
source .venv/bin/activate
pip install 'strands-agents' spotipy python-dotenv
Lo que necesitas:
- Python 3.10+
- Una cuenta de Spotify Developer, para conectar con la API
- AWS CLI configurado con acceso a Amazon Bedrock, porque vamos a usar un modelo en la nube
¿Por qué Bedrock y no Ollama? En el artículo anterior usamos llama3.1:8b corriendo local, y funciona bien con 1-2 tools. Pero las capas 5 y 6 tienen 7-8 herramientas cada una. Para tool-calling confiable con muchas herramientas, necesitas un modelo más capaz. Amazon Bedrock con Nova Pro resuelve eso, y como vimos antes, cambiar de proveedor es cambiar una línea de código gracias a la abstracción del SDK.
Capa 5: El DJ controla Spotify
El concepto: tools que llaman APIs externas
Hasta ahora, nuestros tools eran funciones puras. buscar_canciones() filtra un JSON local. analizar_energia() hace cálculos sobre datos en memoria. No salen de tu proceso de Python.
Pero un @tool puede hacer cualquier cosa que Python pueda hacer. Incluyendo llamar APIs externas.
La mecánica es la misma: decoras una función con @tool, escribes un docstring claro, y el modelo decide cuándo invocarla. La diferencia es que dentro de esa función, en vez de filtrar un JSON, haces un HTTP request a un servicio externo.
Fíjate: para el modelo, no hay diferencia entre un tool local y uno que llama a Spotify. El modelo ve el tool spec (nombre, descripción, parámetros) y decide si lo necesita. No sabe ni le importa si por dentro es un json.load() o un requests.get(). Esa es la elegancia del patrón.
Configurar Spotify Developer
Antes del código, necesitas credenciales. Crea una app en el Spotify Developer Dashboard:
- Click en Create App
- Nombre: lo que quieras (ej: "DJ Agent")
- Redirect URI:
http://127.0.0.1:8000/callback - Marca Web API
- Guarda el Client ID y Client Secret
Crea un archivo .env en la raíz del proyecto:
SPOTIFY_CLIENT_ID=TU-CLIENT-ID
SPOTIFY_CLIENT_SECRET=TU-CLIENT-SECRET
Nota: La primera vez que ejecutes el script, se abrirá el navegador para autorizar la app con tu cuenta de Spotify. Después, el token se cachea automáticamente y no necesitas volver a autorizar.
La conexión con Spotify
Usamos spotipy, una librería de Python que envuelve la Spotify Web API con OAuth2:
from strands import Agent, tool
from strands.models import BedrockModel
import json
import os
from dotenv import load_dotenv
import spotipy
from spotipy.oauth2 import SpotifyOAuth
load_dotenv()
sp = spotipy.Spotify(
auth_manager=SpotifyOAuth(
client_id=os.getenv("SPOTIFY_CLIENT_ID"),
client_secret=os.getenv("SPOTIFY_CLIENT_SECRET"),
redirect_uri="http://127.0.0.1:8000/callback",
scope="playlist-modify-public,playlist-modify-private,user-library-read,user-top-read,user-modify-playback-state,user-read-playback-state",
)
)
usuario = sp.current_user()
print(f"✅ Conectado a Spotify como: {usuario['display_name']}")
El scope define qué permisos tiene tu app. Necesitamos leer tu biblioteca, crear playlists, y controlar la reproducción. Spotify usa OAuth2, el estándar de la industria para autorización delegada.
El primer tool externo: buscar en Spotify
@tool
def buscar_en_spotify(query: str, limite: int = 10) -> str:
"""Busca canciones en Spotify por nombre, artista o género.
SIEMPRE usa esta herramienta cuando el usuario pregunte por canciones o artistas.
Los resultados son datos REALES y actualizados de Spotify.
Args:
query: Texto de búsqueda (ej: "Shakira", "rock alternativo", "Bad Bunny último")
limite: Número máximo de resultados (default: 10)
"""
resultados = sp.search(q=str(query), type="track", limit=min(limite, 10))
tracks = resultados["tracks"]["items"]
if not tracks:
return f"No encontré canciones en Spotify para: {query}"
canciones = []
for t in tracks:
canciones.append({
"titulo": t["name"],
"artista": t["artists"][0]["name"],
"album": t["album"]["name"],
"uri": t["uri"],
"duracion_min": round(t["duration_ms"] / 60000, 1),
})
return json.dumps(canciones, ensure_ascii=False, indent=2)
¿Qué está pasando aquí? La estructura es idéntica a buscar_canciones del artículo anterior. Misma firma: recibe parámetros, devuelve un string JSON. Mismo decorador @tool. Mismo docstring descriptivo.
La diferencia está dentro: en vez de filtrar BIBLIOTECA, llama a sp.search() que hace un HTTP GET a https://api.spotify.com/v1/search. El resultado viene con datos reales: URIs de Spotify, duración exacta, álbum, fecha de lanzamiento.
Y fíjate en el uri. Ese spotify:track:xxx es lo que necesitamos para reproducir o agregar a playlists. Es el identificador único de cada canción en Spotify.
Reproducir música: el agente toma acción real
Aquí es donde el agente deja de ser un "recomendador" y se convierte en un controlador:
@tool
def reproducir_cancion(nombre_cancion: str, artista: str = "") -> str:
"""Reproduce una canción en el dispositivo activo de Spotify del usuario.
Busca la canción por nombre y la reproduce automáticamente.
Requiere que Spotify esté abierto en algún dispositivo (celular, computadora, etc.).
Args:
nombre_cancion: Nombre de la canción a reproducir
artista: Nombre del artista (opcional, ayuda a encontrar la correcta)
"""
dispositivos = sp.devices()
if not dispositivos["devices"]:
return ("No hay dispositivos activos de Spotify. "
"Abre Spotify en tu celular o computadora e intenta de nuevo.")
# Buscar la canción
query = f"track:{nombre_cancion}"
if artista:
query += f" artist:{artista}"
resultados = sp.search(q=query, type="track", limit=5)
tracks = resultados["tracks"]["items"]
if not tracks:
return f"No encontré '{nombre_cancion}' en Spotify."
track = tracks[0]
device_id = next(
(d["id"] for d in dispositivos["devices"] if d["is_active"]),
dispositivos["devices"][0]["id"]
)
sp.start_playback(device_id=device_id, uris=[track["uri"]])
return json.dumps({
"status": "reproduciendo",
"cancion": track["name"],
"artista": track["artists"][0]["name"],
"mensaje": f"▶️ Reproduciendo: {track['name']} — {track['artists'][0]['name']}",
}, ensure_ascii=False)
Esto es un tool que modifica estado en el mundo real. Cuando el modelo lo invoca, tu parlante empieza a sonar. No es un mock, no es una simulación. Es la API de Spotify ejecutando PUT /v1/me/player/play.
Crear playlists reales
@tool
def crear_playlist_en_spotify(nombre: str, descripcion: str, canciones_uris: list) -> str:
"""Crea una playlist en la cuenta de Spotify del usuario con las canciones indicadas.
Args:
nombre: Nombre de la playlist (ej: "Viernes de Rock", "Cena Romántica")
descripcion: Descripción breve de la playlist
canciones_uris: Lista de URIs de Spotify o nombres de canciones
"""
if not canciones_uris:
return "No me diste canciones para agregar a la playlist."
# Resolver URIs — si no es una URI válida, buscar la canción
uris_validas = []
for item in canciones_uris:
item = str(item).strip()
if item.startswith("spotify:track:"):
uris_validas.append(item)
else:
r = sp.search(q=item, type="track", limit=1)
tracks = r["tracks"]["items"]
if tracks:
uris_validas.append(tracks[0]["uri"])
if not uris_validas:
return "No pude encontrar ninguna de las canciones en Spotify."
# Crear la playlist
playlist = sp.user_playlist_create(
user=sp.current_user()["id"],
name=str(nombre),
public=False,
description=str(descripcion)
)
# Agregar canciones (en batches de 100, límite de la API)
for i in range(0, len(uris_validas), 100):
sp.playlist_add_items(playlist["id"], uris_validas[i:i + 100])
return json.dumps({
"status": "ok",
"mensaje": f"Playlist '{nombre}' creada con {len(uris_validas)} canciones",
"url": playlist["external_urls"]["spotify"],
}, ensure_ascii=False)
Fíjate en un detalle importante: el tool acepta tanto URIs (spotify:track:xxx) como nombres de canciones. Si el modelo pasa nombres en vez de URIs, el tool los resuelve buscando en Spotify. Esto hace al tool más robusto, el modelo no necesita recordar URIs exactas entre llamadas.
Conocer al usuario: top artistas y canciones
@tool
def mis_top_artistas(periodo: str = "medium_term") -> str:
"""Obtiene los artistas más escuchados del usuario en Spotify.
Args:
periodo: "short_term" (último mes), "medium_term" (6 meses), "long_term" (siempre)
"""
resultados = sp.current_user_top_artists(limit=10, time_range=periodo)
artistas = []
for a in resultados["items"]:
artistas.append({
"nombre": a["name"],
"generos": a["genres"][:3],
"popularidad": a["popularity"],
})
return json.dumps(artistas, ensure_ascii=False, indent=2)
@tool
def mis_top_canciones(periodo: str = "medium_term") -> str:
"""Obtiene las canciones más escuchadas del usuario en Spotify.
Args:
periodo: "short_term" (último mes), "medium_term" (6 meses), "long_term" (siempre)
"""
resultados = sp.current_user_top_tracks(limit=20, time_range=periodo)
canciones = []
for t in resultados["items"]:
canciones.append({
"titulo": t["name"],
"artista": t["artists"][0]["name"],
"uri": t["uri"],
})
return json.dumps(canciones, ensure_ascii=False, indent=2)
Estos tools le dan al agente algo que la memoria local no puede: datos reales de comportamiento. No es lo que el usuario dice que le gusta, es lo que realmente escucha. Esa diferencia importa cuando armas recomendaciones.
El agente completo de la Capa 5
modelo = BedrockModel(model_id="us.amazon.nova-pro-v1:0", region_name="us-east-1")
dj = Agent(
model=modelo,
system_prompt="""Eres un DJ personal conectado a Spotify. Controlas la música del usuario.
REGLAS:
1. SIEMPRE usa buscar_en_spotify antes de recomendar música.
2. NUNCA inventes canciones, artistas o datos.
3. Para reproducir: usa reproducir_cancion con el nombre.
4. Para crear playlists: usa crear_playlist_en_spotify con las URIs.
5. Basa TODAS tus respuestas en datos reales de las herramientas.
Respondes en español, con onda y buen gusto musical.""",
tools=[
buscar_en_spotify,
crear_playlist_en_spotify,
reproducir_cancion,
mis_top_artistas,
mis_top_canciones,
],
)
# Conversación interactiva
while True:
mensaje = input("🎵 Tú: ").strip()
if mensaje.lower() in ("salir", "exit"):
break
print("\n🎧 DJ: ", end="", flush=True)
dj(mensaje)
print("\n")
Ahora puedes decirle "ponme algo de Daft Punk" y tu parlante empieza a sonar. Puedes decirle "arma una playlist de jazz para cenar" y aparece en tu cuenta de Spotify. Datos reales, acciones reales.
Nota: Necesitas una cuenta premium para tener acceso a la API de Spotify.
El salto conceptual: de tool local a tool externo
Hagamos una pausa para entender qué acaba de pasar.
En la Capa 2 del artículo anterior, un tool era esto:
@tool
def buscar_canciones(genero: str = "") -> str:
"""Busca canciones en la biblioteca local."""
resultados = [c for c in BIBLIOTECA if genero.lower() in c["genero"].lower()]
return json.dumps(resultados)
En la Capa 5, un tool es esto:
@tool
def buscar_en_spotify(query: str) -> str:
"""Busca canciones en Spotify."""
resultados = sp.search(q=query, type="track", limit=10)
return json.dumps(resultados["tracks"]["items"])
Misma interfaz. Misma mecánica. Diferente poder.
Para el agente, ambos son iguales: una función que recibe parámetros y devuelve un string. El modelo no sabe (ni necesita saber) si por dentro hay un filtro de lista o un HTTP request con OAuth2.
Eso significa que puedes conectar tu agente a cualquier API con el mismo patrón:
- Un
@toolque consulta tu base de datos de producción - Un
@toolque envía emails via SendGrid - Un
@toolque crea tickets en Jira - Un
@toolque despliega código en AWS
El patrón es siempre el mismo: función Python + decorador @tool + docstring claro = el modelo decide cuándo usarlo.
Capa 6: Multi-agente: el DJ delega
El problema: un agente que hace demasiado
La Capa 5 funciona. Pero tiene un system prompt largo, 7-8 tools, y tiene que manejar situaciones muy diferentes:
- "Recomiéndame algo de rock" → necesita conocer tus gustos
- "Arma una playlist de 3 horas para una fiesta" → necesita planificar duración y energía
- "Estoy triste, ponme algo" → necesita entender emociones y mapearlas a música
Un solo agente puede hacer todo eso. Pero entre más responsabilidades le das, más largo es el system prompt, más tools tiene que considerar, y más probable es que se confunda.
La solución no es un agente más grande. Es varios agentes especializados.
El concepto: Agent as a Tool
Y aquí viene el patrón más elegante de este artículo.
¿Recuerdas que un @tool puede hacer cualquier cosa que Python pueda hacer? Incluyendo... invocar otro agente.
@tool
def consultar_dj_personal(mensaje: str) -> str:
"""Delega al DJ Personal: experto en gustos musicales y recomendaciones.
Args:
mensaje: El mensaje del usuario para el DJ Personal
"""
respuesta = dj_personal(mensaje)
return str(respuesta)
Eso es todo. Un agente completo, con su propio system prompt, sus propios tools, su propia personalidad, expuesto como un @tool de otro agente.
El agente que tiene estos tools se llama orquestador. No busca canciones, no crea playlists. Su único trabajo es entender qué necesita el usuario y decidir a cuál especialista delegarle.
Los sub-agentes especializados
Cada sub-agente tiene un rol claro y un conjunto de tools específico:
modelo = BedrockModel(model_id="us.amazon.nova-pro-v1:0", region_name="us-east-1")
dj_personal = Agent(
model=modelo,
system_prompt="""Eres un DJ personal experto. Conoces los gustos del usuario.
SIEMPRE usa buscar_en_spotify antes de recomendar. NUNCA inventes datos.
Puedes consultar mis_top_artistas y mis_top_canciones para conocer al usuario.""",
tools=[buscar_en_spotify, crear_playlist_en_spotify, reproducir_cancion,
mis_top_artistas, mis_top_canciones],
callback_handler=None, # Silenciar output
)
dj_eventos = Agent(
model=modelo,
system_prompt="""Eres un DJ profesional de eventos. Armas playlists para fiestas,
bodas, cenas. Verificas que la duración cubra el evento completo.""",
tools=[buscar_en_spotify, crear_playlist_en_spotify, reproducir_cancion,
planificar_evento],
callback_handler=None,
)
dj_emocional = Agent(
model=modelo,
system_prompt="""Eres un DJ empático especializado en emociones y música.
Primero analizas la emoción, luego buscas música que la acompañe.
Eres sensible y no juzgas.""",
tools=[buscar_en_spotify, crear_playlist_en_spotify, reproducir_cancion,
analizar_emocion],
callback_handler=None,
)
Fíjate en callback_handler=None. Eso silencia el output de los sub-agentes, solo el orquestador habla con el usuario. Los sub-agentes trabajan en silencio y devuelven su resultado al orquestador.
Cada sub-agente tiene:
- Un system prompt enfocado en su especialidad
- Solo los tools que necesita (no todos los disponibles)
- Una personalidad diferente (el emocional es empático, el de eventos es profesional)
Los tools del orquestador: agentes como herramientas
@tool
def consultar_dj_personal(mensaje: str) -> str:
"""Delega al DJ Personal: experto en gustos musicales y recomendaciones.
Úsalo cuando el usuario quiera recomendaciones, descubrir música nueva,
o pida algo basado en sus gustos.
Args:
mensaje: El mensaje completo del usuario para el DJ Personal
"""
respuesta = dj_personal(mensaje)
return str(respuesta)
@tool
def consultar_dj_eventos(mensaje: str) -> str:
"""Delega al DJ de Eventos: experto en armar playlists para ocasiones específicas.
Úsalo cuando el usuario mencione un evento, fiesta, boda, cena,
o pida una playlist con duración específica.
Args:
mensaje: El mensaje completo del usuario para el DJ de Eventos
"""
respuesta = dj_eventos(mensaje)
return str(respuesta)
@tool
def consultar_dj_emocional(mensaje: str) -> str:
"""Delega al DJ Emocional: experto en música y estados de ánimo.
Úsalo cuando el usuario exprese cómo se siente o quiera música
para acompañar un estado de ánimo.
Args:
mensaje: El mensaje completo del usuario para el DJ Emocional
"""
respuesta = dj_emocional(mensaje)
return str(respuesta)
El docstring de cada tool-agente es clave. Le dice al orquestador cuándo usar cada uno. "Cuando el usuario exprese cómo se siente" → DJ Emocional. "Cuando mencione un evento" → DJ Eventos. El modelo del orquestador lee estos docstrings y decide a quién delegar.
El orquestador: el punto de entrada
orquestador = Agent(
model=modelo,
system_prompt="""Eres el DJ principal. Tu trabajo es entender qué necesita el usuario
y delegarlo al sub-agente especializado correcto.
Tienes 3 DJs especializados:
1. consultar_dj_personal: Recomendaciones basadas en gustos
2. consultar_dj_eventos: Playlists para eventos con duración específica
3. consultar_dj_emocional: Música para estados de ánimo
REGLAS:
- SIEMPRE delega al sub-agente apropiado.
- Pasa el mensaje COMPLETO del usuario.
- Si no estás seguro, usa consultar_dj_personal como default.
- Presenta la respuesta del sub-agente de forma natural.""",
tools=[consultar_dj_personal, consultar_dj_eventos, consultar_dj_emocional,
reproducir_cancion, reproducir_playlist],
)
El orquestador también tiene reproducir_cancion y reproducir_playlist directamente. Si el usuario dice "ponme Bohemian Rhapsody", no necesita delegar a nadie, puede reproducir directamente.
Cómo funciona en la práctica
🎵 Tú: Estoy triste, ponme algo suave
🎧 DJ: [internamente: invoca consultar_dj_emocional("Estoy triste, ponme algo suave")]
[DJ Emocional: invoca analizar_emocion("triste")]
[DJ Emocional: invoca buscar_en_spotify("indie folk acoustic")]
[DJ Emocional: invoca reproducir_cancion("Skinny Love", "Bon Iver")]
Entiendo. Te puse "Skinny Love" de Bon Iver — indie folk suave,
perfecto para este momento. Si quieres, puedo armar una playlist
completa con ese mood.
El usuario habla con un solo agente. No sabe que detrás hay tres especialistas. No necesita elegir un menú. El orquestador decide, delega, y presenta la respuesta como si fuera suya.
¿Por qué no un solo agente con todos los tools?
Podrías meter todos los tools en un solo agente con un system prompt gigante. Funcionaría... a veces. Pero:
| Aspecto | Un solo agente | Multi-agente |
|---|---|---|
| System prompt | Largo, genérico | Corto, enfocado por especialista |
| Tools por agente | 10+ (confunde al modelo) | 4-5 por especialista |
| Personalidad | Una sola para todo | Diferente por contexto |
| Debugging | Difícil saber qué falló | Sabes exactamente qué agente falló |
| Escalabilidad | Agregar tools degrada calidad | Agregas un nuevo sub-agente |
El patrón multi-agente no es sobre complejidad. Es sobre separación de responsabilidades. El mismo principio que usas en microservicios, aplicado a agentes.
El código completo
El código completo de ambas capas está en el repo:
# Capa 5 — Spotify
python capa5_spotify.py
# Capa 6 — Multi-agente
python capa6_multi_agente.py
Repo: github.com/hsaenzG/OpenSource-agents-demo
Nota: Sin Spotify configurado, las capas 5-6 funcionan con la biblioteca local como fallback. Verás un aviso
⚠️ Spotify no disponiblepero el agente seguirá respondiendo.
Lo que aprendiste
- Un
@toolpuede hacer cualquier cosa que Python pueda hacer, incluyendo llamar APIs externas con OAuth2, crear recursos en servicios reales, y controlar dispositivos - Para el modelo, no hay diferencia entre un tool local y uno externo. La interfaz es la misma: función + decorador + docstring
- El patrón agent as a tool permite crear sistemas multi-agente donde un orquestador delega a especialistas
- Los sub-agentes se silencian con
callback_handler=None, solo el orquestador habla con el usuario - Separar responsabilidades en agentes especializados mejora la calidad de las respuestas y facilita el debugging
Qué sigue
Con 6 capas, tienes un agente que habla, busca, razona, recuerda, controla servicios externos, y delega a especialistas. Todo con Python, Strands Agents y APIs abiertas.
Si quieres ir más allá:
- Documentación de Strands Agents — guías, ejemplos, y API reference
- Multi-agent patterns en Strands — swarms, graphs, y más patrones de orquestación
- Repo del demo — el código completo de las 6 capas
- Spotipy — la librería de Python para Spotify
- Spotify Developer Dashboard — para crear tu app
¿Te resultó útil este artículo? Compártelo con tu equipo o déjame saber en los comentarios qué API te gustaría conectar a tu agente. Y si ya estás construyendo agentes multi-agente o conectando APIs externas, me encantaría escuchar tu experiencia.



Top comments (0)