Investigation — no changes shipped

Clara's conversation loop: where the 21 seconds go

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.

Strategy first: what we are actually optimizing

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.

Headline. Median production SMS reply: 21.5 s. 70% of it (15.0 s) is context assembly — a single serialized run of DynamoDB reads inside 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.
21.5 s
Median prod SMS reply (n=475)
15.0 s
Context assembly — 70% of the turn
6.4 s
Everything model-side, combined
+4 %/day
Context-assembly growth, last 7 days
−56 %
Projected after the fix package
6–12×
Cheaper per turn when the prompt cache actually hits (bench vs prod)

The stage budget

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.

StageMedianMeanShareWhat happens
Ingest → agent entry120 ms129 ms0.6%Webhook → router → conversation lookup → identity resolution
Context assembly14,976 ms14,331 ms69.6%~50 serial awaits between agent:identity_resolution and agent:capability_composition. Zero trace steps emitted.
Claude call 1 (initial)3,519 ms3,777 ms16.4%~25k-token system prompt, 20 tools, adaptive thinking, effort low
First-turn retry0 ms625 ms2.9%Fires on 87/475 turns (18%) when leasing turn 1 skips its tools. Median 3,251 ms when it fires.
Tool executions54 ms327 ms1.5%Serial within a round; per-tool 16–870 ms
Subsequent rounds1,457 ms1,880 ms8.7%51% of turns need ≥1 more round; each ~2,780 ms
Fair-housing gate1,313 ms1,408 ms6.5%A separate blocking Haiku call after the reply is composed. Runs on 444/475 turns (93%).
PII strip + prompt-strip + hallucination guard0 ms0 ms0%Pure regex — free
Delivery (Twilio)1 ms50 ms0.2%Handoff only; carrier time is not in the trace
Total21,523 ms100%Sum of medians = 21,439 ms — the budget closes to within 0.4%
Context assemblyDynamoDB, serial
14,976 ms
Claude call 1initial
3,519 ms
Subsequent roundsmean across all turns
1,880 ms
Fair-housing gateseparate Haiku call
1,313 ms
First-turn retryamortized (18% of turns)
625 ms
Tools + ingest + deliveryeverything else
~500 ms

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.

Culprit 1 — context assembly (15.0 s, 70%)

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.

What is actually slow, measured

I timed the individual data-layer calls on this path directly against propflow-prod, read-only, three runs each:

CallMedianRowsVerdict
getWorkOrders(propertyId)4,170 ms1,337N+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.
getPmOrgIdsForConversation472 msRuns on the PM-roster branch; returns 0 orgs for most turns
resolveTurnoverContext297 msPhone→persons fan-out across orgs; unconditional on SMS
getConversation(byId)160 ms1Fine
getActiveTenants(propertyId)118 ms40Fine
property / knowledge / leasing-settings / saga / occupancy reads53–68 ms eachIndividually fine — but ~12 of them run strictly one after another
(contrast) getConversations() whole-set6,760 ms2,486Not 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.

It is getting worse, measurably. Median context-assembly time by day: Jul 26 13.0 s → Jul 28 14.7 s → Jul 30 15.5 s → Aug 1 16.2 s. That is +25% in six days, and it is the same mechanism the July audit named for the operator pages: reads whose cost is O(everything ever written). Clara's own traffic writes the work orders that make Clara slower.

Culprit 2 — the system prompt misses its own cache on every turn

Prompt caching inside a turn is working perfectly. Across turns it is almost entirely failing, and the reason is a fixable ordering bug.

Call positionnCache readCache writeFresh inputHit rate
initial (first call of the turn)4758,50124,48672925%
first-turn retry8741,737097498%
loop round 124441,73201,18397%
loop round 25029,74701,93094%
loop round 3321,01001,99696%

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.

Culprit 3 — an entire wasted round on 18% of turns

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.

Culprit 4 — the fair-housing gate is a serial post-hoc LLM call

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.

Culprit 5 — serial tool execution (small; the dossier over-ranked it)

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.

Per-model loop latency

Production A/B (strongest evidence)

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:

ModelnLLM time / turnInitial callLoop roundCost / turnOutput tokens
claude-sonnet-4-62014,812 ms3,010 ms2,800 ms$0.0079106
claude-opus-52745,267 ms3,748 ms2,778 ms$0.009988
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.

Controlled bench, 3 models, test property

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.

ModelrunsTotal (median)p25 – p75Context assemblyLLM timeFair-housingCost / turn
claude-sonnet-4-61010,546 ms10,311 – 13,5525,114 ms2,602 ms1,419 ms$0.00143
claude-sonnet-51010,128 ms9,887 – 10,8454,874 ms2,456 ms1,305 ms$0.00068
claude-opus-51010,049 ms9,330 – 10,4245,066 ms2,000 ms1,388 ms$0.00096
The bench says model choice is not a latency lever for this loop. All three models land within 500 ms of each other on total turn time (a 5% spread), and on model time Opus 5 was the fastest — the opposite sign to the production A/B. Two measurements disagreeing at this magnitude is itself the finding: the honest read is no reliable latency difference between the three, ±500 ms. Model selection should be decided on quality and cost, which is what the sibling Sonnet-5 eval is for. It should not be sold as a speed fix.

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.

The fix package: can we cut total reply time by half?

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.

#FixSavingRunning totalBasis · token/cost effect
Current median production SMS turn21,523 ms475 prod traces
1Kill 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 est18,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.
2Parallelize 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 est11,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.
3Move 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 est10,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.
4Force tool_choice on leasing turn 1 instead of detecting the miss and re-running the call. −625 ms measured9,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.
5Concurrent parallel-safe tool execution (dossier #1) — read-only tools in a Promise.all, writes stay serial. −58 ms measured9,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 measured9,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 sClears the 50% bar without touching the compliance gates and without relying on a model change.
6Optional, 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.

Cost interplay — how this maps onto metered spend −80%

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:

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.

Methodology & provenance

What is production data

What is probe data

What is bench data

What is an estimate

Relationship to prior work

Nothing here shipped. No production code was changed, no PRs were opened, and no production data was mutated beyond the sanctioned bench-property flows on the test property. The recommended first move is the cheapest and highest-confidence one: prove Fix 1 with a before/after on the real path, since it is a one-line swap to a function that already exists.
PropFlow Docs