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:
- CORS: el browser bloquea requests cross-origin a localhost
- Seguridad: la URL del servidor ADK (y sus credenciales) quedan expuestas al cliente
- 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_URLvive 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:
- La API REST de ADK es estándar — sesiones, run, SSE. No hay magia.
- El patrón BFF (Next.js como proxy) resuelve CORS y seguridad sin overhead.
- 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:
- Código del cliente Next.js — el commit que agrega la app web
- Repositorio completo
- Google ADK Docs
- Next.js Route Handlers

