Cost-free mode for Claude Code. Helper subagents (Explore and friends) run on Haiku, and a summarize_free tool lets Claude hand big files and logs to a free…

Spend Claude on thinking, not chores. A Claude Code mod.

A real, unedited session: Claude hands a 6,000 line log to free Gemini and gets back a timeline.
Two ways to spend Claude on thinking instead of chores:
summarize_free, a tool Claude can call to hand a big file or log to a free Gemini model and get back only what it needs. Gemini models are tried in order (Lite first), so one being busy doesn't stop it.A line above the prompt counts what went where; /frugal status adds all-time totals.
An honest note from making the demo: for "find the error" questions Claude reaches for Grep, which is cheaper still. summarize_free earns its place on summaries, timelines and questions grep can't answer, or whenever you ask for it.
Get a free key at aistudio.google.com, then either set GEMINI_API_KEY or run /plugin configure frugal@nerfsaksham-frugal (the key goes to secure storage).
/frugal on, /frugal off, /frugal statushaiku or sonnet), which subagent types count as helpersThe file you ask it to summarise, one call to Gemini per summary, and GEMINI_API_KEY.
Claude Code 2.1.287 or newer runs mods out of the box. On 2.1.273 to 2.1.286, start it with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 first.
claude plugin marketplace add saksham10arora-dotcom/claude-frugal
claude plugin install frugal@nerfsaksham-frugal
Every option has a default, so the installer's "userConfig options not yet set" note is safe to ignore.
Or try it without installing:
git clone https://github.com/saksham10arora-dotcom/claude-frugal && claude --plugin-dir claude-frugal
claude plugin validate .
claude plugin test .
Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.
MIT. Made by Saksham Arora (@nerfsaksham).
hooks/register.ts 178 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// frugal: spend Claude on thinking, not on chores.
4// 1. Helper subagents (Explore and the like) that don't ask for a model run on
5// the helper model, Haiku by default.
6// 2. A summarize_free tool: Claude hands a big file or log to a free Gemini
7// model and gets back only what it needs, instead of reading it whole.
8// A one-line band above the prompt counts what was offloaded this session.
9
10const TOOL = 'summarize_free'
11const MAX_CHARS = 600_000 // what one Gemini call is sent at most
12const MUTED = '#8a8f98'
13const GREEN = '#3ecf8e'
14
15type Counts = { helpers: number; summaries: number; chars: number }
16const ZERO: Counts = { helpers: 0, summaries: 0, chars: 0 }
17
18const list = (v: unknown, fallback: string) =>
19 String(typeof v === 'string' && v.trim() ? v : fallback)
20 .split(',')
21 .map(s => s.trim())
22 .filter(Boolean)
23
24const asCounts = (v: unknown): Counts => {
25 const o = (v && typeof v === 'object' ? v : {}) as Partial<Counts>
26 return { helpers: Number(o.helpers) || 0, summaries: Number(o.summaries) || 0, chars: Number(o.chars) || 0 }
27}
28
29// This session's counts. Module state, like the pet's animation loop: a hot
30// reload starts it over.
31let session: Counts = { ...ZERO }
32
33async function bump($: EngineInterface, add: Partial<Counts>) {
34 session = {
35 helpers: session.helpers + (add.helpers ?? 0),
36 summaries: session.summaries + (add.summaries ?? 0),
37 chars: session.chars + (add.chars ?? 0),
38 }
39 const life = asCounts(await $.store.get('lifetime'))
40 await $.store.set('lifetime', {
41 helpers: life.helpers + (add.helpers ?? 0),
42 summaries: life.summaries + (add.summaries ?? 0),
43 chars: life.chars + (add.chars ?? 0),
44 })
45 $.ui.invalidate('ui.render')
46}
47
48const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
49const kchars = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
50
51export const register: Register = (on, options) => {
52 const helperModel = String(options.helperModel || 'haiku')
53 const helperTypes = new Set(list(options.helperTypes, 'Explore,claude-code-guide,statusline-setup'))
54 const geminiModels = list(options.geminiModels, 'gemini-flash-lite-latest,gemini-3.5-flash-lite,gemini-flash-latest')
55 const configuredKey = typeof options.geminiKey === 'string' ? options.geminiKey.trim() : ''
56
57 let enabled = true
58
59 on('session.start', async ($, e, next) => {
60 const r = await next(e)
61 enabled = (await $.store.get('enabled')) !== false
62 await $.command.register({
63 name: 'frugal',
64 description: 'Cost-free mode: helper agents on Haiku, big reads on free Gemini (on, off, status)',
65 argumentHint: '[on | off | status]',
66 immediate: true,
67 })
68 await $.tool.register({
69 name: TOOL,
70 description:
71 'Summarise, or answer a question about, a LARGE file or text (logs, build or test output, long docs, ' +
72 'generated files, anything over about 500 lines) using a free model, so the main context and the ' +
73 "user's plan limits are spared. Pass `path` (a file) or `text`, and `question`: what you need from it. " +
74 'Returns only that. Prefer it to reading a huge file whole when you need the gist or one fact; ' +
75 'read the exact lines yourself when you need precise code to edit.',
76 inputSchema: {
77 type: 'object',
78 properties: {
79 path: { type: 'string', description: 'A file to read, relative to the working directory or absolute' },
80 text: { type: 'string', description: 'The text itself, when there is no file' },
81 question: { type: 'string', description: 'What you need from it; empty means a short summary' },
82 },
83 },
84 })
85 return r
86 })
87
88 on('command.run', { command: 'frugal' }, async ($, e) => {
89 const arg = String(e.args ?? '').trim().toLowerCase()
90 if (arg === 'on' || arg === 'off') {
91 enabled = arg === 'on'
92 await $.store.set('enabled', enabled)
93 $.ui.invalidate('ui.render')
94 } else if (arg && arg !== 'status') {
95 return { text: 'Try /frugal on, /frugal off or /frugal status.' }
96 }
97 const life = asCounts(await $.store.get('lifetime'))
98 const key = configuredKey || (await $.env.get('GEMINI_API_KEY'))
99 const state = enabled ? `on: helper agents run on ${helperModel}` : 'off: every agent keeps the model it asks for'
100 return {
101 text:
102 `frugal is ${state}.\n` +
103 `This session: ${plural(session.helpers, 'helper agent')} on ${helperModel}, ` +
104 `${plural(session.summaries, 'big read')} on free Gemini (${kchars(session.chars)} characters summarised for free).\n` +
105 `All time: ${plural(life.helpers, 'helper agent')}, ${plural(life.summaries, 'big read')}, ${kchars(life.chars)} characters.\n` +
106 (key ? '' : 'No Gemini key yet: set GEMINI_API_KEY, or `claude plugin configure frugal`.'),
107 }
108 })
109
110
111 // A helper that names no model gets the cheap one. One that asks for a model
112 // keeps it, and forks always inherit their parent's.
113 on('agent.spawn', async ($, e, next) => {
114 if (!enabled || e.fork || e.model || !helperTypes.has(e.subagentType)) return next(e)
115 const r = await next({ ...e, model: helperModel })
116 await bump($, { helpers: 1 })
117 return r
118 })
119
120 on('tool.call', { tool: 'mcp__frugal__summarize_free' }, async ($, e) => {
121 const input = e as unknown as { path?: unknown; text?: unknown; question?: unknown }
122 const key = configuredKey || (await $.env.get('GEMINI_API_KEY'))
123 if (!key) return { result: 'summarize_free has no Gemini key. Read the file directly instead.', isError: true }
124
125 let body = typeof input.text === 'string' ? input.text : ''
126 const path = typeof input.path === 'string' ? input.path : ''
127 if (!body && path) {
128 try {
129 body = String(await $.fs.read(path))
130 } catch (err) {
131 return { result: `Could not read ${path}: ${String(err)}`, isError: true }
132 }
133 }
134 if (!body) return { result: 'Pass `path` or `text`.', isError: true }
135
136 const question = typeof input.question === 'string' && input.question.trim() ? input.question.trim() : 'Summarise it in under 200 words: what it is, and anything that looks wrong.'
137 const clipped = body.length > MAX_CHARS
138 const prompt =
139 'You are helping a coding agent that cannot afford to read this whole thing. ' +
140 'Answer only from the content below. Be concise and concrete: quote exact names, ' +
141 'numbers, error messages and line fragments when they matter. If the answer is not in it, say so.\n\n' +
142 `Question: ${question}\n\n----- ${path || 'text'}${clipped ? ` (first ${MAX_CHARS} characters of ${body.length})` : ''} -----\n` +
143 body.slice(0, MAX_CHARS)
144
145 for (const model of geminiModels) {
146 const res = await $.http.fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
147 method: 'POST',
148 headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
149 body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
150 })
151 if (!res.ok) continue // busy or out of quota: try the next model
152 let answer = ''
153 try {
154 const j = JSON.parse(res.text) as { candidates?: { content?: { parts?: { text?: string }[] } }[] }
155 answer = (j.candidates?.[0]?.content?.parts ?? []).map(p => p.text ?? '').join('').trim()
156 } catch {
157 continue
158 }
159 if (!answer) continue
160 await bump($, { summaries: 1, chars: Math.min(body.length, MAX_CHARS) })
161 return { result: `${answer}\n\n(summarised by ${model} from ${body.length} characters${clipped ? ', clipped' : ''})` }
162 }
163 return { result: 'Every Gemini model was busy or out of quota. Read the file directly instead.', isError: true }
164 })
165
166 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
167 const original = await next(e)
168 if (!enabled || session.helpers + session.summaries === 0) return original
169 const { Box, Text } = $.ui.resolve(e)
170 const parts = [
171 session.helpers ? `${plural(session.helpers, 'helper')} on ${helperModel}` : '',
172 session.summaries ? `${plural(session.summaries, 'big read')} on free Gemini, ${kchars(session.chars)} chars summarised free` : '',
173 ].filter(Boolean)
174 const line = Text({ wrap: 'truncate', children: [Text({ color: GREEN, children: '● frugal ' }), Text({ color: MUTED, children: parts.join(' · ') })] })
175 return original ? Box({ flexDirection: 'column', children: [original, line] }) : line
176 })
177}
178