
Un hook PreToolUse refuse les lectures de fichiers au-delà d'un seuil de lignes et renvoie l'agent vers un skill qui fait résumer le fichier par un modèle moins cher. Sur un fichier de 8 861 lignes et 305 215 octets, le contexte de l'agent principal encaisse 1 595 octets au lieu de 305 215, soit un rapport de 191 pour 1. Chez Spotify, le même montage fait tomber d'environ 90 % les tokens de lecture d'un monorepo Java.
Pourquoi ta facture Claude Code explose
Regarder le ShortLa chaîne YouTube Gekkode publie d’autres vidéos sur l’IA et le code.
S’abonner pour soutenir la chaîneUne lecture de fichier n’a l’air de rien tant qu’on ne la compte pas : le wp-includes/post.php d’une installation WordPress pèse 8 861 lignes et 305 215 octets, soit de l’ordre de 87 000 tokens qui entrent dans le contexte et y restent jusqu’au prochain /clear. Spotify a publié le 3 septembre 2026 un billet expliquant comment ses équipes ont fait tomber cette dépense d’environ 90 %, et le mécanisme se reproduit sans leur plateforme.
Ce que Spotify a réellement mesuré
Portal by Spotify est la distribution commerciale de Backstage. Le billet, signé Dimitri Mazmanov, y décrit des modes : des agents déclaratifs qui tournent sur un runtime éphémère, avec leur propre modèle et leurs propres outils MCP attachés. Deux modes portent l’essentiel du gain : bulk-reader, qui répond à une question en lisant plusieurs fichiers, et code-writer, qui produit du code répétitif à partir de motifs existants. Les exemples de l’article les font tourner sur Gemini 2.5 Flash.
Côté Claude Code, c’est le plugin shunt du dépôt public spotify/portal-ai-plugins (Apache-2.0) qui impose le détour. Son fichier de documentation décrit trois couches : des hooks PreToolUse, check-file-size et check-bash-read, qui bloquent les lectures au-delà de 350 lignes, seuil réglable par la variable d’environnement SHUNT_MIN_LINES, des scripts bash qui appellent la CLI Portal, des skills Markdown qui expliquent à l’agent quand s’en servir. L’économie annoncée tourne autour de 90 % sur la lecture en masse d’un monorepo Java.
Le chiffre qui pique est ailleurs dans le billet : un quart des responsables d’ingénierie interrogés dépenseraient déjà 200 à 500 dollars par développeur et par mois en tokens, certains au-delà de 2 000. Sur Hacker News, le fil ouvert le 4 septembre (269 points et 173 commentaires quand je l’ai lu, le 7) ne conteste pas le principe mais son périmètre : un commentateur rapporte qu’un modèle bon marché a laissé passer un défaut de concurrence subtil, plusieurs rappellent que l’agent principal finit souvent par relire les fichiers lui-même, ce qui annule le gain. La règle qui revient le plus souvent tient en une phrase : le petit modèle a le droit de désigner, pas de décider.
Pourquoi une lecture de fichier coûte-t-elle si cher ?
La version 2.1.257 de Claude Code, publiée le 1er septembre 2026, a fait de Claude Fable 5.1 le modèle par défaut : contexte d’un million de tokens, 10 $ par million de tokens en entrée, 50 $ en sortie, 0,25 $ le million en lecture de cache. Reprenons le fichier du début.
| Opération sur post.php | Tokens | Coût, tarif Fable 5.1 |
|---|---|---|
Lecture intégrale par Read | ≈ 87 000 en entrée | 0,87 $ |
| Le même contenu relu depuis le cache | ≈ 87 000 en lecture de cache | 0,02 $ |
| Réponse d’un modèle ouvrier (1 595 octets) | ≈ 456 en entrée | 0,005 $ |
Ces 87 000 tokens ne se paient pas une fois. Ils sont renvoyés à chaque tour suivant. Tant que le cache tient, ils repassent à 0,02 $ le tour, dès qu’il est froid, la note repart à 0,87 $. La documentation d’Anthropic le dit sans détour : une question d’une ligne posée dans une session ouverte depuis la matinée tire de l’usage pour toute la conversation. Elle donne aussi les ordres de grandeur observés en entreprise, environ 13 $ par développeur et par jour actif, 150 à 250 $ par mois, avec 90 % des utilisateurs sous 30 $ par jour.
Écrire le hook PreToolUse
Un hook PreToolUse reçoit sur son entrée standard le JSON de l’appel d’outil, tool_name, tool_input, cwd, permission_mode, et rend sa décision sur sa sortie standard. Trois issues : un code de sortie 0 avec {} laisse le flux de permissions habituel décider, un code 0 avec un objet hookSpecificOutput tranche, un code 2 bloque quoi qu’il arrive, avec le message pris sur stderr. Pour ce montage, c’est la deuxième forme qu’il faut : refuser et expliquer où aller.
Le hook se déclare dans le settings.json du projet, donc versionnable avec le dépôt :
{
"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…"
}
]
}
]
}
}Et voici le script, dans la version que j’ai exécutée :
#!/usr/bin/env bash
# bulk-read-guard.sh — hook PreToolUse.
# Entrée : le JSON du hook sur stdin. Sortie : JSON sur stdout, code de sortie 0.
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 0Trois détails comptent. Le matcher couvre Read et Bash, sans quoi l’agent contourne le refus avec un cat. Une lecture déjà bornée par un limit sous le seuil passe sans discussion, sinon le hook renvoie l’agent vers le skill en boucle. Et le permissionDecisionReason s’adresse à l’agent, pas à vous, c’est le texte qu’il lit pour décider de la suite, donc il doit nommer le chemin exact du script à lancer. Voici ce que le hook renvoie sur un refus :
{
"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é."
}Le skill derrière le hook
Le hook sait dire non, il ne sait pas travailler. Le skill s’en charge, et seules ses métadonnées, nom et description, occupent le contexte tant qu’il n’est pas déclenché, là où les mêmes consignes placées dans un CLAUDE.md sont chargées à chaque session. Le format est celui du standard décrit dans notre guide des Agent Skills : un dossier, un SKILL.md, des scripts à côté.
---
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.Le script appelé par le skill tient en une douzaine de lignes. La commande du modèle ouvrier passe par une variable, ce qui permet de la remplacer pendant les essais :
#!/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_CMDCe qu’a donné le banc d’essai
Un hook se teste sans agent : il lit du JSON sur son entrée standard et rend sa décision sur sa sortie. J’ai donc écrit un banc d’essai qui fabrique les charges utiles décrites dans la documentation des hooks, les envoie au script et compare la décision rendue à celle attendue. Dix cas, dix conformités :
$ 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=0Le cas 09 est le plus instructif : sed -n '1,4000p' passe. Le hook ne connaît que cat, head et tail, et n’importe quelle autre commande de lecture le traverse. Un hook réduit une dépense moyenne, il ne ferme pas une porte.
Reste la partie modèle. J’ai branché la variable SHUNT_CMD sur codex exec avec GPT-6 Astra, celui du comparatif Astra contre Fable 5.1, qui n’est pas un modèle bon marché, mais qui mesure la plomberie de bout en bout :
$ 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 principalTrente-six secondes, 91 805 tokens facturés au modèle ouvrier, et 1 595 octets qui reviennent à l’agent principal : un rapport de 191 pour 1 sur ce que le contexte encaisse. La réponse a été vérifiée à la main, au grep : six appels à current_user_can(), lignes 3436, 3489, 4734, 4736, 5105 et 7705, les six numéros sont exacts. Avec un substitut déterministe, un simple grep des déclarations à la place du modèle, le même montage rend 3 460 octets en 0,56 seconde : quand la question est structurelle, l’ouvrier n’a pas toujours besoin d’être un modèle.
Combien coûte Claude Code, au juste ?
À l’API, tout se lit dans la grille rappelée plus haut. À l’abonnement, la question devient celle des limites. Le 31 août 2026, la promotion « +50 % » sur les limites hebdomadaires a pris fin, remplacée par un relèvement permanent de 25 % de la limite de base, soit, pour qui profitait de la promotion, environ 17 % de moins qu’auparavant, ce qui a occupé un fil de Hacker News le jour même. Dans les deux cas, la commande /usage est le point de départ : elle affiche le coût de la session, la répartition par modèle, et depuis la version 2.1.251 une ligne « Prompt cache » qui donne la part des tokens d’entrée servis par le cache et le nombre de défauts. Sur un abonnement, elle ajoute la part de consommation attribuée aux skills, aux sous-agents, aux plugins et à chaque serveur MCP.
Les leviers à activer avant d’installer quoi que ce soit
- Le cache de prompt. Il dure une heure sur abonnement, cinq minutes sur des crédits d’usage ou une clé d’API. Si la ligne « Prompt cache » de
/usageannonce des défauts, cherchez la cause avant toute autre optimisation : une définition d’outil qui bouge en cours de session suffit à tout réécrire. - Les sous-agents. La sortie verbeuse d’une suite de tests ou d’un journal reste dans leur contexte, seul le résumé remonte. Pour les tâches simples, la documentation recommande
model: haikudans la configuration du sous-agent : c’est la version intégrée de l’idée de Spotify, sans plateforme à installer. - Le ménage dans les skills. Un skill chargé mais jamais invoqué paie quand même ses métadonnées à chaque session. La commande
/skill-doctor, arrivée avec la version 2.1.261 du 4 septembre, liste précisément ceux-là et leur coût en contexte, nous lui consacrons un article entier.
Spotify n’a d’ailleurs rien inventé sur ce point. La documentation des coûts donne son propre exemple de hook de filtrage, un PreToolUse qui réécrit la commande de test pour ne remonter que les échecs, en utilisant le champ updatedInput plutôt qu’un refus. Réécrire plutôt que refuser est souvent plus doux, l’agent ne perd pas son tour.
Ce qu’il faut retenir
- Le montage tient en deux fichiers : un hook
PreToolUsequi refuse au-delà d’un seuil de lignes, un skill qui fait faire le travail ailleurs. - Le refus doit passer par
permissionDecision: "deny"et unpermissionDecisionReasonqui nomme le script de rechange, pas par un code de sortie 2. - Sur un fichier de 8 861 lignes, l’agent principal encaisse 1 595 octets au lieu de 305 215, soit 191 pour 1.
- Couvrez
Bashautant queRead, et laissez passer les lectures déjà bornées par unlimit. - Avant d’installer quoi que ce soit :
/usage, le cache de prompt, les sous-agents en modèle bon marché.
Erreurs fréquentes
Read et oublier Bash L'agent contourne le refus avec cat. Le matcher doit couvrir Read|Bash et le script inspecter .tool_input.command.Read avec un limit sous le seuil ne coûte presque rien. Sans ce test, le hook renvoie l'agent vers le skill en boucle et vous payez le détour pour rien.permissionDecision: deny et un permissionDecisionReason qui nomme le script.sed -n '1,4000p' passe, comme toute commande de lecture non prévue. Un hook fait baisser une moyenne, il ne garantit rien..env, les dumps et tout ce qui porte des identifiants.


