Shunting The Boring Work: How Spotify Cut Claude Code Token Usage By 90%, And How To Do The Same Without Their Plugin
Spotify cut Claude Code token usage by 90% by routing repetitive, low judgment engineering tasks to cheaper automated tools instead of the main model, reserving Claude for decisions that genuinely need its reasoning. You can replicate the same technique locally with a simple script that delegates chores like formatting and boilerplate generation, keeping costs low without a third party plugin.
Spotify’s engineering blog published a piece in September 2026 with a title designed to stop a platform engineer mid scroll: “Portal by Spotify cut my Claude Code token usage by 90%”. The claim is narrower than the headline suggests, and the mechanism behind it is simpler than the headline suggests as well, which is exactly why it is worth understanding properly. What follows is an explanation of what the technique actually does, one script that installs the whole thing, and worked examples of a session on either side of the change. The version here sends the delegated work to DeepSeek Flash, which is cheap enough that the worker’s own tokens stop being an argument against the technique, and the same script points at a self hosted endpoint or a model on your own laptop with one environment variable, which is the configuration anyone with a data boundary to defend will want. The installer has been run end to end rather than merely written down, every saving quoted is a saving on a specific class of operation rather than on your total bill, and none of the figures here are my own benchmark. The durable idea underneath all of it, worth more than any particular implementation, is that a frontier model should not be your I/O layer.
1. The observation underneath the headline
The argument in the Spotify post begins with a line that will be familiar to anyone who has watched a coding agent work through a large repository: “Most of what an AI coding agent does for me isn’t thinking. It’s I/O.” When you ask an agent what a service does, it does not reason its way to the answer, it reads the service class, then the repository class it calls, then the configuration file that wires them together, and only once all of that text has landed in the context window does any reasoning begin. The reading is the expensive part, because every line of every file that enters the context window is charged at the frontier model’s input rate the first time it is processed, and because the file then stays in the conversation, occupying context and being sent again on every subsequent turn.
Two different costs are hiding in that last sentence and they are worth separating, because conflating them is the most common error in the commentary around this technique. The first is context consumption, which is absolute: a file that takes twenty five thousand tokens of window is twenty five thousand tokens you no longer have for anything else, whatever it costs you, and that budget is the binding constraint on long sessions. The second is incremental money, which is where prompt caching changes the arithmetic considerably. Claude Code caches the conversation prefix by default, and Anthropic’s published pricing puts cache reads at a tenth of the base input rate with cache writes at one and a quarter times it on the five minute cache, so the file you read in the third turn is not being paid for again at full price in the thirtieth, it is being paid for at roughly a tenth of full price, repeatedly, for as long as the cache holds and the session continues. The saving from delegation is therefore real but smaller than a naive multiplication of tokens by turns suggests, and the context saving is the part that is unconditional.
The economic framing the Spotify author uses is that AI coding costs are on a path to exceed the average developer’s salary by 2028, which is a projection rather than a measurement and should be treated as such, but the underlying arithmetic does not depend on believing the projection. A two thousand line Java file is roughly twenty five thousand tokens of input, and the answer to “what does this service do” is perhaps three hundred tokens of output. You are paying frontier model prices to move twenty five thousand tokens of text so that you can receive three hundred tokens of judgement, and the judgement is the only part that needed the frontier model at all.
The fix is model routing, which as an idea is old enough to be unremarkable: use a cheap, fast model for the mechanical work, and reserve the expensive model for the parts that genuinely need it. What makes the Spotify implementation interesting is not the idea but the enforcement, and I will come back to that point several times, because it is the part most people get wrong when they try this themselves.
2. What Portal actually provides
Portal is Spotify’s commercial packaging of Backstage, and the relevant feature here is AiKA Modes, which the post describes as declarative agents running on ephemeral runtimes. A mode is configured in YAML and is defined by its instructions, its model and a small number of parameters, and the two modes that do the work in the article are published for anyone on Portal to call rather than being something you have to author yourself. Their definitions are given in roughly this shape:
name: bulk-reader
description: Bulk file reader for code analysis
model: gemini-2.5-flash
resourceLimits:
temperature: 0.2name: code-writer
description: Boilerplate code generator
model: gemini-2.5-flash
resourceLimits:
temperature: 0.2The bulk-reader mode takes a question and a set of file paths, reads the files in full, and returns a compact structured answer with file and line references rather than prose. The code-writer mode takes a specification and one or more reference files, and generates boilerplate that matches the conventions of the references. The crucial property of the second mode, and the one that makes its savings genuinely difficult to measure, is that the generated code is written to disk by the worker rather than returned through the conversation, so as the author puts it, Claude never sees the generated code.
Sitting on top of the two modes are three layers of plumbing inside Claude Code, and these are the parts you can reproduce anywhere:
- Hooks that fire before a tool call, intercept file reads above a configurable line count, and refuse them. The default threshold in the plugin is 350 lines, adjustable through a
SHUNT_MIN_LINESenvironment variable. There are two of them, one watching theReadtool and one watchingcat,head,tail,lessandmoreinvocations throughBash, because an agent that is blocked from reading a file the official way will cheerfully reach for the shell instead. - Scripts, named
bulk-readandcode-write, which wrap the call to the worker, marshal the files into it, and report what the delegation cost. - Skills, which are Markdown files telling Claude when delegation is appropriate and what the command line looks like, so that the agent reaches for the cheap path deliberately rather than only being pushed onto it by a refusal.
The reason all three layers exist is the point I promised to keep returning to. Instructions alone do not work. An agent that has been politely asked in a Markdown file to prefer a delegation script will comply for a while and then, under pressure from a task that feels urgent, read the file directly, because reading the file directly is the shortest path to the answer it has been asked for. The hook is what turns the preference into a rule, and the published gist that circulated alongside the Spotify post makes the same point rather bluntly: blocking is mandatory, not optional.
3. The numbers, stated precisely
The post reports that across four scenarios on a Java monorepo, mean bulk-read savings were around 90%. Three qualifications are worth attaching to that figure before you repeat it to anyone. It is a mean across four scenarios rather than a distribution, so the spread is unknown. It measures the bulk-read case, which is the case the technique is designed to win, rather than a whole working day of mixed activity in which plenty of turns involve no large file reads at all. And it is a saving on tokens sent to the frontier model, which is not the same as a saving on total spend, because in Spotify’s configuration the delegated call still costs something at the worker model’s rate.
The code-write case is explicitly described in the post as harder to quantify, precisely because the generated output never enters the context window, so there is no counterfactual measurement sitting anywhere to compare against. My own view is that this is the more valuable of the two modes in practice and the one you should be most careful about, for reasons I will come to in section 12.
There is now at least one independent rebuild worth reading next to the original. AIDive reconstructed the same three layer pattern on the Fastify repository using a Haiku subagent for reading, a Sonnet subagent for writing and a hook blocking reads over 350 lines, ran four scenarios twice each, and reported a 59.6% reduction in the tokens the expensive model actually saw, with total cost falling by about a third because the worker’s own tokens are not free. The same test found that elapsed time rose by around 65% on average, and that a 45 line scenario came out roughly 2.6% more expensive with delegation than without it. That is a different codebase, a different language and a different worker model, so it is a second data point rather than a refutation, and the two results together say something more useful than either alone: the size of the win is shaped by your workload, and it is mostly a win on context with a smaller win on money.
The worker this article uses is DeepSeek Flash, reached through an OpenAI compatible endpoint, and the choice moves those terms in a particular direction. The delegated tokens are not free, so the cost side behaves like AIDive’s configuration rather than better than it, and the ratio between what you pay the worker and what you save on the frontier model is the number that decides whether the technique pays for itself on your workload. What you get for that is a reader with a very large context window and a level of comprehension well above anything that fits on a laptop, which matters because the quality of the delegated summary is the whole risk of the pattern. Running the same script against a model on your own hardware is one environment variable, and section 10 is about when that variable is the important one.
4. Route one: install the Spotify plugin
If your organisation already runs Portal, this is the short path, and the whole installation is three marketplace commands plus one authentication step inside a session:
claude plugin marketplace add spotify/portal-ai-plugins
claude plugin install portal@portal
claude plugin install shunt@portal
claude plugin listThen, inside a Claude Code session, run /portal:setup to authenticate against your Portal instance, and ask a question about a large file to confirm that the read is intercepted rather than performed. The portal plugin carries the Portal integration and the shunt plugin carries the hooks, the wrapper scripts and the skills described in section 2, and because the bulk-reader and code-writer modes are published on Portal you do not need to author any YAML unless you want a different model or different instructions. The threshold moves through settings, either in the project at .claude/settings.json or globally at ~/.claude/settings.json:
{ "env": { "SHUNT_MIN_LINES": "500" } }Two notes before you take that path. The marketplace and plugin names are taken from the Spotify post, so if the repository has been renamed since publication, claude plugin marketplace add will tell you immediately rather than failing quietly. And this route assumes you have a Portal instance and that your delegated reads will run on whatever model that instance is configured with, which is a question for section 10 rather than a detail.
5. Route two: one script, with DeepSeek Flash as the worker
Most readers do not have Portal, and the technique does not require it. What the plugin gives you is a managed runtime, a published pair of modes and someone else’s maintenance burden, and what it does not give you is any part of the mechanism that could not be reproduced in a couple of hundred lines of bash. The version below uses DeepSeek Flash as the worker, reached over the OpenAI compatible endpoint at https://api.deepseek.com, because it is inexpensive, it takes a very large context so most files go over in a single call, and the open weights behind it are published under the MIT licence, which means the same worker can later be moved inside your own estate without rewriting anything but one variable.
Export your key first, with export DEEPSEEK_API_KEY=sk-..., then paste the whole block below into a terminal. It writes itself to install-shunt.sh, makes that file executable and runs it, so there is nothing to save by hand and nothing to copy in pieces. What it then installs is the two workers, the guard hook, a diagnostic script and the skill, with the hook wired into settings.json through jq so that whatever is already in there survives, and a check that the endpoint answers and is not silently truncating what you send it. It is idempotent, so pasting it again after changing a setting, or running ./install-shunt.sh with a different environment variable, is the normal way to reconfigure. Without a key it still lays down every file and tells you what is missing rather than failing half way.
cat > install-shunt.sh <<'SHUNT_EOF'
#!/usr/bin/env bash
# install-shunt.sh
# Delegates bulk file reading and boilerplate generation in Claude Code to DeepSeek
# Flash, and hard blocks the expensive path so the delegation actually happens.
# Requires: bash 4+, curl, jq, and a DeepSeek API key in DEEPSEEK_API_KEY.
# To keep the work inside your own estate instead, point SHUNT_API_BASE at a
# self hosted endpoint (the V4-Flash weights are MIT) or at ollama on this machine.
set -euo pipefail
CLAUDE_HOME="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
SHUNT_HOME="$CLAUDE_HOME/shunt"
SKILL_HOME="$CLAUDE_HOME/skills/shunt"
SETTINGS="$CLAUDE_HOME/settings.json"
MIN_LINES="${SHUNT_MIN_LINES:-350}" # reads at or above this are blocked
CHUNK_LINES="${SHUNT_CHUNK_LINES:-6000}" # lines per worker call
WORKER_MODEL="${SHUNT_MODEL:-deepseek-flash}"
API_BASE="${SHUNT_API_BASE:-https://api.deepseek.com}"
API_KEY="${SHUNT_API_KEY:-${DEEPSEEK_API_KEY:-}}"
for bin in curl jq; do
command -v "$bin" >/dev/null || { echo "install-shunt: '$bin' is required" >&2; exit 1; }
done
mkdir -p "$SHUNT_HOME" "$SKILL_HOME"
# ---------------------------------------------------------------- worker: read
cat > "$SHUNT_HOME/bulk-read" <<'WORKER'
#!/usr/bin/env bash
# bulk-read --question "..." --paths FILE [FILE...]
# Reads whole files with the worker model and prints a short cited answer.
set -euo pipefail
API_BASE="${SHUNT_API_BASE:-https://api.deepseek.com}"
MODEL="${SHUNT_MODEL:-deepseek-flash}"
API_KEY="${SHUNT_API_KEY:-${DEEPSEEK_API_KEY:-}}"
CHUNK=${SHUNT_CHUNK_LINES:-6000}
[[ -n "$API_KEY" ]] || { echo "bulk-read: set DEEPSEEK_API_KEY (or SHUNT_API_KEY)" >&2; exit 1; }
question=""; paths=()
while [[ $# -gt 0 ]]; do
case "$1" in
--question) question="${2:-}"; shift 2 ;;
--paths) shift; while [[ $# -gt 0 && "$1" != --* ]]; do paths+=("$1"); shift; done ;;
-h|--help) echo "usage: bulk-read --question TEXT --paths FILE [FILE...]"; exit 0 ;;
*) echo "bulk-read: unexpected argument '$1'" >&2; exit 2 ;;
esac
done
[[ -n "$question" && ${#paths[@]} -gt 0 ]] || { echo "usage: bulk-read --question TEXT --paths FILE [FILE...]" >&2; exit 2; }
SYSTEM='You are a precise code analyst. Every line you are given is prefixed with its
real line number followed by a colon: quote those numbers, never your own count, and
never a range you did not see. Answer the question in terse bullets, no preamble and no
restatement of the question, and cite path:line for every claim. If what you were given
does not answer the question, say so in one bullet instead of guessing.'
ask() { # ask SYSTEM_PROMPT USER_PROMPT -> answer on stdout
local payload resp
payload=$(jq -n --arg m "$MODEL" --arg s "$1" --arg u "$2" \
'{model:$m, temperature:0.2, messages:[{role:"system",content:$s},{role:"user",content:$u}]}')
resp=$(curl -sS --fail-with-body -X POST "$API_BASE/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
--data-binary "$payload")
if [[ $(jq -r '.choices[0].finish_reason // ""' <<<"$resp") == "length" ]]; then
echo "bulk-read: worker hit its output limit, answer may be truncated" >&2
fi
jq -r '.choices[0].message.content // empty' <<<"$resp"
}
total_chars=0; notes=""; calls=0
for p in "${paths[@]}"; do
[[ -f "$p" ]] || { echo "bulk-read: no such file: $p" >&2; exit 1; }
lines=$(wc -l < "$p" | tr -d ' ')
total_chars=$(( total_chars + $(wc -c < "$p") ))
start=1
while [[ $start -le $lines || $start -eq 1 ]]; do
end=$(( start + CHUNK - 1 ))
[[ $lines -gt 0 && $end -gt $lines ]] && end=$lines
# nl -ba numbers blank lines too, so the worker sees real coordinates.
slice=$(nl -ba -w1 -s': ' -- "$p" | sed -n "${start},${end}p")
[[ -n "$slice" ]] || break
notes+=$(ask "$SYSTEM" "QUESTION
$question
FILE $p, lines $start to $end
$slice")$'\n'
calls=$(( calls + 1 ))
start=$(( end + 1 ))
done
done
if [[ $calls -gt 1 ]]; then
# Reduce: merge the per chunk notes into one answer.
ask 'Merge these notes into one answer. Keep every path:line citation exactly as
given, drop duplicates and anything that does not bear on the question, and output
terse bullets only.' "QUESTION
$question
NOTES
$notes"
else
printf '%s\n' "$notes"
fi
printf '[shunt] %d worker call(s) over %d file(s), %d bytes kept out of the expensive context\n' \
"$calls" "${#paths[@]}" "$total_chars" >&2
WORKER
# --------------------------------------------------------------- worker: write
cat > "$SHUNT_HOME/code-write" <<'WORKER'
#!/usr/bin/env bash
# code-write --spec "..." --reference FILE [--reference FILE] --target PATH
# Generates boilerplate with the worker and writes it straight to disk, so the
# generated code never passes through the expensive model's context.
set -euo pipefail
API_BASE="${SHUNT_API_BASE:-https://api.deepseek.com}"
MODEL="${SHUNT_MODEL:-deepseek-flash}"
API_KEY="${SHUNT_API_KEY:-${DEEPSEEK_API_KEY:-}}"
[[ -n "$API_KEY" ]] || { echo "code-write: set DEEPSEEK_API_KEY (or SHUNT_API_KEY)" >&2; exit 1; }
spec=""; target=""; refs=()
while [[ $# -gt 0 ]]; do
case "$1" in
--spec) spec="${2:-}"; shift 2 ;;
--reference) refs+=("${2:-}"); shift 2 ;;
--target) target="${2:-}"; shift 2 ;;
-h|--help) echo "usage: code-write --spec TEXT --reference FILE [--reference FILE] --target PATH"; exit 0 ;;
*) echo "code-write: unexpected argument '$1'" >&2; exit 2 ;;
esac
done
[[ -n "$spec" && -n "$target" && ${#refs[@]} -gt 0 ]] || {
echo "usage: code-write --spec TEXT --reference FILE [--reference FILE] --target PATH" >&2; exit 2; }
context=""
for r in "${refs[@]}"; do
[[ -f "$r" ]] || { echo "code-write: no such reference: $r" >&2; exit 1; }
context+="<reference path=\"$r\">"$'\n'"$(cat -- "$r")"$'\n'"</reference>"$'\n\n'
done
system='You generate code that matches the conventions of the reference files exactly:
same imports, same naming, same test framework, same formatting. Output only the file
contents. No explanation, no commentary, no markdown fences.'
payload=$(jq -n --arg m "$MODEL" --arg s "$system" --arg spec "$spec" --arg t "$target" --arg c "$context" \
'{model:$m, temperature:0.2, messages:[{role:"system",content:$s},
{role:"user",content:("TARGET FILE\n" + $t + "\n\nSPEC\n" + $spec + "\n\nREFERENCES\n" + $c)}]}')
resp=$(curl -sS --fail-with-body -X POST "$API_BASE/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
--data-binary "$payload")
mkdir -p "$(dirname -- "$target")"
tmp="$target.shunt.$$"
trap 'rm -f "$tmp"' EXIT
jq -r '.choices[0].message.content // empty' <<<"$resp" \
| sed -e '1{/^```/d}' -e '${/^```$/d}' > "$tmp"
# Never overwrite a real file with a bad generation: check the candidate first.
bytes=$(wc -c < "$tmp" | tr -d ' ')
if [[ "${bytes:-0}" -lt 20 ]]; then
echo "code-write: worker returned $bytes bytes, refusing to write $target" >&2
jq -r '.choices[0].finish_reason // "no finish_reason"' <<<"$resp" >&2; exit 1
fi
if head -c 200 "$tmp" | grep -Eqi '^[[:space:]]*(null|as an ai|sorry|i[^[:alpha:]]{0,2}(m| am)? ?(sorry|afraid)|i (cannot|can.t|am unable))'; then
echo "code-write: worker returned a refusal or null, refusing to write $target" >&2; exit 1
fi
if [[ -f "$target" ]]; then
cp -p -- "$target" "$target.bak"
echo "code-write: previous contents kept at $target.bak" >&2
fi
mv -- "$tmp" "$target" # atomic within the same filesystem
trap - EXIT
printf 'wrote %s (%s lines). Review with: git diff -- %s\n' \
"$target" "$(wc -l < "$target" | tr -d ' ')" "$target"
WORKER
# ------------------------------------------------------------------ guard hook
cat > "$SHUNT_HOME/shunt-guard.sh" <<'GUARD'
#!/usr/bin/env bash
# PreToolUse guard: denies whole file reads at or above SHUNT_MIN_LINES and tells the
# agent to delegate instead. Reads the hook payload on stdin, answers on stdout.
set -uo pipefail
MIN=${SHUNT_MIN_LINES:-350}
READER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bulk-read"
payload=$(cat)
tool=$(jq -r '.tool_name // empty' <<<"$payload")
deny() {
jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",
permissionDecision:"deny", permissionDecisionReason:$r}}'
exit 0
}
too_big() { # too_big FILE -> line count on stdout, non zero exit if small
[[ -f "$1" ]] || return 1
local n; n=$(wc -l < "$1" 2>/dev/null | tr -d ' ') || return 1
[[ ${n:-0} -ge $MIN ]] || return 1
echo "$n"
}
case "$tool" in
Read)
file=$(jq -r '.tool_input.file_path // empty' <<<"$payload")
limit=$(jq -r '.tool_input.limit // empty' <<<"$payload")
[[ -n "$limit" ]] && exit 0 # a windowed read is already cheap
[[ "$file" == *.claude/* ]] && exit 0 # never block config and skills
if n=$(too_big "$file"); then
deny "$file is $n lines, above the $MIN line delegation threshold. Do not read it whole.
Either run: $READER --question \"<your question>\" --paths $file
or re-read with an explicit offset and limit if you need specific lines."
fi
;;
Bash)
cmd=$(jq -r '.tool_input.command // empty' <<<"$payload")
grep -Eq '(^|[;&|] *)(cat|less|more|head|tail) ' <<<"$cmd" || exit 0
grep -Eq '(^| )-(n|c) *[0-9]+' <<<"$cmd" && exit 0 # already a bounded window
for word in $cmd; do
[[ "$word" == -* ]] && continue
if n=$(too_big "$word"); then
deny "$word is $n lines, above the $MIN line delegation threshold. Do not page it into context.
Run instead: $READER --question \"<your question>\" --paths $word"
fi
done
;;
esac
exit 0
GUARD
# ---------------------------------------------------------------- doctor check
cat > "$SHUNT_HOME/shunt-doctor" <<'DOCTOR'
#!/usr/bin/env bash
# Checks that the worker is reachable and is not silently truncating prompts.
set -uo pipefail
API_BASE="${SHUNT_API_BASE:-https://api.deepseek.com}"
MODEL="${SHUNT_MODEL:-deepseek-flash}"
API_KEY="${SHUNT_API_KEY:-${DEEPSEEK_API_KEY:-}}"
PROBE_LINES=${SHUNT_PROBE_LINES:-2500}
marker="SHUNT-$RANDOM$RANDOM"
filler=$(yes 'int filler = 0; // padding line to push the marker back through the context' \
| head -n "$PROBE_LINES")
prompt="MARKER: $marker
$filler
Reply with the marker code from the top of this message and nothing else."
start=$(date +%s)
resp=$(curl -sS --fail-with-body -X POST "$API_BASE/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
--data-binary "$(jq -n --arg m "$MODEL" --arg u "$prompt" \
'{model:$m, temperature:0, messages:[{role:"user",content:$u}]}')") || {
echo "doctor: cannot reach $API_BASE. Is the model server running?" >&2; exit 1; }
elapsed=$(( $(date +%s) - start ))
answer=$(jq -r '.choices[0].message.content // empty' <<<"$resp")
printf 'doctor: %s answered a ~%d line prompt in %ds\n' "$MODEL" "$PROBE_LINES" "$elapsed"
if grep -qF "$marker" <<<"$answer"; then
echo "doctor: context OK, the top of the prompt survived"
else
echo "doctor: FAILED. The marker at the top of the prompt did not come back, which"
echo "doctor: usually means the endpoint truncated it. Hosted DeepSeek Flash should"
echo "doctor: not, so suspect a proxy in front of it; with a self hosted ollama the"
echo "doctor: OpenAI endpoint ignores num_ctx, so build a variant with a bigger"
echo "doctor: context or set OLLAMA_CONTEXT_LENGTH. Answer was: ${answer:0:120}"
exit 1
fi
DOCTOR
chmod +x "$SHUNT_HOME/bulk-read" "$SHUNT_HOME/code-write" \
"$SHUNT_HOME/shunt-guard.sh" "$SHUNT_HOME/shunt-doctor"
# ----------------------------------------------------------------------- skill
cat > "$SKILL_HOME/SKILL.md" <<'SKILL'
---
name: shunt
description: Delegate bulk file reading and boilerplate generation to the DeepSeek Flash worker. Use when a question needs several large files read, when a file is over the line threshold, or when generating tests, configs, DTOs or other boilerplate that follows an existing reference file.
---
## When to delegate
Delegate the reading when answering needs whole files rather than a few lines:
"what does this service do", "where is X configured", "which classes implement Y".
```bash
~/.claude/shunt/bulk-read --question "What does this service do and what does it call?" \
--paths src/main/java/com/acme/OrderService.java src/main/java/com/acme/OrderRepo.java
```
Delegate the writing when the output follows an existing pattern: tests mirroring a
sibling test, config files, DTOs, fixtures, migrations.
```bash
~/.claude/shunt/code-write --spec "JUnit 5 tests covering every public method of UserService, including the null email case" \
--reference src/test/java/com/acme/OrderServiceTest.java \
--reference src/main/java/com/acme/UserService.java \
--target src/test/java/com/acme/UserServiceTest.java
```
## When not to delegate
Do not delegate edits to existing files, debugging, concurrency or security analysis,
architecture decisions, or anything where being subtly wrong is expensive. Do not
delegate small reads: a delegation costs seconds to tens of seconds per call, so
reading a 40 line file directly is both cheaper and faster.
## After delegating
Treat generated files as a draft: read the diff, not the whole file, and fix the
handful of lines that are wrong rather than regenerating. If a delegated answer looks
thin or oddly generic, run `~/.claude/shunt/shunt-doctor` before trusting it.
SKILL
# -------------------------------------------------------------------- settings
tmp=$(mktemp)
[[ -f "$SETTINGS" ]] || echo '{}' > "$SETTINGS"
jq --arg guard "$SHUNT_HOME/shunt-guard.sh" --arg min "$MIN_LINES" \
--arg chunk "$CHUNK_LINES" --arg model "$WORKER_MODEL" --arg base "$API_BASE" '
.env = ((.env // {}) + {SHUNT_MIN_LINES:$min, SHUNT_CHUNK_LINES:$chunk,
SHUNT_MODEL:$model, SHUNT_API_BASE:$base})
| .hooks = (.hooks // {})
| .hooks.PreToolUse = ((.hooks.PreToolUse // [])
| map(select(.hooks[0].command != $guard))
+ [{matcher:"Read|Bash", hooks:[{type:"command", command:$guard, timeout:5000}]}])
' "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"
# ---------------------------------------------------------------- worker check
if [[ -z "$API_KEY" ]]; then
echo
echo "install-shunt: no API key found, so the worker was not tested."
echo "install-shunt: add this to your shell profile and re-run:"
echo "install-shunt: export DEEPSEEK_API_KEY=sk-..."
echo "install-shunt: keep the key in the environment, not in settings.json."
else
echo "install-shunt: checking the worker"
SHUNT_API_BASE="$API_BASE" SHUNT_MODEL="$WORKER_MODEL" SHUNT_API_KEY="$API_KEY" \
"$SHUNT_HOME/shunt-doctor" || true
fi
cat <<EOF
shunt installed.
workers $SHUNT_HOME/bulk-read, $SHUNT_HOME/code-write
guard $SHUNT_HOME/shunt-guard.sh (blocks whole reads at $MIN_LINES lines and above)
doctor $SHUNT_HOME/shunt-doctor (re-run any time the answers look thin)
skill $SKILL_HOME/SKILL.md
settings $SETTINGS
worker $WORKER_MODEL at $API_BASE, $CHUNK_LINES lines per call
Your source files are sent to that endpoint, so read section 10 before pointing this
at anything you do not control. To keep the work inside your own estate, export
SHUNT_API_BASE for a self hosted endpoint, or for ollama on this machine:
export SHUNT_API_BASE=http://127.0.0.1:11434/v1 SHUNT_MODEL=shunt-worker SHUNT_API_KEY=ollama
Start a new Claude Code session so the hook and the skill are picked up, then ask it
something that needs a large file. The read should be refused and delegated.
EOF
SHUNT_EOF
chmod +x install-shunt.sh
./install-shunt.shThe outer wrapper is a quoted heredoc, which matters for two reasons worth knowing if you adapt it. Quoting the delimiter stops the shell expanding anything inside while it writes the file, so $HOME and ${SHUNT_MIN_LINES:-350} reach the script intact rather than being resolved at paste time. And the delimiter is SHUNT_EOF rather than the conventional EOF because the script itself ends with a heredoc terminated by EOF, which would otherwise close the outer one early and leave you with half a file. The whole block is a little under four hundred lines, and the only dependency beyond Ollama is jq.
6. What the script sets up, and the trap it steps over for you
Six details are worth calling out, because each one is a mistake I would rather you did not have to make yourself.
The first is that the key lives in the environment and never in settings.json. The installer reads DEEPSEEK_API_KEY, passes it to the workers at run time and writes only the endpoint and the model name into settings, because a settings file is the kind of thing that ends up in a dotfiles repository, and a key in a dotfiles repository is a bad afternoon.
The second is that the script verifies the worker rather than assuming it. shunt-doctor puts a random marker on the first line of a deliberately long prompt, asks the worker to echo the marker back, and fails loudly if it does not come back, because a marker that vanishes is the signature of a prompt that was silently truncated somewhere between you and the model. It also reports how long the call took, which is what you want before setting the threshold. This check matters most if you later point the script at something you host yourself: Ollama’s OpenAI compatible endpoint, for instance, does not accept a context length parameter and its stock context is small enough that a large file simply does not fit, and rather than failing it truncates and answers anyway. The fix there is a derived model with the context baked in, which is a three line Modelfile, and the doctor is how you find out you needed it:
FROM <your base model>
PARAMETER num_ctx 32768
PARAMETER temperature 0.2The third is line numbering. Every file is piped through nl -ba before it reaches the worker, so the model is reading real line numbers printed in front of real lines rather than counting in its head. Spotify is explicit that delegated summaries do not produce line numbers reliable enough to edit against, and numbering the input does not fully solve that, but it removes the most avoidable source of the problem and it makes the instruction to cite path:line an instruction the worker can actually follow.
The fourth is chunking. bulk-read slices each file into runs of SHUNT_CHUNK_LINES lines, asks the question of each slice, and, if there was more than one slice, runs a final pass that merges the notes and drops duplicates. With DeepSeek Flash the default of six thousand lines means almost everything goes over in a single call and the merge never runs, so the machinery is there for the case where you move the worker onto hardware with a smaller window, where a tighter chunk is the difference between a real answer and a confident summary of the last part of a file. The line numbers survive the slicing because the numbering happens first.
The fifth is that code-write never writes the model’s response straight over your file. The response goes to a temporary file, which is checked for being implausibly short, for being a literal null and for the handful of refusal openings models produce when they decline, and only then is it moved into place atomically, with any previous contents preserved alongside as a .bak. The check is a heuristic rather than a validator, so the real safety net remains that you are working in git, but the failure mode of a truncated response destroying an existing file is worth engineering out rather than writing about afterwards.
The sixth is the set of escape hatches in the guard. It exits silently when the Read call carries a limit, so a targeted window into a large file still works, it refuses to touch anything under a .claude directory, because blocking the agent from reading its own settings produces a baffling failure the first time you debug it, and the settings edit removes any previously installed copy of the same hook before appending, so reinstalling does not leave you with two hooks arguing about the same read. The settings file it produces looks like this:
{
"env": {
"SHUNT_MIN_LINES": "350",
"SHUNT_CHUNK_LINES": "6000",
"SHUNT_MODEL": "deepseek-flash",
"SHUNT_API_BASE": "https://api.deepseek.com"
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Bash",
"hooks": [
{ "type": "command", "command": "/home/you/.claude/shunt/shunt-guard.sh", "timeout": 5000 }
]
}
]
}
}If you would rather trial this in one repository than across your account, move the hooks and env blocks into .claude/settings.json inside that project, which is also the honest way to evaluate it, since the first week will teach you more about your threshold than any blog post can.
7. What a session looks like without the shunt
Take a concrete task on a Java service: “explain how OrderService handles a failed payment and tell me where the retry count is configured”. The relevant code is spread across a 1,200 line service class, a 400 line repository and a 600 line properties file, and the session proceeds like this:
> explain how OrderService handles a failed payment, and where is the retry count configured?
● Read(src/main/java/com/acme/OrderService.java)
⎿ Read 1200 lines
● Read(src/main/java/com/acme/OrderRepository.java)
⎿ Read 400 lines
● Read(src/main/resources/application.properties)
⎿ Read 600 lines
● On a payment failure OrderService.handlePaymentFailure (line 812) marks the order
PAYMENT_FAILED, publishes an OrderFailedEvent, and schedules a retry through
RetryScheduler. The retry count comes from orders.payment.retry.max, set to 3 in
application.properties line 214.Three reads of roughly 2,200 lines together are somewhere around twenty seven thousand tokens of input, and they buy about a hundred tokens of answer. Those twenty seven thousand tokens then sit in the conversation for the rest of the session, occupying window that later work cannot use and going back to the model on every turn at the cached rate.
8. The same session with the shunt in place
With the guard installed, the first read never happens. The hook fires before the tool call, returns a deny decision, and the reason string is handed back to the agent as the tool result, which is what turns a refusal into a redirection:
> explain how OrderService handles a failed payment, and where is the retry count configured?
● Read(src/main/java/com/acme/OrderService.java)
⎿ Denied by hook: OrderService.java is 1200 lines, above the 350 line delegation
threshold. Do not read it whole. Either run: ~/.claude/shunt/bulk-read
--question "<your question>" --paths OrderService.java, or re-read with an
explicit offset and limit if you need specific lines.
● Bash(~/.claude/shunt/bulk-read --question "How is a failed payment handled, and where
is the retry count configured?" --paths src/main/java/com/acme/OrderService.java
src/main/java/com/acme/OrderRepository.java
src/main/resources/application.properties)
⎿ - OrderService.handlePaymentFailure sets status PAYMENT_FAILED
(OrderService.java:812)
- publishes OrderFailedEvent on the orders.failed topic (OrderService.java:831)
- schedules retry via RetryScheduler.schedule with backoff
(OrderService.java:840)
- retry ceiling read from orders.payment.retry.max
(application.properties:214, value 3)
[shunt] 1 worker call(s) over 3 file(s), 109648 bytes kept out of the
expensive context
● On a payment failure the order is marked PAYMENT_FAILED and an OrderFailedEvent is
published, with the retry scheduled through RetryScheduler. The ceiling is
orders.payment.retry.max = 3, in application.properties line 214.The frontier model in the second version sees the command it ran and roughly a hundred and fifty tokens of structured result, in place of twenty seven thousand tokens of source. On that single operation the reduction in tokens reaching the expensive model is above 99%, which is higher than Spotify’s reported mean because it is one favourable case rather than an average over four, and because the tool call, the deny message and the agent’s own reasoning carry a small overhead I have not counted. The reported figure of approximately 90% across four Java monorepo scenarios is the number to quote; the arithmetic here illustrates the mechanism rather than benchmarking it. All three files fit in one worker call at the default chunk size, and that single call is the ten to thirty seconds you wait for.
9. The code-write case, where the saving is invisible
The second worker is where the accounting stops being straightforward and the practical value goes up. Ask an agent to write a test class mirroring an existing one and it will normally read the class under test, read the reference test, generate two hundred lines of JUnit into the conversation, and then write them to disk, which means those two hundred lines are billed as expensive output and then live in the context for the rest of the session. The delegated version keeps all of it outside:
~/.claude/shunt/code-write \
--spec "JUnit 5 tests covering every public method of UserService, including the null email case and the duplicate username case" \
--reference src/test/java/com/acme/OrderServiceTest.java \
--reference src/main/java/com/acme/UserService.java \
--target src/test/java/com/acme/UserServiceTest.java
# wrote src/test/java/com/acme/UserServiceTest.java (214 lines).
# Review with: git diff -- src/test/java/com/acme/UserServiceTest.javaWhat comes back into the conversation is one line of confirmation. The frontier model never sees the two hundred and fourteen lines it would otherwise have generated, which is exactly why the Spotify post says the saving here is hard to quantify: there is no measured counterfactual, only the observation that the tokens did not pass through.
This is also where discipline matters most, because an unread generated file is a liability rather than an asset, whichever model produced it. The rule I would hold to is that generated code is reviewed as a diff and never regenerated to fix a small problem: run the tests, read the failures, and have the expensive model fix the six lines that are wrong with a targeted edit. Regenerating is how you end up paying for the file three times and still not having read it.
10. The data boundary, which this version does move
This is the section to read before anyone runs the block above inside an organisation, because the change it makes is not only economic. The delegated worker receives whole source files, so installing the hook creates an automatic, unattended path by which your code leaves your estate and reaches a third party. The automation is the part that should give you pause: delegation happening without anyone choosing it is exactly the property you wanted for cost control and exactly the property you least want for data control.
The questions to settle before the first delegated read, rather than after an auditor asks, are the ordinary ones, and they are not specific to this provider. Whether the endpoint is on your approved list at all. What the retention and training terms say for the specific API tier you are buying, rather than what the marketing page says. Which jurisdiction the processing happens in and whether that satisfies your data residency position, which for a provider outside your own region is a question to answer explicitly rather than by omission. How the key is issued, scoped and rotated, and whether it is a personal key doing corporate work. And whether the code in scope carries obligations beyond your own, such as client material, regulated data or third party licences.
There are three configurations that keep the technique and move the boundary back, and the script is written so that each is one variable rather than a rewrite. Point SHUNT_API_BASE at the same model hosted inside your own tenancy, which the MIT licence on the V4-Flash weights permits, though at roughly two hundred and eighty billion parameters with thirteen billion active it wants a serious GPU box rather than a spare VM. Point it at a smaller open model on an internal server, trading comprehension for control. Or point it at Ollama on the developer’s own laptop, where nothing traverses the network boundary at all:
export SHUNT_API_BASE=http://127.0.0.1:11434/v1 SHUNT_MODEL=shunt-worker SHUNT_API_KEY=ollamaMy own position, wearing the CIO hat, is that the cheap model question is a procurement question dressed as an engineering one, and that the right order is to decide the endpoint first and the threshold second. The technique survives either answer; the approval does not.
11. Tuning, and the escape hatches you will want
Four settings matter, and the defaults are an opening position rather than an answer. SHUNT_MIN_LINES at 350 decides what gets blocked, and the trade is symmetrical: too low and you delegate reads the frontier model should have done directly, paying ten to thirty seconds of elapsed time to save a few hundred tokens, too high and the largest files, which are the ones doing the damage, slip straight past. SHUNT_CHUNK_LINES at 6000 decides how much goes over at once, and it should sit comfortably inside the worker’s context rather than at its edge, because a model near its limit degrades before it fails, so a smaller worker needs a smaller number here. SHUNT_MODEL and SHUNT_API_BASE decide which worker and which endpoint, and together they are the switch between a hosted provider, a model inside your own tenancy and a laptop. DEEPSEEK_API_KEY stays in your shell profile rather than in settings, and is worth scoping to this use so that revoking it costs you nothing else.
If your repository is mostly Kotlin or Go with files in the two hundred line range, a line count threshold is a poor proxy for cost and you would do better keying the guard on file size in bytes, which is a two line change inside too_big. The escape hatches are worth knowing too, for you as much as for the agent: a Read with an explicit offset and limit passes the guard untouched, grep and rg are never intercepted because a search returning matching lines is already the cheap shape of the operation, anything under .claude is exempt, and SHUNT_MIN_LINES=999999 claude switches the whole thing off for one session more quickly than editing settings and remembering to put them back.
12. You may not need any of this
Claude Code can do a meaningful part of the same job natively. Subagents run in their own context window and return only their result to the parent conversation, which is the same context isolation the bulk reader gives you, and the documentation recommends exactly this use of them, advising that subagents be used to keep codebase research out of the main context because reading many files consumes it. A subagent can also be pinned to a cheaper model in its frontmatter, which supplies the routing half of the idea:
---
name: bulk-reader
description: Reads large files and returns a terse, cited summary. Use for questions that need whole files rather than specific lines.
tools: Read, Glob, Grep
model: haiku
---
Read the files you are given in full and answer the delegating question in terse
bullets with path:line citations. Do not propose changes. Do not summarise what you
were not asked about.Pair that with the same guard hook and you have context isolation, model routing and enforcement using nothing but Claude Code itself, which is roughly what the independent AIDive rebuild did. What you lose relative to section 5 is that the files still go to a hosted model rather than staying on the machine, that the delegated tokens are billed rather than free, and that there is no code-write path where generated output never touches the conversation. What you gain is that there is nothing to install and nothing to maintain. If you are evaluating this for a team, try the native version first, because it is the smaller change and it will tell you within a week whether your workload contains enough large reads to justify the rest.
13. Where delegation is the wrong answer
The Spotify post is refreshingly direct about the limits, and the gist that circulated alongside it is blunter still: never delegate reasoning, debugging or architectural decisions. Four constraints deserve emphasis, and the fourth is specific to running the worker locally.
Editing is the first, and the reason is mechanical rather than intellectual. Line numbers coming back from a delegated read are not reliable enough to edit against, which is why this pattern delegates reading and whole file generation but never in place modification, and why numbering the input improves the citations without making them authoritative.
Latency is the second. A hosted delegation costs something like ten to thirty seconds, and a local one on a laptop can be slower still, particularly on the first call of a session when the model is being loaded into memory. For a two thousand line file that is an easy trade and for a forty line file it is an obviously bad one, which is the entire reason the threshold exists.
Judgement is the third and the one that actually bites. A weaker model reading a concurrency sensitive class will produce a summary that reads perfectly well and quietly omits the thing that mattered, namely that the lock is held across the network call. When you delegate reading, you are trusting a weaker model’s judgement about what is important in the file, and that summary is now the only version of the file your expensive model will ever see. The interesting problem here is not really tokens, it is lossy context compression: you are trading raw evidence for a cheaper reader’s interpretation of that evidence. For code review, security analysis, thread safety and anything where the interesting detail is the exception rather than the pattern, read it properly and pay for it.
The fourth is the ladder of readers, which is the part people forget when they tune this for cost. A large hosted worker such as DeepSeek Flash is a weaker reader than the frontier model you are protecting, and a small model on a laptop is weaker again, so every step you take down that ladder for price or for data control makes the compression lossier and the summaries blunter. That is an argument for delegating where the question is structural and the answer is checkable, and against letting any delegated summary be the only thing that ever reads a file you are about to make a decision on.
14. What I would actually adopt
Three things from this are worth taking regardless of whether Portal is anywhere near your stack. The first is the framing, which is that an agent’s bill can be dominated by moving tool output through expensive context rather than by the reasoning itself, and that this is a routing problem with a well understood solution rather than a pricing problem you have to wait out. The second is the insistence on enforcement, because an instruction the model is free to ignore under pressure is not a control, and the hook that turns guidance into a refusal is the line carrying the whole technique. The third is the reporting, because printing what each delegation kept out of context is what turns a plausible sounding optimisation into something you can put a number against at the end of the month.
If all of that compresses to one line, it is this: a frontier model should not be your I/O layer. Reading, searching, summarising, classifying, generating boilerplate and marshalling data are work for cheap workers in isolated contexts, and frontier tokens belong to ambiguity, judgement and the parts of a problem where being subtly wrong is expensive. Portal and Shunt are one implementation of that architecture, the block in section 5 pointed at DeepSeek Flash is another, the same block pointed at a model inside your own network is a third, and a Haiku subagent behind a hook is a fourth; the architecture is the durable part and the implementations are interchangeable.
What I would not do is repeat the 90% figure without its qualifiers. It is a mean over four bulk-read scenarios on one Java monorepo, measured on tokens reaching the frontier model rather than on total spend, in the case the technique is built to win, and the one independent rebuild I have found measured 59.6% on the same metric with a third off cost and a 65% increase in elapsed time on a different codebase. Install the script, run the doctor, watch what the reporting line tells you across a fortnight of your own work, and let the number you measure be the number you quote.
Sources
- Portal by Spotify cut my Claude Code token usage by 90%, Spotify Engineering
- The same post on the Spotify Portal blog
- MCP in Portal, Spotify for Backstage documentation
- Community gist generalising the pattern to other agents
- AIDive’s independent rebuild and measurement on the Fastify repository
- DeepSeek API documentation, for the OpenAI compatible endpoint and model names
- DeepSeek V4 Flash model card, for the MIT licence and the open weights
- Ollama’s OpenAI compatibility, including which parameters it accepts
- Ollama Modelfile reference, for num_ctx and ollama create
- Claude Code hooks reference
- Claude Code settings and precedence
- Claude Code plugin marketplaces
- Claude Code skills
- Claude Code subagents, for context isolation and the model field
- Claude Code best practices, on using subagents to keep research out of the main context
- How Claude Code uses prompt caching
- Anthropic pricing, for the cache read and cache write multipliers