0070 — Clara recognizes when a conversation is over (end_conversation)
- Status: Proposed
- Date: 2026-06-24
- Deciders: Fede
Context
Clara always takes the last word on SMS. A tenant ends the exchange — "No that is all", "Ok I'll do that as soon as I'm back from work" — and Clara replies with another warm sign-off ("take care, reach out anytime!", "no rush, just text me"). A human would let those rest. The result is unnatural chattiness, Clara always getting the final message, and extra outbound SMS (cost + mild annoyance).
A read-only investigation across the inbound stack found two layers to the cause:
- No skip-gate on SMS. Every inbound SMS runs the full Claude agent loop and
returns a non-empty reply. The only path that sends nothing is the
runaway-ceiling guard (≥40 consecutive automated replies → returns
'', which the dispatcher suppresses). Email already has a real gate (src/lib/integrations/email/response-policy.tsshouldRespond→'respond' | 'skip'); the SMS/renewal path has no equivalent. - No closing protocol on SMS. The voice delivery rules (
getSpokenRulesinclara-delivery.ts) include a "CALL CLOSING" block; the SMS rules (getPlaintextRules) have none, so on a closer Clara fills the silence.
Crucially, the send-suppression mechanism already exists:
agents/clara/lib/messaging/process-envelope.ts (~line 239) does
if (!reply || !reply.trim()) { return; } — when the agent loop returns empty,
nothing is dispatched. The runaway-ceiling already uses this by returning ''.
So the missing piece is a deliberate way for Clara to choose silence — not new
delivery plumbing.
The danger: going silent while a duty is undischarged. The highest-risk case is a renewal decline where Clara has not yet pushed the official Notice to Vacate — the exact Verity Pretendish (Unit 603, 2026-06-24) failure mode. Silence must never strand an obligation.
Decision
Add an explicit end_conversation tool that Clara calls when she judges the
conversation is naturally over, following the same pattern as her other
decision tools (renewal_accepted / renewal_declined / renewal_escalate).
When the tool fires, its handler:
- Returns an empty reply to the agent loop, so the existing
process-envelopesuppression sends nothing (no new send-path). - Marks the conversation resolved (
status = 'resolved',selfServeResolved = true) — the same end-statedetectSelfServeResolutionalready produces for maintenance, so the next inbound opens a fresh conversation cleanly. - Signals the Temporal renewal workflow (when an open renewal saga exists)
so the D+2 / D+9 reminder ladder stops — mirroring the leasing reply-bridge
(
signalProspectReplied). Without this, Clara goes quiet but the system keeps nagging via reminders.
Clara is taught (prompt) to call end_conversation instead of emitting another
sign-off when the tenant's last message is a pure closer with nothing pending.
Fail-open guardrail (non-negotiable)
The tool is refused (Clara is told to respond instead of going silent)
whenever a duty is undischarged. The check uses signals already computed in
conversation-state.ts:
| Must-respond condition | Detection signal |
|---|---|
| Decline captured but official NTV not yet pushed | theme.toolCalls has a successful renewal_declined AND the last assistant message lacks NTV routing language (ntvSignInInstructions / "notice to vacate" / portal link) |
| Acceptance captured but signing path not yet delivered | successful renewal_accepted AND no follow-up portal/signing instruction |
| Clara asked an open question | theme.openQuestionFromClara is non-null |
| Unkept "I'll text you X" promise | prior assistant turn promised an SMS but the matching tool (send_offer_sms / send_portal_link_sms / renewal_escalate) did not fire |
| Pending work-order / photo action | computeActionSignal() returns non-null |
| Ambiguous / sarcastic / non-leaseholder | renewal hard rules #9/#10 → escalate, never silent |
Default is fail-open: if in doubt, Clara responds. Silence is only for a pure closer after every obligation is met.
This ADR proposes no new export interface (the tool's input is a small inline
schema), so the entity-classification table is omitted.
Consequences
- Clara can end a conversation gracefully — sometimes with a short reply, sometimes with nothing — instead of always getting the last word.
- The renewal reminder ladder stops when a conversation is genuinely resolved, even if no accept/decline tool fired (e.g., a question-only thread that wraps up).
- New guardrail surface: the must-respond checks must stay in sync with the obligations they guard (especially the post-decline NTV push, which the SMS prompt is also being taught to deliver — see the renewal SMS NTV-push change).
- The behavior is opt-in per Clara's judgment + server-enforced; it does not
change the maintenance
detectSelfServeResolutionpath, which keeps working.
Alternatives considered
- Prompt-only closing protocol. Add an SMS "CALL CLOSING" rule and let Clara
reply minimally or stay silent. Ships fastest, but relies entirely on LLM
judgment to actually go quiet — inconsistent, and no clean hook to stop the
reminder ladder or stamp
resolved. Rejected as the primary mechanism (kept as a supporting prompt rule). - Code-side closer classifier. Detect a pure-closer inbound + a sign-off-only draft and suppress the send. Deterministic but heuristic — over-fires (eats a real reply) or under-fires (misses unusual phrasings), and still can't stamp state or signal Temporal at the right moment. Rejected.