SWE-bench Verified, live, four arms: 13% cheaper than no compaction, and the highest reward. See the numbers

Keep an idle prompt cache warm

Anthropic's prompt cache has a five-minute lifetime. A session idle longer than that loses its whole cached prefix, and the next turn re-bills every token of it at the cache-creation rate.

On this service, measured over 19,805 requests and $3,139.97 of spend:

miss reason requests share cost share of spend $/request
hit 16,368 82.6% $1,928.65 61.4% $0.1178
ttl_expiry 742 3.7% $741.07 23.6% $0.9987
cold_start 1,626 8.2% $253.82 8.1% $0.1561
prefix_change 292 1.5% $205.05 6.5% $0.7022

A TTL-expired request costs 8.5x a request that hit, and against each row's own counterfactual the penalty is 11.35x, of which 91.2% is pure re-write. $584.83 — 18.6% of all spend — sits in misses whose idle gap was under an hour.

What the keep-alive does

Two sentences from the provider's documentation make this fixable:

By default, the cache has a 5-minute lifetime. The cache is refreshed for no additional cost each time the cached content is used.

The lifetime is measured from the start of the request that writes or reads the cache entry, not from the end of its response.

So a cache read refreshes the lifetime, and a read costs 0.1x base input where re-creating the prefix costs 1.25x. After a session has been idle for X seconds, the proxy re-sends that session's last Anthropic request with max_tokens: 1 — a pure cache read, which refreshes the entry. One ping buys back about 11.5 of itself.

Nothing else about the request changes. The prefix hash is cumulative over tools → system → messages, so a ping that altered any of it would create an entry at 1.25x instead of refreshing one at 0.1x — the one way this mechanism costs money instead of saving it. Only max_tokens and stream are touched, and neither is inside the hashed prefix.

For GPT-5.6+ OpenAI Responses, the documented default is a 30-minute minimum after the latest write or reuse, not a five-minute tier or an exact expiry. The proxy schedules a separate ping at 28 minutes, leaving two minutes for dispatch and upstream latency. It replays the same input with max_output_tokens: 16 (the minimum accepted by the tested IBM LiteLLM Azure route) and stream: false. It never adds prompt_cache_options.ttl. A ping is credited only when usage confirms a cache read; a cache write stops further pings for that session. Earlier OpenAI models and OpenAI Chat Completions are not enabled until their retention and ping shapes are separately validated.

Turning it on

It is off by default, because it spends the caller's own money without the caller asking. On the Settings page: "Keep my prompt cache warm while I am away." In a configuration document:

cache:
  keepalive: true
  keepalive_idle_seconds: 270          # X — must be under 300
  keepalive_openai_idle_seconds: 1680  # GPT-5.6+ Responses; must be under 1730
  keepalive_max_pings: 2               # K
  keepalive_min_prefix_tokens: 80000   # the gate
  keepalive_max_usd_per_ping: 0.50
  keepalive_idle_seconds_1h: 3540      # X for a 1-hour cache (59 min); must be under 3600
  keepalive_max_pings_1h: 1            # K for a 1-hour cache

Each session follows its own cache lifetime

The proxy reads the lifetime from the traffic, per session (per thread). It does not use one proxy-wide setting.

Cache lifetime auto (default) 1-ping 2-ping
5 minutes first ping at 270 s, at most 2 1 ping at 270 s same as auto
1 hour 1 ping at 59 minutes 1 ping at 59 minutes 2 pings, 59 minutes apart

One proxy can serve a 1-hour subscription session and a 5-minute subagent or API-key session at once. Each follows its own row. The 59-minute time is one constant, AnthropicDefaultKeepAliveIdle1h in internal/modelinfo/cachefrac.go, or keepalive_idle_seconds_1h in the config. The credential hold for a 1-hour thread is (K+1) x 59 min: 118 min at K=1, and at most 236 min, because keepalive_max_pings_1h is capped at 3. A 5-minute thread holds about 14 min. An armed session override does not change a 1-hour thread's schedule; it enables the session and sets its prefix floor. If the installed proxy binary is older than the plugin and rejects the _1h keys, start-proxy.sh re-renders the config without them and a 1-hour cache is pinged on the 5-minute schedule until the proxy is updated. The re-render is tied to the installed proxy version, so the keys come back after an update.

The 270-second Anthropic interval is chosen for robustness to where the cache cliff sits (it ties 280 on the full replay window; counterfactual, see #435), not as a measured optimum; the 1680-second OpenAI interval is a safety margin below the documented minimum, not an optimized value from a production replay. Replayed through the shipped decision function over the production window, the earlier gated policy (X=280, K=2, 20k floor; replaced by the defaults above, #435) nets +$125.08 against $90.76 of ping cost — within 1.7% of an ungated blanket policy while sending 9.8x fewer pings (912 vs 8,915). What the gate drops is the near-free pings on tiny prefixes — ping cost is bimodal, p50 $0.0004 against a p99 of $0.2275 — so most of the real gateway load disappears and almost none of the value does, which matters on a path that already returned HTTP 429s in the same window.

In that replay X = 280 beat 240 because it wastes fewer pings on gaps that would have hit anyway. K peaks at 2–3 and falls away after: a session that has ended looks exactly like one that is thinking, so every additional ping is spent partly on sessions that will never come back. K=2 ships because K=2 and K=3 are within noise here while K=2 sends 26% fewer requests.

Never use a per-session ping cap. It truncates exactly the long large-prefix sessions holding the value: capping the window's pings per session at 20 drops the net to $92.34, and at 10 to $54.04. The guard that works is the per-ping cost budget above.

What it will not do

Where the money shows up

Every ping is a dashboard row marked keepalive, with its own tokens, its own cost, and its own session — so an account can audit what was spent on its behalf while nobody was at the keyboard. The Overview ledger carries both halves:

line meaning
Keep-alive pings what the pings cost
Keep-alive savings the prefix re-creations they avoided
keepalive_misses_avoided how many requests resumed past the lifetime and hit anyway

Ping rows are excluded from every other aggregate (Filter.WithKeepAlive). A ping is a request we made on the user's behalf, not traffic their agent sent, so counting it would inflate the request count and drag every per-request average towards a one-token response. They are still listed on the Requests tab, flagged, because that list is the audit trail. Spend figures do include them: the money was really spent on the caller's key.

The saving is credited only where a ping of ours preceded the request, the gap exceeded the provider's lifetime, the provider read more than it wrote, and the amount is capped at the tokens that ping actually refreshed. It is a ceiling, not a floor: the provider's cache is keyed on content, so another session sending the same prefix would have refreshed the entry for free.

The honest downside

The policy is a very small tax on almost every session it touches, funding a large rebate for a few dozen. Over the production window:

policy sessions touched losing money worst session total losses winners
blanket X=280 K=2 3,809 3,773 (99.1%) −$0.95 −$13.10 36
gated (shipped) 119 85 (71.4%) −$2.42 −$13.59 34

The gate does not make the tax rarer per session touched — it makes it fall on 119 sessions instead of 3,809, leaving the other 3,690 untouched entirely. That is the fairness argument, and it is independent of the dollars: a session that is never pinged cannot lose.

Sessions that lose are short ones and abandoned ones. Sessions that win are long ones with large prefixes and human-paced gaps. An account whose work is many brief sessions should leave this off. Given this shape, the opt-in default and the per-account ledger are not optional: a user has to be able to see that they are one of the ~70% paying the tax rather than one of the few collecting the rebate, and turn it off.

Operator kill switch

CONTEXT_GURU_KEEPALIVE=off

Set in the environment, read at startup. An environment variable rather than a control-plane route on purpose: the thing an operator needs at 3am is one that works when the control plane is what is broken. /stats publishes keepalive with pings, skipped, failed, wrote_instead_of_read and spend_usd; wrote_instead_of_read above zero is a bug, not an expense.

Client lifecycle signals

The proxy cannot see a user leave: a session that ended looks like one that is idle. Clients that run hooks tell it, on POST /signal, with a content-free event: the plugins and the hosted setup snippet send only the session id, the event name and its reason (the hook picks those out of Claude Code's payload, which also holds the working directory and the transcript path). This is on whenever the keep-alive runs; CONTEXT_GURU_CLIENT_SIGNALS=off turns it off.

event sent when what the keep-alive does
SessionEnd /exit, Ctrl-C twice, Ctrl-D, closing the terminal, /clear, logout releases the session's held entries; no further pings
SessionStart compact after /compact or auto-compaction releases the entries that cache the pre-compaction prefix
SessionStart resume / clear, Notification recorded only

A resumed session (claude --resume, same id) is tracked again from its first request after the end; the provider's cache entry is not touched by any signal, so a prompt resume still reads it. Every effect only removes spend: a lost or late event leaves the keep-alive as it is without signals. The Claude Code plugin sends these by default to its own local proxy and to a hosted service (client_signals option: auto, on, off); the hosted setup page includes the hooks; the Codex plugin sends SessionEnd and SessionStart once you trust its hooks (/hooks).

What is held in memory, and for how long

To ping a session the proxy keeps that session's last request body, and — where the upstream is caller-pays, which is the default — the caller's own provider credential. There is no alternative: the ping happens when no request is in flight, so nothing else can authenticate it or supply the byte-identical prefix a cache read requires, and pinging on the operator's key would bill the wrong party.

The body is conversation content, and enabling the keep-alive is what consents to holding it. It is up to 8 MiB of that session's last request — system prompt, tools, and the message history — held for the length of the idle gap. This is deliberately not covered by the transcript-storage consent, which is a different promise about a different thing: that one governs writing content to disk, where a manager can read it and a retention window applies. This is memory only, masked, overwritten on release, never persisted, and gone within about 14 minutes. It cannot be reduced further — the prefix hash covers the whole tools → system → messages sequence, so a ping that trimmed the body would miss the cache and cost 12.5x instead of saving 11.5x, which is the mechanism inverted.

Controls, because this is the one place the service holds a caller's credential beyond the life of a request:

control what it does
Hard deadline released at (K+1) × X ≈ 14 min regardless of traffic.
Eager release dropped the moment the next request arrives, the gate refuses, or the process shuts down — whichever comes first.
Zeroized the bytes are overwritten, not just dereferenced.
Masked at rest the credential and the body are XORed under a random per-process key, so a leaked snapshot doesn't leak a working credential or a readable transcript.
Consent per request re-read from the tenancy on every request; turning the setting off retires what's already held.
Audit row per ping every use is a durable requests row: tenant, session, timestamp, cost, read-or-wrote.
Purged on revocation account deletion, token revocation and disablement release held material immediately.
Kill switch CONTEXT_GURU_KEEPALIVE=off stops retention, not merely pinging.
No ping mid-request a due ping waits while a request of the same thread is in flight. Other threads are not held back: a subagent's slow request never delays the main thread's ping.
Failed pings stop a 401 is about the key: it stops every session pinging with that credential, and only those (a teammate on the same account pings on), for an hour; a 403 (often a per-model permission or a gateway policy) does the same for that credential on that model only. A ping refused with any other non-retryable 4xx or answered 2xx with no usage at all (a translating gateway that hid an error) is counted failed and stops that session; 3 in a row for the same account, upstream and model switch the keep-alive off for that route, for that account only, for an hour (route_trips in /stats). A 429 or 5xx (an overload) is transient: counted skipped, and the next ping slot retries. A ping that lands after expiry and reads nothing is a real miss (missed): it re-wrote the prefix, so pinging goes on; a second miss in a row is a prefix that does not match and stops the session.
Claude models only through a relay native Anthropic, Bedrock and Vertex upstreams are eligible by type, whatever the deployment is called. The Anthropic route forwarded to anything else (LiteLLM, a corporate gateway) may serve another family (an OpenAI or Azure model, with no explicit cache control), so there the model name must contain claude, opus, sonnet or haiku; CONTEXT_GURU_KEEPALIVE_MODELS=alias1,alias2 adds names for aliases that do not.

Bounded on volume too: 128 MiB across at most 512 sessions, and a single body over 8 MiB is not held. The masking is obfuscation at rest against an accidental-capture read (a crash dump, a /proc read) — it does not stop an attacker with code execution in this process.

Gating pings on stop_reason (opt-in, per strategy)

Pings above fire unconditionally regardless of why the previous turn ended. A manager-controlled strategy can instead name a predictor — predictor_id: "stop-reason-gated" plus a predictor_threshold — so a ping only fires when the request it would refresh ended on end_turn, max_tokens, or refusal. Measured at +1.54% vs fixed-5m pooled, because tool_use/stop_sequence-preceded gaps almost never land in the addressable band while end_turn/max_tokens/refusal-preceded ones do far more often — see KV-cache TTL predictor: features for the measurement.

This is opt-in per strategy; a strategy with no predictor_id behaves exactly as before. Gains are concentrated in a few tenants, so scope it to the tenants it actually helps rather than applying it fleet-wide.

Client lifecycle signals (opt-in)

The proxy cannot see from the request stream that a user has quit or that Claude Code has compacted the conversation, so by default it keeps pinging a cache nobody will read. With signals on, the Claude Code plugin tells the proxy, and the proxy stops holding that cache.

Turn it on in two places:

  1. On the proxy: CONTEXT_GURU_CLIENT_SIGNALS=on. This enables POST /signal; without it the route returns 404.
  2. In the plugin: set the client_signals option to on (/plugin configure), or export CONTEXT_GURU_CLIENT_SIGNALS=on in the shell that runs Claude Code.
Event (hook) What the proxy does
SessionEnd (any reason) Retires every entry held for that session and remembers the end for up to 12 h, so a request that was still in flight when the user left is not held afterwards. A resumed session's next request starts after the mark and is held as usual.
SessionStart with source=compact Retires the entries holding the pre-compaction prefix (those whose last request started at least 2 s before the event). The session goes on and its next request is held as usual.
SessionStart resume / clear / startup / fork, Notification Recorded only. Offline replay found that acting on these earns little or loses money.

The event carries no content: the session id, the event name and a reason from a closed set. The hook reads the hook payload only to pick those three fields, sends them with the same credential Claude Code already sends to ANTHROPIC_BASE_URL, and never blocks or fails the session (2 s timeout, exit 0 on every path).

How it fails safe. A signal can only remove spend: it retires a held entry, it never extends a hold or schedules a ping. A lost or late event leaves the keep-alive as it is without signals; a forged one can only stop pings for the sender's own sessions, because the session key is scoped to the authenticated tenant. End marks are capped per tenant (2,000), so one tenant cannot evict another's.

If ANTHROPIC_BASE_URL points at something other than a context-guru proxy (a gateway, or a proxy in front of one), that host receives POST /signal with the token it already sees on every request. Leave the option off in that setup.

Predictive campaigns (opt-in, per tenant)

Instead of one fixed ping count for everyone, a manager can build a campaign for a tenant from that tenant's own history: either a learned model that decides at the end of each response how many pings pay for themselves, or (builder: "auto") the best fixed plan for that tenant, rebuilt daily. A campaign runs in shadow (scores live decisions, changes nothing) or active (sets the ping count of a keep-alive the tenant already has on). active is refused unless the build's own held-out comparison recommended it. Routes, settings and safety properties: predictive/README.md in the repository.