Problem
A session drags — there's a multi-second pause after a tool call, over and over, and it's slowing down
how fast you can iterate. Is it the model thinking? A specific MCP server having a bad day? A built-in
tool (Bash, Read) doing something heavy? You want a name and a number —
"[playwright] browser_navigate is averaging 4.2 seconds a call" — not a vague sense that
"MCP feels slow today." And once you have a name, you want to know whether it's worth fixing at all: a
tool that's slow on every call is a different problem than a tool that's fast 95% of the time and
occasionally spikes.
Cause
tool_events.latency_ms is captured at parse time by pairing a tool_use block
with its matching tool_result and taking the timestamp delta — src/parser.ts
for Claude Code, src/codex-parser.ts for Codex (both compute
Math.max(0, resultTs - callTs)). Both sources populate it the same way, so this isn't a
Claude Code-only signal the way some other audit findings are. But a per-call number sitting in SQLite
isn't a finding — nothing groups it, averages it, or tells you when it crosses a line worth caring about.
That's what the slow_tool detector (D3 of the audit's six) does: it groups
tool_events by (tool_name, mcp_server, source, project) and flags a group only
once it clears two bars together — mean latency ≥3,000ms and
≥5 calls with a recorded latency in the window
(SLOW_TOOL_LATENCY_MS_THRESHOLD / SLOW_TOOL_MIN_CALLS in
src/audit/config.ts). Four slow calls, or twenty calls averaging 2.9 seconds, produce
nothing — not enough evidence either way. That bar is deliberately identical to the older
HIGH_LATENCY pattern in trim-suggestions.ts (the Pro auto-trim-suggestions
feature), so "slow" means the same 3-second/5-call thing whether you're reading a trim suggestion or an
audit finding.
Once a group qualifies, p50 and p95 are fetched directly from SQLite as
ORDER BY latency_ms ASC LIMIT 1 OFFSET <rank> (the nearest-rank method) rather than
pulling every row for a busy tool into JS and sorting there — one bounded row per percentile no matter
how many calls are in the group. Calls with no recorded latency_ms (NULL) are
excluded from mean/p50/p95/max entirely — never treated as 0ms — and counted separately as
callsMissingLatency, so the exclusion stays visible in the finding's evidence instead of
silently shrinking the sample. And failure_rate is always null: this schema has
no error/failure/status column on tool_events at all, so the field is reported as
"unavailable," never faked as 0%.
Command
Prerequisite: token-meter ingest must have run at least once — audit reads the
same local SQLite DB (~/.tokenpulse/usage.db) every other command uses. Nothing below calls
out to Anthropic, OpenAI, or any MCP server.
1. Run the audit
npx -y @whdrnr2583/token-meter audit --days 7
--days defaults to 7 and is clamped to your tier's history cap (7d Free, 30d Pro, unlimited
Pro+) — a clamp warning prints to stderr if you asked for more than you're entitled to.
--limit defaults to 5 in terminal mode, 20 in --json mode.
2. Make sure a slow_tool finding isn't hiding behind cost-bearing ones
slow_tool never carries a dollar figure (costLabel: 'not_available',
estimatedCostUsd: null — latency has no direct cost in this schema). The audit engine's
cross-detector ranking sorts every cost-bearing finding first, by cost descending, and only then the
no-cost findings — so a genuinely bad latency finding can sit past the default terminal --limit 5
behind three cheaper-but-priced findings. Raise the limit, or go straight to JSON and filter by type:
token-meter audit --days 7 --limit 20 # machine-readable — filter to just the latency findings (needs jq) token-meter audit --days 7 --limit 20 --json \ | jq '.findings[] | select(.type == "slow_tool")'
3. Scope to one MCP server, source, or project
--source all|claude|codex and --project <value> narrow the underlying
query itself for this detector — its GROUP BY already includes source and
project, so scoping actually confines which calls feed each group (unlike
oversized_tool_response, whose aggregate has no source/project columns and just echoes back
whatever you asked for — see the MCP token usage guide
for that caveat).
token-meter audit --days 7 --source claude --project /Users/you/repo --json \ | jq '.findings[] | select(.type == "slow_tool")'
Synthetic output
Fabricated example numbers below — not real user data — to show the shape of each command's output.
token-meter audit --days 14 (terminal, one finding shown — note it's ranked below cost-bearing findings)
5. [playwright] browser_navigate calls averaging 4.2s Source: claude-code Project: /Users/example/mcp-agent-demo Session: - Tool: browser_navigate Evidence: Mean latency 4.2s (4,187.6ms) across 42 call(s) with a recorded latency_ms, in the 14d window. p50 (median) 1.1s, p95 18.3s, max 22.0s. failure_rate is not available: tool_events has no error/failure/status column in this schema, so it is reported as null rather than fabricated. 3 additional call(s) to [playwright] browser_navigate in this window had no recorded latency_ms and were excluded from the stats above (not counted, not treated as 0ms). Confidence: high Suggested action: Investigate whether [playwright] browser_navigate's output is actually used downstream; if not, drop the call or cache the result to avoid the 4.2s penalty per call.
token-meter audit --days 14 --json | jq '.findings[] | select(.type == "slow_tool")'
{
"id": "d3a1f70b2c9e4411",
"rank": 5,
"type": "slow_tool",
"title": "[playwright] browser_navigate calls averaging 4.2s",
"description": "[playwright] browser_navigate averaged 4.2s per call over 42 call(s) in the last 14d (p50 1.1s, p95 18.3s, max 22.0s).",
"source": "claude-code",
"project": "/Users/example/mcp-agent-demo",
"sessionId": null,
"toolName": "browser_navigate",
"metrics": {
"toolName": "browser_navigate",
"mcpServer": "playwright",
"meanMs": 4187.6,
"p50Ms": 1120,
"p95Ms": 18340,
"maxMs": 21980,
"callCount": 42,
"failureRate": null
},
"estimatedCostUsd": null,
"costLabel": "not_available",
"confidence": "high",
"evidence": [
"Mean latency 4.2s (4,187.6ms) across 42 call(s) with a recorded latency_ms, in the 14d window.",
"p50 (median) 1.1s, p95 18.3s, max 22.0s.",
"failure_rate is not available: tool_events has no error/failure/status column in this schema, so it is reported as null rather than fabricated.",
"3 additional call(s) to [playwright] browser_navigate in this window had no recorded latency_ms and were excluded from the stats above (not counted, not treated as 0ms)."
],
"recommendations": [
"Investigate whether [playwright] browser_navigate's output is actually used downstream; if not, drop the call or cache the result to avoid the 4.2s penalty per call.",
"If the latency looks environmental (network, cold start), check the \"playwright\" MCP server's health rather than the calling code first."
],
"costEventIds": []
}
{
"id": "9f2b6a0d1c7e3355",
"rank": 6,
"type": "slow_tool",
"title": "[notion] notion_search calls averaging 3.8s",
"description": "[notion] notion_search averaged 3.8s per call over 19 call(s) in the last 14d (p50 3.8s, p95 4.1s, max 4.3s).",
"source": "claude-code",
"project": "/Users/example/mcp-agent-demo",
"sessionId": null,
"toolName": "notion_search",
"metrics": {
"toolName": "notion_search",
"mcpServer": "notion",
"meanMs": 3812.4,
"p50Ms": 3760,
"p95Ms": 4090,
"maxMs": 4310,
"callCount": 19,
"failureRate": null
},
"estimatedCostUsd": null,
"costLabel": "not_available",
"confidence": "medium",
"evidence": [
"Mean latency 3.8s (3,812.4ms) across 19 call(s) with a recorded latency_ms, in the 14d window.",
"p50 (median) 3.8s, p95 4.1s, max 4.3s.",
"failure_rate is not available: tool_events has no error/failure/status column in this schema, so it is reported as null rather than fabricated."
],
"recommendations": [
"Investigate whether [notion] notion_search's output is actually used downstream; if not, drop the call or cache the result to avoid the 3.8s penalty per call.",
"If the latency looks environmental (network, cold start), check the \"notion\" MCP server's health rather than the calling code first."
],
"costEventIds": []
}
Interpretation
-
mean— the average across every call with a recorded latency in the window. Easy to skew: a handful of very slow calls can drag the mean well above what a "typical" call actually feels like. -
p50(median) — the midpoint call. Half of all calls were faster than this, half slower. This is what a typical call actually feels like, immune to a small number of extreme values. -
p95— 95% of calls were at or faster than this. Ifp95is close tomean, the slowness isn't rare — most calls are paying it. Ifp95is far abovep50but still well belowmax, a meaningful minority (not just one call) are the slow ones. -
max— the single worst call in the window. On its own it tells you almost nothing about the pattern — it's one data point. Read it next top95: if they're close, several calls are that bad; ifmaxtowers overp95, it's likely one pathological event (a timeout, a cold start, a network blip) rather than a trend.
The two synthetic findings above are the same shape of problem — both cross the 3s/5-call bar — but read very differently once you line up all four numbers:
-
[playwright] browser_navigate: mean 4.2s but p50 only 1.1s — most calls are fast. p95 jumps to 18.3s and max is 22.0s, both far above p50. That gap is the signature of occasional outliers: a small share of calls (page loads that hung, a flaky navigation, a cold browser context) are dragging the mean up, while the bulk of calls are perfectly reasonable. Fixing "the tool" here is the wrong frame — the question is what's different about the slow minority. -
[notion] notion_search: mean 3.8s, p50 3.8s, p95 4.1s, max 4.3s — every number sits in a tight band. That's uniformly slow: there's no outlier to chase, because every call pays roughly the same tax. This is a structural cost of the tool itself (a slow API round-trip, no server-side caching), not an anomaly.
Confidence reflects sample size only — high at ≥20 calls with a recorded
latency, medium below that (the audit's HAVING clause already filters out
anything under 5 calls, so nothing below the 5-call floor reaches this metric at all). It says nothing
about whether the pattern is outlier-driven or uniform — that judgment is entirely in reading mean
against p50/p95/max yourself, the way the two examples above do.
Actions
This is a spotlight, not an accusation (see docs/audit.md) — a slow call that isn't on the
critical path, or that ran while other work proceeded, may not have cost you any real time at all. Read
the numbers, then decide.
-
Outlier pattern (mean ≫ p50): don't treat the tool as broken — treat the slow minority
as a separate question. Narrow
--daysto the window right around when the session felt slow, and cross-reference the timestamp against your own Claude Code/Codex transcript for that session (Token Meter never stores call arguments, so it can tell you "this tool+server was slow," never "on this specific query" — see Limitations). Common culprits: the first call of a session (cold start/connection setup), a specific target (one flaky page, one large repo), or a transient network condition. -
Uniform pattern (mean ≈ p50 ≈ p95): treat it as a fixed cost, not an anomaly to
debug. Options: cache the result if the call is idempotent and repeated, reduce how often it's called
(check whether the audit's
repeated_similar_tool_calls/ D4 finding also fired for the same tool+session), switch to a lighter equivalent tool or narrower query, or accept the round-trip if what it returns is worth the wait. -
If
mcp_serveris set, the finding's own recommendation is to check that server's health (rate limits, region, cold-start behavior) before assuming your own code or prompt is at fault — the latency lives on their side of the call. -
If
mcp_serverisnull, the slow tool is one of Claude Code's/Codex's own built-ins (Bash,Read, …) — the latency is local (a slow command, a large file, a slow filesystem), not a remote dependency, so look at what the call was actually doing rather than an MCP server's health. - Ask whether the output is used at all. If a slow tool's result is read once and mostly ignored downstream, dropping the call outright removes both the latency and the tokens it cost — often the cheapest fix available.
- Re-run periodically. The 3s/5-call bar is fixed, not adaptive — a tool creeping toward 2,900ms won't show up until it actually crosses the line, so a one-time check doesn't catch a slow regression introduced later.
Limitations
-
failure_rateis alwaysnull.tool_eventshas no error/failure/status column in this schema at all — there's no way to know from this data whether a slow call also failed, or whether some of a group's calls errored out entirely. This is called out explicitly in every finding's evidence rather than defaulted to a misleading0%. -
No call arguments are ever stored.
tool_eventsdeliberately never captures what a tool was called with (seeToolEventinsrc/types.ts) — this is a privacy invariant, not a gap waiting to be filled.slow_toolcan tell you "browser_navigateagainst theplaywrightserver was slow," never "it was slow because it hit this specific URL." If you need that level of detail, you have to go find it yourself in the raw session transcript, using the flagged time window as a starting point. - No sense of the critical path. This schema has no concept of whether a given tool call blocked you waiting at the screen or ran quietly alongside other work (a background sub-agent call, for instance). A "slow" finding measures wall-clock duration of the call itself, not its actual impact on how long you waited.
- A group needs ≥5 calls with a recorded latency to appear at all. One catastrophic 60-second call sitting among two or three fast ones for the same tool won't surface here — the sample is too small for the detector's own confidence bar, by design. You'd have to notice that one manually (a session that felt oddly slow with no matching finding is a hint to look closer, not evidence nothing happened).
-
Never carries a cost, which affects default visibility.
costLabelis always'not_available'— latency has no direct dollar figure in this schema — soslow_toolfindings always rank after every cost-bearing finding in the audit's cross-detector ranking. See the Command section above for the--limit/--jsonworkaround; the default terminal view alone can genuinely miss one. -
Not a Codex-vs-Claude-Code gap, unlike some other detectors. Worth saying explicitly
because it's easy to assume otherwise: the
cache_inefficiencydetector (D5) never fires for Codex because Codex'susagepayload never reportscache_write_tokens.slow_toolhas no equivalent gap —codex-parser.tscomputeslatency_msthe same wayparser.tsdoes for Claude Code, so this detector fires equally for both sources.