Skip to content
Intermedio
8 min
LLMs & APIs

Agente que consulta inventario en Postgres

September 11, 2026 · Tool use con la API de Claude · Stack: Node.js, TypeScript, Express, PostgreSQL, @anthropic-ai/sdk, pg

Be the first to say if it helps

Nova

Un agente mínimo en Node.js que usa tool use de Claude para consultar una tabla real de productos en Postgres y responder en lenguaje natural, incluso cuando la consulta falla.

You leave with

  • El tool_use no es magia: es un loop explícito donde tú ejecutas la función y devuelves el resultado como 'tool_result'
  • El error de la base de datos hay que atraparlo en el ejecutor del tool, no dejar que reviente el server
  • El input_schema del tool es lo único que Claude 've' de tu base: si está mal descrito, va a pedir parámetros que no existen
  • Separar db.ts, tools.ts y agent.ts hace que el mismo tool se pueda testear sin llamar a la API de Claude

Chapters

  1. 0:00Agente que consulta inventario en Postgres
  2. 0:15El problema con los chatbots que alucinan
  3. 0:39El loop de tool use
  4. 1:27schema.sql
  5. 2:02src/db.ts
  6. 2:25src/tools.ts
  7. 3:29src/agent.ts
  8. 4:39src/server.ts
  9. 6:15agente-inventario
  10. 6:56Errores comunes
  11. 7:20Pro tip de Nova
  12. 7:45Lo que construimos

You need

  • Node.js 18+
  • PostgreSQL corriendo localmente (o Docker)
  • psql CLI instalado
  • Cuenta y API key de Anthropic
  • Conocimientos básicos de SQL y Express

Devs backend/fullstack que ya usan la API de Claude y quieren dar el salto a tool use conectado a una base de datos real

6 steps, one project that runs

Step 1Crear la tabla y datos de prueba

Tener una tabla real de productos con stock y precio para que el agente consulte datos verdaderos

schema.sql

CREATE TABLE IF NOT EXISTS products (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  category TEXT NOT NULL,
  stock INTEGER NOT NULL DEFAULT 0,
  price NUMERIC(10,2) NOT NULL
);

INSERT INTO products
  (name, category, stock, price)
VALUES
  ('Teclado mecanico', 'Perifericos',
   34, 89.90),
  ('Monitor 27 pulgadas 4K', 'Monitores',
   12, 349.00),
  ('Mouse inalambrico', 'Perifericos',
   58, 29.50),
  ('Silla ergonomica', 'Muebles',
   7, 219.00)
ON CONFLICT DO NOTHING;

command: psql -U postgres -d inventory -f schema.sql

Step 2Conectar Node a Postgres

Crear un pool de conexiones reutilizable con manejo de errores de conexión

src/db.ts

import { Pool } from 'pg';
import 'dotenv/config';

export const pool = new Pool({
  connectionString:
    process.env.DATABASE_URL,
});

pool.on('error', (err) => {
  console.error(
    'Error en el pool de PG:',
    err
  );
});

Step 3Definir el tool y su ejecutor

Declarar el schema del tool que Claude puede llamar y la función que corre el SQL real con try/catch

src/tools.ts

import { pool } from './db';

export const inventoryTool = {
  name: 'query_inventory',
  description:
    'Consulta el inventario. Recibe una ' +
    'categoria opcional y devuelve nombre, ' +
    'stock y precio de los productos.',
  input_schema: {
    type: 'object' as const,
    properties: {
      category: {
        type: 'string',
        description:
          'Categoria a filtrar',
      },
    },
  },
};

type Input = { category?: string };

export async function runInventoryQuery(
  input: Input
) {
  try {
    const { category } = input;
    const base =
      'SELECT name, stock, price ' +
      'FROM products';
    const sql = category
      ? `${base} WHERE category ILIKE $1`
      : base;
    const values = category
      ? [category]
      : [];

    const { rows } = await pool.query(
      sql,
      values
    );

    if (rows.length === 0) {
      return {
        ok: false,
        error:
          'No hay productos en esa ' +
          'categoria.',
      };
    }
    return { ok: true, data: rows };
  } catch (err) {
    console.error('Error en PG:', err);
    return {
      ok: false,
      error: 'La base de datos no respondio.',
    };
  }
}

Step 4Armar el loop de tool use

Entender cómo Claude pide ejecutar el tool, cómo se le devuelve el resultado y cómo se sigue el loop hasta la respuesta final

src/agent.ts

import Anthropic from '@anthropic-ai/sdk';
import {
  inventoryTool,
  runInventoryQuery,
} from './tools';

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

export async function askAgent(
  question: string
) {
  const messages: any[] = [
    { role: 'user', content: question },
  ];

  let response = await anthropic.messages.create({
    model: 'claude-sonnet-4-5',
    max_tokens: 1024,
    tools: [inventoryTool],
    messages,
  });

  while (response.stop_reason === 'tool_use') {
    const toolUse = response.content.find(
      (b: any) => b.type === 'tool_use'
    );
    if (!toolUse) break;

    const result = await runInventoryQuery(
      toolUse.input as { category?: string }
    );

    messages.push({
      role: 'assistant',
      content: response.content,
    });
    messages.push({
      role: 'user',
      content: [
        {
          type: 'tool_result',
          tool_use_id: toolUse.id,
          content: JSON.stringify(result),
        },
      ],
    });

    response = await anthropic.messages.create({
      model: 'claude-sonnet-4-5',
      max_tokens: 1024,
      tools: [inventoryTool],
      messages,
    });
  }

  const finalText = response.content.find(
    (b: any) => b.type === 'text'
  );
  return finalText ? finalText.text : '';
}

Step 5Exponer el agente por HTTP

Crear un endpoint Express que recibe la pregunta, llama al agente y devuelve un error controlado si algo falla

src/server.ts

import express from 'express';
import 'dotenv/config';
import { askAgent } from './agent';

const app = express();
app.use(express.json());
app.use(express.static('public'));

app.post('/api/chat', async (req, res) => {
  const { question } = req.body;
  if (!question) {
    return res.status(400).json({
      error: 'Falta la pregunta',
    });
  }
  try {
    const answer = await askAgent(question);
    res.json({ answer });
  } catch (err) {
    console.error('Error del agente:', err);
    res.status(500).json({
      error: 'El agente no pudo responder.',
    });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server en puerto ${PORT}`);
});

command: npx ts-node src/server.ts

Step 6Probar el agente en el navegador

Ver el agente respondiendo con datos reales y comprobar que un caso sin resultados o con error de conexión se explica en lenguaje natural en vez de romper la app

public/index.html

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8" />
  <title>Agente de Inventario</title>
</head>
<body>
  <h1>Agente de Inventario</h1>
  <input
    id="q"
    placeholder="Pregunta por el stock..."
  />
  <button id="send">Enviar</button>
  <p id="answer"></p>

  <script>
    const send =
      document.getElementById('send');
    send.onclick = async () => {
      const q =
        document.getElementById('q').value;
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: {
          'Content-Type':
            'application/json',
        },
        body: JSON.stringify({
          question: q,
        }),
      });
      const data = await res.json();
      document.getElementById(
        'answer'
      ).innerText = data.answer || data.error;
    };
  </script>
</body>
</html>

command: npx ts-node src/server.ts

En localhost:3000 se ve el input de pregunta. Al escribir '¿Cuánto stock hay de monitores?' responde con los datos reales de la tabla; al preguntar por una categoría inexistente, responde explicando que no encontró productos

What breaks

¿Qué problema resuelve Agente que consulta inventario en Postgres?

Los chatbots que solo 'alucinan' respuestas no sirven para soporte real: hace falta que el modelo consulte datos verdaderos y sepa explicar cuando algo no existe o la base falla. Un agente mínimo en Node.js que usa tool use de Claude para consultar una tabla real de productos en Postgres y responder en lenguaje natural, incluso cuando la consulta falla.

¿Qué necesito saber antes de seguir esta clase?

Necesitas Node.js 18+, PostgreSQL corriendo localmente (o Docker), psql CLI instalado, Cuenta y API key de Anthropic, Conocimientos básicos de SQL y Express. La clase es de nivel intermedio y dura 8 minutos.

¿Qué stack se usa para Tool use con la API de Claude?

El proyecto usa Node.js, TypeScript, Express, PostgreSQL, @anthropic-ai/sdk, pg. Todo el código se escribe en pantalla durante la clase.

¿Por qué falla al olvidar el while(stop_reason === 'tool_use') y devolver solo la primera respuesta, que suele venir vacía o…?

Olvidar el while(stop_reason === 'tool_use') y devolver solo la primera respuesta, que suele venir vacía o incompleta

¿Por qué falla al dejar que una excepción de pg tumbe el proceso en vez de capturarla y devolver un objeto { ok: false, error }?

Dejar que una excepción de pg tumbe el proceso en vez de capturarla y devolver un objeto { ok: false, error }

¿Por qué falla al usar SELECT * en el tool, exponiendo columnas sensibles que no querías que el modelo viera ni repitiera?

Usar SELECT * en el tool, exponiendo columnas sensibles que no querías que el modelo viera ni repitiera

¿Por qué falla al no mandar el content del tool_result como string: si mandas un objeto crudo, algunos SDKs lo serializan mal y…?

No mandar el content del tool_result como string: si mandas un objeto crudo, algunos SDKs lo serializan mal y el modelo recibe basura

¿Hay algún truco que no esté en la documentación oficial?

Serializa SIEMPRE el resultado del tool con JSON.stringify, incluso cuando es un error ({ ok: false, error: '...' }). Claude interpreta mejor un string estructurado y consistente que un objeto crudo o un mensaje de error suelto, y eso reduce muchísimo los casos donde el modelo se queda repitiendo la misma pregunta o inventa una respuesta genérica en vez de explicar la falla real.

Transcript

Transcript with timestamps

0:00Bienvenido a AI Talks. Soy Nova. Hoy construimos un agente en Node.js que usa tool use de Claude para consultar inventario real en Postgres, y responde incluso cuando la consulta falla.

0:15Imaginá un bot de soporte que dice que hay veinte teclados en stock, y en realidad quedan cero. Eso pasa cuando el modelo alucina en vez de consultar la base real. Hoy resolvemos eso con tool use: el modelo pide datos, nosotros los traemos de Postgres, y él arma la respuesta.

0:39Tool use es como darle a Claude un brazo robótico: el modelo no ejecuta código, solo decide qué función llamar y con qué parámetros. Nosotros corremos esa función contra Postgres, devolvemos el resultado como tool_result, y recién ahí arma la respuesta final. Vos mantenés el control total de qué SQL se ejecuta, mientras el modelo interpreta y explica, incluso cuando algo falla.

1:09Arranquemos rápido. Creamos la carpeta, inicializamos el proyecto e instalamos express, pg, dotenv y el SDK de Anthropic. Después sumamos TypeScript y sus tipos como dependencias de desarrollo, inicializamos el tsconfig, y creamos la base con createdb inventory.

1:27Empezamos por los datos reales: una tabla products con id, nombre, categoría, stock y precio. Insertamos cuatro productos de prueba, desde teclados hasta una silla ergonómica, para que el agente tenga algo verdadero que consultar en vez de inventar números.

1:46Corremos el script con psql apuntando a la base inventory. La consola confirma: CREATE TABLE y luego INSERT 0 4, cuatro filas insertadas. Ya tenemos inventario real esperando a que alguien le pregunte.

2:02En db.ts creamos un pool de conexiones reutilizable con pg, leyendo la cadena de conexión desde las variables de entorno. Este pool.on error es la primera línea de defensa: si Postgres se cae a mitad de una consulta, lo logueamos en vez de dejar que tumbe todo el servidor.

2:25Acá declaramos el tool que Claude va a poder invocar. El input_schema es literalmente lo único que el modelo ve de tu base de datos: si la descripción es vaga, Claude va a inventar parámetros que no existen. Por eso somos bien explícitos con category como filtro opcional.

2:48Ahora el ejecutor real. Armamos el SQL parametrizado: si viene category, filtramos con ILIKE y el placeholder dollar uno, evitando inyección SQL. Nunca concatenamos el valor directo, siempre va como parámetro separado en el array values.

3:06Si la consulta no devuelve filas, respondemos ok false con un mensaje claro en vez de un array vacío que confunda al modelo. Y si Postgres explota, el catch atrapa el error y devuelve el mismo formato: ok false, error. Nunca dejamos que la excepción llegue cruda hasta Claude.

3:29En agent.ts armamos el cerebro del sistema. Inicializamos el cliente de Anthropic, guardamos la pregunta del usuario en el array messages, y hacemos la primera llamada a claude-sonnet-4-5 pasándole el tool inventoryTool disponible. Es la nave despegando: todavía no sabemos si va a pedir ayuda o responder directo.

3:52Acá está el corazón del patrón: mientras stop_reason sea tool_use, buscamos el bloque tool_use en la respuesta, ejecutamos runInventoryQuery con el input que Claude decidió mandar, y empujamos dos mensajes: la respuesta del asistente y el resultado como tool_result. Fijate el JSON.stringify en content, eso es innegociable.

4:15Cerramos el loop volviendo a llamar a Claude con el historial actualizado, así puede pedir otro tool o ya responder en texto. Cuando stop_reason deja de ser tool_use, buscamos el bloque de tipo text y lo devolvemos. Si te olvidás este while, te quedás con la primera respuesta, casi siempre vacía.

4:39server.ts expone el agente por HTTP. Montamos express.json para leer el body, servimos public como estático para la UI, y en el POST slash api slash chat validamos que venga la pregunta antes de tocar al agente. Si falta, devolvemos 400 sin ni siquiera llamar a Claude.

5:02Si askAgent explota, ya sea porque la API de Claude falla o porque algo no capturamos antes, el catch devuelve 500 con un mensaje entendible en vez de un stack trace crudo. Levantamos el server en el puerto 3000 y quedamos listos para recibir preguntas.

5:23Levantamos todo con ts-node. La consola confirma: Server en puerto 3000. Ahora sí, vamos a construir la interfaz mínima para probarlo desde el navegador.

5:35Esta es la cara visible del agente: un h1, un input con placeholder pregunta por el stock, y un botón enviar. Fijate cómo el preview de la derecha va armándose línea por línea, como si el dev server recargara en caliente cada vez que guardás.

5:56El botón dispara un fetch POST a slash api slash chat con la pregunta, y pinta la respuesta o el error en el párrafo answer. Capturamos data.answer o data.error, así el usuario nunca se queda mirando una pantalla rota.

6:15Momento de la verdad. Escribimos: cuánto stock hay de monitores. Claude decide llamar a query_inventory con category monitores, corremos el SQL contra Postgres, y la respuesta llega en lenguaje natural: Monitor 27 pulgadas 4K, 12 unidades a 349 dólares. Dato real, no alucinación.

6:35Probemos el caso límite: tienen drones en stock. No existe esa categoría, así que runInventoryQuery devuelve ok false con el mensaje no hay productos en esa categoria. Claude, en vez de inventar un número, lo traduce en una respuesta honesta para el usuario.

6:56Los errores más comunes que veo: olvidarse el while y quedarse con la primera respuesta, casi siempre incompleta. Dejar que pg explote sin try catch. Usar SELECT asterisco y exponer columnas que no querías mostrar. Y mandar el tool_result como objeto crudo en vez de string, lo que confunde al modelo.

7:20Acá va algo que no está en la doc oficial: serializá siempre el resultado del tool con JSON.stringify, incluso cuando es un error. Claude interpreta muchísimo mejor un string estructurado y consistente que un objeto crudo, y eso reduce los casos donde el modelo repite la pregunta o inventa una respuesta genérica.

7:45Construimos un agente que consulta Postgres de verdad y sabe explicar cuando algo falla, gracias al loop de tool use de Claude. Seguí a AI Talks y visitá aitalks.cl para el resto de la serie. La próxima vez metemos streaming de respuestas en este mismo agente. Nos vemos en la siguiente órbita de código.