Tutorial de LangGraph.js: agente de triaje con Zod y aprobación
Tutorial de LangGraph.js en TypeScript: un agente de triaje que clasifica con Zod, limita sus búsquedas, pide aprobación con interrupt() y se testea en Vitest.

Los tests estaban en verde. Todos. El agente clasificaba la incidencia, buscaba en la documentación, redactaba una respuesta y se paraba a esperar mi aprobación.
Entonces, en la revisión de código, apareció un camino que ningún test cubría. Si el modelo pedía una búsqueda justo en la última vuelta permitida, el grafo saltaba directo a redactar. La API de Anthropic habría recibido un tool_use sin su tool_result y habría contestado con un 400.
El modelo falso de los tests no valida esa regla. El real sí. Y ese fallo explica por qué este tutorial de LangGraph.js empieza por un agente de triaje: no es un chatbot con herramientas, sino algo que tiene ramas, límites y una pausa que puede durar un día.
En corto: un agente de triaje con LangGraph.js es un StateGraph con cinco pasos: clasificar con salida estructurada de Zod, investigar en un bucle con límite, redactar, pausar con interrupt() para que una persona apruebe y publicar. El checkpointer guarda cada paso, así que la aprobación puede llegar horas después y en otro proceso. Todo se testea con Vitest y fakeModel(), sin llamar a ninguna API.
¿Qué es un agente de triaje con LangGraph.js?
Un agente de triaje es un sistema que recibe una incidencia de un usuario, la clasifica por tipo y gravedad, busca información para responderla y propone una respuesta que una persona aprueba, edita o rechaza antes de publicarse. En LangGraph.js cada uno de esos pasos es un nodo, y las decisiones entre pasos son aristas condicionales.
El código de este post es Triaje, el proyecto conductor del curso de agentes en TypeScript que estoy preparando. Atiende las incidencias de «Cuaderno», una app de notas inventada, con @langchain/langgraph 1.5.3, @langchain/anthropic 1.6.0, Zod 4.6.7 y Vitest 5.0.5. Versiones fijadas y comprobadas el 29 de octubre de 2026.
El grafo completo cabe en una línea:
START → classify → research ⇄ tools → writeDraft → approve ─┬→ publish → END
└→ END (rechazada)
Por qué el triaje sí compensa como grafo
El triaje compensa como grafo porque cumple a la vez los tres síntomas que justifican LangGraph: ramificación real, un humano que decide a mitad de la ejecución y un estado que tiene que sobrevivir al proceso.
En cuándo un grafo de LangGraph gana al while loop defendí que la mayoría de los agentes deben quedarse en un while. El triaje es la excepción: quien aprueba la respuesta no está mirando la terminal. Contesta cuando puede.
| Requisito del triaje | while loop |
StateGraph de LangGraph.js |
|---|---|---|
| Salida de la clasificación | Parseas texto o validas a mano | withStructuredOutput(ClassificationSchema) |
| Límite de búsquedas | Un contador dentro del bucle | Campo researchSteps con reducer, guardado en el checkpoint |
| Aprobación humana | Proceso vivo esperando un await |
interrupt() + Command({ resume }) |
| Validar la decisión humana | Lo escribes tú | responseSchema de Zod en el propio interrupt() |
| Sobrevivir a un reinicio | No | Sí, con SqliteSaver o PostgresSaver |
| Test sin API | Mockeas tu cliente HTTP | fakeModel() de langchain |
| Riesgo | Rehaces a mano persistencia y reanudación | Nombres de nodo, forma de los mensajes y versiones del framework que tienes que conocer |
El estado del agente de triaje en LangGraph.js
El estado es el contrato del grafo. Cada campo se guarda en el checkpoint, así que una ejecución pausada se reanuda con todo lo que sabía.
// s05-triaje-langgraph/src/state.ts
import { MessagesValue, ReducedValue, StateSchema } from "@langchain/langgraph";
import { z } from "zod/v4";
export const TriageState = new StateSchema({
threadId: z.string(),
issue: IssueSchema,
messages: MessagesValue,
classification: ClassificationSchema.optional(),
// Vueltas de búsqueda: se suman, para cortar el bucle.
researchSteps: new ReducedValue(z.number().default(0), { reducer: (total, step) => total + step }),
// Fragmentos encontrados: se acumulan entre vueltas.
hits: new ReducedValue(z.array(SearchHitSchema).default(() => []), {
inputSchema: z.array(SearchHitSchema),
reducer: (all, found) => [...all, ...found],
}),
draft: DraftSchema.optional(),
decision: DecisionSchema.optional(),
published: z.boolean().default(false),
});
Cada vuelta de búsqueda devuelve researchSteps: 1 y el reducer lo suma. El contador vive en el estado, no en una variable local, así que sobrevive a un reinicio.
Un detalle que me costó un error: un nodo no puede llamarse igual que un campo del estado. LangGraph lanza «draft is already being used as a state attribute» al construir el grafo. Por eso el nodo que redacta se llama writeDraft.
Clasificar la incidencia con salida estructurada de Zod
La clasificación no devuelve texto libre. Devuelve un objeto que cumple este esquema:
// shared/src/schemas.ts
export const ClassificationSchema = z.object({
type: IssueTypeSchema.describe("bug, question (duda de uso) o feature_request (petición de funcionalidad)"),
severity: SeveritySchema.describe("high si se pierde trabajo o no se puede usar la app"),
reason: z.string().describe("Una frase que justifica la clasificación"),
});
Los .describe() llegan al modelo como parte del JSON Schema. Lo detallo en diseñar schemas Zod para LLM, y a fondo en el curso de Zod para TypeScript.
El nodo:
// s05-triaje-langgraph/src/nodes/classify.ts
export const makeClassify = (model: BaseChatModel) =>
async (state: Pick<TriageStateValue, "issue">) => {
const classifier = model.withStructuredOutput(ClassificationSchema, { name: "classification", method: "jsonSchema" });
const incident = new HumanMessage(`Incidencia de un usuario:\n\n${state.issue.text}`);
const classification = await classifier.invoke([new SystemMessage(CLASSIFY_INSTRUCTIONS), incident]);
// La incidencia abre también la conversación del investigador.
return { classification, messages: [incident] };
};
Lo importante es method: "jsonSchema". El método por defecto de @langchain/anthropic 1.6.0 fuerza una llamada a herramienta con tool_choice, y en mis pruebas Claude Sonnet 5.5 rechazó ese tool_choice forzado con un 400. Con jsonSchema se envía output_config.format. El repo tiene un test que intercepta fetch y falla si alguien quita esa opción.
El bucle de búsqueda: aristas condicionales con límite
research le da al modelo una herramienta search; si pide búsquedas, tools las ejecuta y vuelve a research:
// s05-triaje-langgraph/src/graph.ts
function afterResearch(state: TriageStateValue) {
const last = state.messages.at(-1);
return last && AIMessage.isInstance(last) && (last.tool_calls?.length ?? 0) > 0 ? "tools" : "writeDraft";
}
// El límite de vueltas se aplica DESPUÉS de ejecutar las herramientas.
const afterTools = (state: TriageStateValue) => (state.researchSteps >= MAX_RESEARCH_STEPS ? "writeDraft" : "research");
const afterApproval = (state: TriageStateValue) => (state.decision?.action === "reject" ? END : "publish");
export function buildTriageGraph({ model, classifierModel = model, tracker, checkpointer }: Deps) {
return new StateGraph(TriageState)
.addNode("classify", makeClassify(classifierModel))
.addNode("research", makeResearch(model))
.addNode("tools", runTools)
.addNode("writeDraft", makeDraft(model))
.addNode("approve", approve)
.addNode("publish", makePublish(tracker))
.addEdge(START, "classify")
.addEdge("classify", "research")
.addConditionalEdges("research", afterResearch, ["tools", "writeDraft"])
.addConditionalEdges("tools", afterTools, ["research", "writeDraft"])
.addEdge("writeDraft", "approve")
.addConditionalEdges("approve", afterApproval, ["publish", END])
.addEdge("publish", END)
.compile({ checkpointer });
}
Aquí está el bug de la apertura. La primera versión comprobaba el límite en afterResearch y writeDraft recibía toda la conversación: si la última vuelta pedía una búsqueda, llegaba un tool_use sin respuesta, y Anthropic devuelve un 400 en ese caso. Hoy writeDraft solo recibe la incidencia y los fragmentos, pero cualquier nodo nuevo que lea messages heredaría el hueco.
La regla es simple: si el modelo pidió herramientas, se ejecutan siempre. El límite se mira después, en afterTools, con MAX_RESEARCH_STEPS = 4.
No es un problema exótico. En el issue #1423 de langgraphjs, de julio de 2025, un usuario reportó «unexpected tool_use_id found in tool_result» con interrupt(), PostgresSaver y Claude, y culpó al checkpointer. Dieciocho días después lo cerró él mismo: recortaba el historial para frenar la degradación del contexto y dejaba un tool_result huérfano. El framework no tenía la culpa; la tenía la forma de los mensajes.
Aprobación humana con interrupt() y responseSchema
interrupt() para el grafo, guarda el estado en el checkpointer y devuelve el borrador a quien lo ejecutó; la ejecución sigue cuando alguien la reanuda con new Command({ resume }). El nodo de aprobación cabe en cuatro líneas:
// s05-triaje-langgraph/src/nodes/approve.ts
export async function approve(state: Pick<TriageStateValue, "draft">): Promise<{ decision: Decision }> {
const decision = interrupt(state.draft, { responseSchema: DecisionSchema }) as Decision;
return { decision };
}
La decisión es una unión discriminada de Zod:
// shared/src/schemas.ts
export const DecisionSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("approve") }),
z.object({ action: z.literal("edit"), reply: z.string().trim().min(1) }),
z.object({ action: z.literal("reject"), reason: z.string().optional() }),
]);
Con responseSchema, una decisión inválida como { action: "publish" } lanza un error al reanudar y el hilo queda intacto para reintentar. Y publish usa el texto que decidió la persona, no el del modelo: si editaste, se publica tu versión.
Ojo: en mis pruebas con 1.5.3, los interrupts tipados con runtime.interrupt.nombre() de las notas de la v1 fallaron. El patrón de arquitectura completo está en human in the loop en TypeScript.
Reanudar el triaje tras un reinicio
El CLI arranca el grafo con SqliteSaver.fromConnString("data/langgraph.db"). Para retomar un hilo, mira dónde se quedó:
// s05-triaje-langgraph/src/cli.ts (recortado)
const snapshot = await deps.graph.getState({ configurable: { thread_id: threadId } });
const values = snapshot.values as Partial<TriageStateValue>;
if (snapshot.next.length === 0) {
deps.print(`Ese hilo ya terminó: ${values.published ? "publicado" : "rechazado"}. No se vuelve a publicar.`);
return 0;
}
if (!snapshot.next.includes("approve")) {
// Se cortó antes de la pausa: invoke(null) sigue desde el último checkpoint.
await runStreamed(null, threadId, deps);
}
await approveAndFinish(threadId, deps); // pide la decisión y reanuda con new Command({ resume: decision })
snapshot.next es ["approve"] si está pausado y [] si terminó, lo que evita publicar dos veces. Para llevarlo a Postgres y a un servidor, mira desplegar agentes LangChain en producción.
Cómo testear el agente de triaje con Vitest
Se testea con fakeModel() de langchain, que devuelve respuestas programadas, y un checkpointer real sobre un fichero temporal. Ni clave de API ni red.
Este test simula un reinicio:
// s05-triaje-langgraph/tests/graph.test.ts
test("tras un «reinicio» (otro grafo sobre el mismo SQLite) se reanuda y publica", async () => {
const first = setup();
await first.graph.invoke({ threadId: "t-5", issue }, config("t-5"));
const second = setup(first.dbFile); // nuevo proceso: modelos y grafo nuevos, mismo fichero
await second.graph.invoke(new Command({ resume: { action: "approve" } }), config("t-5"));
expect(second.model.callCount).toBe(0);
expect(second.tracker.list()[0]!.reply).toBe(draftReply.reply);
});
callCount en cero demuestra que reanudar no repite la investigación. No mide la clasificación ni la redacción: fakeModel no cuenta las llamadas que pasan por withStructuredOutput. Y setup() programa el modelo falso en orden:
const model = fakeModel()
.respondWithTools([{ name: "search", args: { query: "exportar PDF tabla" }, id: "c1" }])
.respond(new AIMessage("Tengo lo que necesito."))
.structuredResponse(draftReply);
Dos rarezas de fakeModel. .structuredResponse() es un único valor para todas las llamadas estructuradas, y por eso buildTriageGraph acepta un classifierModel aparte. Y no valida que cada tool_use tenga su tool_result: el test del límite lo comprueba a mano recorriendo los mensajes. Las 24 pruebas pasan en poco más de un segundo.
Límites de este enfoque
La clasificación no enruta. En este grafo, type y severity se guardan y se publican en el tracker, pero todas las incidencias recorren el mismo camino. Si quieres que un high vaya directo a una persona sin investigar, te toca añadir otra arista condicional después de classify.
Encontrar fragmentos no significa que la respuesta sea correcta. Por eso la aprobación humana no es opcional.
Las cifras son de una sola pasada. Con Claude Sonnet 5.5, cada incidencia costó de media 0,0155 USD sin contar el juez y tardó 9,95 segundos, con un 100 % de acierto en el tipo y un 90 % en la severidad. Son 20 incidencias sintéticas medidas el 29 de octubre de 2026: suficiente para detectar regresiones, no para presumir de precisión.
El tracker en JSON es de un solo proceso. Evita publicar dos veces el mismo hilo, pero no aguanta escrituras concurrentes de varios servidores. Para eso necesitas Postgres y un almacén transaccional.
Pagas en conocimiento del framework. Cada trampa de este post salió del código real. Con un while no las tendrías, pero tampoco la pausa.
¿Dónde ver Triaje completo con LangGraph.js y Mastra?
En el curso de agentes en TypeScript que estoy preparando, todavía sin fecha. Ahí el mismo agente se construye dos veces sobre el mismo contrato, con LangGraph.js y con Mastra, después de escribir en la primera sección un bucle sin framework en unas 60 líneas para ver qué te resuelve el framework. Las dos versiones se comparan en calidad, latencia, coste y depuración con las mismas 20 incidencias. El temario sigue con una interfaz de revisión en React (AG-UI y CopilotKit), evals y despliegue con Docker.
Por dónde empezar tu propio agente de triaje
Empieza por el estado, no por los nodos. Escribe el StateSchema y los esquemas de Zod de la clasificación y la decisión antes de tocar el grafo. Si el contrato está bien, los nodos salen casi solos; es la misma idea de escribir la especificación antes que el código que defiendo en el libro de Spec-Driven Development.
Y si quieres la base que hay debajo de cualquier framework, descárgate gratis el ebook El Developer Agéntico: 194 páginas sobre herramientas, memoria, RAG, MCP y sistemas de varios agentes.
Preguntas frecuentes
¿Qué versión de LangGraph.js usa este agente de triaje?
@langchain/langgraph 1.5.3, con @langchain/core 1.3.1, @langchain/anthropic 1.6.0, Zod 4.6.7 y Vitest 5.0.5, comprobadas el 29 de octubre de 2026. Ya existe @langchain/langgraph 1.6.0 (30 de octubre), pero el repo no se ha probado con ella. El estado usa StateSchema, no la API antigua de Annotation.
¿Por qué falla withStructuredOutput con Claude en LangGraph.js?
Porque el método por defecto de @langchain/anthropic 1.6.0 fuerza tool_choice, y en mis pruebas Claude Sonnet 5.5 lo rechazó con un 400. La solución es pasar { method: "jsonSchema" }, que envía el esquema por output_config.format.
¿Hace falta un checkpointer para usar interrupt()?
Sí. interrupt() guarda el estado del hilo para reanudarlo después con new Command({ resume }). Sin checkpointer no hay dónde guardarlo. En tests vale MemorySaver o un SQLite temporal; en un servidor, PostgresSaver.
¿Cuándo usar LangGraph.js en lugar de un while loop?
Cuando cumple al menos uno de tres síntomas: ramificación real, una persona que decide a mitad de la ejecución o un estado que debe sobrevivir a un reinicio. Si no cumple ninguno, un while con un contador es más simple y más fácil de depurar.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.