DEV Community

Cover image for De DJ local a DJ con Spotify: tools externos y multi-agente
Hazel Saenz for AWS Español

Posted on • Originally published at builder.aws.com

De DJ local a DJ con Spotify: tools externos y multi-agente

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.

Integración del Agente DJ con Spotify, flujo de alto nivel

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
Enter fullscreen mode Exit fullscreen mode

Lo que necesitas:

  1. Python 3.10+
  2. Una cuenta de Spotify Developer, para conectar con la API
  3. 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.

De tools locales a APIs externas

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:

  1. Click en Create App
  2. Nombre: lo que quieras (ej: "DJ Agent")
  3. Redirect URI: http://127.0.0.1:8000/callback
  4. Marca Web API
  5. 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
Enter fullscreen mode Exit fullscreen mode

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']}")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

¿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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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 @tool que consulta tu base de datos de producción
  • Un @tool que envía emails via SendGrid
  • Un @tool que crea tickets en Jira
  • Un @tool que 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)
Enter fullscreen mode Exit fullscreen mode

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.

Agent as a Tool Multi-Agente

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,
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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],
)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 disponible pero el agente seguirá respondiendo.

Lo que aprendiste

  1. Un @tool puede hacer cualquier cosa que Python pueda hacer, incluyendo llamar APIs externas con OAuth2, crear recursos en servicios reales, y controlar dispositivos
  2. Para el modelo, no hay diferencia entre un tool local y uno externo. La interfaz es la misma: función + decorador + docstring
  3. El patrón agent as a tool permite crear sistemas multi-agente donde un orquestador delega a especialistas
  4. Los sub-agentes se silencian con callback_handler=None, solo el orquestador habla con el usuario
  5. 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á:

¿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)