Google ADK: consumiendo tu agente desde una app web con Next.js

Google ADK: consumiendo tu agente desde una app web con Next.js

Este es el octavo y último post de la serie sobre Google ADK. Si llegaste directamente acá, los anteriores cubren la introducción al framework, el setup, y el resto del camino hasta el deploy a producción.

Hasta ahora la serie cubrió cómo construir el agente: herramientas, agentes especializados, orquestación, instrucciones, testing y deploy. Pero hay un tema que no tocamos: cómo consumir el agente desde una app real.

El ADK Developer UI (adk web) es genial para explorar y probar, pero no es lo que le mostrás a un usuario. Para producción necesitás integrarlo en tu propio frontend. En este post construimos un cliente Next.js completo para el FinancialAdvisorAI.

La API REST del servidor ADK

Cuando ejecutás npx adk web, el servidor levanta una API REST en http://localhost:8000. No es solo la UI de desarrollo — también expone endpoints que podés consumir desde cualquier cliente HTTP.

Los endpoints relevantes para una app de chat son:

GET  /list-apps
     Lista los agentes disponibles. Devuelve el "app name" que usarás en
     el resto de los endpoints.

POST /apps/:appName/users/:userId/sessions
     Crea una nueva sesión. Devuelve { id, appName, userId, state, events }.

GET  /apps/:appName/users/:userId/sessions
     Lista las sesiones activas de un usuario.

POST /run
     Envía un mensaje y espera a que el agente complete. Devuelve un
     array de Events con toda la trayectoria: tool calls, delegaciones,
     respuesta final.

POST /run_sse
     Igual que /run pero con Server-Sent Events. Soporta streaming de
     tokens cuando streaming: true.

La estructura de un Event

Cada interacción del agente produce una lista de Event. Para una pregunta simple sobre presupuesto, podés ver eventos como estos:

[
  {
    "author": "financial_advisor",
    "content": { "role": "model", "parts": [{ "text": null }] },
    "actions": { "transferToAgent": "budget_savings_agent" }
  },
  {
    "author": "budget_savings_agent",
    "content": { "role": "model", "parts": [{ "text": null }] },
    "actions": { "functionCalls": [{ "name": "analyze_budget", "args": {} }] }
  },
  {
    "author": "budget_savings_agent",
    "content": {
      "role": "model",
      "parts": [{ "text": "Basándome en tu análisis de presupuesto..." }]
    }
  }
]

El último evento con texto de un autor que no sea "user" es la respuesta final. Todo lo anterior son pasos intermedios del proceso multi-agente.

Arquitectura: el patrón BFF

Un error común cuando se integra un agente ADK es exponerlo directamente al browser. Eso genera tres problemas:

  1. CORS: el browser bloquea requests cross-origin a localhost
  2. Seguridad: la URL del servidor ADK (y sus credenciales) quedan expuestas al cliente
  3. Falta de control: no podés validar, transformar ni loguear antes de llegar al agente

La solución es el patrón Backend for Frontend (BFF): las API routes de Next.js actúan como proxy entre el browser y el agente.

sequenceDiagram
    participant B as Browser
    participant N as Next.js API Route
    participant A as ADK Server

    B->>N: POST /api/chat
    N->>N: valida y transforma
    N->>A: POST /run
    A-->>N: respuesta del agente
    N-->>B: { text }

Con esto:

  • El browser solo habla con el mismo origen (localhost:3000)
  • ADK_BACKEND_URL vive en variables de entorno del servidor
  • Podés agregar autenticación, rate limiting y logging en las API routes

Estructura del proyecto

src/webapp/
├── app/
│   ├── api/
│   │   ├── session/route.ts   # Crea sesiones ADK
│   │   └── chat/route.ts      # Proxy hacia ADK /run
│   ├── page.tsx
│   └── layout.tsx
├── components/
│   └── Chat.tsx               # UI completa con manejo de sesión
└── lib/
    └── adk.ts                 # Tipos compartidos + utilidades
# Variables de entorno (src/webapp/.env.local)
ADK_BACKEND_URL=http://localhost:8000

Implementación paso a paso

1. Tipos compartidos y utilidad de extracción

// lib/adk.ts

export interface AdkEvent {
  id?: string;
  author: string; // "user" | nombre del agente
  content?: {
    role: string;
    parts: Array<{ text?: string }>;
  };
  partial?: boolean; // true en eventos de streaming parcial
}

export interface SessionInfo {
  sessionId: string;
  userId: string;
  appName: string;
}

/**
 * Extrae la respuesta final del agente de la lista de eventos.
 *
 * ADK devuelve todos los pasos: tool calls, delegaciones a sub-agentes,
 * respuestas parciales. La respuesta visible al usuario es el último evento
 * con texto de un autor que no sea "user".
 */
export function extractAgentResponse(events: AdkEvent[]): string {
  const agentTextEvents = events.filter(
    e => e.author !== 'user' && e.content?.parts?.some(p => p.text)
  );

  if (agentTextEvents.length === 0) return 'No response received.';

  const last = agentTextEvents[agentTextEvents.length - 1];
  return (last.content?.parts ?? [])
    .filter(p => p.text)
    .map(p => p.text!)
    .join('');
}

extractAgentResponse es la función más importante del cliente: encapsula el conocimiento de que el array de eventos contiene ruido (pasos internos) y extrae únicamente lo que le interesa al usuario.

2. Creación de sesión

Las sesiones en ADK son el mecanismo de memoria conversacional — sin una sesión, el agente no tiene contexto de turno en turno.

// app/api/session/route.ts
import { NextResponse } from 'next/server';

const ADK_URL = process.env.ADK_BACKEND_URL ?? 'http://localhost:8000';

export async function POST(): Promise<NextResponse> {
  try {
    // 1. Descubrir el nombre del agente dinámicamente
    const appsRes = await fetch(`${ADK_URL}/list-apps`);
    if (!appsRes.ok) {
      throw new Error(
        `Servidor ADK no disponible en ${ADK_URL}. ¿Está corriendo "npm run dev"?`
      );
    }

    const apps: string[] = await appsRes.json();
    if (apps.length === 0) throw new Error('No se encontraron agentes.');

    const appName = apps[0];
    const userId = crypto.randomUUID();

    const sessionRes = await fetch(
      `${ADK_URL}/apps/${appName}/users/${userId}/sessions`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({}),
      }
    );

    if (!sessionRes.ok) throw new Error('No se pudo crear la sesión.');

    const session = await sessionRes.json();

    return NextResponse.json({
      sessionId: session.id,
      userId,
      appName,
    });
  } catch (error) {
    const message =
      error instanceof Error ? error.message : 'Error desconocido';
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

Un detalle importante: GET /list-apps devuelve el app name de forma dinámica. No lo hardcodees — el nombre depende de la estructura de directorios del proyecto ADK y puede cambiar.

3. API route de chat (el proxy)

// app/api/chat/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { extractAgentResponse, type AdkEvent } from '@/lib/adk';

const ADK_URL = process.env.ADK_BACKEND_URL ?? 'http://localhost:8000';

export async function POST(req: NextRequest): Promise<NextResponse> {
  try {
    const { message, sessionId, userId, appName } = await req.json();

    if (!message?.trim()) {
      return NextResponse.json({ error: 'Mensaje requerido' }, { status: 400 });
    }
    if (!sessionId || !userId || !appName) {
      return NextResponse.json(
        { error: 'Contexto de sesión inválido.' },
        { status: 400 }
      );
    }

    const res = await fetch(`${ADK_URL}/run`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        appName,
        userId,
        sessionId,
        newMessage: {
          role: 'user',
          parts: [{ text: message.trim() }],
        },
        stateDelta: {},
      }),
    });

    if (!res.ok) {
      const detail = await res.text().catch(() => res.statusText);
      return NextResponse.json({ error: detail }, { status: res.status });
    }

    const events: AdkEvent[] = await res.json();
    const text = extractAgentResponse(events);

    return NextResponse.json({ text });
  } catch (error) {
    const message =
      error instanceof Error ? error.message : 'Error desconocido';
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

4. El componente Chat

El componente cliente maneja tres responsabilidades: sesión, historial de mensajes, y comunicación con las API routes.

// components/Chat.tsx
'use client';

import { useState, useEffect, useRef, FormEvent } from 'react';
import type { SessionInfo } from '@/lib/adk';

interface Message {
  role: 'user' | 'agent';
  text: string;
}

const SESSION_STORAGE_KEY = 'adk-financial-session';

export default function Chat() {
  const [messages, setMessages] = useState<Message[]>([
    {
      role: 'agent',
      text: 'Hola, soy tu asesor financiero. ¿En qué te ayudo?',
    },
  ]);
  const [input, setInput] = useState('');
  const [session, setSession] = useState<SessionInfo | null>(null);
  const [loading, setLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    initSession();
  }, []);
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, loading]);

  async function initSession() {
    const stored = localStorage.getItem(SESSION_STORAGE_KEY);
    if (stored) {
      setSession(JSON.parse(stored));
      return;
    }
    const res = await fetch('/api/session', { method: 'POST' });
    const data = await res.json();
    localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(data));
    setSession(data);
  }

  async function sendMessage(e: FormEvent) {
    e.preventDefault();
    const text = input.trim();
    if (!text || !session || loading) return;

    setInput('');
    setMessages(prev => [...prev, { role: 'user', text }]);
    setLoading(true);

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: text, ...session }),
      });

      const data = await res.json();
      if (!res.ok || data.error)
        throw new Error(data.error ?? 'Error desconocido');

      setMessages(prev => [...prev, { role: 'agent', text: data.text }]);
    } catch (err) {
      setMessages(prev => [
        ...prev,
        {
          role: 'agent',
          text: `Error: ${err instanceof Error ? err.message : 'Intenta de nuevo.'}`,
        },
      ]);
    } finally {
      setLoading(false);
    }
  }
}

Puntos clave del componente:

Persistencia de sesión. La sesión se guarda en localStorage. Al recargar la página, el componente reutiliza la sesión existente en lugar de crear una nueva — el agente mantiene el contexto de la conversación completa.

Manejo de errores inline. Los errores se muestran como mensajes del agente en el chat, no como modales o toasts. Esto es más natural para una interfaz de chat y permite continuar la conversación.

Optimistic UI. El mensaje del usuario se agrega al historial inmediatamente (antes de recibir respuesta), lo que hace la interfaz sentirse más responsiva.

Streaming de respuestas con SSE

La implementación anterior usa POST /run — el cliente espera hasta que el agente termina y recibe todo el texto de una vez. Para conversaciones con el asesor financiero, eso puede ser 5-15 segundos de pantalla en blanco.

La alternativa es usar POST /run_sse con streaming: true, que envía tokens a medida que el modelo los genera.

La API route con streaming

// app/api/chat/route.ts — versión streaming
export async function POST(req: NextRequest) {
  const { message, sessionId, userId, appName } = await req.json();

  const adkRes = await fetch(`${ADK_URL}/run_sse`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      appName,
      userId,
      sessionId,
      newMessage: message,
      streaming: true,
      stateDelta: {},
    }),
  });

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const reader = adkRes.body!.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() ?? '';

        for (const line of lines) {
          if (!line.startsWith('data: ')) continue;
          try {
            const event = JSON.parse(line.slice(6));
            const text = event.content?.parts?.[0]?.text;
            if (text && event.author !== 'user') {
              controller.enqueue(
                encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
              );
            }
          } catch {
            /* ignorar eventos mal formados */
          }
        }
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

El cliente consumiendo el stream

// En Chat.tsx — sendMessage con streaming
async function sendMessage(e: FormEvent) {
  e.preventDefault();
  const text = input.trim();
  if (!text || !session || loading) return;

  setInput('');
  setMessages(prev => [...prev, { role: 'user', text }]);

  // Agregar mensaje vacío del agente que se va llenando
  setMessages(prev => [...prev, { role: 'agent', text: '' }]);
  setLoading(true);

  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: text, ...session }),
  });

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';

    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      try {
        const { text: chunk } = JSON.parse(line.slice(6));
        setMessages(prev => {
          const updated = [...prev];
          const last = updated[updated.length - 1];
          if (last.role === 'agent') {
            updated[updated.length - 1] = { ...last, text: last.text + chunk };
          }
          return updated;
        });
      } catch {
        /* ignorar */
      }
    }
  }

  setLoading(false);
}

El truco del streaming en React: agregás un mensaje vacío al estado antes de empezar a leer, y cada chunk actualiza ese último mensaje con texto acumulado.

Patrones importantes

El BFF como capa de seguridad

// app/api/chat/route.ts
export async function POST(req: NextRequest) {
  // Autenticación
  const session = await getServerSession(req);
  if (!session)
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // Rate limiting básico
  const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
  if (await isRateLimited(ip)) {
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
  }

  // Sanitización de input
  const { message } = await req.json();
  if (message.length > 2000) {
    return NextResponse.json({ error: 'Message too long' }, { status: 400 });
  }

  // ... proxy al agente
}

Logging de eventos completos

Para producción, loguear los eventos completos del agente (no solo el texto final) es valioso para detectar problemas de routing:

const events: AdkEvent[] = await res.json();

console.log(
  JSON.stringify({
    sessionId,
    userId,
    userMessage: message,
    agentsInvoked: [...new Set(events.map(e => e.author))],
    toolsUsed: events
      .flatMap(e => (e as any).actions?.functionCalls ?? [])
      .map((c: any) => c.name),
    finalText: extractAgentResponse(events),
  })
);

Consideraciones de producción

Timeouts. Las respuestas del agente pueden tardar 10-30 segundos con un sistema multi-agente complejo. Configurá el timeout de Next.js para las API routes que hacen llamadas largas:

// app/api/chat/route.ts
export const maxDuration = 60; // segundos (en Vercel Pro/Enterprise)

Estado de sesión persistente. En producción, reemplazá el InMemorySessionService del ADK con un servicio persistente que sobreviva reinicios del servidor:

// src/server.ts (en el proyecto del agente)
import { DatabaseSessionService } from '@google/adk';

const server = new HttpServer({
  agent: rootAgent,
  sessionService: new DatabaseSessionService({
    connectionString: process.env.DATABASE_URL,
  }),
});

La sesión como identidad real. En un sistema real, la sesión ADK refleja la identidad del usuario autenticado:

// Con NextAuth.js u otro provider
const authSession = await getServerSession(authOptions);
const userId = authSession.user.id; // el ID real del usuario

const sessions = await fetch(
  `${ADK_URL}/apps/${appName}/users/${userId}/sessions`
).then(r => r.json());

const sessionId = sessions[0]?.id ?? (await createNewSession(userId));

Esto permite que un usuario retome la conversación desde cualquier dispositivo o browser.

El flujo completo, end-to-end

Inicialización de sesión

Cuando el usuario abre la app, Chat.tsx busca una sesión existente en localStorage. Si no hay, la crea a través de la API:

sequenceDiagram
    actor U as Usuario
    participant C as Chat.tsx
    participant LS as localStorage
    participant API as Next.js API
    participant ADK as ADK Server

    U->>C: Abre localhost:3000
    C->>LS: initSession()
    LS-->>C: vacío
    C->>API: POST /api/session
    API->>ADK: GET /list-apps
    API->>ADK: POST /sessions
    ADK-->>API: { id, userId, appName }
    API-->>C: sessionData
    C->>LS: Guarda sesión

Envío de un mensaje

Con la sesión activa, cada mensaje del usuario pasa por la API Route que lo reenvía al agente ADK:

sequenceDiagram
    actor U as Usuario
    participant C as Chat.tsx
    participant API as Next.js API
    participant ADK as ADK Server

    U->>C: Escribe un mensaje
    C->>C: addMessage(user)
    C->>API: POST /api/chat
    API->>API: Valida request
    API->>ADK: POST /run
    ADK->>ADK: rootAgent → sub-agente → tool
    ADK-->>API: Events[]
    API->>API: extractAgentResponse()
    API-->>C: { text }
    C->>C: addMessage(agent, text)

Resumen

Integrar un agente ADK en una app web es directo una vez que entendés tres cosas:

  1. La API REST de ADK es estándar — sesiones, run, SSE. No hay magia.
  2. El patrón BFF (Next.js como proxy) resuelve CORS y seguridad sin overhead.
  3. Gestión de sesiones es lo que da memoria a la conversación — sin eso, cada mensaje es una interacción sin contexto.

La base que construimos en este post — BFF pattern, session management, event extraction — es la misma independientemente de si tu agente es el FinancialAdvisorAI o cualquier otro sistema construido con Google ADK.


Con esto cerramos la serie. Ocho posts cubriendo el ciclo completo: desde los conceptos hasta un sistema en producción integrado con un frontend real. El código fuente del proyecto tiene todo lo que usamos como ejemplo a lo largo de la serie.

Recursos:

Comments

Share your thoughts and join the discussion


Related Posts

Google ADK: dev experience, testing y debugging

Google ADK: dev experience, testing y debugging

11 min read

Desarrollar sistemas de agentes tiene un ciclo de feedback diferente al desarrollo tradicional. No podés hacer un expect(agent.response).toBe("...") — el output no es determinístico. Pero sí podés testear la capa determinística, observar el comportamiento sistemáticamente, y tener confianza razonable antes de deployar.