2026-08-02 · stage-by-stage latency budget for inbound → reply · 475 real production SMS traces (Jul 26 – Aug 2) + read-only prod probes + a 3-model bench on the test property. No production code changed; no PRs opened.
The goal is a fast reply, and "fast" here means total wall-clock from inbound webhook to outbound send — not time-to-first-token. Nobody watches an SMS stream in. Two hard constraints sit on that number: the Twilio webhook budget, and the fact that our compliance gates run after the model and before delivery, so every millisecond anywhere in the loop lands on an already-serialized tail.
That framing decides where to look. The instinct — and the framing of the sibling research dossier — is that an agent loop's wall clock is dominated by model rounds, tool serialization and prefill. For Clara today that instinct is wrong by a factor of two. Every LLM call, every tool, and every compliance gate in a median production turn adds up to about 6.4 seconds. The turn takes 21.5. The missing 15 seconds are spent in one unbroken block of database work between two trace steps, before Claude is ever called.
handleIncomingMessage, containing an N+1 fan-out that reads and re-assembles all 1,337 work orders of a property to find one tenant's open ones. It is invisible in the trace (zero steps emitted across it) and it is growing ~4%/day: 13.0 s on Jul 26 → 16.2 s on Aug 1. The model is the second-largest cost, not the first.
Every production Clara turn writes an AgentTrace row to DynamoDB with a per-step duration ledger. I pulled every trace from the entityType-createdAt-index GSI on propflow-prod for Jul 26 – Aug 2 (1,077 rows), kept the 475 SMS turns that ran the real production code path, and reconstructed the wall clock — including the gaps between steps, which is where the answer turned out to live.
| Stage | Median | Mean | Share | What happens |
|---|---|---|---|---|
| Ingest → agent entry | 120 ms | 129 ms | 0.6% | Webhook → router → conversation lookup → identity resolution |
| Context assembly | 14,976 ms | 14,331 ms | 69.6% | ~50 serial awaits between agent:identity_resolution and agent:capability_composition. Zero trace steps emitted. |
| Claude call 1 (initial) | 3,519 ms | 3,777 ms | 16.4% | ~25k-token system prompt, 20 tools, adaptive thinking, effort low |
| First-turn retry | 0 ms | 625 ms | 2.9% | Fires on 87/475 turns (18%) when leasing turn 1 skips its tools. Median 3,251 ms when it fires. |
| Tool executions | 54 ms | 327 ms | 1.5% | Serial within a round; per-tool 16–870 ms |
| Subsequent rounds | 1,457 ms | 1,880 ms | 8.7% | 51% of turns need ≥1 more round; each ~2,780 ms |
| Fair-housing gate | 1,313 ms | 1,408 ms | 6.5% | A separate blocking Haiku call after the reply is composed. Runs on 444/475 turns (93%). |
| PII strip + prompt-strip + hallucination guard | 0 ms | 0 ms | 0% | Pure regex — free |
| Delivery (Twilio) | 1 ms | 50 ms | 0.2% | Handoff only; carrier time is not in the trace |
| Total | 21,523 ms | — | 100% | Sum of medians = 21,439 ms — the budget closes to within 0.4% |
Two LLM calls are not in this ledger because neither is wrapped in tracedClaudeCall: the pre-loop Haiku intent classifier in classify-message.ts (runs in the router, before the agent loop) and the fair-housing Haiku gate (measured separately above via its own fair_housing_check step). A median SMS turn therefore makes four or more serial LLM calls, not one.
Between the agent:identity_resolution and agent:capability_composition trace steps sits roughly 1,000 lines of conversation-manager.ts containing 50 awaits, 15 dynamic import()s, and only 3 Promise.alls. Nothing in it is instrumented, which is why a year of latency work never saw it.
It is not identity-dependent — the gap is essentially identical for verified tenants (14.4 s), verified prospects (15.1 s) and unknown callers (14.6 s), so the cost is in the unconditional spine, not the enrichment branches.
I timed the individual data-layer calls on this path directly against propflow-prod, read-only, three runs each:
| Call | Median | Rows | Verdict |
|---|---|---|---|
getWorkOrders(propertyId) | 4,170 ms | 1,337 | N+1 fan-out. One Query for the META rows, then _assembleWorkOrder — an extra read per work order — for all 1,337. The result is used only to filter for one tenant's open WOs. getWorkOrdersMeta() already exists and skips the assembly entirely. |
getPmOrgIdsForConversation | 472 ms | — | Runs on the PM-roster branch; returns 0 orgs for most turns |
resolveTurnoverContext | 297 ms | — | Phone→persons fan-out across orgs; unconditional on SMS |
getConversation(byId) | 160 ms | 1 | Fine |
getActiveTenants(propertyId) | 118 ms | 40 | Fine |
| property / knowledge / leasing-settings / saga / occupancy reads | 53–68 ms each | — | Individually fine — but ~12 of them run strictly one after another |
(contrast) getConversations() whole-set | 6,760 ms | 2,486 | Not on the Clara path, but the same shape — this is what the July audit found on the operator pages |
These probes ran from a laptop (≈50 ms round-trip to us-east-1 vs ≈5–15 ms from Vercel), so the per-call numbers are an upper bound and the fan-out costs are inflated more than the point reads. The shape is what matters: one N+1 that scales with a property's work-order history, sitting in front of ~12 point reads that could be one wave.
Prompt caching inside a turn is working perfectly. Across turns it is almost entirely failing, and the reason is a fixable ordering bug.
| Call position | n | Cache read | Cache write | Fresh input | Hit rate |
|---|---|---|---|---|---|
| initial (first call of the turn) | 475 | 8,501 | 24,486 | 729 | 25% |
| first-turn retry | 87 | 41,737 | 0 | 974 | 98% |
| loop round 1 | 244 | 41,732 | 0 | 1,183 | 97% |
| loop round 2 | 50 | 29,747 | 0 | 1,930 | 94% |
| loop round 3 | 3 | 21,010 | 0 | 1,996 | 96% |
Median tokens per call. This directly refutes the dossier's hypothesis #3 (20-block lookback exhaustion): there is no cache falloff by round — zero calls at any round position returned cache_read_input_tokens = 0 after the initial call. The lookback window is not our problem.
The problem is the first call of every turn, and it is a prefix-ordering defect. Anthropic renders tools → system → messages and a cache breakpoint caches the prefix from byte 0. Clara's system prompt is 101,484 characters ≈ 25,400 tokens, and its section order is:
line 2 ## Security Rules <- static line 23 CLARA'S VOICE ... <- static line 45 PROPERTY INFO: <- VOLATILE (per property, per conversation) line 68 LEASING WORKFLOW: <- static ... ~530 more lines of static policy, rules, examples line 601 ## ACTIVE CAPABILITIES
Because the per-conversation PROPERTY INFO block sits at line 45, ~24,000 tokens of completely static policy text live behind a volatile byte and are re-written to cache on essentially every turn instead of being read from it. The code comment at conversation-manager.ts:1026 already names this exact fix as known-and-unshipped ("reordering getUnifiedSystemPrompt so its volatile sections land last… held for a follow-up PR"). The trace data is the missing evidence for how much it is worth: 10.92 M cache-creation tokens across 475 turns, billed at the 1h-TTL write premium instead of the 0.1× read rate.
The first-turn tool gate (added after the 2026-04-08 hallucinated-price incident) checks whether a leasing turn 1 called any tool. If not, it pushes a reminder and re-runs the whole call with a forced tool_choice. In production this fires on 87 of 475 turns (18%) at a median 3,251 ms — 625 ms amortized across all turns, plus a second full ~42k-token prefill.
The retry already knows the answer: it forces tool_choice: {type:'tool', name:'get_available_units'}. Setting that on the first call for a fresh leasing turn removes the failure mode rather than correcting it, at zero behavioral risk — the retry path proves the forced choice produces the desired turn.
checkFairHousing() is a blocking Haiku call that runs after the reply text is final and before delivery, on 93% of turns, costing 1,313 ms median. It cannot be parallelized against the model call that produces its input. It is a real compliance control and I am not proposing to delete it — but its position and its per-turn scope are product decisions worth surfacing, and a Haiku-tier rewrite gate on every turn (including tenant maintenance replies, where fair-housing risk is negligible) is a broader net than the risk requires.
The research dossier ranks concurrent execution of parallel tool_use blocks as opportunity #1, and the mechanism is real: conversation-manager.ts has exactly one await dispatchToolCall site and it sits inside for (const block of response.content), so a 3-tool fan-out costs t1+t2+t3 instead of max(t1,t2,t3). But measured against production:
The reason it is small is that our tools are fast: get_available_units 100 ms, check_availability 261 ms, get_tour_slots 395 ms. It is still worth fixing — it is cheap, it is correct, and it protects against tools getting slower — but it is a 0.3% fix, not a top-three one. This is the main correction this measurement makes to the dossier.
Production has been running a natural split — 274 SMS turns on claude-opus-5 and 201 on claude-sonnet-4-6 in the same window, same code, same table:
| Model | n | LLM time / turn | Initial call | Loop round | Cost / turn | Output tokens |
|---|---|---|---|---|---|---|
claude-sonnet-4-6 | 201 | 4,812 ms | 3,010 ms | 2,800 ms | $0.0079 | 106 |
claude-opus-5 | 274 | 5,267 ms | 3,748 ms | 2,778 ms | $0.0099 | 88 |
| Sonnet 4.6 is 455 ms (−9%) faster per turn on model time and 20% cheaper. Whole-turn medians (19.8 s vs 22.7 s) are not a clean comparison — the Opus cohort skews later in the window, when context assembly had grown — so only the model-time and cost columns should be read as a model effect. The controlled bench below disagrees on the latency direction; see the call-out there. | ||||||
I drove the real loop through runPipeline against the bench property appfolio-45 (The Willows) with a stress-synthetic sender, 5 runs × 2 scenarios per model, CLAUDE_MODEL overriding all AGENT-tier call sites. Credential verified as [anthropic/client] credential=bearer — subscription, never metered. Outbound is unconditionally suppressed for +1206555xxxx senders by stressSuppressionFor(), so nothing reached a carrier.
| Model | runs | Total (median) | p25 – p75 | Context assembly | LLM time | Fair-housing | Cost / turn |
|---|---|---|---|---|---|---|---|
claude-sonnet-4-6 | 10 | 10,546 ms | 10,311 – 13,552 | 5,114 ms | 2,602 ms | 1,419 ms | $0.00143 |
claude-sonnet-5 | 10 | 10,128 ms | 9,887 – 10,845 | 4,874 ms | 2,456 ms | 1,305 ms | $0.00068 |
claude-opus-5 | 10 | 10,049 ms | 9,330 – 10,424 | 5,066 ms | 2,000 ms | 1,388 ms | $0.00096 |
Two structural signals from the bench matter more than the model column:
Bench caveat, stated plainly: scenarios were replayed on a persisting conversation, so runs 2+ settle into single-round, zero-tool turns (Clara correctly answers "already reported"). Conditions were identical across all three models, so the comparison is fair, but these are steady-state simple turns — not a substitute for the production distribution above.
Yes — and the two items that get us there are both in context assembly, not in the model. The waterfall below stacks residually: each fix is applied to what the previous one left, so savings on overlapping stages do not double-count. Every row is labelled measured or estimate.
| # | Fix | Saving | Running total | Basis · token/cost effect |
|---|---|---|---|---|
| — | Current median production SMS turn | — | 21,523 ms | 475 prod traces |
| 1 | Kill the work-order N+1. Swap getWorkOrders(propertyId) for the existing getWorkOrdersMeta(propertyId) on this path — the code only needs status/personId to filter one tenant's open WOs, never timeline or quotes. |
−3,000 ms est | 18,523 ms | Probe measured 4,170 ms for the assembled read; discounted to ~3,000 ms for in-region round-trips. Zero token effect. Also removes ~1,300 DDB reads/turn — a direct DynamoDB RCU line-item win, and it stops the O(WO-history) growth. |
| 2 | Parallelize the rest of context assembly. ~50 serial awaits with only 3 Promise.alls; the reads are overwhelmingly independent (property, knowledge, leasing settings, occupancy, sagas, turnover context, PM scope, prospect views). Restructure into 2–3 waves. |
−7,500 ms est | 11,023 ms | Estimate anchored two ways: the bench floor for the same code with warm reads is ~5 s, and ~12 independent point reads at 50–470 ms each collapse to roughly one round-trip. Held deliberately conservative — the floor assumed is ~4.5 s, not zero. Zero token effect. |
| 3 | Move volatile system-prompt sections behind the static body. PROPERTY INFO and other per-conversation blocks currently sit at line 45, ahead of ~24k static tokens. |
−800 ms est | 10,223 ms | Latency saving is an estimate (cached prefill vs fresh prefill on ~24k tokens). The cost effect is measured and is the biggest single token lever in this report: 24,486 cache-creation tokens per turn today, 10.92 M across the sample. Converting the static ~24k to a cache read moves that slice from the 1h-TTL write premium to the 0.1× read rate. Byte-changing → needs an eval gate. |
| 4 | Force tool_choice on leasing turn 1 instead of detecting the miss and re-running the call. |
−625 ms measured | 9,598 ms | 87/475 turns × 3,251 ms, amortized. Eliminates a second full ~42k-token prefill on 18% of turns — roughly a 15% cut to total agent-loop input tokens on the leasing path. |
| 5 | Concurrent parallel-safe tool execution (dossier #1) — read-only tools in a Promise.all, writes stay serial. |
−58 ms measured | 9,540 ms | 241 ms median on the 22% of turns with a multi-tool round. Zero token effect. |
| — | Model change (claude-sonnet-4-6 or -5, PR #5254 held for review) |
±0 ms measured | 9,540 ms | Deliberately scored at zero latency. The prod A/B favours Sonnet 4.6 by 455 ms; the controlled bench favours Opus 5 by 456 ms. Two measurements of opposite sign means no reliable effect. Keep it as a cost lever (−20% per turn in the prod A/B) and decide it on quality. |
| Projected median after the package | −56% | ≈ 9.5 s | Clears the 50% bar without touching the compliance gates and without relying on a model change. | |
| 6 | Optional, needs a product/compliance decision: scope the fair-housing gate to leasing-capability turns rather than all non-tenant turns, or fold it into the main call as a self-check. | −1,313 ms measured | ≈ 8.2 s (−62%) | Removes one serial Haiku call per turn. Compliance decision, not an engineering one. |
Honest read of the stack-up: items 1 and 2 carry 85% of the win and both are estimates, because I could not safely re-run the production path with the fix applied. They are estimates with measured floors underneath them (the probe timings, and the bench's demonstrated ~5 s assembly on identical code), not guesses — but the package's claim to −57% rests on them, and the first thing to do is to prove item 1 with a before/after on the real path, which is cheap.
If items 1 and 2 underdeliver — say assembly only drops to 9 s instead of 4.5 s — the package lands at roughly 14.0 s, a 35% cut, short of the bar. A model change would not rescue it: the measurement above scores that at zero. What would close the remaining gap is architectural, not incremental:
Both are real projects, which is why they are the fallback and not the plan. The plan is that items 1 and 2 are ordinary database hygiene against code that nobody has profiled.
The north star is $3,541.87/month (July) → ≤$708/month. Latency and spend are the same problem here more often than not, because both are driven by tokens prefilled per turn:
tool_choice) deletes a whole duplicate prefill on 18% of turns — roughly 15% of agent-loop input tokens on the leasing path.classify-message.ts and fair-housing.ts — import the raw anthropic client directly rather than going through clientForProperty(). They therefore bill the metered key even for bench and test-property traffic, which the 2026-07-31 policy puts on the subscription. Two one-line import changes; no latency effect; pure spend.Median LLM cost per SMS turn today is $0.0084 (475 traces, llmCostUsd). The turn-level spend story is dominated by cache-write tokens, not by output — median output is 88–106 tokens against ~25k of prompt.
AgentTrace rows queried from propflow-prod via the entityType-createdAt-index GSI, Jul 26 – Aug 2, filtered to the 475 SMS turns whose source is production (unset or production). Gaps between steps were reconstructed from startedAt + durationMs per step against the trace's own startedAt/endedAt.+1206555xxxx numbers against The Willows — real production code, real production table, real production infrastructure, synthetic identities. Only 9 turns in the window are live humans (7 Camellia, 2 Willows). Those 9 show the same shape with smaller numbers (context assembly 2.6–8.4 s, totals 6.6–21.1 s) because Camellia's data volumes are smaller than the stress corpus's. Read the 475-turn medians as the code path's cost at Willows-scale data, and the 9 real turns as confirmation that the same gap dominates real traffic.propflow-prod, 3 runs per call, no writes. Laptop-to-us-east-1 round-trip inflates every number by roughly 50 ms and fan-outs proportionally more; treated as an upper bound throughout.runPipeline against appfolio-45, 5 runs × 2 scenarios × 3 models (30 turns total). Safety: stress-synthetic sender (stressSuppressionFor() refuses +1206555xxxx unconditionally, so no carrier was reached), bench property only, no real vendor or tenant touched, no prod mailbox written. Credential confirmed [anthropic/client] credential=bearer; ANTHROPIC_API_KEY never exported.effort: low) is endorsed unchanged.