Tutorial de harness engineering: la regla fuera del prompt
El ticket dice: "Un cliente pagó 4,95 € de envío en un pedido de 45 €. Debería haber sido gratis".
Se lo pasas al agente. Lee shipping.ts, encuentra FREE_SHIPPING_THRESHOLD = 50 y concluye que el código está bien. El pedido era de 45. Cerrado.
Solo que negocio bajó el umbral a 39 € hace dos meses. Esa decisión está en un fichero de catálogo. En el código, no. En el prompt, tampoco. Este tutorial de harness engineering va de eso: de que el agente no tenga que adivinar ni tú que acordarte.
En corto: la regla de negocio no se escribe en el prompt ni se deja solo en el código: vive en un catálogo del servicio y un harness mínimo la inyecta en cada ejecución. Ese mismo harness decide qué ficheros puede tocar el agente, rechaza cualquier cambio fuera de la lista y usa los tests como feedback para reintentar un número limitado de veces. El agente propone; el harness controla, y nunca despliega.
¿Qué significa sacar la regla de negocio fuera del prompt?
Sacar la regla de negocio del prompt significa que el valor correcto (un umbral, un límite, un plazo) vive en una fuente de verdad versionada que el harness lee e inyecta, en lugar de depender de que la persona que escribe el prompt se acuerde de mencionarlo.
Harness engineering es la disciplina de diseñar el sistema que rodea al modelo (qué contexto recibe, qué puede tocar y cómo se verifica su trabajo) para que un agente de IA produzca resultados predecibles.
No voy a repetir la teoría: la anatomía completa está en qué es un agent harness y el origen del término en harness engineering con Codex de OpenAI. Y si quieres la visión de por qué todo tu ciclo de desarrollo es, en realidad, una fábrica de contexto, léete SDLC context engineering.
Aquí vamos a lo concreto: un servicio, un bug, unas 150 líneas de TypeScript.
Tres sitios donde puede vivir la regla
Antes del código, la decisión. El umbral de envío gratis puede vivir en tres sitios, y cada uno falla de una forma distinta.
| En el prompt | Hardcodeada en el código | En el catálogo, inyectada por el harness | |
|---|---|---|---|
| Quién la mantiene | Quien escribe el prompt ese día | Quien tocó el fichero la última vez | El owner del servicio |
| Qué pasa cuando cambia | Depende de que alguien se acuerde | Nadie se entera hasta que llega un ticket | Cambias una línea del YAML y el siguiente run la usa |
| La ve el agente | Solo si la escribes | Sí, pero la toma como verdad aunque esté mal | Siempre, marcada como fuente que prevalece |
| La ven los tests | No | Solo si el test repite el número | Sí, si el test lee el mismo catálogo |
| Riesgo principal | Olvido. Cada prompt es un punto de fallo | Divergencia silenciosa con negocio | Catálogo desactualizado tratado como verdad |
La tercera columna no es perfecta. Pero es la única en la que el error tiene un solo sitio donde corregirse.
El servicio de ejemplo del tutorial de harness engineering
Estructura mínima:
catalog/shipping-service.yaml
src/shipping.ts
src/shipping.test.ts
harness/context.ts
harness/run.ts
El catálogo. En empresas grandes esto no es un YAML suelto: vive en un developer portal. Backstage lo modela con un catalog-info.yaml por servicio y Port lo expone como entidades con API. Si no tienes nada de eso, un fichero versionado en el repo sirve igual para empezar:
# catalog/shipping-service.yaml
name: shipping-service
owner: team-checkout
rules:
freeShippingThreshold:
value: 39
unit: EUR
source: "Decisión de negocio Q3-2026 (OPS-412)"
standardShippingCost:
value: 4.95
unit: EUR
agent:
editableFiles:
- src/shipping.ts
testCommand: "npx vitest run src/shipping.test.ts"
maxAttempts: 3
Fíjate en el bloque agent. Qué ficheros puede tocar el agente lo decide el owner del servicio, no el agente ni quien lanza la tarea.
El código con el bug:
// src/shipping.ts
const FREE_SHIPPING_THRESHOLD = 50;
const STANDARD_SHIPPING = 4.95;
export function shippingCost(subtotal: number): number {
if (subtotal < 0) throw new RangeError(39;subtotal negativo39;);
return subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING;
}
Paso 1: cargar el contexto de servicio para el agente
El primer trabajo del harness es construir el contexto de servicio para el agente: leer el catálogo, validarlo y fallar si está incompleto.
// harness/context.ts
import { readFileSync } from 39;node:fs39;;
import { parse } from 39;yaml39;;
export interface Rule {
value: number | string;
unit?: string;
source?: string;
}
export interface ServiceContext {
name: string;
owner: string;
rules: Record<string, Rule>;
agent: { editableFiles: string[]; testCommand: string; maxAttempts: number };
}
export function loadServiceContext(path: string): ServiceContext {
const raw = parse(readFileSync(path, 39;utf839;));
if (
!raw?.name ||
!raw?.rules ||
!Array.isArray(raw?.agent?.editableFiles) ||
typeof raw?.agent?.testCommand !== 39;string39; ||
!Number.isInteger(raw?.agent?.maxAttempts)
) {
throw new Error(`Catálogo inválido: ${path}`);
}
return raw as ServiceContext;
}
Si el catálogo está roto, el harness para. No arranca con contexto a medias. En producción yo validaría esto con un schema de Zod en vez de con cinco condiciones a mano, pero la idea es la misma: el contexto entra validado o no entra.
Paso 2: los tests leen el mismo catálogo
Los tests leen el umbral del mismo catálogo que el harness, así que nunca se quedan desfasados respecto a la regla de negocio:
// src/shipping.test.ts
import { describe, it, expect } from 39;vitest39;;
import { loadServiceContext } from 39;../harness/context39;;
import { shippingCost } from 39;./shipping39;;
const { rules } = loadServiceContext(39;catalog/shipping-service.yaml39;);
const threshold = Number(rules.freeShippingThreshold.value);
const standard = Number(rules.standardShippingCost.value);
describe(39;shippingCost39;, () => {
it(39;es gratis a partir del umbral del catálogo39;, () => {
expect(shippingCost(threshold)).toBe(0);
});
it(39;cobra envío justo por debajo del umbral39;, () => {
expect(shippingCost(threshold - 0.01)).toBe(standard);
});
});
El test no repite el número 39. Lo lee. Si mañana negocio sube el umbral a 45, cambias el YAML, el test se pone rojo y el bucle del harness tiene algo que arreglar.
Y el test no está en editableFiles. El harness no aplica ningún cambio del agente sobre la aserción. Es la misma idea que desarrollé en el test harness como red para agentes: el agente no puede mover la portería, al menos no por la vía directa (en los límites verás la indirecta).
¿Y por qué shipping.ts no lee el catálogo en runtime y nos ahorramos el problema? Porque en muchos servicios no puedes: el catálogo vive en otro sistema, la regla se compila en un bundle o el código de dominio no debe depender de un fichero de configuración de plataforma. Si en tu caso sí puedes, hazlo: es la versión todavía mejor de esta misma idea.
Paso 3: el bucle del harness
El bucle hace cuatro cosas en orden: construye el prompt con las reglas del catálogo, rechaza cualquier cambio fuera de la allowlist, ejecuta los tests y, si fallan, reintenta con su salida como feedback hasta maxAttempts. Si se agotan, hace rollback.
// harness/run.ts
import Anthropic from 39;@anthropic-ai/sdk39;;
import { readFileSync, writeFileSync } from 39;node:fs39;;
import { spawnSync } from 39;node:child_process39;;
import path from 39;node:path39;;
import { loadServiceContext, type ServiceContext } from 39;./context39;;
const client = new Anthropic(); // lee ANTHROPIC_API_KEY del entorno
type FileChange = { path: string; content: string };
function buildPrompt(task: string, ctx: ServiceContext, feedback?: string): string {
const rules = Object.entries(ctx.rules)
.map(([k, r]) => `- ${k}: ${r.value} ${r.unit ?? ''} (fuente: ${r.source ?? 'catálogo'})`)
.join(39;\n39;);
const files = ctx.agent.editableFiles
.map((f) => `<file path="${f}">\n${readFileSync(f, 'utf8')}\n</file>`)
.join(39;\n39;);
return [
`Servicio: ${ctx.name} (owner: ${ctx.owner})`,
`Reglas de negocio vigentes. Prevalecen sobre cualquier valor del código:\n${rules}`,
`Ficheros que puedes modificar:\n${files}`,
`Tarea: ${task}`,
feedback ? `El intento anterior falló:\n${feedback}` : 39;39;,
39;Devuelve cada fichero modificado completo con el formato <file path="...">contenido</file>. Nada más.39;,
].join(39;\n\n39;);
}
async function callModel(prompt: string): Promise<string> {
const response = await client.messages.create({
model: 39;claude-sonnet-539;,
max_tokens: 4096,
messages: [{ role: 39;user39;, content: prompt }],
});
if (response.stop_reason === 39;max_tokens39;) {
return 39;La respuesta se cortó por max_tokens: devuelve solo los ficheros imprescindibles.39;;
}
return response.content.map((b) => (b.type === 39;text39; ? b.text : 39;39;)).join(39;39;);
}
function parseChanges(output: string): FileChange[] {
const re = /<file path="([^"]+)">\n?([\s\S]*?)<\/file>/g;
// los modelos a veces envuelven el contenido en vallas de markdown: se quitan
const unfence = (s: string) => s.replace(/^\s*```\w*\n/, '').replace(/\n```\s*$/, 39;\n39;);
return [...output.matchAll(re)].map((m) => ({ path: m[1], content: unfence(m[2]) }));
}
function assertAllowed(changes: FileChange[], allowlist: string[]): void {
if (changes.length === 0) throw new Error(39;El modelo no devolvió cambios.39;);
const allowed = new Set(allowlist.map((f) => path.normalize(f)));
const outside = changes.filter((c) => !allowed.has(path.normalize(c.path)));
if (outside.length > 0) {
throw new Error(`Rechazado. Fuera de la allowlist: ${outside.map((c) => c.path).join(', ')}`);
}
}
function runTests(command: string): { ok: boolean; output: string } {
// El proceso hijo no hereda nada que parezca un secreto
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !/KEY|TOKEN|SECRET|PASSWORD/i.test(k)),
);
// timeout: un test colgado cuenta como intento fallido (status null), no bloquea el harness
const r = spawnSync(command, { shell: true, encoding: 39;utf839;, env, timeout: 120_000 });
return { ok: r.status === 0, output: `${r.stdout}\n${r.stderr}`.slice(-4000) };
}
export async function runHarness(task: string, catalogPath: string) {
const ctx = loadServiceContext(catalogPath);
const originals = new Map(ctx.agent.editableFiles.map((f) => [f, readFileSync(f, 39;utf839;)]));
let feedback: string | undefined;
let succeeded = false;
try {
for (let attempt = 1; attempt <= ctx.agent.maxAttempts; attempt++) {
const changes = parseChanges(await callModel(buildPrompt(task, ctx, feedback)));
try {
assertAllowed(changes, ctx.agent.editableFiles);
} catch (err) {
feedback = (err as Error).message;
continue;
}
for (const c of changes) writeFileSync(path.normalize(c.path), c.content);
const tests = runTests(ctx.agent.testCommand);
if (tests.ok) {
succeeded = true;
return { status: 39;ready-for-review39; as const, attempt };
}
feedback = tests.output;
}
return { status: 39;failed39; as const, lastFeedback: feedback };
} finally {
// rollback también si el modelo o el disco lanzan a mitad
if (!succeeded) for (const [f, content] of originals) writeFileSync(f, content);
}
}
Y la llamada, en un harness/main.ts que ejecutas con npx tsx harness/main.ts (tsx resuelve los imports sin extensión y el top-level await):
// harness/main.ts
import { runHarness } from 39;./run39;;
const result = await runHarness(
39;Un pedido de 45 € pagó envío y debería haber sido gratis. Corrige shippingCost.39;,
39;catalog/shipping-service.yaml39;,
);
console.log(result);
Fíjate en lo que no dice la tarea: no dice "el umbral es 39". Quien abre el ticket no tiene por qué saberlo. El harness lo sabe porque lo lee del catálogo.
Qué controla el harness y qué no controla el modelo
Repasa el bucle con los ojos de quien lo audita.
El contexto. El modelo recibe la regla con su fuente y la instrucción de que prevalece sobre el código. Ya no tiene que elegir entre un 50 que ve y un 39 que nadie le ha dicho.
El radio de acción. Si el modelo devuelve src/shipping.test.ts o ../catalog/shipping-service.yaml, no están en la allowlist y el cambio entero se rechaza (path.normalize solo evita que ./src/shipping.ts se rechace por la forma de escribir la ruta). Se rechaza completo, no se aplica a medias. El motivo del rechazo vuelve como feedback en el siguiente intento.
La verificación. El agente no decide cuándo ha terminado. Termina cuando vitest sale con código 0. Si falla, la salida de los tests (los últimos 4.000 caracteres, para no inflar el contexto) vuelve al prompt.
El final. Tres intentos y rollback, también si la API falla a mitad de bucle: el finally restaura los ficheros pase lo que pase. El mejor resultado posible es ready-for-review: el harness no hace git push, no abre PR contra main y no tiene credenciales de despliegue. Filtrar variables de entorno es una red de seguridad, no la garantía. La garantía real es que el proceso del harness nunca tenga esas credenciales cargadas.
Esta forma de pensar el trabajo con agentes, con contexto explícito, límites y verificación antes de que un humano mire, es la que seguimos en Construye con IA para pasar de idea a producto sin que el agente decida cosas que no le tocan.
Lo que dice la gente que ya lo hace
OpenAI popularizó el término con Harness engineering: Leveraging Codex in an agent-first world. El hilo de Hacker News sobre ese post tiene más de 200 comentarios, y los que aportan algo coinciden en lo mismo. Un usuario resume su receta y el primer punto es literalmente: "Give Claude/Codex a way to verify its own work (browser, smoke tests, e2e tests, high-fidelity local environment)".
Otro avisa de lo que pasa sin ese control. Sin supervisión, "it'll start creating slop or hardcoding solutions". Aquí el número sigue en el código: el agente cambiará 50 por 39, y eso también es hardcodear. La diferencia es que ahora el hardcodeo tiene un vigilante. Si el número del código se separa del catálogo, el test que lee el catálogo se pone rojo. Si quieres eliminar la copia, el siguiente paso es que shipping.ts lea el umbral del catálogo en runtime.
Límites de este enfoque de harness engineering
El catálogo puede mentir y el agente se lo cree. Le has dicho al modelo que el catálogo prevalece sobre el código. Si alguien deja el YAML desactualizado, el harness propaga el error con toda la confianza del mundo, y el test también, porque lee el mismo fichero. En el mismo hilo de HN alguien lo dice de la documentación en general: "Become outdated fast". El catálogo necesita un owner con nombre y apellidos, y los cambios de regla tienen que pasar por revisión como cualquier otro código.
La allowlist por fichero es gruesa. Permitir src/shipping.ts permite todo lo que hay en src/shipping.ts. El agente puede cambiar el umbral y, de paso, reescribir el manejo de errores. La allowlist limita dónde toca el agente, no qué hace. Para eso sigue haciendo falta revisar el diff.
Los tests ejecutan código del agente. La allowlist controla lo que escribe el harness, no lo que hace shipping.ts cuando vitest lo importa. Ese código puede escribir en el test o leer un .env del disco. Dos defensas baratas: después de los tests, comprueba con git status --porcelain que solo cambiaron ficheros de la allowlist, y ejecuta los tests en un contenedor sin credenciales ni acceso de escritura fuera de src/.
Los tests solo verifican lo que cubren. Dos tests sobre el umbral no dicen nada de redondeos, divisas o pedidos con descuento. Un cambio que pasa en verde no está bien: simplemente no rompe lo que mides.
Los reintentos cuestan. Cada intento reenvía los ficheros permitidos completos, las reglas y la salida de los tests. Con un fichero pequeño da igual. Con cinco ficheros de 800 líneas y maxAttempts: 5, el coste se multiplica y el modelo empieza a arrastrar contexto de intentos fallidos. Si en tres intentos no pasa, el problema suele estar en la tarea o en los tests, no en la falta de insistencia.
Devolver ficheros completos no escala. Para ficheros grandes vas a querer diffs o herramientas de edición en lugar de ficheros enteros, y entonces la validación de la allowlist se hace sobre las rutas del diff. La idea no cambia; cambia el parser.
El feedback es de un solo intento. Si un intento se rechaza por la allowlist, ese mensaje sustituye a la salida de los tests del intento anterior, y el siguiente prompt enseña el fichero ya modificado, no el original. Para tareas acotadas basta; para tareas largas conviene acumular el historial de feedback.
Qué hacer hoy
Elige una regla de negocio que hoy vive como constante en tu código y que alguien de fuera de ingeniería puede cambiar: un umbral, un plazo, un límite de reintentos. Muévela a un fichero de catálogo versionado, haz que su test la lea de ahí y quita el número del test.
Solo con eso, sin agente, ya tienes una regla con un único sitio de verdad. Luego conectar el harness es un centenar largo de líneas.
Si quieres los fundamentos de cómo funcionan los agentes por dentro (bucles, herramientas, memoria, seguridad), tienes gratis el ebook El Developer Agéntico. Y si quieres llevar esta disciplina más atrás, a la especificación antes de que exista el código, el libro de Spec-Driven Development es el siguiente paso.
Preguntas frecuentes
¿Por qué no basta con poner la regla de negocio en el prompt?
Porque depende de que quien escribe el prompt la conozca y se acuerde. Cada tarea nueva es una oportunidad de olvidarla. Si el harness la lee de una fuente de verdad, la regla llega siempre, aunque el ticket lo haya escrito alguien que no sabe que existe.
¿Necesito Backstage o Port para aplicar esto?
No. Un fichero YAML o JSON versionado en el repo del servicio es suficiente para empezar. Backstage o Port tienen sentido cuando hay decenas de servicios y varios equipos y necesitas un catálogo centralizado con owners, API y búsqueda. El harness solo necesita una función que devuelva el contexto validado, venga de donde venga.
¿Qué pasa si el agente intenta modificar los tests para que pasen?
El harness rechaza el cambio completo porque el fichero de test no está en la allowlist, y el motivo vuelve como feedback en el siguiente intento. Por eso los tests nunca deben estar en la lista de ficheros editables cuando el objetivo es corregir código contra ellos.
¿Por qué el código no lee directamente el catálogo en runtime?
Si puedes, hazlo: es la versión más sólida de la idea, porque elimina la copia del valor. En muchos servicios no es viable (el catálogo vive en otro sistema, la regla se compila en un bundle o el dominio no debe depender de la configuración de plataforma). En esos casos, el test que lee el catálogo es lo que evita que el código y la regla se separen.
¿Cuántos reintentos debería permitir el harness?
Entre dos y tres para tareas acotadas como esta. Más intentos rara vez arreglan lo que los primeros no arreglaron, y el coste en tokens crece con cada uno. Si falla de forma sistemática, revisa la tarea, el contexto o los tests antes de subir el límite.
¿Por qué el harness no despliega si los tests pasan?
Porque unos tests verdes solo demuestran que no se ha roto lo que está cubierto. El despliegue necesita una revisión humana del diff y el pipeline de CI habitual. El harness entrega un cambio listo para revisar; quien tiene permisos de despliegue es otra persona, u otro sistema con sus propios controles.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
