Claude Code pricing: cut your bill with hooks and skills

Spotify claims a 90% cut in read tokens thanks to a PreToolUse hook and skills. I reproduced the setup without their platform, on an 8,861-line PHP file.

Claude Code pricing: cut your bill with hooks and skills
Quick answer

A PreToolUse hook refuses file reads past a line threshold and sends the agent to a skill that has a cheaper model summarise the file instead. On a file of 8,861 lines and 305,215 bytes, the main agent's context absorbs 1,595 bytes instead of 305,215, a ratio of 191 to 1. At Spotify, the same setup cuts the read tokens of a Java monorepo by around 90%.

A file read doesn’t look like much until you actually count it: wp-includes/post.php in a WordPress install weighs 8,861 lines and 305,215 bytes, on the order of 87,000 tokens that enter the context and stay there until the next /clear. Spotify published a post on 3 September 2026 explaining how its teams cut that expense by around 90%, and the mechanism reproduces just fine without their platform.

What Spotify actually measured

Portal by Spotify is the commercial distribution of Backstage. The post, signed by Dimitri Mazmanov, describes modes: declarative agents that run on an ephemeral runtime, with their own model and their own attached MCP tools. Two modes carry most of the gain: bulk-reader, which answers a question by reading several files, and code-writer, which produces repetitive code from existing patterns. The article’s examples run them on Gemini 2.5 Flash.

On the Claude Code side, it’s the shunt plugin from the public spotify/portal-ai-plugins repo (Apache-2.0) that forces the detour. Its documentation file describes three layers: PreToolUse hooks, check-file-size and check-bash-read, which block reads beyond 350 lines, a threshold adjustable via the SHUNT_MIN_LINES environment variable, bash scripts that call the Portal CLI, and Markdown skills that tell the agent when to use them. The claimed saving is around 90% on bulk reads of a Java monorepo.

The figure that stings is elsewhere in the post: a quarter of the engineering leads surveyed say they’re already spending 200 to 500 dollars per developer per month on tokens, some over 2,000. On Hacker News, the thread opened on 4 September (269 points and 173 comments when I read it, on the 7th) doesn’t dispute the principle, only its scope: one commenter reports that a cheap model let a subtle concurrency bug through, several point out that the main agent often ends up re-reading the files itself anyway, which cancels the gain. The rule that comes up most often fits in one sentence: the small model gets to point, not to decide.

Why does a file read cost so much?

Claude Code version 2.1.257, released on 1 September 2026, made Claude Fable 5.1 the default model: a one-million-token context, $10 per million input tokens, $50 on output, $0.25 per million on cache reads. Let’s go back to the file from the start.

Operation on post.php Tokens Cost, Fable 5.1 rate
Full read via Read ≈ 87,000 input $0.87
The same content re-read from cache ≈ 87,000 cache read $0.02
Worker model response (1,595 bytes) ≈ 456 input $0.005

Those 87,000 tokens are not paid for once. They get sent back on every following turn. As long as the cache holds, they come back at $0.02 a turn. The moment it goes cold, the bill jumps back to $0.87. Anthropic’s documentation says it plainly: a one-line question asked in a session that’s been open since the morning draws on the usage of the whole conversation. It also gives the orders of magnitude observed in the enterprise, around $13 per developer per active day, $150 to $250 a month, with 90% of users under $30 a day.

Writing the PreToolUse hook

A PreToolUse hook receives the tool call’s JSON on its standard input, tool_name, tool_input, cwd, permission_mode, and returns its decision on standard output. Three outcomes: exit code 0 with {} leaves the usual permission flow to decide, exit code 0 with a hookSpecificOutput object settles it, exit code 2 blocks no matter what, with the message taken from stderr. For this setup, it’s the second form you need: refuse and explain where to go instead.

The hook is declared in the project’s settings.json, so it can be versioned with the repo:

json
{
  "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…"
          }
        ]
      }
    ]
  }
}

And here’s the script, in the version I ran:

bash
#!/usr/bin/env bash
# bulk-read-guard.sh: PreToolUse hook.
# Input: the hook JSON on stdin. Output: JSON on stdout, exit code 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')
    # A read already capped under the threshold costs almost nothing.
    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 and tail -20 stay cheap.
    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

Three details matter. The matcher covers Read and Bash, otherwise the agent works around the refusal with a cat. A read already capped by a limit under the threshold passes without question, otherwise the hook sends the agent back to the skill in a loop. And permissionDecisionReason is addressed to the agent, not to you, it’s the text it reads to decide what to do next, so it has to name the exact path of the script to run. Here’s what the hook returns on a refusal:

json
{
  "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é."
}

The skill behind the hook

The hook knows how to say no, it doesn’t know how to work. The skill takes that on, and only its metadata, name and description, occupies the context until it’s triggered, whereas the same instructions placed in a CLAUDE.md load on every session. The format follows the standard described in our Agent Skills guide: a folder, a SKILL.md, scripts alongside it.

markdown
---
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.

The script the skill calls fits in about a dozen lines. The worker model’s command goes through a variable, which lets you swap it out during testing:

bash
#!/usr/bin/env bash
# resume-fichier.sh: sends a whole file to a cheap model and returns only the answer.
# The file never passes through the main agent's context.
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}"
# In production: claude -p --model haiku. During testing: 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_CMD

What the test bench showed

A hook can be tested without an agent: it reads JSON on standard input and returns its decision on standard output. So I wrote a test bench that builds the payloads described in the hooks documentation, sends them to the script, and compares the decision it returns against the expected one. Ten cases, ten passes:

bash
$ 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=0

Case 09 is the most instructive: sed -n '1,4000p' gets through. The hook only knows about cat, head and tail, and any other read command slips past it. A hook brings down an average expense, it doesn’t close a door.

That leaves the model side. I wired the SHUNT_CMD variable to codex exec on GPT-6 Astra, the one from our Astra vs Fable 5.1 comparison, which isn’t a cheap model, but it measures the plumbing end to end:

bash
$ 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          # billed to the worker model
$ wc -lc < resultats/resume-codex.txt
      10    1595            # what comes back to the main agent

Thirty-six seconds, 91,805 tokens billed to the worker model, and 1,595 bytes coming back to the main agent: a 191-to-1 ratio on what the context absorbs. The answer was checked by hand, with grep: six calls to current_user_can(), on lines 3436, 3489, 4734, 4736, 5105 and 7705, all six numbers check out. With a deterministic stand-in, a plain grep for the declarations instead of the model, the same setup returns 3,460 bytes in 0.56 seconds: when the question is structural, the worker doesn’t always need to be a model.

How much does Claude Code actually cost?

On the API, it’s all in the pricing grid above. On a subscription, the question becomes one of limits. On 31 August 2026, the “+50%” promotion on weekly limits ended, replaced by a permanent 25% increase to the base limit, which works out, for anyone who’d been on the promotion, to around 17% less than before, a point that occupied a Hacker News thread the same day. Either way, the /usage command is the place to start: it shows the session’s cost, the breakdown by model, and, since version 2.1.251, a “Prompt cache” line giving the share of input tokens served from cache and the number of misses. On a subscription, it also adds the share of usage attributed to skills, subagents, plugins and each MCP server.

The levers to pull before installing anything

  • The prompt cache. It lasts an hour on a subscription, five minutes on usage-based credits or an API key. If the “Prompt cache” line in /usage shows misses, look for the cause before any other optimisation: a tool definition that shifts mid-session is enough to rewrite the whole thing.
  • Subagents. The verbose output of a test suite or a log stays in their own context, only the summary comes back up. For simple tasks, the documentation recommends model: haiku in the subagent’s configuration: it’s the built-in version of Spotify’s idea, with no platform to install.
  • Tidying up your skills. A skill that’s loaded but never invoked still pays for its metadata on every session. The /skill-doctor command, which arrived with version 2.1.261 on 4 September, lists exactly those and what they cost in context. We’ve given it a whole article.

Spotify didn’t invent anything here, as it happens. The costs documentation gives a filtering-hook example of its own, a PreToolUse that rewrites the test command to surface only the failures, using the updatedInput field rather than a refusal. Rewriting instead of refusing is often gentler, the agent doesn’t lose its turn.

What to remember

  • The setup fits in two files: a PreToolUse hook that refuses past a line threshold, and a skill that gets the work done elsewhere.
  • The refusal has to go through permissionDecision: "deny" and a permissionDecisionReason that names the fallback script, not an exit code of 2.
  • On a file of 8,861 lines, the main agent absorbs 1,595 bytes instead of 305,215, a ratio of 191 to 1.
  • Cover Bash as much as Read, and let through reads already capped by a limit.
  • Before installing anything: /usage, the prompt cache, subagents on a cheap model.

Common errors

Blocking Read and forgetting Bash The agent works around the refusal with cat. The matcher has to cover Read|Bash and the script has to inspect .tool_input.command.
Refusing a read that's already capped A Read with a limit under the threshold costs almost nothing. Without that check, the hook sends the agent back to the skill in a loop and you pay for the detour for nothing.
Exiting with code 2 to block Code 2 blocks no matter what, and the message comes from stderr. To tell the agent what to do instead, exit with code 0 and a permissionDecision: deny plus a permissionDecisionReason that names the script.
Assuming the hook closes the door sed -n '1,4000p' gets through, like any read command that wasn't anticipated. A hook brings down an average, it doesn't guarantee anything.
Summarising a sensitive file The script sends the whole file to another model, often at another provider. Exclude .env files, dumps, and anything carrying credentials.

Claude CodeMCPPerformanceSkills

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.