Lectura: unos 20 minutos
Aquí se monta un agente de verdad, desde una carpeta vacía hasta verlo razonar en tu pantalla. No hace falta saber programar. Hace falta saber copiar, pegar y leer lo que sale, que es una habilidad distinta y la única que se necesita.
Los trece pasos
- Lo que hace falta tener (15 minutos, una vez)
- La carpeta y la clave
- Hablar con el modelo una vez, sin agente
- Los datos: un CSV que ya tienes
- Las dos herramientas
- Describírselas al modelo
- El bucle: treinta líneas
- Ejecutarlo y leer lo que hace
- Romperlo a propósito cuatro veces
- Los cinco errores de la terminal
- Qué le puedes preguntar y qué no
- Que corra solo cada mañana
- Lo que tienes y lo que te falta
1. Lo que hace falta tener
Tres cosas, y las tres son gratis. Esto se hace una vez en la vida.
| Qué | Para qué | Cuánto cuesta |
|---|---|---|
| Node.js (versión 20 o más) | Ejecutar el programa en tu ordenador | Gratis. nodejs.org, botón grande, siguiente-siguiente |
| Un editor de texto | Escribir los ficheros. Visual Studio Code va bien | Gratis |
| Una clave de Google AI Studio | Hablar con el modelo | Gratis con límite diario. Lo viste en la lección de AI Studio |
Para comprobar que Node está puesto, abre la terminal —en Windows, «Símbolo del sistema»; en Mac, «Terminal»— y escribe:
$ node --version
v22.11.0
Si sale un número, ya está. Si sale «no se reconoce el comando», es que no está instalado:
vuelve a nodejs.org.
2. La carpeta y la clave
Crea una carpeta —llámala agente— y dentro, tres ficheros vacíos:
agente.js, .env y stock.csv. Desde la terminal,
colócate en ella:
$ cd agente
$ npm init -y
Eso crea un package.json. Ábrelo y añádele una línea, "type":
"module", para poder usar la forma moderna de escribir JavaScript:
{
"name": "agente",
"version": "1.0.0",
"type": "module",
"main": "agente.js"
}
Ahora la clave. Va en el fichero .env, sola, en una línea:
GEMINI_API_KEY=AIza...tu_clave_aquiagente.js. No es una manía: el
día que subas esa carpeta a GitHub —y la vas a subir— la clave queda publicada, y hay
robots que rastrean GitHub buscando exactamente eso. Una clave filtrada se gasta en horas
y la factura es tuya. Si trabajas con Git, añade también un fichero
.gitignore con la línea .env dentro.
3. Hablar con el modelo una vez, sin agente
Antes de montar ningún bucle hay que comprobar que la clave funciona. Este programa hace una pregunta y escribe la respuesta. Nada más.
// Lee el fichero .env sin instalar nada: Node 20+ lo trae de serie.
import { readFileSync } from 'node:fs';
const env = readFileSync('.env', 'utf8');
const CLAVE = env.split('=')[1].trim();
const MODELO = 'gemini-2.5-flash-lite';
const URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODELO}:generateContent`;
const res = await fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': CLAVE },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: 'Di "funciona" y nada mas.' }] }],
}),
});
const datos = await res.json();
console.log(JSON.stringify(datos, null, 2));Guárdalo y ejecútalo:
$ node agente.js
Si todo va bien sale un bloque largo con la respuesta dentro. Lo que importa es que
aparezca "text": "funciona" en algún sitio. Si en vez de eso sale un
error con API key not valid, la clave está mal copiada.
4. Los datos: un CSV que ya tienes
Tu programa de gestión exporta el stock a CSV. Para la prueba vale con inventárselo — el
agente no nota la diferencia. Pon esto en stock.csv:
codigo;nombre;stock;minimo;venta_mes
653321;IBUPROFENO 600MG 40 COMP;12;30;96
712004;PARACETAMOL 1G 40 COMP;58;40;120
889012;OMEPRAZOL 20MG 28 CAPS;4;25;71
451188;AMOXICILINA 500MG 24 CAPS;31;20;28
990341;ENANTYUM 25MG 20 COMP;0;15;445. Las dos herramientas
Una herramienta es una función normal. Estas dos no tienen nada de especial: leen el CSV y devuelven datos.
function leerStock() {
const lineas = readFileSync('stock.csv', 'utf8').trim().split('\n');
return lineas.slice(1).map((l) => {
const [codigo, nombre, stock, minimo, venta_mes] = l.split(';');
return { codigo, nombre, stock: +stock, minimo: +minimo, venta_mes: +venta_mes };
});
}
// Herramienta 1 — buscar un producto por nombre
function buscar_producto({ texto }) {
const t = String(texto || '').toLowerCase();
const hit = leerStock().filter((p) => p.nombre.toLowerCase().includes(t));
if (!hit.length) {
// ⚠️ NO lanzamos un error: lo devolvemos como dato.
return { encontrado: false, motivo: `No hay ningun producto que contenga "${texto}".` };
}
return { encontrado: true, productos: hit.map((p) => ({ codigo: p.codigo, nombre: p.nombre })) };
}
// Herramienta 2 — situacion de un producto, o de todos los que estan bajo minimo
function situacion_stock({ codigo, solo_bajo_minimo }) {
let filas = leerStock();
if (codigo) filas = filas.filter((p) => p.codigo === String(codigo));
if (solo_bajo_minimo) filas = filas.filter((p) => p.stock < p.minimo);
if (!filas.length) return { encontrado: false, motivo: 'Ninguna fila cumple eso.' };
return {
encontrado: true,
total: filas.length,
filas: filas.map((p) => ({
codigo: p.codigo, nombre: p.nombre, stock: p.stock, minimo: p.minimo,
venta_mes: p.venta_mes, dias_de_cobertura: Math.round((p.stock / (p.venta_mes / 30)) * 10) / 10,
})),
};
}
const HERRAMIENTAS = { buscar_producto, situacion_stock };{ encontrado: false, motivo: "..." } cuando no hay
nada, en vez de fallar. Es la regla más importante de toda la lección y la razón
de la mitad de los agentes que se atascan: si una herramienta lanza un error, el agente se
para; si devuelve el motivo escrito, el modelo lo lee, entiende qué pasó y busca por otro
lado. Un error que se puede leer es un dato.
dias_de_cobertura: lo calcula la herramienta, no
el modelo. Cualquier cuenta que se pueda hacer con una fórmula se hace en tu código —es
exacta, es gratis y siempre da lo mismo— y al modelo se le deja lo que sí sabe hacer:
decidir qué mirar y explicarlo. Pedirle a un modelo que divida es regalarle la
oportunidad de equivocarse en lo único que no debería fallar.
6. Describírselas al modelo
El modelo no ve ese código. Ve esto — y sólo esto:
const DECLARACIONES = [
{
name: 'buscar_producto',
description: 'Busca productos del catalogo por parte de su nombre y devuelve su codigo. '
+ 'Usala SIEMPRE antes de situacion_stock si solo tienes el nombre: los codigos no se inventan.',
parameters: {
type: 'object',
properties: { texto: { type: 'string', description: 'Parte del nombre, p.ej. "ibuprofeno"' } },
required: ['texto'],
},
},
{
name: 'situacion_stock',
description: 'Devuelve stock, minimo, venta del mes y dias de cobertura. Con codigo, de ese '
+ 'producto. Con solo_bajo_minimo=true, de todos los que estan por debajo de su minimo. '
+ 'Sin ninguno de los dos, de todo el catalogo (evitalo: son muchas filas).',
parameters: {
type: 'object',
properties: {
codigo: { type: 'string', description: 'Codigo exacto obtenido de buscar_producto' },
solo_bajo_minimo: { type: 'boolean', description: 'true para listar solo los que faltan' },
},
},
},
];7. El bucle: treinta líneas
historial es un array al que sólo se le añaden cosas: la petición del modelo y el resultado de la herramienta, cada vuelta. Y en cada vuelta se envía entero. Por eso la vuelta 3 cuesta el triple que la 1, por eso hay un MAX_VUELTAS, y por eso una herramienta que devuelve tres mil filas no es cara una vez: es cara todas las vueltas que queden.Aquí está el agente entero. Es más corto de lo que parece porque la mayor parte ya está escrita.
const INSTRUCCIONES = `Eres un asistente de gestion de una farmacia.
Contestas sobre stock usando UNICAMENTE las herramientas; no inventas cifras.
Si necesitas un codigo, lo obtienes con buscar_producto: nunca lo construyes.
Si una herramienta falla o no encuentra nada, intenta otra via antes de rendirte
y DILO en la respuesta final.
Contesta corto, en espanol, y cita las cifras exactas que te hayan devuelto.`;
const MAX_VUELTAS = 8; // ⚠️ obligatorio
async function agente(encargo) {
const historial = [{ role: 'user', parts: [{ text: encargo }] }];
for (let vuelta = 1; vuelta <= MAX_VUELTAS; vuelta++) {
const res = await fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': CLAVE },
body: JSON.stringify({
systemInstruction: { parts: [{ text: INSTRUCCIONES }] },
contents: historial,
tools: [{ functionDeclarations: DECLARACIONES }],
}),
});
const datos = await res.json();
const partes = datos?.candidates?.[0]?.content?.parts || [];
const peticiones = partes.filter((p) => p.functionCall);
if (!peticiones.length) {
const texto = partes.map((p) => p.text).filter(Boolean).join('');
return { texto, vueltas: vuelta };
}
historial.push({ role: 'model', parts }); // lo que pidio
const respuestas = [];
for (const { functionCall } of peticiones) {
const { name, args } = functionCall;
console.log(` vuelta ${vuelta} PIDE ${name}`, JSON.stringify(args));
const fn = HERRAMIENTAS[name];
const salida = fn
? fn(args || {})
: { encontrado: false, motivo: `No existe la herramienta ${name}.` };
respuestas.push({ functionResponse: { name, response: salida } });
}
historial.push({ role: 'user', parts: respuestas }); // lo que devolvimos
}
return { texto: 'Me he quedado sin vueltas sin llegar a una respuesta.', vueltas: MAX_VUELTAS };
}
const r = await agente(process.argv[2] || '¿De que me estoy quedando corto?');
console.log('\n' + r.texto + `\n\n(${r.vueltas} vueltas)`);Eso es todo. Ni una librería, ni un framework, ni un servicio de nadie. Y ahora la parte que importa: saber qué hace cada trozo.
-
El historial
Es el contexto de la lección anterior, hecho carne. Un array al que se le va añadiendo todo. Se manda entero en cada vuelta — por eso crecía el coste.
-
El
forcon topeOcho vueltas y ni una más. Si las agota, contesta que no llegó. No es elegante y es exactamente lo que hay que hacer: que un fallo se note y se pare, en vez de seguir gastando.
-
La bifurcación
partes.filter(p => p.functionCall). Si el modelo no ha pedido ninguna herramienta, es que ha contestado: se devuelve y se acaba. Eseifes el criterio de parada natural, y ocupa tres líneas. -
Los dos
pushEl primero guarda lo que pidió el modelo; el segundo, lo que devolvieron las herramientas. Los dos son obligatorios. Si te saltas el primero, el modelo ve un resultado sin recordar haberlo pedido y se vuelve loco — es el fallo más frecuente al escribir esto por primera vez.
-
El
console.logde dentroEsa línea es tu registro. Parece un detalle y es la diferencia entre depurar y adivinar: sin ella no sabes qué ha pedido ni con qué argumentos.
-
La herramienta que no existe
Si el modelo se inventa un nombre de herramienta —pasa—, no reventamos: devolvemos «no existe» como dato y el modelo se corrige solo en la vuelta siguiente.
8. Ejecutarlo y leer lo que hace
$ node agente.js "¿de que me estoy quedando corto?"
vuelta 1 PIDE situacion_stock {"solo_bajo_minimo":true}
Estas por debajo de minimo en tres productos:
- ENANTYUM 25MG 20 COMP: 0 unidades (minimo 15). Sin stock.
- OMEPRAZOL 20MG 28 CAPS: 4 unidades (minimo 25), 1,7 dias de cobertura.
- IBUPROFENO 600MG 40 COMP: 12 unidades (minimo 30), 3,8 dias de cobertura.
(2 vueltas)Una vuelta con herramienta y una para contestar. Ahora una pregunta que obliga a encadenar:
$ node agente.js "¿cuanto me queda de enantyum y cuanto vendo?"
vuelta 1 PIDE buscar_producto {"texto":"enantyum"}
vuelta 2 PIDE situacion_stock {"codigo":"990341"}
De ENANTYUM 25MG 20 COMP no te queda nada: 0 unidades, con un minimo de 15.
Vendes 44 al mes, o sea algo mas de 1,4 al dia.
(3 vueltas)9. Rómpelo a propósito cuatro veces
Esto no es opcional. Los fallos de la lección anterior se entienden de verdad cuando los provocas tú en un sitio donde no pasa nada.
-
Quita el tope de vueltas
Cambia
MAX_VUELTASa 200 y pregúntale por un producto que no existe en el CSV. Verás la misma petición repetida hasta que te canses de mirar. Ahí está el bucle infinito, y ahí entiendes por qué el tope no se negocia. -
Haz que la herramienta falle de verdad
Sustituye el
return { encontrado: false, ... }porthrow new Error('no existe'). El programa entero se cae en la primera pregunta rara. Vuelve a ponerlo como estaba y compara: con el error como dato, el agente sigue trabajando y además te lo cuenta. -
Empeora una descripción
Quita de
buscar_productola frase «los códigos no se inventan» y pregunta por un producto por su nombre. Tarde o temprano verássituacion_stock {"codigo":"123456"}con un código que no existe. No ha cambiado ni una línea de lógica: has cambiado una frase. -
Devuelve demasiado
Quita el filtro de
situacion_stockpara que devuelva siempre el catálogo entero, y duplica el CSV unas cuantas veces. Verás dos cosas a la vez: que tarda más y que el modelo empieza a confundirse. Contexto de más no es información de más: es ruido pagado.
10. Los cinco errores que vas a ver en la terminal
Todos. Sin excepción. Están aquí para que cuando salgan no pierdas la tarde: en cuanto reconoces el mensaje, el arreglo es de un minuto.
| Lo que sale | Qué pasa de verdad | Arreglo |
|---|---|---|
API key not valid |
La clave está mal copiada, o has copiado también el GEMINI_API_KEY= |
Cópiala otra vez de AI Studio, entera y sin espacios |
429 RESOURCE_EXHAUSTED |
Has agotado la cuota gratuita del día o estás llamando muy seguido | Esperar. Y mirar cuántas vueltas estás dando de más |
ENOENT: no such file'stock.csv' |
Estás ejecutando desde otra carpeta | cd a la carpeta del agente y volver a lanzarlo |
Cannot read properties ofundefined |
La respuesta no traía lo que esperabas — casi siempre porque venía un error dentro | Imprime datos entero antes de leerlo. Siempre |
400 INVALID_ARGUMENT |
El cuerpo que mandas tiene algo que ese modelo no admite | Mirar el mensaje: dice el campo. Suele ser el historial mal armado |
{ "error": ... } en vez de candidates,
así que el fallo real está escrito una línea más arriba de donde miras. De ahí la manía
de imprimir la respuesta cruda del paso 3: el mensaje útil casi nunca es el que te da
JavaScript, es el que te da Google dentro del JSON.
11. Qué le puedes preguntar y qué no
Un agente sólo sabe lo que sus herramientas le dejan saber. Con estas dos, esto es exactamente lo que cubre — y conviene tenerlo claro antes de enseñárselo a nadie:
| Pregunta | ¿Puede? | Por qué |
|---|---|---|
| «¿De qué me estoy quedando corto?» | Sí | Es literalmente solo_bajo_minimo |
| «¿Cuántos días me aguanta el omeprazol?» | Sí | La cobertura la calcula la herramienta |
| «¿Qué pido hoy y en qué cantidad?» | A medias | Puede sugerirlo, pero la cantidad es una decisión tuya: nada en el CSV dice cuánto tardas en reponer |
| «¿Por qué se agotó el Enantyum?» | No del todo | Le falta el histórico de entradas. Con una tercera herramienta, sí |
| «¿Cuánto he facturado este mes?» | No | No tiene ninguna herramienta que lo sepa. Y contestará igualmente si no se lo prohíbes |
12. Que corra solo cada mañana
Un agente que hay que lanzar a mano se usa tres días. Con dos cambios pequeños se convierte en algo que te espera hecho:
import { appendFileSync } from 'node:fs';
const r = await agente(process.argv[2] || '¿De que me estoy quedando corto?');
const linea = `
===== ${new Date().toISOString()} =====
${r.texto}
(${r.vueltas} vueltas)`;
appendFileSync('informe.txt', linea);
console.log(r.texto);Y después, que lo lance el ordenador:
- En Mac o Linux:
crontab -ey una línea0 8 * * 1-6 cd /ruta/agente && /usr/local/bin/node agente.js— a las 8:00 de lunes a sábado. - En Windows: el «Programador de tareas», acción «iniciar un programa»,
programa
node, argumentosagente.js, y la carpeta del agente como directorio de inicio.
node no es un capricho. Cuando lo lanza
el sistema y no tú, no existen los atajos de tu terminal: la causa número uno de «funciona
a mano y no funciona programado» es esa, y el segundo es haber olvidado el cd
— que es el error ENOENT de la tabla de arriba, otra vez.
13. Lo que tienes y lo que te falta
| Ya tienes | Te falta |
|---|---|
| El bucle completo, con tope | Que las herramientas lean tu base de datos de verdad, no un CSV |
| Dos herramientas que leen | Herramientas que escriban, con lo que eso obliga (lección 3) |
| Errores devueltos como dato | Un registro guardado, no un
console.log que se pierde |
| Un registro en pantalla | Que no se ejecute sólo en tu portátil (lección 4) |
- Me ha contestado con mis datos al menos una vez.
- He visto en el registro que encadena dos herramientas solo.
- He provocado el bucle infinito y he vuelto a poner el tope.
- He comprobado que un
throwtumba el agente y unreturnno. - Mi clave está en
.envy no en el código.
En la lección siguiente se trata lo único que queda entre esto y algo que toque tu farmacia de verdad: cómo se escribe una herramienta que escribe.