Long Claude Code sessions get expensive in a quiet way: you step away for lunch, come back, and your next message re-reads the whole conversation at full price because the prompt cache went cold. The Claude Code cache timer mod I built shows exactly how long the cache stays warm, keeps it warm with one click, and can stop you from sending an expensive message into a cold cache by accident. This post has the complete, MIT-licensed code, so you can drop it in and run it.

By Jayesh Mali, Techqueto. Licensed under MIT.

What the Claude Code cache timer mod does

The mod adds a small card above the prompt box with four things:

  • A live countdown of the one-hour prompt cache, which turns amber and then red as it runs down.
  • A Keep warm button that arms a keep-warm window and sends a tiny question when the session has been idle for 50 minutes, well inside the hour.
  • Two slash commands: /keepwarm (with off, status, guard and an hours argument) and /cache_timer (toggle the card, or ping to warm the cache right now).
  • An optional cold-send guard that holds the first message after the cache has expired and tells you roughly how much context it would re-read uncached.

If you are new to mods, read our overview of Claude Code mods first, and our step-by-step guide to creating a Claude Code mod for how the pieces fit together.

Do this: Read the folder layout below before copying anything. The four files must sit exactly where shown, or the mod will not load.

Why a prompt cache timer is worth having

Anthropic's prompt caching lets the API reuse the part of a conversation it has already processed. In this mod, the cache is treated as living for one hour from the last read or write, so every reply you receive (and every ping) resets the clock to 60:00. If the clock reaches zero, your next message has to be processed from scratch.

The timer does not change that behaviour. It makes it visible, so the decision to step away, ping, or continue is yours instead of a surprise on your usage.

Do this: Before a long break, glance at the card. If you will be gone for well under an hour, do nothing. If you will be gone longer and the session is large, arm keep-warm.

Folder layout

```
cache-timer/
├── .claude-plugin/plugin.json
├── hooks/hooks.json
├── hooks/register.tsx
└── types/index.d.ts
```

Do this: Create the cache-timer folder with these exact names, then paste each file below into place.

File 1: .claude-plugin/plugin.json

```json
{
"name": "cache-timer",
"version": "0.3.1",
"description": "Prompt-cache card above the prompt: live countdown, keep-warm pings and an optional cold-send guard.",
"author": {
"name": "Techqueto",
"url": "https://techqueto.com"
},
"types": "./types/index.d.ts",
"license": "MIT",
"keywords": [
"switchboard-aware"
]
}
```

File 2: hooks/hooks.json

```json
{ "modules": ["./register.tsx"] }
```

File 3: types/index.d.ts

```ts
export type CacheState = {
expiresAt: number | null
secondsLeft: number | null
}

export type Warm = {
/** Keep-warm window end (epoch ms); null when not armed. */
until: number | null
/** Hold the first message after a cold return and show the rewrite cost. */
guard: boolean
lastTurnAt: number | null
lastPingAt: number | null
/** Last ping outcome, one short line. */
note: string
busy: boolean
pinging: boolean
/** The cold-send guard already held a message since the last turn. */
held: boolean
/** Input tokens of the last reply: what a cold resend would re-read. */
tokens: number
}

declare module 'claude-code' {
interface PluginState {
'mod-switchboard': { prefs: Record<string, 'on' | 'hidden' | 'off'> }
'cache-timer': { cacheState: CacheState; isHidden: boolean; warm: Warm }
}
}
```

File 4: hooks/register.tsx

This is the main file. It holds the state, the countdown tick, the ping logic, the two commands, the cold-send guard and the card itself.

```tsx
// Cache Timer — a Claude Code mod by Jayesh Mali, Techqueto (https://techqueto.com)
// MIT License · Copyright (c) 2026 Jayesh Mali / Techqueto LLP

import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register } from 'claude-code'
import type { CacheState, Warm } from '../types'

// Anthropic's prompt cache lives one hour from the last read or write.
const CACHE_TTL_MS = 60 * 60 * 1000
const CACHE_TTL_S = CACHE_TTL_MS / 1000

// Ping once the session has been idle this long, well inside the hour.
const PING_AFTER_MS = 50 * 60 * 1000
const DEFAULT_HOURS = 6
// A ping that writes this share of what it reads is not warming, it is re-caching.
const MAX_WRITE_SHARE = 0.1
// The cold-send guard only matters once the context is worth protecting.
const GUARD_MIN_PERCENT = 20

const WARN_S = 20 * 60
const ALERT_S = 10 * 60
const COLOR_OK = '#4caf6e'
const COLOR_WARN = '#e2a94e'
const COLOR_ALERT = '#e0846b'
const COLOR_WARM = '#5fa8d3'

const cacheState = atom({ plugin: 'cache-timer', key: 'cacheState' } as const, {
expiresAt: null,
secondsLeft: null,
} as CacheState)

const isHidden = atom({ plugin: 'cache-timer', key: 'isHidden' } as const, false)

const warm = atom({ plugin: 'cache-timer', key: 'warm' } as const, {
until: null,
guard: false,
lastTurnAt: null,
lastPingAt: null,
note: '',
busy: false,
pinging: false,
held: false,
tokens: 0,
} as Warm)

// The Mods board (mod-switchboard) owns this choice: on, hidden (runs, draws nothing) or off.
const board = atom({ plugin: 'mod-switchboard', key: 'prefs' } as const, {} as Record<string, 'on' | 'hidden' | 'off'>)
const modeOf = async ($: EngineInterface) => (await read($, board))['cache-timer'] ?? 'on'

const pad = (n: number) => String(n).padStart(2, '0')
const colorFor = (s: number | null) =>
s === null ? COLOR_OK : s <= ALERT_S ? COLOR_ALERT : s <= WARN_S ? COLOR_WARN : COLOR_OK
const clockText = (s: number | null) =>
s === null ? '--:--' : s === 0 ? 'EXPIRED' : ${pad(Math.floor(s / 60))}:${pad(s % 60)}
const hm = (ms: number) => {
const m = Math.max(0, Math.round(ms / 60000))
return m >= 60 ? ${Math.floor(m / 60)}h${pad(m % 60)}m : ${m}m
}
const fresh = (now: number) => ({ expiresAt: now + CACHE_TTL_MS, secondsLeft: CACHE_TTL_S })

// One tool-less question over the session's own transcript. The API serves that
// prefix from the prompt cache, which refreshes its hour; cache_read_input_tokens
// is the proof the cache was warm when we asked.
const ping = async ($: EngineInterface, why: 'auto' | 'manual') => {
if ((await read($, warm)).pinging) return
await update($, warm, w => ({ ...w, pinging: true }))

const r = await $.model.fork({ prompt: 'Reply with exactly: ok' })
const now = await $.clock.now()

if (!r.isAnswered) {
const note = r.reason === 'nothing-to-fork' ? 'nothing to ping yet: send a message first' : ping failed (${r.reason})
await update($, warm, w => ({ ...w, pinging: false, lastPingAt: now, note }))
if (why === 'auto') $.ui.toast(cache-timer: ${note}, { timeoutMs: 15000 })
return
}

const read_ = r.usage.cache_read_input_tokens
const wrote = r.usage.cache_creation_input_tokens
const isWarming = read_ > 0 && wrote < read_ * MAX_WRITE_SHARE
const note = isWarming
? pinged ${Math.round(read_ / 1000)}k read from cache
: read_ === 0
? cache was cold: ${Math.round(wrote / 1000)}k rewritten
: ping rewrote ${Math.round(wrote / 1000)}k of ${Math.round(read_ / 1000)}k

// A read or a write both leave the entry fresh for another hour.
await update($, cacheState, () => fresh(now))
await update($, warm, w => ({
...w,
pinging: false,
lastPingAt: now,
note,
// An auto ping that was not warming means the entry had lapsed: stop spending.
until: why === 'auto' && !isWarming ? null : w.until,
}))
if (why === 'auto' && !isWarming) $.ui.toast(cache-timer: keep-warm stopped, ${note}, { timeoutMs: 20000 })
}

const tick = async ($: EngineInterface) => {
if ((await modeOf($)) === 'off') return
const now = await $.clock.now()

const c = await read($, cacheState)
if (c.expiresAt !== null) {
const left = Math.max(0, Math.floor((c.expiresAt - now) / 1000))
if (left !== c.secondsLeft) await update($, cacheState, s => ({ ...s, secondsLeft: left }))
}

const w = await read($, warm)
if (w.until === null) return
if (now >= w.until) {
await update($, warm, s => ({ ...s, until: null, note: 'keep-warm window ended' }))
return
}
if (w.busy || w.pinging) return

const last = Math.max(w.lastTurnAt ?? 0, w.lastPingAt ?? 0)
if (last === 0 || now - last < PING_AFTER_MS) return
await ping($, 'auto')
}

const status = async ($: EngineInterface) => {
const w = await read($, warm)
const now = await $.clock.now()
const win = w.until === null ? 'keep-warm off' : keep-warm on, ${hm(w.until - now)} left
return ${win}; guard ${w.guard ? 'on' : 'off'}${w.note ? `; last: ${w.note} : ''}`
}

const toggleKeepWarm = async ($: EngineInterface) => {
const w = await read($, warm)
if (w.until !== null) {
await update($, warm, s => ({ ...s, until: null }))
return
}
const now = await $.clock.now()
await update($, warm, s => ({ ...s, until: now + DEFAULT_HOURS * 3600 * 1000 }))
}

const clockIcon = (color: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<circle cx="12" cy="12" r="9" fill="none" stroke="${color}" stroke-width="1.8"/>
<path d="M12 7v5l3 2" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`

const BAR_W = 140
const barIcon = (color: string, fraction: number) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BAR_W} 4" width="${BAR_W}" height="4">
<rect width="${BAR_W}" height="4" rx="2" fill="#8a8f8c" fill-opacity="0.25"/>
<rect width="${Math.max(0, Math.min(1, fraction)) * BAR_W}" height="4" rx="2" fill="${color}"/></svg>`

const barText = (fraction: number, cells: number) => {
const filled = Math.round(Math.max(0, Math.min(1, fraction)) * cells)
return '━'.repeat(filled) + '─'.repeat(cells - filled)
}

const tokensText = (n: number) => (n >= 1000 ? ${Math.round(n / 1000)}k tokens cost : n > 0 ? ${n} tokens cost : 'your context costs')

export const register: Register = on => {
on('session.start', async ($, e, next) => {
const result = await next(e)

await $.command.register({
name: 'cache_timer',
description: 'Toggle the cache band; "ping" warms the cache now',
})
await $.command.register({
name: 'keepwarm',
description: 'Keep the prompt cache warm: /keepwarm [hours|off|status|guard]',
})

const now = await $.clock.now()
if ((await read($, cacheState)).expiresAt === null) await update($, cacheState, () => fresh(now))
await update($, warm, w => ({ ...w, lastTurnAt: w.lastTurnAt ?? now, busy: false, pinging: false }))

$.clock.every(1000, () => tick($))
return result
})

on('command.run', { command: 'cache_timer' }, async ($, e) => {
if (e.args.trim().toLowerCase() === 'ping') {
await ping($, 'manual')
return { text: (await read($, warm)).note }
}
const hidden = !(await read($, isHidden))
await update($, isHidden, () => hidden)
return { text: hidden ? 'Cache band hidden.' : 'Cache band shown.' }
})

on('command.run', { command: 'keepwarm' }, async ($, e) => {
const arg = e.args.trim().toLowerCase()
const now = await $.clock.now()

if (arg === 'off') {
await update($, warm, w => ({ ...w, until: null }))
return { text: 'Keep-warm off.' }
}
if (arg === 'guard') {
const guard = !(await read($, warm)).guard
await update($, warm, w => ({ ...w, guard }))
return { text: guard ? 'Cold-send guard on.' : 'Cold-send guard off.' }
}
if (arg === 'status') return { text: await status($) }

const hours = arg === '' ? DEFAULT_HOURS : Number(arg)
if (!Number.isFinite(hours) || hours <= 0 || hours > 24) {
return { text: 'Usage: /keepwarm [hours 1-24 | off | status | guard]' }
}
await update($, warm, w => ({ ...w, until: now + hours * 3600 * 1000 }))
return { text: Keep-warm armed for ${hours}h: pings after ${PING_AFTER_MS / 60000} idle minutes. }
})

on('turn.start', async ($, e, next) => {
if (e.agentId === undefined) await update($, warm, w => ({ ...w, busy: true }))
return next(e)
})

on('turn.complete', async ($, e, next) => {
const result = await next(e)
if (e.agentId === undefined) {
const now = await $.clock.now()
await update($, cacheState, () => fresh(now))
const tokens = (await $.session.usage()).context.tokens ?? 0
await update($, warm, w => ({ ...w, busy: false, lastTurnAt: now, held: false, tokens }))
}
return result
})

// Cold-send guard: after the cache lapsed, hold the first message once and say
// what resending costs. The same text sent again goes through.
on('prompt.submit', async ($, e, next) => {
if ((await modeOf($)) === 'off') return next(e)
const w = await read($, warm)
const c = await read($, cacheState)
if (!w.guard || w.held || e.isMeta || e.command !== undefined) return next(e)
if (c.secondsLeft !== 0) return next(e)

const { context } = await $.session.usage()
const percent = context.percent ?? 0
if (percent < GUARD_MIN_PERCENT) return next(e)

await update($, warm, s => ({ ...s, held: true }))
return {
drop: Cache expired: this message re-reads about ${percent}% of the context uncached. Send it again to go ahead.,
}
})

on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (e.props.hasSurvey || (await modeOf($)) !== 'on' || (await read($, isHidden))) return next(e)

const { secondsLeft } = await read($, cacheState)
const w = await read($, warm)
const now = await $.clock.now()
const ui = $.ui.resolve(e)
const { Box, Text, Button } = ui
const Svg = 'Svg' in ui ? ui.Svg : null

const isCold = secondsLeft === 0
const isLow = secondsLeft !== null && secondsLeft > 0 && secondsLeft <= WARN_S
const color = w.pinging ? COLOR_WARM : isCold ? COLOR_ALERT : colorFor(secondsLeft)
const title = w.pinging ? 'Warming' : isCold ? 'Cache cold' : isLow ? 'Cache cooling' : 'Cache warm'
const time = clockText(secondsLeft)
const fraction = secondsLeft === null ? 0 : secondsLeft / CACHE_TTL_S
const cost = tokensText(w.tokens)
const left = w.until === null ? '' : hm(w.until - now)

const lead = w.pinging
? 'Sending a small question to refresh the cache.'
: isCold
? 'Cache expired.'
: isLow
? Expires in ${Math.max(1, Math.ceil((secondsLeft ?? 0) / 60))} min.
: "1 hour cache, as Anthropic's last reply set it."
const tail = w.pinging
? ''
: isCold
? The next reply re-reads ${cost.replace(' cost', '')} at full price.
: w.until !== null
? Keep-warm on, ${left} left. ${w.note}.trim()
: Reply now and ${cost} 5 to 10% as much.
const button = w.pinging ? 'Pinging' : w.until === null ? 'Keep warm' : Warming ${left}

const cells = Math.max(8, Math.min(24, Math.floor(e.props.bodyColumns / 4)))

return (
<Box flexDirection="column" paddingX={1} borderStyle="round" borderColor={color}>
<Box flexDirection="row" alignItems="center" columnGap={1}>
{Svg ? <Svg source={clockIcon(color)} alt="cache" width={18} height={18} /> : <Text color={color}>◷</Text>}
<Text>{title}</Text>
<Text bold color={color}>{time}</Text>
{Svg ? (
<Svg source={barIcon(color, fraction)} alt="time left" width={BAR_W} height={4} />
) : (
<Text color={color}>{barText(fraction, cells)}</Text>
)}
<Box flexGrow={1} />
<Button key="warm" label={button} variant="secondary" onPress={() => void toggleKeepWarm($)} />
</Box>
<Box flexDirection="row" columnGap={1} flexWrap="wrap">
<Text>{lead}</Text>
<Text dimColor>{tail}</Text>
</Box>
</Box>
)
})
}
```

Do this: Paste the code exactly as shown, including the ../types import path, and keep the license header with your copy if you share it.

How the keep-warm ping works

Keep-warm sends one tool-less question ("Reply with exactly: ok") over the session's own transcript. Because that prefix is served from the prompt cache, the reply also refreshes the cache's hour. The mod then checks the usage numbers: if a good amount was read from cache and very little was written, the ping counted as warming. If the ping mostly rewrote the context, the cache had already lapsed, so the mod stops auto-pinging instead of quietly spending more.

Auto pings only fire when a keep-warm window is armed (6 hours by default, or 1 to 24 hours with /keepwarm 3), the session is not mid-turn, and 50 minutes have passed since the last turn or ping.

Do this: Arm keep-warm for the length of your working block, not the whole day. /keepwarm 3 for a three-hour stretch is a sensible habit, and /keepwarm status tells you what the last ping did.

The cold-send guard

Turn it on with /keepwarm guard. After the cache has expired, the first message you send is held once, with a note that it would re-read about a given percentage of the context uncached. Send the same message again and it goes through. The guard stays out of the way when the context is small (under 20% in this version), and it ignores slash commands.

Do this: Switch the guard on for big refactors or long research sessions, and leave it off for short chats where a cold cache costs almost nothing.

Quick setup checklist

  • Create the cache-timer folder with the four files in the layout above.
  • Paste each file's contents exactly as shown.
  • Load the folder as a Claude Code mod, following the steps in our mod-building guide.
  • Start a session and send one message, so the timer starts from a real reply.
  • Try /cache_timer ping, then /keepwarm status, and press the Keep warm button once.

Bottom line

A cache you cannot see is a cache you cannot manage. This small mod turns Claude Code's prompt cache into a visible, controllable resource: a countdown you can trust, a one-click way to keep it warm, and a guard against expensive cold sends. The code is MIT licensed, so use it, change it and share it. If you build something on top, I would like to hear about it.


Made by Jayesh Mali, Techqueto — techqueto.com.

Need help with this? Get in touch with Techqueto — we build and grow websites for 100+ brands across India and the UAE. Ready to get started? Get started here.