
Un hook PreToolUse rechaza las lecturas de archivos por encima de un umbral de líneas y redirige al agente hacia un skill que hace resumir el archivo con un modelo más barato. Sobre un archivo de 8.861 líneas y 305.215 bytes, el contexto del agente principal recibe 1.595 bytes en lugar de 305.215, una proporción de 191 a 1. En Spotify, el mismo montaje reduce en un 90 % aproximadamente los tokens de lectura de un monorepo Java.
Leer un archivo no parece gran cosa hasta que lo cuentas: el wp-includes/post.php de una instalación de WordPress pesa 8.861 líneas y 305.215 bytes, del orden de 87.000 tokens que entran en el contexto y se quedan ahí hasta el siguiente /clear. Spotify publicó el 3 de septiembre de 2026 una entrada explicando cómo sus equipos redujeron ese gasto en un 90 % aproximadamente, y el mecanismo se puede reproducir sin su plataforma.
Lo que midió Spotify exactamente
Portal by Spotify es la distribución comercial de Backstage. La entrada, firmada por Dimitri Mazmanov, describe ahí unos modes: agentes declarativos que se ejecutan sobre un runtime efímero, cada uno con su propio modelo y sus propias herramientas MCP asociadas. Dos modes concentran la mayor parte de la ganancia: bulk-reader, que responde a una pregunta leyendo varios archivos, y code-writer, que genera código repetitivo a partir de patrones existentes. Los ejemplos del artículo los ejecutan sobre Gemini 2.5 Flash.
En el lado de Claude Code, es el plugin shunt del repositorio público spotify/portal-ai-plugins (Apache-2.0) el que impone el desvío. Su documentación describe tres capas: hooks PreToolUse, check-file-size y check-bash-read, que bloquean las lecturas por encima de 350 líneas, un umbral configurable con la variable de entorno SHUNT_MIN_LINES, scripts bash que llaman a la CLI de Portal, skills en Markdown que explican al agente cuándo usarlos. El ahorro anunciado ronda el 90 % en la lectura masiva de un monorepo Java.
La cifra que más escuece está en otra parte de la entrada: una cuarta parte de los responsables de ingeniería encuestados ya gastarían entre 200 y 500 dólares por desarrollador y mes en tokens, algunos por encima de 2.000. En Hacker News, el hilo abierto el 4 de septiembre (269 puntos y 173 comentarios cuando lo leí, el día 7) no discute el principio sino su alcance: un comentarista cuenta que un modelo barato dejó pasar un fallo de concurrencia sutil, varios recuerdan que el agente principal a menudo acaba releyendo los archivos él mismo, lo que anula la ganancia. La regla que más se repite cabe en una frase: el modelo pequeño tiene derecho a señalar, no a decidir.
¿Por qué cuesta tanto leer un archivo?
La versión 2.1.257 de Claude Code, publicada el 1 de septiembre de 2026, convirtió a Claude Fable 5.1 en el modelo predeterminado: contexto de un millón de tokens, 10 $ por millón de tokens de entrada, 50 $ de salida, 0,25 $ el millón en lectura de caché. Retomemos el archivo del principio.
| Operación sobre post.php | Tokens | Coste, tarifa Fable 5.1 |
|---|---|---|
Lectura completa con Read | ≈ 87.000 de entrada | 0,87 $ |
| El mismo contenido releído desde la caché | ≈ 87.000 en lectura de caché | 0,02 $ |
| Respuesta de un modelo obrero (1.595 bytes) | ≈ 456 de entrada | 0,005 $ |
Esos 87.000 tokens no se pagan una sola vez. Se reenvían en cada turno siguiente. Mientras la caché aguanta, vuelven a costar 0,02 $ por turno, en cuanto se enfría, la cuenta vuelve a 0,87 $. La documentación de Anthropic lo dice sin rodeos: una pregunta de una línea planteada en una sesión abierta desde por la mañana arrastra el consumo de toda la conversación. También da los órdenes de magnitud observados en empresas: unos 13 $ por desarrollador y día activo, de 150 a 250 $ al mes, con el 90 % de los usuarios por debajo de 30 $ al día.
Escribir el hook PreToolUse
Un hook PreToolUse recibe por su entrada estándar el JSON de la llamada a la herramienta, tool_name, tool_input, cwd, permission_mode, y devuelve su decisión por la salida estándar. Tres desenlaces: un código de salida 0 con {} deja decidir al flujo de permisos habitual, un código 0 con un objeto hookSpecificOutput zanja la cuestión, un código 2 bloquea pase lo que pase, con el mensaje tomado de stderr. Para este montaje hace falta la segunda forma: denegar y explicar adónde ir.
El hook se declara en el settings.json del proyecto, así que se puede versionar junto con el repositorio:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/bulk-read-guard.sh",
"timeout": 10,
"statusMessage": "Contrôle de la taille du fichier…"
}
]
}
]
}
}Y aquí está el script, en la versión que ejecuté:
#!/usr/bin/env bash
# Una lectura ya acotada por debajo del umbral casi no cuesta nada.
if est_entier "$limite" && [ "$limite" -le "$SEUIL" ]; then laisser_passer; fi
;;
Bash)
commande=$(printf '%s' "$charge" | jq -r '.tool_input.command // empty')
printf '%s' "$commande" | grep -Eq '^[[:space:]]*(cat|head|tail)([[:space:]]|$)' || laisser_passer
# head -n 40 y tail -20 siguen siendo baratos.
borne=$(printf '%s' "$commande" \
| sed -nE "s/.*-n[[:space:]]*([0-9]+).*/\1/p;s/.*[[:space:]]-([0-9]+).*/\1/p" | head -1)
if est_entier "$borne" && [ "$borne" -le "$SEUIL" ]; then laisser_passer; fi
for jeton in $commande; do
case "$jeton" in -*) continue ;; esac
if [ -f "$jeton" ]; then fichier="$jeton"; break; fi
done
;;
*) laisser_passer ;;
esac
[ -n "$fichier" ] && [ -f "$fichier" ] || laisser_passer
lignes=$(wc -l < "$fichier" | tr -d ' ')
[ "$lignes" -gt "$SEUIL" ] || laisser_passer
jq -n --arg f "$fichier" --arg l "$lignes" --arg s "$SEUIL" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ($f + " fait " + $l + " lignes, au-dessus du seuil de " + $s
+ ". Passez par le skill bulk-read : "
+ ".claude/skills/bulk-read/scripts/resume-fichier.sh " + $f + " \"votre question\"."
+ " Lecture directe autorisée uniquement avec un limit sous le seuil.")
},
systemMessage: ("bulk-read : " + $f + " (" + $l + " lignes) dévié vers le modèle bon marché.")
}'
exit 0
# facturados al modelo obrero
$ wc -lc < resultats/resume-codex.txt
10 1595 # lo que llega al agente principal
set -uo pipefail
set -f
SEUIL="${SHUNT_MIN_LINES:-350}"
laisser_passer() { echo '{}'; exit 0; }
est_entier() { case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }
charge=$(cat)
outil=$(printf '%s' "$charge" | jq -r '.tool_name // empty')
fichier=""
case "$outil" in
Read)
fichier=$(printf '%s' "$charge" | jq -r '.tool_input.file_path // empty')
limite=$(printf '%s' "$charge" | jq -r '.tool_input.limit // empty')
# Une lecture déjà bornée sous le seuil ne coûte presque rien.
if est_entier "$limite" && [ "$limite" -le "$SEUIL" ]; then laisser_passer; fi
;;
Bash)
commande=$(printf '%s' "$charge" | jq -r '.tool_input.command // empty')
printf '%s' "$commande" | grep -Eq '^[[:space:]]*(cat|head|tail)([[:space:]]|$)' || laisser_passer
# head -n 40 et tail -20 restent bon marché.
borne=$(printf '%s' "$commande" \
| sed -nE "s/.*-n[[:space:]]*([0-9]+).*/\1/p;s/.*[[:space:]]-([0-9]+).*/\1/p" | head -1)
if est_entier "$borne" && [ "$borne" -le "$SEUIL" ]; then laisser_passer; fi
for jeton in $commande; do
case "$jeton" in -*) continue ;; esac
if [ -f "$jeton" ]; then fichier="$jeton"; break; fi
done
;;
*) laisser_passer ;;
esac
[ -n "$fichier" ] && [ -f "$fichier" ] || laisser_passer
lignes=$(wc -l < "$fichier" | tr -d ' ')
[ "$lignes" -gt "$SEUIL" ] || laisser_passer
jq -n --arg f "$fichier" --arg l "$lignes" --arg s "$SEUIL" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ($f + " fait " + $l + " lignes, au-dessus du seuil de " + $s
+ ". Passez par le skill bulk-read : "
+ ".claude/skills/bulk-read/scripts/resume-fichier.sh " + $f + " \"votre question\"."
+ " Lecture directe autorisée uniquement avec un limit sous le seuil.")
},
systemMessage: ("bulk-read : " + $f + " (" + $l + " lignes) dévié vers le modèle bon marché.")
}'
exit 0Tres detalles importan. El matcher cubre Read y Bash, si no, el agente evita el rechazo con un cat. Una lectura ya acotada con un limit por debajo del umbral pasa sin discusión, o si no el hook devuelve al agente al skill en bucle. Y el permissionDecisionReason se dirige al agente, no a ti, es el texto que lee para decidir qué hacer a continuación, así que debe nombrar la ruta exacta del script que hay que lanzar. Esto es lo que devuelve el hook al denegar:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "wp-includes/post.php fait 8861 lignes, au-dessus du seuil de 350. Passez par le skill bulk-read : .claude/skills/bulk-read/scripts/resume-fichier.sh wp-includes/post.php \"votre question\". Lecture directe autorisée uniquement avec un limit sous le seuil."
},
"systemMessage": "bulk-read : wp-includes/post.php (8861 lignes) dévié vers le modèle bon marché."
}El skill detrás del hook
El hook sabe decir que no, no sabe trabajar. De eso se encarga el skill, y solo sus metadatos, nombre y descripción, ocupan el contexto mientras no se activa, frente a las mismas instrucciones puestas en un CLAUDE.md, que se cargan en cada sesión. El formato es el del estándar descrito en nuestra guía de Agent Skills: una carpeta, un SKILL.md, scripts al lado.
---
name: bulk-read
description: Lire, inventorier ou résumer un fichier de plus de 350 lignes sans le charger dans le contexte. À utiliser dès qu'un hook refuse une lecture pour cause de taille, avant d'ouvrir un gros fichier PHP, un journal d'erreurs ou un dump SQL.
---
# Lecture déportée vers un modèle bon marché
Le hook `bulk-read-guard.sh` refuse les lectures intégrales au-delà de 350 lignes.
Ne contournez pas le refus avec `cat` : la même règle s'applique à Bash.
## Marche à suivre
1. Formulez une question précise. « Où la validation du panier est-elle faite ? » coûte
moins cher que « résume ce fichier ».
2. Lancez le script :
```
.claude/skills/bulk-read/scripts/resume-fichier.sh <chemin> "<question>"
```
3. Le script envoie le fichier entier au modèle bon marché et ne renvoie que sa réponse,
avec des numéros de ligne.
4. Relisez ensuite les seules zones utiles, avec `Read` et un `limit` sous le seuil :
```
Read(file_path: "<chemin>", offset: 412, limit: 60)
```
## Quand ne pas s'en servir
- Fichier de moins de 350 lignes : lisez-le directement.
- Refactorisation qui doit modifier le fichier : il faut le texte exact, pas un résumé.
- Fichier contenant des secrets : le script l'envoie à un autre modèle.El script que llama el skill cabe en una docena de líneas. El comando del modelo obrero pasa por una variable, lo que permite sustituirlo durante las pruebas:
#!/usr/bin/env bash
# resume-fichier.sh — envoie un fichier entier à un modèle bon marché et ne renvoie que la réponse.
# Le fichier ne traverse jamais le contexte de l'agent principal.
set -uo pipefail
fichier="${1:?usage : resume-fichier.sh <chemin> [question]}"
question="${2:-Inventaire des classes, fonctions et points d entree, avec numeros de ligne}"
# En production : claude -p --model haiku. Pendant le test : SHUNT_CMD=./faux-modele.sh
: "${SHUNT_CMD:=claude -p --model haiku}"
{
printf 'Question : %s\n' "$question"
printf 'Reponds en 40 lignes maximum, en citant les numeros de ligne. Ne recopie pas le fichier.\n\n'
printf -- '--- %s ---\n' "$fichier"
cat -- "$fichier"
} | $SHUNT_CMDLo que dio el banco de pruebas
Un hook se prueba sin agente: lee JSON en su entrada estándar y devuelve su decisión por la salida. Escribí entonces un banco de pruebas que fabrica las cargas útiles descritas en la documentación de los hooks, las envía al script y compara la decisión devuelta con la esperada. Diez casos, diez conformidades:
$ bash test-hook.sh
Seuil : 350 lignes
Cible : wp-includes/post.php (8861 lignes)
OK 01-read-gros-fichier decision=deny code=0
OK 02-read-limit-120 decision=allow code=0
OK 03-read-limit-2000 decision=deny code=0
OK 04-read-petit-fichier decision=allow code=0
OK 05-bash-cat-gros decision=deny code=0
OK 06-bash-head-40 decision=allow code=0
OK 07-bash-grep decision=allow code=0
OK 08-read-fichier-absent decision=allow code=0
OK 09-bash-sed-plage decision=allow code=0
OK 10-edit-ignore decision=allow code=0El caso 09 es el más revelador: sed -n '1,4000p' pasa. El hook solo conoce cat, head y tail, y cualquier otro comando de lectura lo atraviesa. Un hook reduce un gasto medio, no cierra una puerta.
Queda la parte del modelo. Conecté la variable SHUNT_CMD a codex exec sobre GPT-6 Astra, el del comparativo entre Astra y Fable 5.1, que no es un modelo barato, pero que sirve para medir la fontanería de principio a fin:
$ SHUNT_CMD="codex exec --skip-git-repo-check --sandbox read-only -m gpt-6-astra -" \
./.claude/skills/bulk-read/scripts/resume-fichier.sh wp-includes/post.php \
"Ou est faite la verification des capacites (current_user_can) dans ce fichier ?"
code=0 duree=36s
tokens used 91 805 # facturés au modèle ouvrier
$ wc -lc < resultats/resume-codex.txt
10 1595 # ce qui revient à l'agent principalTreinta y seis segundos, 91.805 tokens facturados al modelo obrero, y 1.595 bytes que llegan al agente principal: una proporción de 191 a 1 sobre lo que encaja el contexto. La respuesta se comprobó a mano, con grep: seis llamadas a current_user_can(), en las líneas 3436, 3489, 4734, 4736, 5105 y 7705, los seis números son exactos. Con un sustituto determinista, un simple grep de las declaraciones en lugar del modelo, el mismo montaje entrega 3.460 bytes en 0,56 segundos: cuando la pregunta es estructural, el obrero no siempre necesita ser un modelo.
¿Cuánto cuesta Claude Code, exactamente?
En la API, todo se lee en la tabla de tarifas recordada más arriba. Con suscripción, la pregunta pasa a ser la de los límites. El 31 de agosto de 2026 terminó la promoción «+50 %» sobre los límites semanales, sustituida por un aumento permanente del 25 % del límite base, lo que, para quien se beneficiaba de la promoción, supone alrededor de un 17 % menos que antes, un asunto que ocupó un hilo de Hacker News ese mismo día. En ambos casos, el comando /usage es el punto de partida: muestra el coste de la sesión, el desglose por modelo y, desde la versión 2.1.251, una línea «Prompt cache» que indica qué parte de los tokens de entrada sirvió la caché y cuántos fallos hubo. Con una suscripción, añade además la parte del consumo atribuida a los skills, los subagentes, los plugins y cada servidor MCP.
Las palancas que activar antes de instalar nada
- La caché de prompt. Dura una hora con suscripción, cinco minutos con créditos de uso o una clave de API. Si la línea «Prompt cache» de
/usageanuncia fallos, busca la causa antes que cualquier otra optimización: basta con que la definición de una herramienta cambie a media sesión para que todo se reescriba. - Los subagentes. La salida detallada de una batería de tests o de un registro se queda en su contexto; solo sube el resumen. Para tareas simples, la documentación recomienda
model: haikuen la configuración del subagente: es la versión integrada de la idea de Spotify, sin plataforma que instalar. - La limpieza de skills. Un skill cargado pero nunca invocado paga igualmente sus metadatos en cada sesión. El comando
/skill-doctor, llegado con la versión 2.1.261 del 4 de septiembre, lista exactamente esos casos y su coste en contexto; le dedicamos un artículo entero.
Spotify, por cierto, no inventó nada en este punto. La documentación sobre costes da su propio ejemplo de hook de filtrado, un PreToolUse que reescribe el comando de test para que solo suban los fallos, usando el campo updatedInput en lugar de un rechazo. Reescribir en vez de rechazar suele ser menos brusco, el agente no pierde su turno.
Lo que hay que recordar
- El montaje cabe en dos archivos: un hook
PreToolUseque rechaza por encima de un umbral de líneas, un skill que delega el trabajo a otro sitio. - El rechazo debe pasar por
permissionDecision: "deny"y unpermissionDecisionReasonque nombre el script alternativo, no por un código de salida 2. - En un archivo de 8.861 líneas, el agente principal recibe 1.595 bytes en lugar de 305.215, es decir, 191 a 1.
- Cubre
Bashtanto comoRead, y deja pasar las lecturas ya acotadas con unlimit. - Antes de instalar nada:
/usage, la caché de prompt, los subagentes con un modelo barato.
Errores frecuentes
Read y olvidar Bash El agente evita el rechazo con cat. El matcher debe cubrir Read|Bash y el script debe inspeccionar .tool_input.command.Read con un limit por debajo del umbral cuesta casi nada. Sin esa comprobación, el hook devuelve al agente al skill en bucle y pagas el desvío para nada.permissionDecision: deny junto con un permissionDecisionReason que nombre el script.sed -n '1,4000p' pasa, como cualquier comando de lectura no previsto. Un hook baja una media, no garantiza nada..env, los dumps y todo lo que lleve credenciales.

